Skip to content

Commit 4b21d38

Browse files
authored
fix(dexdex): harden workspace validation for repository actions (#279)
## Summary - reconcile persisted active workspace IDs so invalid or legacy values do not break repository actions - guard workspace-scoped queries/stream/tray/shortcut paths when no active workspace exists - improve Repositories UX with workspace-required guidance and inline validation/mutation error rendering - add regression tests for legacy ID migration, empty-workspace blocking, and create error visibility - update DexDex project/app contract docs to match the new workspace reconciliation behavior ## Verification - pnpm --filter dexdex test - pnpm --filter dexdex build
1 parent 40dd4f8 commit 4b21d38

13 files changed

Lines changed: 395 additions & 77 deletions

apps/dexdex/src/App.test.tsx

Lines changed: 150 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
33
import { describe, expect, it, beforeEach } from "vitest";
44
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
55
import { TransportProvider } from "@connectrpc/connect-query";
6-
import { createRouterTransport } from "@connectrpc/connect";
6+
import { Code, ConnectError, createRouterTransport } from "@connectrpc/connect";
77
import { MemoryRouter } from "react-router";
88
import { create } from "@bufbuild/protobuf";
99
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
@@ -18,6 +18,7 @@ import {
1818
ReviewAssistService,
1919
ReviewCommentService,
2020
RepositoryService,
21+
WorkspaceSchema,
2122
UnitTaskSchema,
2223
SubTaskSchema,
2324
RepositorySchema,
@@ -34,6 +35,7 @@ import {
3435
NotificationType,
3536
PrStatus,
3637
AgentCliType,
38+
WorkspaceType,
3739
PullRequestRecordSchema,
3840
} from "./gen/v1/dexdex_pb";
3941

@@ -50,6 +52,9 @@ const localStorageMock = (() => {
5052

5153
Object.defineProperty(window, "localStorage", { value: localStorageMock });
5254

55+
const DEFAULT_WORKSPACE_ID = "ws-default";
56+
const LEGACY_DEFAULT_WORKSPACE_ID = "workspace-default";
57+
5358
// Mock proto data matching the old MOCK_TASKS shape
5459
const mockUnitTasks = [
5560
create(UnitTaskSchema, {
@@ -206,14 +211,14 @@ const mockPullRequests = [
206211
const mockRepositories = [
207212
create(RepositorySchema, {
208213
repositoryId: "repo-oss",
209-
workspaceId: "workspace-default",
214+
workspaceId: DEFAULT_WORKSPACE_ID,
210215
repositoryUrl: "https://github.com/example/oss",
211216
createdAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
212217
updatedAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
213218
}),
214219
create(RepositorySchema, {
215220
repositoryId: "repo-infra",
216-
workspaceId: "workspace-default",
221+
workspaceId: DEFAULT_WORKSPACE_ID,
217222
repositoryUrl: "https://github.com/example/infra",
218223
createdAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
219224
updatedAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
@@ -223,7 +228,7 @@ const mockRepositories = [
223228
const mockRepositoryGroups = [
224229
create(RepositoryGroupSchema, {
225230
repositoryGroupId: "repo-group-main",
226-
workspaceId: "workspace-default",
231+
workspaceId: DEFAULT_WORKSPACE_ID,
227232
members: [
228233
create(RepositoryGroupMemberSchema, {
229234
repositoryId: "repo-oss",
@@ -238,11 +243,35 @@ const mockRepositoryGroups = [
238243
];
239244

240245
const mockWorkspaceSettings = create(WorkspaceSettingsSchema, {
241-
workspaceId: "workspace-default",
246+
workspaceId: DEFAULT_WORKSPACE_ID,
242247
defaultAgentCliType: AgentCliType.CLAUDE_CODE,
243248
});
244249

245-
function createTestTransport() {
250+
interface TestTransportOptions {
251+
workspaces?: Array<{
252+
workspaceId: string;
253+
name: string;
254+
type?: WorkspaceType;
255+
}>;
256+
createRepositoryErrorMessage?: string;
257+
}
258+
259+
function createTestTransport(options: TestTransportOptions = {}) {
260+
const workspaces = (options.workspaces ?? [
261+
{
262+
workspaceId: DEFAULT_WORKSPACE_ID,
263+
name: "Default Workspace",
264+
type: WorkspaceType.LOCAL_ENDPOINT,
265+
},
266+
]).map((workspace) =>
267+
create(WorkspaceSchema, {
268+
workspaceId: workspace.workspaceId,
269+
name: workspace.name,
270+
type: workspace.type ?? WorkspaceType.LOCAL_ENDPOINT,
271+
createdAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
272+
}),
273+
);
274+
246275
return createRouterTransport((router) => {
247276
router.service(TaskService, {
248277
listUnitTasks: () => ({ unitTasks: mockUnitTasks }),
@@ -290,8 +319,27 @@ function createTestTransport() {
290319
stopAgentSession: () => ({}),
291320
});
292321
router.service(WorkspaceService, {
293-
getWorkspace: () => ({ workspace: undefined }),
294-
listWorkspaces: () => ({ workspaces: [] }),
322+
getWorkspace: (req) => ({
323+
workspace: workspaces.find((workspace) => workspace.workspaceId === req.workspaceId),
324+
}),
325+
listWorkspaces: () => ({ workspaces }),
326+
createWorkspace: (req) => {
327+
const createdWorkspace = create(WorkspaceSchema, {
328+
workspaceId: `ws-${Date.now()}`,
329+
name: req.name,
330+
type: req.type,
331+
createdAt: timestampFromDate(new Date()),
332+
});
333+
workspaces.push(createdWorkspace);
334+
return { workspace: createdWorkspace };
335+
},
336+
setActiveWorkspace: (req) => {
337+
const workspace = workspaces.find((item) => item.workspaceId === req.workspaceId);
338+
if (!workspace) {
339+
throw new Error(`workspace not found: ${req.workspaceId}`);
340+
}
341+
return { workspace };
342+
},
295343
getWorkspaceWorkStatus: () => ({ status: 0 }),
296344
getWorkspaceSettings: () => ({ settings: mockWorkspaceSettings }),
297345
updateWorkspaceSettings: (req) => ({
@@ -315,15 +363,20 @@ function createTestTransport() {
315363
router.service(RepositoryService, {
316364
getRepository: () => ({ repository: mockRepositories[0] }),
317365
listRepositories: () => ({ repositories: mockRepositories }),
318-
createRepository: (req) => ({
319-
repository: create(RepositorySchema, {
320-
repositoryId: `repo-${Date.now()}`,
321-
workspaceId: req.workspaceId,
322-
repositoryUrl: req.repositoryUrl,
323-
createdAt: timestampFromDate(new Date()),
324-
updatedAt: timestampFromDate(new Date()),
325-
}),
326-
}),
366+
createRepository: async (req) => {
367+
if (options.createRepositoryErrorMessage) {
368+
throw new ConnectError(options.createRepositoryErrorMessage, Code.Internal);
369+
}
370+
return {
371+
repository: create(RepositorySchema, {
372+
repositoryId: `repo-${Date.now()}`,
373+
workspaceId: req.workspaceId,
374+
repositoryUrl: req.repositoryUrl,
375+
createdAt: timestampFromDate(new Date()),
376+
updatedAt: timestampFromDate(new Date()),
377+
}),
378+
};
379+
},
327380
updateRepository: (req) => ({
328381
repository: create(RepositorySchema, {
329382
repositoryId: req.repositoryId,
@@ -379,7 +432,13 @@ function createTestTransport() {
379432
});
380433
}
381434

382-
function renderWithProviders(ui: React.ReactElement, { initialEntries = ["/tasks"] }: { initialEntries?: string[] } = {}) {
435+
function renderWithProviders(
436+
ui: React.ReactElement,
437+
{
438+
initialEntries = ["/tasks"],
439+
transportOptions,
440+
}: { initialEntries?: string[]; transportOptions?: TestTransportOptions } = {},
441+
) {
383442
const queryClient = new QueryClient({
384443
defaultOptions: {
385444
queries: {
@@ -390,7 +449,7 @@ function renderWithProviders(ui: React.ReactElement, { initialEntries = ["/tasks
390449
});
391450
return render(
392451
<QueryClientProvider client={queryClient}>
393-
<TransportProvider transport={createTestTransport()}>
452+
<TransportProvider transport={createTestTransport(transportOptions)}>
394453
<MemoryRouter initialEntries={initialEntries}>
395454
{ui}
396455
</MemoryRouter>
@@ -402,6 +461,7 @@ function renderWithProviders(ui: React.ReactElement, { initialEntries = ["/tasks
402461
beforeEach(() => {
403462
localStorageMock.clear();
404463
document.documentElement.classList.remove("dark");
464+
localStorageMock.setItem("dexdex-active-workspace-id", DEFAULT_WORKSPACE_ID);
405465
});
406466

407467
describe("App", () => {
@@ -755,4 +815,75 @@ describe("App", () => {
755815
expect(await screen.findByText("Plan approval needed")).toBeTruthy();
756816
expect(screen.getByText("CI failure on PR #42")).toBeTruthy();
757817
});
818+
819+
it("migrates legacy persisted workspace id to canonical workspace id", async () => {
820+
localStorageMock.setItem("dexdex-active-workspace-id", LEGACY_DEFAULT_WORKSPACE_ID);
821+
renderWithProviders(<App />);
822+
823+
await screen.findByTestId("task-list");
824+
await waitFor(() => {
825+
expect(localStorageMock.getItem("dexdex-active-workspace-id")).toBe(DEFAULT_WORKSPACE_ID);
826+
});
827+
});
828+
829+
it("blocks repository creation when no workspace exists", async () => {
830+
localStorageMock.removeItem("dexdex-active-workspace-id");
831+
renderWithProviders(<App />, {
832+
initialEntries: ["/repositories"],
833+
transportOptions: { workspaces: [] },
834+
});
835+
836+
expect(await screen.findByTestId("repositories-page")).toBeTruthy();
837+
expect(screen.getByTestId("repository-workspace-hint")).toBeTruthy();
838+
839+
const createInput = screen.getByTestId("create-repository-url") as HTMLInputElement;
840+
const createButton = screen.getByRole("button", { name: "Add Repository" }) as HTMLButtonElement;
841+
expect(createInput.disabled).toBe(true);
842+
expect(createButton.disabled).toBe(true);
843+
});
844+
845+
it("shows repository create validation errors", async () => {
846+
const user = userEvent.setup();
847+
renderWithProviders(<App />, { initialEntries: ["/repositories"] });
848+
849+
expect(await screen.findByTestId("repositories-page")).toBeTruthy();
850+
const createInput = screen.getByTestId("create-repository-url") as HTMLInputElement;
851+
const createButton = screen.getByRole("button", { name: "Add Repository" }) as HTMLButtonElement;
852+
853+
await waitFor(() => {
854+
expect(createInput.disabled).toBe(false);
855+
expect(createButton.disabled).toBe(false);
856+
});
857+
858+
await user.clear(createInput);
859+
await user.type(createInput, "github.com/example/new-repo");
860+
await user.click(createButton);
861+
862+
const mutationError = await screen.findByTestId("repository-mutation-error");
863+
expect(mutationError.textContent).toBe("Repository URL must start with http:// or https://.");
864+
});
865+
866+
it("shows repository create mutation errors", async () => {
867+
const user = userEvent.setup();
868+
renderWithProviders(<App />, {
869+
initialEntries: ["/repositories"],
870+
transportOptions: { createRepositoryErrorMessage: "rpc create failed" },
871+
});
872+
873+
expect(await screen.findByTestId("repositories-page")).toBeTruthy();
874+
const createInput = screen.getByTestId("create-repository-url") as HTMLInputElement;
875+
const createButton = screen.getByRole("button", { name: "Add Repository" }) as HTMLButtonElement;
876+
877+
await waitFor(() => {
878+
expect(createInput.disabled).toBe(false);
879+
expect(createButton.disabled).toBe(false);
880+
});
881+
882+
await user.type(createInput, "https://github.com/example/new-repo");
883+
await user.click(createButton);
884+
885+
const mutationError = await screen.findByTestId("repository-mutation-error");
886+
expect(mutationError.textContent?.includes("Failed to add repository:")).toBe(true);
887+
expect(mutationError.textContent?.includes("rpc create failed")).toBe(true);
888+
});
758889
});

apps/dexdex/src/components/sidebar.tsx

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
* Sidebar navigation component with Linear-style layout.
33
*/
44

5-
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
6-
import { useAppStore } from "../stores/app-store";
5+
import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from "react";
6+
import { CANONICAL_DEFAULT_WORKSPACE_ID, LEGACY_DEFAULT_WORKSPACE_ID, useAppStore } from "../stores/app-store";
77
import { useCreateWorkspaceMutation, useListWorkspaces, useSetActiveWorkspaceMutation } from "../hooks/use-dexdex-queries";
88
import { WorkspaceType } from "../gen/v1/dexdex_pb";
99

@@ -28,18 +28,57 @@ function formatMutationError(error: unknown): string {
2828
return String(error);
2929
}
3030

31+
function resolveValidWorkspaceId(activeWorkspaceId: string, workspaceIds: string[]): string {
32+
if (workspaceIds.length === 0) {
33+
return "";
34+
}
35+
36+
const normalizedActiveWorkspaceId = activeWorkspaceId.trim();
37+
if (normalizedActiveWorkspaceId && workspaceIds.includes(normalizedActiveWorkspaceId)) {
38+
return normalizedActiveWorkspaceId;
39+
}
40+
41+
if (
42+
normalizedActiveWorkspaceId === LEGACY_DEFAULT_WORKSPACE_ID &&
43+
workspaceIds.includes(CANONICAL_DEFAULT_WORKSPACE_ID)
44+
) {
45+
return CANONICAL_DEFAULT_WORKSPACE_ID;
46+
}
47+
48+
return workspaceIds[0];
49+
}
50+
3151
export function Sidebar({ activePath, onNavigate }: SidebarProps) {
3252
const { sidebarOpen, connectionStatus, activeWorkspaceId, setActiveWorkspaceId } = useAppStore();
3353
const [dropdownOpen, setDropdownOpen] = useState(false);
3454
const [createWorkspaceError, setCreateWorkspaceError] = useState<string | null>(null);
3555
const dropdownRef = useRef<HTMLDivElement>(null);
36-
const { data: workspacesData } = useListWorkspaces();
56+
const workspacesQuery = useListWorkspaces();
3757
const setActiveWorkspaceMutation = useSetActiveWorkspaceMutation();
3858
const createWorkspaceMutation = useCreateWorkspaceMutation();
3959

40-
const workspaces = workspacesData?.workspaces ?? [];
60+
const workspaces = workspacesQuery.data?.workspaces ?? [];
61+
const workspaceIds = useMemo(() => workspaces.map((workspace) => workspace.workspaceId), [workspaces]);
62+
const hasWorkspaces = workspaceIds.length > 0;
4163
const currentWorkspace = workspaces.find((w) => w.workspaceId === activeWorkspaceId);
42-
const currentWorkspaceName = currentWorkspace?.name || activeWorkspaceId;
64+
const currentWorkspaceName = currentWorkspace?.name || activeWorkspaceId || "No workspace selected";
65+
66+
useEffect(() => {
67+
if (!workspacesQuery.isSuccess) {
68+
return;
69+
}
70+
71+
const resolvedWorkspaceId = resolveValidWorkspaceId(activeWorkspaceId, workspaceIds);
72+
if (resolvedWorkspaceId === activeWorkspaceId) {
73+
return;
74+
}
75+
76+
setCreateWorkspaceError(null);
77+
setActiveWorkspaceId(resolvedWorkspaceId);
78+
if (resolvedWorkspaceId) {
79+
setActiveWorkspaceMutation.mutate({ workspaceId: resolvedWorkspaceId });
80+
}
81+
}, [activeWorkspaceId, setActiveWorkspaceId, setActiveWorkspaceMutation, workspaceIds, workspacesQuery.isSuccess]);
4382

4483
const handleWorkspaceSwitch = useCallback(
4584
(workspaceId: string) => {
@@ -313,6 +352,19 @@ export function Sidebar({ activePath, onNavigate }: SidebarProps) {
313352
{createWorkspaceError}
314353
</p>
315354
)}
355+
{!createWorkspaceError && !hasWorkspaces && (
356+
<p
357+
style={{
358+
marginTop: "var(--space-2)",
359+
marginBottom: 0,
360+
fontSize: "var(--font-size-xs)",
361+
color: "var(--color-text-tertiary)",
362+
}}
363+
data-testid="workspace-required-hint"
364+
>
365+
Create a workspace to enable repositories, repository groups, and task workflows.
366+
</p>
367+
)}
316368
</div>
317369
<div style={navStyle}>
318370
{NAV_ITEMS.map((item) => {

apps/dexdex/src/features/inbox/inbox-page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ function getNotificationBadgeColor(type: NotificationType): string {
5151
export function InboxPage() {
5252
const navigate = useNavigate();
5353
const store = useContext(AppStoreContext);
54-
const workspaceId = store?.activeWorkspaceId ?? "workspace-default";
54+
const workspaceId = store?.activeWorkspaceId ?? "";
5555

5656
const { data: notifications = [], isLoading } = useListNotifications(workspaceId);
5757
const markReadMutation = useMarkNotificationReadMutation();

0 commit comments

Comments
 (0)