Skip to content

Commit 0618508

Browse files
authored
feat(dexdex): support repository selector for CreateUnitTask (#280)
## Summary - add `repository_id` to `CreateUnitTaskRequest` and enforce exactly-one selector validation with `repository_group_id` - extend dexdex main server to create/reuse deterministic singleton auto groups (`auto-repo-singleton-<repository_id>`) when `repository_id` is used - enforce auto-group guardrails: reserved create prefix, blocked update/delete, and cleanup on repository delete - update DexDex desktop Create Task dialog to use one unified selector (Repository Groups + Repositories) - hide system-managed auto groups from Repository Groups UI and render repository-friendly task metadata for auto-group-backed tasks - synchronize DexDex proto/app/server/project contracts in `docs/` ## Test Plan - `cd protos/dexdex && buf lint && buf build` - `cd protos/dexdex && PATH="$(pwd)/../../apps/dexdex/node_modules/.bin:$PATH" buf generate && PATH="$(pwd)/../../apps/dexdex/node_modules/.bin:$PATH" buf generate --template buf.gen.web.yaml` - `go test ./servers/dexdex-main-server/...` - `cd apps/dexdex && pnpm test`
1 parent 4b21d38 commit 0618508

20 files changed

Lines changed: 749 additions & 55 deletions

apps/dexdex/src/App.test.tsx

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
WorkspaceType,
3939
PullRequestRecordSchema,
4040
} from "./gen/v1/dexdex_pb";
41+
import { AUTO_REPOSITORY_GROUP_PREFIX } from "./lib/repository-target";
4142

4243
// Mock localStorage
4344
const localStorageMock = (() => {
@@ -105,7 +106,7 @@ const mockUnitTasks = [
105106
unitTaskId: "task-005",
106107
prompt: "Update CI pipeline for monorepo",
107108
status: UnitTaskStatus.FAILED,
108-
repositoryGroupId: "repo-group-main",
109+
repositoryGroupId: `${AUTO_REPOSITORY_GROUP_PREFIX}repo-oss`,
109110
agentCliType: AgentCliType.OPENCODE,
110111
usePlanMode: false,
111112
createdAt: timestampFromDate(new Date("2026-03-13T09:00:00Z")),
@@ -240,13 +241,34 @@ const mockRepositoryGroups = [
240241
createdAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
241242
updatedAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
242243
}),
244+
create(RepositoryGroupSchema, {
245+
repositoryGroupId: `${AUTO_REPOSITORY_GROUP_PREFIX}repo-oss`,
246+
workspaceId: DEFAULT_WORKSPACE_ID,
247+
members: [
248+
create(RepositoryGroupMemberSchema, {
249+
repositoryId: "repo-oss",
250+
branchRef: "HEAD",
251+
displayOrder: 0,
252+
repository: mockRepositories[0],
253+
}),
254+
],
255+
createdAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
256+
updatedAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
257+
}),
243258
];
244259

245260
const mockWorkspaceSettings = create(WorkspaceSettingsSchema, {
246261
workspaceId: DEFAULT_WORKSPACE_ID,
247262
defaultAgentCliType: AgentCliType.CLAUDE_CODE,
248263
});
249264

265+
let lastCreateUnitTaskRequest:
266+
| {
267+
repositoryGroupId?: string;
268+
repositoryId?: string;
269+
}
270+
| null = null;
271+
250272
interface TestTransportOptions {
251273
workspaces?: Array<{
252274
workspaceId: string;
@@ -271,7 +293,6 @@ function createTestTransport(options: TestTransportOptions = {}) {
271293
createdAt: timestampFromDate(new Date("2026-03-10T00:00:00Z")),
272294
}),
273295
);
274-
275296
return createRouterTransport((router) => {
276297
router.service(TaskService, {
277298
listUnitTasks: () => ({ unitTasks: mockUnitTasks }),
@@ -281,18 +302,25 @@ function createTestTransport(options: TestTransportOptions = {}) {
281302
if (req.unitTaskId === "task-004") return { subTasks: mockSubTasksFor004 };
282303
return { subTasks: [] };
283304
},
284-
createUnitTask: (req) => ({
285-
unitTask: create(UnitTaskSchema, {
286-
unitTaskId: `task-${Date.now()}`,
287-
prompt: req.prompt,
305+
createUnitTask: (req) => {
306+
lastCreateUnitTaskRequest = {
288307
repositoryGroupId: req.repositoryGroupId,
289-
agentCliType: req.agentCliType,
290-
usePlanMode: req.usePlanMode,
291-
status: UnitTaskStatus.QUEUED,
292-
createdAt: timestampFromDate(new Date()),
293-
updatedAt: timestampFromDate(new Date()),
294-
}),
295-
}),
308+
repositoryId: req.repositoryId,
309+
};
310+
return {
311+
unitTask: create(UnitTaskSchema, {
312+
unitTaskId: `task-${Date.now()}`,
313+
prompt: req.prompt,
314+
repositoryGroupId:
315+
req.repositoryGroupId || (req.repositoryId ? `${AUTO_REPOSITORY_GROUP_PREFIX}${req.repositoryId}` : ""),
316+
agentCliType: req.agentCliType,
317+
usePlanMode: req.usePlanMode,
318+
status: UnitTaskStatus.QUEUED,
319+
createdAt: timestampFromDate(new Date()),
320+
updatedAt: timestampFromDate(new Date()),
321+
}),
322+
};
323+
},
296324
submitPlanDecision: () => ({
297325
updatedSubTask: undefined,
298326
createdSubTask: undefined,
@@ -460,6 +488,7 @@ function renderWithProviders(
460488

461489
beforeEach(() => {
462490
localStorageMock.clear();
491+
lastCreateUnitTaskRequest = null;
463492
document.documentElement.classList.remove("dark");
464493
localStorageMock.setItem("dexdex-active-workspace-id", DEFAULT_WORKSPACE_ID);
465494
});
@@ -521,6 +550,7 @@ describe("App", () => {
521550
await user.click(screen.getByTestId("nav-repository-groups"));
522551
expect(await screen.findByTestId("repository-groups-page")).toBeTruthy();
523552
expect(screen.getByRole("heading", { name: "Repository Groups", level: 1 })).toBeTruthy();
553+
expect(screen.queryByText(`${AUTO_REPOSITORY_GROUP_PREFIX}repo-oss`)).toBeNull();
524554
});
525555

526556
it("navigates to repositories via sidebar", async () => {
@@ -595,7 +625,7 @@ describe("App", () => {
595625
await user.click(screen.getByTestId("create-task-button"));
596626

597627
await user.type(screen.getByTestId("task-prompt-input"), "My new task prompt");
598-
await user.selectOptions(screen.getByTestId("task-repo-group-select"), "repo-group-main");
628+
await user.selectOptions(screen.getByTestId("task-repo-group-select"), "group:repo-group-main");
599629
await user.selectOptions(screen.getByTestId("task-agent-select"), `${AgentCliType.CLAUDE_CODE}`);
600630
if (screen.queryByTestId("task-plan-mode-toggle")) {
601631
await user.click(screen.getByTestId("task-plan-mode-toggle"));
@@ -604,6 +634,31 @@ describe("App", () => {
604634

605635
// Dialog should close
606636
expect(screen.queryByTestId("create-dialog")).toBeNull();
637+
expect(lastCreateUnitTaskRequest?.repositoryGroupId).toBe("repo-group-main");
638+
expect(lastCreateUnitTaskRequest?.repositoryId).toBe("");
639+
});
640+
641+
it("creates a new task via repository selector", async () => {
642+
const user = userEvent.setup();
643+
renderWithProviders(<App />);
644+
645+
await screen.findByTestId("task-list");
646+
await user.click(screen.getByTestId("create-task-button"));
647+
648+
await user.type(screen.getByTestId("task-prompt-input"), "Fix CI for repo target");
649+
await user.selectOptions(screen.getByTestId("task-repo-group-select"), "repository:repo-oss");
650+
await user.selectOptions(screen.getByTestId("task-agent-select"), `${AgentCliType.CLAUDE_CODE}`);
651+
await user.click(screen.getByTestId("submit-create-task"));
652+
653+
expect(screen.queryByTestId("create-dialog")).toBeNull();
654+
expect(lastCreateUnitTaskRequest?.repositoryId).toBe("repo-oss");
655+
expect(lastCreateUnitTaskRequest?.repositoryGroupId).toBe("");
656+
});
657+
658+
it("renders repository-based metadata instead of internal auto group id", async () => {
659+
renderWithProviders(<App />);
660+
expect(await screen.findByTestId("task-row-task-005")).toBeTruthy();
661+
expect(screen.getByText("Repository: repo-oss")).toBeTruthy();
607662
});
608663

609664
it("opens command palette with keyboard shortcut", async () => {

apps/dexdex/src/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
persistActiveWorkspaceId,
4545
} from "./stores/app-store";
4646
import { summarizePrompt } from "./lib/adapters";
47+
import type { RepositoryTargetSelection } from "./lib/repository-target";
4748
import { PlanDecision } from "./lib/status";
4849
import { AgentCliType, PlanDecision as ProtoPlanDecision } from "./gen/v1/dexdex_pb";
4950

@@ -182,11 +183,12 @@ function App() {
182183
const createTaskMutation = useCreateUnitTaskMutation();
183184

184185
const handleCreateTask = useCallback(
185-
(prompt: string, repositoryGroupId: string, agentCliType: AgentCliType, usePlanMode: boolean) => {
186+
(prompt: string, target: RepositoryTargetSelection, agentCliType: AgentCliType, usePlanMode: boolean) => {
186187
createTaskMutation.mutate({
187188
workspaceId: activeWorkspaceId,
188189
prompt,
189-
repositoryGroupId,
190+
repositoryGroupId: target.kind === "group" ? target.repositoryGroupId : "",
191+
repositoryId: target.kind === "repository" ? target.repositoryId : "",
190192
agentCliType,
191193
usePlanMode,
192194
});

apps/dexdex/src/features/repositories/repository-groups-page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
useListRepositoryGroups,
1111
useUpdateRepositoryGroupMutation,
1212
} from "../../hooks/use-dexdex-queries";
13+
import { isAutoRepositoryGroupId } from "../../lib/repository-target";
1314
import { useAppStore } from "../../stores/app-store";
1415

1516
interface EditableGroupMember {
@@ -32,7 +33,9 @@ export function RepositoryGroupsPage() {
3233
const [groupFormError, setGroupFormError] = useState("");
3334

3435
const repositories = repositoriesQuery.data?.repositories ?? [];
35-
const repositoryGroups = repositoryGroupsQuery.data?.repositoryGroups ?? [];
36+
const repositoryGroups = (repositoryGroupsQuery.data?.repositoryGroups ?? []).filter(
37+
(group) => !isAutoRepositoryGroupId(group.repositoryGroupId),
38+
);
3639

3740
function selectGroupForEdit(repositoryGroupId: string) {
3841
setEditingGroupId(repositoryGroupId);

apps/dexdex/src/features/tasks/create-dialog.tsx

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,24 @@ import { type CSSProperties, type FormEvent, useEffect, useMemo, useRef, useStat
66
import { AgentCliType } from "../../gen/v1/dexdex_pb";
77
import {
88
useGetWorkspaceSettings,
9+
useListRepositories,
910
useListRepositoryGroups,
1011
useListSessionCapabilities,
1112
} from "../../hooks/use-dexdex-queries";
13+
import {
14+
decodeRepositoryTargetSelection,
15+
encodeRepositoryTargetSelection,
16+
isAutoRepositoryGroupId,
17+
type RepositoryTargetSelection,
18+
} from "../../lib/repository-target";
1219
import { useEscapeToClose, useFocusOnShow } from "../../hooks/use-dialog-accessibility";
1320
import { useDraftStore } from "../../stores/draft-store";
1421

1522
interface CreateDialogProps {
1623
isOpen: boolean;
1724
workspaceId: string;
1825
onClose: () => void;
19-
onCreate: (prompt: string, repositoryGroupId: string, agentCliType: AgentCliType, usePlanMode: boolean) => void;
26+
onCreate: (prompt: string, target: RepositoryTargetSelection, agentCliType: AgentCliType, usePlanMode: boolean) => void;
2027
}
2128

2229
interface AgentOption {
@@ -33,18 +40,22 @@ const FALLBACK_AGENT_OPTIONS: AgentOption[] = [
3340

3441
export function CreateDialog({ isOpen, workspaceId, onClose, onCreate }: CreateDialogProps) {
3542
const [prompt, setPrompt] = useState("");
36-
const [selectedRepoGroupId, setSelectedRepoGroupId] = useState("");
43+
const [selectedTarget, setSelectedTarget] = useState("");
3744
const [selectedAgent, setSelectedAgent] = useState<AgentCliType>(AgentCliType.UNSPECIFIED);
3845
const [usePlanMode, setUsePlanMode] = useState(false);
3946
const promptInputRef = useRef<HTMLTextAreaElement>(null);
4047

4148
const { getDraft, setDraft: saveDraft, clearDraft } = useDraftStore();
4249

50+
const repositoriesQuery = useListRepositories(workspaceId);
4351
const repoGroupsQuery = useListRepositoryGroups(workspaceId);
4452
const capabilitiesQuery = useListSessionCapabilities(workspaceId);
4553
const workspaceSettingsQuery = useGetWorkspaceSettings(workspaceId);
4654

47-
const repositoryGroups = repoGroupsQuery.data?.repositoryGroups ?? [];
55+
const repositories = repositoriesQuery.data?.repositories ?? [];
56+
const repositoryGroups = (repoGroupsQuery.data?.repositoryGroups ?? []).filter(
57+
(group) => !isAutoRepositoryGroupId(group.repositoryGroupId),
58+
);
4859
const agentOptions = useMemo<AgentOption[]>(() => {
4960
const capabilities = capabilitiesQuery.data?.capabilities ?? [];
5061
if (capabilities.length === 0) {
@@ -89,7 +100,17 @@ export function CreateDialog({ isOpen, workspaceId, onClose, onCreate }: CreateD
89100
const draft = getDraft(workspaceId);
90101
if (draft) {
91102
setPrompt(draft.prompt);
92-
setSelectedRepoGroupId(draft.repositoryGroupId);
103+
const parsedTarget = decodeRepositoryTargetSelection(draft.repositoryGroupId);
104+
if (parsedTarget) {
105+
setSelectedTarget(draft.repositoryGroupId);
106+
} else if (draft.repositoryGroupId.trim().length > 0) {
107+
setSelectedTarget(
108+
encodeRepositoryTargetSelection({
109+
kind: "group",
110+
repositoryGroupId: draft.repositoryGroupId.trim(),
111+
}),
112+
);
113+
}
93114
setSelectedAgent(draft.agentCliType);
94115
setUsePlanMode(draft.usePlanMode);
95116
}
@@ -101,29 +122,30 @@ export function CreateDialog({ isOpen, workspaceId, onClose, onCreate }: CreateD
101122
const timer = setTimeout(() => {
102123
saveDraft(workspaceId, {
103124
prompt,
104-
repositoryGroupId: selectedRepoGroupId,
125+
repositoryGroupId: selectedTarget,
105126
agentCliType: selectedAgent,
106127
usePlanMode,
107128
});
108129
}, 300);
109130
return () => clearTimeout(timer);
110-
}, [isOpen, workspaceId, prompt, selectedRepoGroupId, selectedAgent, usePlanMode, saveDraft]);
131+
}, [isOpen, workspaceId, prompt, selectedTarget, selectedAgent, usePlanMode, saveDraft]);
111132

112133
useEscapeToClose(isOpen, onClose);
113134
useFocusOnShow(isOpen, promptInputRef);
114135

115136
if (!isOpen) return null;
116137

117-
const canSubmit = prompt.trim().length > 0 && selectedRepoGroupId.trim().length > 0 && selectedAgent !== AgentCliType.UNSPECIFIED;
138+
const selectedTargetValue = decodeRepositoryTargetSelection(selectedTarget);
139+
const canSubmit = prompt.trim().length > 0 && selectedTargetValue !== null && selectedAgent !== AgentCliType.UNSPECIFIED;
118140

119141
function handleSubmit(e: FormEvent) {
120142
e.preventDefault();
121-
if (!canSubmit) return;
143+
if (!canSubmit || !selectedTargetValue) return;
122144

123-
onCreate(prompt.trim(), selectedRepoGroupId, selectedAgent, selectedAgentSupportsPlanMode && usePlanMode);
145+
onCreate(prompt.trim(), selectedTargetValue, selectedAgent, selectedAgentSupportsPlanMode && usePlanMode);
124146
clearDraft(workspaceId);
125147
setPrompt("");
126-
setSelectedRepoGroupId("");
148+
setSelectedTarget("");
127149
setSelectedAgent(AgentCliType.UNSPECIFIED);
128150
setUsePlanMode(false);
129151
onClose();
@@ -205,22 +227,41 @@ export function CreateDialog({ isOpen, workspaceId, onClose, onCreate }: CreateD
205227
</div>
206228

207229
<div style={{ marginBottom: "var(--space-3)" }}>
208-
<label htmlFor="task-repo-group" style={labelStyle}>
209-
Repository Group
230+
<label htmlFor="task-repository-target" style={labelStyle}>
231+
Repository Target
210232
</label>
211233
<select
212-
id="task-repo-group"
234+
id="task-repository-target"
213235
style={inputStyle}
214-
value={selectedRepoGroupId}
215-
onChange={(e) => setSelectedRepoGroupId(e.target.value)}
236+
value={selectedTarget}
237+
onChange={(e) => setSelectedTarget(e.target.value)}
216238
data-testid="task-repo-group-select"
217239
>
218-
<option value="">Select a repository group</option>
219-
{repositoryGroups.map((group) => (
220-
<option key={group.repositoryGroupId} value={group.repositoryGroupId}>
221-
{group.repositoryGroupId} ({group.members.length} repos)
222-
</option>
223-
))}
240+
<option value="">Select repository group or repository</option>
241+
{repositoryGroups.length > 0 && (
242+
<optgroup label="Repository Groups">
243+
{repositoryGroups.map((group) => (
244+
<option
245+
key={`group-${group.repositoryGroupId}`}
246+
value={encodeRepositoryTargetSelection({ kind: "group", repositoryGroupId: group.repositoryGroupId })}
247+
>
248+
{group.repositoryGroupId} ({group.members.length} repos)
249+
</option>
250+
))}
251+
</optgroup>
252+
)}
253+
{repositories.length > 0 && (
254+
<optgroup label="Repositories">
255+
{repositories.map((repository) => (
256+
<option
257+
key={`repository-${repository.repositoryId}`}
258+
value={encodeRepositoryTargetSelection({ kind: "repository", repositoryId: repository.repositoryId })}
259+
>
260+
{repository.repositoryId}
261+
</option>
262+
))}
263+
</optgroup>
264+
)}
224265
</select>
225266
</div>
226267

apps/dexdex/src/features/tasks/task-detail.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { type CSSProperties, useState } from "react";
66
import { StatusBadge } from "../../components/status-badge";
77
import type { UnitTask } from "../../lib/mock-data";
8+
import { formatTaskRepositoryScopeDetail } from "../../lib/repository-target";
89
import { PlanDecision, SubTaskStatus, UnitTaskStatus } from "../../lib/status";
910
import { useListSubTasks, useListSubTasksRaw, useGetSessionOutput, useCancelUnitTaskMutation, useTrackPullRequestMutation } from "../../hooks/use-dexdex-queries";
1011
import { SubtaskTimeline } from "./subtask-timeline";
@@ -189,7 +190,7 @@ export function TaskDetail({ task, onBack, onPlanDecision }: TaskDetailProps) {
189190
color: "var(--color-text-tertiary)",
190191
}}
191192
>
192-
<span>Repository Group: {task.repositoryGroupId || "-"}</span>
193+
<span>Repository Scope: {formatTaskRepositoryScopeDetail(task.repositoryGroupId)}</span>
193194
<span>Agent: {task.agentCliType || "UNSPECIFIED"}</span>
194195
<span>Plan Mode: {task.usePlanMode ? "ON" : "OFF"}</span>
195196
</div>

apps/dexdex/src/features/tasks/task-list.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { type CSSProperties, useEffect, useState } from "react";
66
import { StatusBadge } from "../../components/status-badge";
77
import { TaskListSkeleton } from "../../components/skeleton-loader";
88
import type { UnitTask } from "../../lib/mock-data";
9+
import { formatTaskRepositoryScope } from "../../lib/repository-target";
910
import { UnitTaskStatus } from "../../lib/status";
1011

1112
interface TaskListProps {
@@ -171,7 +172,7 @@ export function TaskList({ tasks, isLoading, onTaskSelect, onCreateTask, selecte
171172
}
172173

173174
function TaskRow({ task, isSelected, onClick }: { task: UnitTask; isSelected?: boolean; onClick: () => void }) {
174-
const metadata = task.repositoryGroupId ? `Group: ${task.repositoryGroupId}` : "No repository group";
175+
const metadata = formatTaskRepositoryScope(task.repositoryGroupId);
175176

176177
const rowStyle: CSSProperties = {
177178
display: "flex",

apps/dexdex/src/gen/v1/dexdex_pb.ts

Lines changed: 6 additions & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)