diff --git a/apps/code/src/main/db/migrations/0005_multi_repo_workspaces.sql b/apps/code/src/main/db/migrations/0005_multi_repo_workspaces.sql new file mode 100644 index 000000000..114ea8e0d --- /dev/null +++ b/apps/code/src/main/db/migrations/0005_multi_repo_workspaces.sql @@ -0,0 +1,6 @@ +-- Drop the unique constraint on task_id to allow multiple workspaces per task +DROP INDEX IF EXISTS `workspaces_taskId_unique`;--> statement-breakpoint +-- Add a non-unique index for query performance +CREATE INDEX `workspaces_task_id_idx` ON `workspaces` (`task_id`);--> statement-breakpoint +-- Add label column for display name (e.g., "posthog-js") +ALTER TABLE `workspaces` ADD `label` text; diff --git a/apps/code/src/main/db/migrations/meta/0005_snapshot.json b/apps/code/src/main/db/migrations/meta/0005_snapshot.json new file mode 100644 index 000000000..78e76e80f --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,526 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "prevId": "c5ddb764-2a46-47c0-82b7-59658c60d306", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_task_id_idx": { + "name": "workspaces_task_id_idx", + "columns": ["task_id"], + "isUnique": false + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 791d110c9..dbe87d7d8 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1774891000000, "tag": "0004_auth_preferences", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1775200000000, + "tag": "0005_multi_repo_workspaces", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/workspace-repository.mock.ts b/apps/code/src/main/db/repositories/workspace-repository.mock.ts index 354814937..7158d6794 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.mock.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.mock.ts @@ -10,7 +10,6 @@ export interface MockWorkspaceRepository extends IWorkspaceRepository { export function createMockWorkspaceRepository(): MockWorkspaceRepository { const workspaces = new Map(); - const taskIndex = new Map(); const clone = (w: Workspace | null): Workspace | null => w ? { ...w } : null; @@ -19,9 +18,15 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { _workspaces: workspaces, findById: (id: string) => clone(workspaces.get(id) ?? null), findByTaskId: (taskId: string) => { - const id = taskIndex.get(taskId); - return clone(id ? (workspaces.get(id) ?? null) : null); + for (const w of workspaces.values()) { + if (w.taskId === taskId) return { ...w }; + } + return null; }, + findAllByTaskId: (taskId: string) => + Array.from(workspaces.values()) + .filter((w) => w.taskId === taskId) + .map((w) => ({ ...w })), findAllByRepositoryId: (repositoryId: string) => Array.from(workspaces.values()) .filter((w) => w.repositoryId === repositoryId) @@ -38,6 +43,7 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { taskId: data.taskId, repositoryId: data.repositoryId, mode: data.mode, + label: data.label ?? null, pinnedAt: null, lastViewedAt: null, lastActivityAt: null, @@ -45,30 +51,41 @@ export function createMockWorkspaceRepository(): MockWorkspaceRepository { updatedAt: now, }; workspaces.set(workspace.id, workspace); - taskIndex.set(workspace.taskId, workspace.id); return { ...workspace }; }, deleteByTaskId: (taskId: string) => { - const id = taskIndex.get(taskId); - if (id) { - workspaces.delete(id); - taskIndex.delete(taskId); + for (const [id, w] of workspaces) { + if (w.taskId === taskId) { + workspaces.delete(id); + } } }, deleteById: (id: string) => { - const workspace = workspaces.get(id); - if (workspace) { - taskIndex.delete(workspace.taskId); - workspaces.delete(id); + workspaces.delete(id); + }, + updatePinnedAt: (taskId: string, pinnedAt: string | null) => { + for (const w of workspaces.values()) { + if (w.taskId === taskId) w.pinnedAt = pinnedAt; + } + }, + updateLastViewedAt: (taskId: string, lastViewedAt: string) => { + for (const w of workspaces.values()) { + if (w.taskId === taskId) w.lastViewedAt = lastViewedAt; + } + }, + updateLastActivityAt: (taskId: string, lastActivityAt: string) => { + for (const w of workspaces.values()) { + if (w.taskId === taskId) w.lastActivityAt = lastActivityAt; + } + }, + updateMode: (taskId: string, mode: string) => { + for (const w of workspaces.values()) { + if (w.taskId === taskId) + w.mode = mode as "cloud" | "local" | "worktree"; } }, - updatePinnedAt: () => {}, - updateLastViewedAt: () => {}, - updateLastActivityAt: () => {}, - updateMode: () => {}, deleteAll: () => { workspaces.clear(); - taskIndex.clear(); }, }; } diff --git a/apps/code/src/main/db/repositories/workspace-repository.ts b/apps/code/src/main/db/repositories/workspace-repository.ts index 433af37b5..b3ecea49e 100644 --- a/apps/code/src/main/db/repositories/workspace-repository.ts +++ b/apps/code/src/main/db/repositories/workspace-repository.ts @@ -12,11 +12,15 @@ export interface CreateWorkspaceData { taskId: string; repositoryId: string | null; mode: WorkspaceMode; + label?: string | null; } export interface IWorkspaceRepository { findById(id: string): Workspace | null; + /** Returns the first workspace for a task, or null. Use findAllByTaskId for multi-repo tasks. */ findByTaskId(taskId: string): Workspace | null; + /** Returns all workspaces for a task (one per repo in multi-repo setups). */ + findAllByTaskId(taskId: string): Workspace[]; findAllByRepositoryId(repositoryId: string): Workspace[]; findAllPinned(): Workspace[]; findAll(): Workspace[]; @@ -57,6 +61,10 @@ export class WorkspaceRepository implements IWorkspaceRepository { ); } + findAllByTaskId(taskId: string): Workspace[] { + return this.db.select().from(workspaces).where(byTaskId(taskId)).all(); + } + findAllByRepositoryId(repositoryId: string): Workspace[] { return this.db .select() @@ -81,6 +89,7 @@ export class WorkspaceRepository implements IWorkspaceRepository { taskId: data.taskId, repositoryId: data.repositoryId, mode: data.mode, + label: data.label ?? null, createdAt: timestamp, updatedAt: timestamp, }; diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 86ec6c432..fb4f4a821 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -22,18 +22,22 @@ export const workspaces = sqliteTable( "workspaces", { id: id(), - taskId: text().notNull().unique(), + taskId: text().notNull(), repositoryId: text().references(() => repositories.id, { onDelete: "set null", }), mode: text({ enum: ["cloud", "local", "worktree"] }).notNull(), + label: text(), pinnedAt: text(), lastViewedAt: text(), lastActivityAt: text(), createdAt: createdAt(), updatedAt: updatedAt(), }, - (t) => [index("workspaces_repository_id_idx").on(t.repositoryId)], + (t) => [ + index("workspaces_task_id_idx").on(t.taskId), + index("workspaces_repository_id_idx").on(t.repositoryId), + ], ); export const worktrees = sqliteTable("worktrees", { diff --git a/apps/code/src/main/services/archive/service.ts b/apps/code/src/main/services/archive/service.ts index 2a302fe9a..4fa4ea21b 100644 --- a/apps/code/src/main/services/archive/service.ts +++ b/apps/code/src/main/services/archive/service.ts @@ -89,7 +89,10 @@ export class ArchiveService { ): Promise { const { taskId } = input; - const workspace = this.workspaceRepo.findByTaskId(taskId); + // For multi-repo tasks, archive operates on the first worktree workspace + const allWorkspaces = this.workspaceRepo.findAllByTaskId(taskId); + const workspace = + allWorkspaces.find((ws) => ws.mode === "worktree") ?? allWorkspaces[0]; if (!workspace) { return { taskId, diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index 973899889..3e7d82f7f 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -556,6 +556,12 @@ export class AuthService extends TypedEventEmitter { return; } + // Skip access check for local dev — the endpoint may not exist + if (this.session.cloudRegion === "dev") { + this.updateState({ hasCodeAccess: true }); + return; + } + try { const apiHost = getCloudUrlFromRegion(this.session.cloudRegion); const response = await this.executeAuthenticatedFetch( diff --git a/apps/code/src/main/services/suspension/service.ts b/apps/code/src/main/services/suspension/service.ts index 7088d6243..100fa51d2 100644 --- a/apps/code/src/main/services/suspension/service.ts +++ b/apps/code/src/main/services/suspension/service.ts @@ -129,9 +129,10 @@ export class SuspensionService extends TypedEventEmitter this.suspensionRepo.findByWorkspaceId(ws.id) !== null, + ); } async suspendLeastRecentIfOverLimit(): Promise { @@ -253,9 +254,18 @@ export class SuspensionService extends TypedEventEmitter ws.mode === "worktree") ?? allWorkspaces[0]; let folderPath: string | null = null; if (workspace.repositoryId) { diff --git a/apps/code/src/main/services/workspace/schemas.ts b/apps/code/src/main/services/workspace/schemas.ts index 2040ef111..dfd94bc81 100644 --- a/apps/code/src/main/services/workspace/schemas.ts +++ b/apps/code/src/main/services/workspace/schemas.ts @@ -19,6 +19,7 @@ export const workspaceInfoSchema = z.object({ mode: workspaceModeSchema, worktree: worktreeInfoSchema.nullable(), branchName: z.string().nullable(), + label: z.string().nullable().optional(), }); export const workspaceSchema = z.object({ @@ -30,9 +31,23 @@ export const workspaceSchema = z.object({ worktreeName: z.string().nullable(), branchName: z.string().nullable(), baseBranch: z.string().nullable(), + label: z.string().nullable().optional(), createdAt: z.string(), }); +// Per-repo configuration for multi-repo workspace creation +export const repoWorkspaceConfigSchema = z.object({ + mainRepoPath: z.string(), + folderId: z.string(), + folderPath: z.string(), + mode: workspaceModeSchema, + branch: z.string().optional(), + useExistingBranch: z.boolean().optional(), + label: z.string().optional(), +}); + +export type RepoWorkspaceConfig = z.infer; + // Input schemas export const createWorkspaceInput = z .object({ @@ -43,6 +58,9 @@ export const createWorkspaceInput = z mode: workspaceModeSchema, branch: z.string().optional(), useExistingBranch: z.boolean().optional(), + label: z.string().optional(), + /** Additional repos for multi-repo tasks. Each gets its own workspace. */ + additionalRepos: z.array(repoWorkspaceConfigSchema).optional(), }) .refine( (data) => @@ -67,13 +85,16 @@ export const getWorkspaceInfoInput = z.object({ }); // Output schemas -export const createWorkspaceOutput = workspaceInfoSchema; +export const createWorkspaceOutput = z.array(workspaceInfoSchema); export const verifyWorkspaceOutput = z.object({ exists: z.boolean(), missingPath: z.string().optional(), }); -export const getWorkspaceInfoOutput = workspaceInfoSchema.nullable(); -export const getAllWorkspacesOutput = z.record(z.string(), workspaceSchema); +export const getWorkspaceInfoOutput = z.array(workspaceInfoSchema); +export const getAllWorkspacesOutput = z.record( + z.string(), + z.array(workspaceSchema), +); export const workspaceErrorPayload = z.object({ taskId: z.string(), diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 0a6200aec..a33eafa61 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -45,14 +45,20 @@ import type { const execFileAsync = promisify(execFile); type TaskAssociation = - | { taskId: string; folderId: string; mode: "local" } - | { taskId: string; folderId: string | null; mode: "cloud" } + | { taskId: string; folderId: string; mode: "local"; label: string | null } + | { + taskId: string; + folderId: string | null; + mode: "cloud"; + label: string | null; + } | { taskId: string; folderId: string; mode: "worktree"; worktree: string; branchName: string | null; + label: string | null; }; async function hasAnyFiles(repoPath: string): Promise { @@ -132,40 +138,48 @@ export class WorkspaceService extends TypedEventEmitter @inject(MAIN_TOKENS.ProvisioningService) private provisioningService!: ProvisioningService; - private creatingWorkspaces = new Map>(); + private creatingWorkspaces = new Map>(); private branchWatcherInitialized = false; - private findTaskAssociation(taskId: string): TaskAssociation | null { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (!workspace) return null; + private findTaskAssociations(taskId: string): TaskAssociation[] { + const allWorkspaces = this.workspaceRepo.findAllByTaskId(taskId); + const result: TaskAssociation[] = []; - if (workspace.mode === "cloud") { - return { - taskId, - folderId: workspace.repositoryId, - mode: "cloud", - }; - } + for (const workspace of allWorkspaces) { + if (workspace.mode === "cloud") { + result.push({ + taskId, + folderId: workspace.repositoryId, + mode: "cloud", + label: workspace.label, + }); + continue; + } - if (!workspace.repositoryId) return null; + if (!workspace.repositoryId) continue; - if (workspace.mode === "worktree") { - const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); - if (!worktree) return null; - return { - taskId, - folderId: workspace.repositoryId, - mode: "worktree", - worktree: worktree.name, - branchName: null, - }; + if (workspace.mode === "worktree") { + const worktree = this.worktreeRepo.findByWorkspaceId(workspace.id); + if (!worktree) continue; + result.push({ + taskId, + folderId: workspace.repositoryId, + mode: "worktree", + worktree: worktree.name, + branchName: null, + label: workspace.label, + }); + } else { + result.push({ + taskId, + folderId: workspace.repositoryId, + mode: "local", + label: workspace.label, + }); + } } - return { - taskId, - folderId: workspace.repositoryId, - mode: "local", - }; + return result; } private getFolderPath(folderId: string): string | null { @@ -183,6 +197,7 @@ export class WorkspaceService extends TypedEventEmitter taskId: workspace.taskId, folderId: workspace.repositoryId, mode: "cloud", + label: workspace.label, }); continue; } @@ -198,12 +213,14 @@ export class WorkspaceService extends TypedEventEmitter mode: "worktree", worktree: worktree.name, branchName: null, + label: workspace.label, }); } else { result.push({ taskId: workspace.taskId, folderId: workspace.repositoryId, mode: "local", + label: workspace.label, }); } } @@ -333,7 +350,9 @@ export class WorkspaceService extends TypedEventEmitter } } - async createWorkspace(options: CreateWorkspaceInput): Promise { + async createWorkspace( + options: CreateWorkspaceInput, + ): Promise { // Prevent concurrent workspace creation for the same task const existingPromise = this.creatingWorkspaces.get(options.taskId); if (existingPromise) { @@ -343,7 +362,7 @@ export class WorkspaceService extends TypedEventEmitter return existingPromise; } - const promise = this.doCreateWorkspace(options); + const promise = this.doCreateAllWorkspaces(options); this.creatingWorkspaces.set(options.taskId, promise); try { @@ -353,8 +372,63 @@ export class WorkspaceService extends TypedEventEmitter } } - private async doCreateWorkspace( + private async doCreateAllWorkspaces( options: CreateWorkspaceInput, + ): Promise { + // Build the list of repo configs: first repo from top-level fields, then additionalRepos + const primaryConfig = { + taskId: options.taskId, + mainRepoPath: options.mainRepoPath, + folderPath: options.folderPath, + folderId: options.folderId, + mode: options.mode, + branch: options.branch, + useExistingBranch: options.useExistingBranch, + label: options.label, + }; + + const results: WorkspaceInfo[] = []; + + // Check if workspaces already exist for this task + const existingWorkspaces = await this.getWorkspaceInfo(options.taskId); + if (existingWorkspaces.length > 0) { + log.info( + `Workspaces already exist for task ${options.taskId}, returning existing`, + ); + return existingWorkspaces; + } + + // Create workspace for primary repo + const primaryResult = await this.doCreateWorkspace(primaryConfig); + results.push(primaryResult); + + // Create workspaces for additional repos + if (options.additionalRepos) { + for (const repoConfig of options.additionalRepos) { + try { + const result = await this.doCreateWorkspace({ + taskId: options.taskId, + ...repoConfig, + }); + results.push(result); + } catch (error) { + log.error( + `Failed to create workspace for additional repo ${repoConfig.label ?? repoConfig.folderPath}:`, + error, + ); + this.emitWorkspaceError( + options.taskId, + `Failed to set up repo ${repoConfig.label ?? repoConfig.folderPath}: ${String(error)}`, + ); + } + } + } + + return results; + } + + private async doCreateWorkspace( + options: CreateWorkspaceInput & { label?: string }, ): Promise { const { taskId, @@ -363,18 +437,11 @@ export class WorkspaceService extends TypedEventEmitter mode, branch, useExistingBranch, + label, } = options; - const existingWorkspace = await this.getWorkspaceInfo(taskId); - if (existingWorkspace) { - log.info( - `Workspace already exists for task ${taskId}, returning existing workspace`, - ); - return existingWorkspace; - } - log.info( - `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode}, useExistingBranch: ${useExistingBranch})`, + `Creating workspace for task ${taskId} in ${mainRepoPath} (mode: ${mode}, label: ${label ?? "none"}, useExistingBranch: ${useExistingBranch})`, ); const repository = this.repositoryRepo.findByPath(mainRepoPath); @@ -385,6 +452,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, repositoryId, mode: "cloud", + label: label ?? null, }); return { @@ -392,6 +460,7 @@ export class WorkspaceService extends TypedEventEmitter mode, worktree: null, branchName: null, + label: label ?? null, }; } @@ -425,6 +494,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, repositoryId, mode: "local", + label: label ?? null, }); const localBranch = await getBranchFromPath(folderPath); @@ -433,6 +503,7 @@ export class WorkspaceService extends TypedEventEmitter mode, worktree: null, branchName: localBranch, + label: label ?? null, }; } @@ -526,6 +597,7 @@ export class WorkspaceService extends TypedEventEmitter taskId, repositoryId, mode: "worktree", + label: label ?? null, }); this.worktreeRepo.create({ @@ -539,69 +611,65 @@ export class WorkspaceService extends TypedEventEmitter mode, worktree, branchName: worktree.branchName, + label: label ?? null, }; } async deleteWorkspace(taskId: string, mainRepoPath: string): Promise { - log.info(`Deleting workspace for task ${taskId}`); + log.info(`Deleting workspaces for task ${taskId}`); - const association = this.findTaskAssociation(taskId); - if (!association) { - log.warn(`No workspace found for task ${taskId}`); + const associations = this.findTaskAssociations(taskId); + if (associations.length === 0) { + log.warn(`No workspaces found for task ${taskId}`); return; } - if (association.mode === "cloud") { - this.removeTaskAssociation(taskId); - log.info(`Cloud workspace deleted for task ${taskId}`); - return; - } - - const folderId = association.folderId; - const folderPath = this.getFolderPath(folderId); - if (!folderPath) { - log.warn(`No folder found for task ${taskId}, removing association only`); - this.removeTaskAssociation(taskId); - return; - } + await this.agentService.cancelSessionsByTaskId(taskId); + this.processTracking.killByTaskId(taskId); - let worktreePath: string | null = null; + for (const association of associations) { + if (association.mode === "cloud") { + continue; + } - if (association.mode === "worktree") { - worktreePath = deriveWorktreePath(folderPath, association.worktree); - } + if (!association.folderId) continue; - await this.agentService.cancelSessionsByTaskId(taskId); - this.processTracking.killByTaskId(taskId); + const folderPath = this.getFolderPath(association.folderId); + if (!folderPath) continue; - if (association.mode === "worktree" && worktreePath) { - await this.cleanupWorktree( - taskId, - mainRepoPath, - worktreePath, - association.branchName, - ); + if (association.mode === "worktree") { + const worktreePath = deriveWorktreePath( + folderPath, + association.worktree, + ); + await this.cleanupWorktree( + taskId, + mainRepoPath, + worktreePath, + association.branchName, + ); - const otherWorkspacesForFolder = this.getAllTaskAssociations().filter( - (a) => - a.folderId === folderId && - a.taskId !== taskId && - a.mode === "worktree", - ); + const otherWorkspacesForFolder = this.getAllTaskAssociations().filter( + (a) => + a.folderId === association.folderId && + a.taskId !== taskId && + a.mode === "worktree", + ); - if (otherWorkspacesForFolder.length === 0) { - await this.cleanupRepoWorktreeFolder(folderPath); + if (otherWorkspacesForFolder.length === 0) { + await this.cleanupRepoWorktreeFolder(folderPath); + } } } - this.removeTaskAssociation(taskId); + this.removeTaskAssociations(taskId); - log.info(`Workspace deleted for task ${taskId}`); + log.info(`Workspaces deleted for task ${taskId}`); } - private removeTaskAssociation(taskId: string): void { - const workspace = this.workspaceRepo.findByTaskId(taskId); - if (workspace) { + private removeTaskAssociations(taskId: string): void { + const allWorkspaces = this.workspaceRepo.findAllByTaskId(taskId); + for (const workspace of allWorkspaces) { this.worktreeRepo.deleteByWorkspaceId(workspace.id); } this.workspaceRepo.deleteByTaskId(taskId); @@ -661,105 +729,127 @@ export class WorkspaceService extends TypedEventEmitter async verifyWorkspaceExists( taskId: string, ): Promise<{ exists: boolean; missingPath?: string }> { - const association = this.findTaskAssociation(taskId); - if (!association) { + const associations = this.findTaskAssociations(taskId); + if (associations.length === 0) { return { exists: false }; } - if (association.mode === "cloud") { - return { exists: true }; - } + // All workspaces must exist for the task to be considered valid + for (const association of associations) { + if (association.mode === "cloud") { + continue; + } - const folderPath = this.getFolderPath(association.folderId); - if (!folderPath) { - this.removeTaskAssociation(taskId); - return { exists: false, missingPath: "(folder not found)" }; - } + if (!association.folderId) { + this.removeTaskAssociations(taskId); + return { exists: false, missingPath: "(folder not found)" }; + } - if (association.mode === "local") { - const exists = fs.existsSync(folderPath); - if (!exists) { - log.info( - `Folder for task ${taskId} no longer exists, removing association`, - ); - this.removeTaskAssociation(taskId); - return { exists: false, missingPath: folderPath }; + const folderPath = this.getFolderPath(association.folderId); + if (!folderPath) { + this.removeTaskAssociations(taskId); + return { exists: false, missingPath: "(folder not found)" }; } - return { exists: true }; - } - if (association.mode === "worktree") { - const worktreePath = deriveWorktreePath(folderPath, association.worktree); - const exists = fs.existsSync(worktreePath); - if (!exists) { - log.info( - `Worktree for task ${taskId} no longer exists, removing association`, + if (association.mode === "local") { + const exists = fs.existsSync(folderPath); + if (!exists) { + log.info( + `Folder for task ${taskId} no longer exists, removing associations`, + ); + this.removeTaskAssociations(taskId); + return { exists: false, missingPath: folderPath }; + } + } + + if (association.mode === "worktree") { + const worktreePath = deriveWorktreePath( + folderPath, + association.worktree, ); - this.removeTaskAssociation(taskId); - return { exists: false, missingPath: worktreePath }; + const exists = fs.existsSync(worktreePath); + if (!exists) { + log.info( + `Worktree for task ${taskId} no longer exists, removing associations`, + ); + this.removeTaskAssociations(taskId); + return { exists: false, missingPath: worktreePath }; + } } - return { exists: true }; } - return { exists: false }; + return { exists: true }; } - async getWorkspaceInfo(taskId: string): Promise { - const association = this.findTaskAssociation(taskId); - if (!association) { - return null; + async getWorkspaceInfo(taskId: string): Promise { + const associations = this.findTaskAssociations(taskId); + if (associations.length === 0) { + return []; } - if (association.mode === "cloud") { - return { - taskId, - mode: "cloud", - worktree: null, - branchName: null, - }; - } + const results: WorkspaceInfo[] = []; - const folderPath = association.folderId - ? this.getFolderPath(association.folderId) - : null; - let worktreeInfo: WorktreeInfo | null = null; - let branchName: string | null = null; + for (const association of associations) { + if (association.mode === "cloud") { + results.push({ + taskId, + mode: "cloud", + worktree: null, + branchName: null, + label: association.label, + }); + continue; + } - if (association.mode === "worktree") { - if (folderPath) { - const worktreePath = deriveWorktreePath( - folderPath, - association.worktree, - ); - const gitBranch = await getBranchFromPath(worktreePath); - branchName = gitBranch ?? association.branchName; - worktreeInfo = { - worktreePath, - worktreeName: association.worktree, - branchName, - baseBranch: "main", - createdAt: new Date().toISOString(), - }; + const folderPath = association.folderId + ? this.getFolderPath(association.folderId) + : null; + let worktreeInfo: WorktreeInfo | null = null; + let branchName: string | null = null; + + if (association.mode === "worktree") { + if (folderPath) { + const worktreePath = deriveWorktreePath( + folderPath, + association.worktree, + ); + const gitBranch = await getBranchFromPath(worktreePath); + branchName = gitBranch ?? association.branchName; + worktreeInfo = { + worktreePath, + worktreeName: association.worktree, + branchName, + baseBranch: "main", + createdAt: new Date().toISOString(), + }; + } + } else if (association.mode === "local" && folderPath) { + branchName = await getBranchFromPath(folderPath); } - } else if (association.mode === "local" && folderPath) { - branchName = await getBranchFromPath(folderPath); + + results.push({ + taskId, + mode: association.mode, + worktree: worktreeInfo, + branchName, + label: association.label, + }); } - return { - taskId, - mode: association.mode, - worktree: worktreeInfo, - branchName, - }; + return results; } - async getAllWorkspaces(): Promise> { + async getAllWorkspaces(): Promise> { const associations = this.getAllTaskAssociations(); - const workspaces: Record = {}; + const workspaces: Record = {}; for (const assoc of associations) { + if (!workspaces[assoc.taskId]) { + workspaces[assoc.taskId] = []; + } + if (assoc.mode === "cloud") { - workspaces[assoc.taskId] = { + workspaces[assoc.taskId].push({ taskId: assoc.taskId, folderId: assoc.folderId ?? "", folderPath: "", @@ -768,8 +858,9 @@ export class WorkspaceService extends TypedEventEmitter worktreeName: null, branchName: null, baseBranch: null, + label: assoc.label, createdAt: new Date().toISOString(), - }; + }); continue; } @@ -795,7 +886,7 @@ export class WorkspaceService extends TypedEventEmitter branchName = await getBranchFromPath(branchPath); } - workspaces[assoc.taskId] = { + workspaces[assoc.taskId].push({ taskId: assoc.taskId, folderId: assoc.folderId, folderPath, @@ -804,8 +895,9 @@ export class WorkspaceService extends TypedEventEmitter worktreeName, branchName, baseBranch: null, + label: assoc.label, createdAt: new Date().toISOString(), - }; + }); } return workspaces; @@ -823,9 +915,14 @@ export class WorkspaceService extends TypedEventEmitter ): Promise { log.info(`Promoting task ${taskId} to worktree mode on branch ${branch}`); - const association = this.findTaskAssociation(taskId); + const associations = this.findTaskAssociations(taskId); + // Find the local-mode association for this specific repo path + const association = associations.find((a) => { + if (a.mode !== "local" || !a.folderId) return false; + return this.getFolderPath(a.folderId) === mainRepoPath; + }); if (!association) { - log.warn(`No association found for task ${taskId}`); + log.warn(`No local association found for task ${taskId}`); return null; } @@ -866,7 +963,11 @@ export class WorkspaceService extends TypedEventEmitter throw new Error(`Failed to promote task to worktree: ${String(error)}`); } - const workspace = this.workspaceRepo.findByTaskId(taskId); + // Find the specific workspace row for this repo and promote it + const allWorkspaces = this.workspaceRepo.findAllByTaskId(taskId); + const workspace = allWorkspaces.find( + (ws) => ws.repositoryId === association.folderId, + ); if (workspace) { this.workspaceRepo.updateMode(taskId, "worktree"); this.worktreeRepo.create({ diff --git a/apps/code/src/main/services/workspace/workspaceEnv.ts b/apps/code/src/main/services/workspace/workspaceEnv.ts index cae998740..6d04ee2be 100644 --- a/apps/code/src/main/services/workspace/workspaceEnv.ts +++ b/apps/code/src/main/services/workspace/workspaceEnv.ts @@ -8,6 +8,12 @@ export interface WorkspaceEnvContext { worktreePath: string | null; worktreeName: string | null; mode: WorkspaceMode; + label?: string | null; +} + +export interface MultiRepoEnvContext { + taskId: string; + workspaces: WorkspaceEnvContext[]; } const PORT_BASE = 50000; @@ -71,3 +77,43 @@ export async function buildWorkspaceEnv( POSTHOG_CODE_WORKSPACE_PORTS_END: String(portAllocation.end), }; } + +/** + * Build env vars for multi-repo tasks. Includes indexed per-repo vars + * (POSTHOG_CODE_REPO_0_*, POSTHOG_CODE_REPO_1_*, etc.) alongside the + * legacy single-repo vars from the first workspace. + */ +export async function buildMultiRepoWorkspaceEnv( + context: MultiRepoEnvContext, +): Promise> { + const nonCloudWorkspaces = context.workspaces.filter( + (ws) => ws.mode !== "cloud", + ); + if (nonCloudWorkspaces.length === 0) return {}; + + // Legacy vars from first workspace + const legacyEnv = await buildWorkspaceEnv(nonCloudWorkspaces[0]); + + // Indexed per-repo vars + const repoEnv: Record = { + POSTHOG_CODE_REPO_COUNT: String(nonCloudWorkspaces.length), + }; + + for (let i = 0; i < nonCloudWorkspaces.length; i++) { + const ws = nonCloudWorkspaces[i]; + const prefix = `POSTHOG_CODE_REPO_${i}`; + const wsPath = ws.worktreePath ?? ws.folderPath; + const wsName = ws.label ?? ws.worktreeName ?? path.basename(ws.folderPath); + + repoEnv[`${prefix}_NAME`] = wsName; + repoEnv[`${prefix}_PATH`] = wsPath; + + try { + repoEnv[`${prefix}_BRANCH`] = (await getCurrentBranch(wsPath)) ?? ""; + } catch { + repoEnv[`${prefix}_BRANCH`] = ""; + } + } + + return { ...legacyEnv, ...repoEnv }; +} diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 49ff66b9f..90064c96b 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -444,6 +444,7 @@ export class PostHogAPIClient { Task, | "title" | "repository" + | "repositories" | "json_schema" | "origin_product" | "signal_report" @@ -490,7 +491,9 @@ export class PostHogAPIClient { return this.createTask({ description: task.description ?? "", title: task.title, - repository: task.repository, + // Use repositories array if available, fall back to legacy field + repositories: task.repositories, + repository: task.repositories?.length ? undefined : task.repository, json_schema: task.json_schema, origin_product: task.origin_product, github_integration: task.github_integration, diff --git a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts b/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts index 4dd162a5d..438340238 100644 --- a/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts +++ b/apps/code/src/renderer/features/code-editor/hooks/useCodeMirror.ts @@ -52,13 +52,15 @@ export function useCodeMirror(options: UseCodeMirrorOptions) { if (result.action.type === "external-app") { const fileName = filePath.split("/").pop() || "file"; - const workspaces = await workspaceApi.getAll(); + const allWorkspaces = await workspaceApi.getAll(); const workspace = - Object.values(workspaces).find( - (ws) => - (ws?.worktreePath && filePath.startsWith(ws.worktreePath)) || - (ws?.folderPath && filePath.startsWith(ws.folderPath)), - ) ?? null; + Object.values(allWorkspaces) + .flat() + .find( + (ws) => + (ws?.worktreePath && filePath.startsWith(ws.worktreePath)) || + (ws?.folderPath && filePath.startsWith(ws.folderPath)), + ) ?? null; await handleExternalAppAction( result.action.action, diff --git a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts index 02a158b7b..1dba9905b 100644 --- a/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts +++ b/apps/code/src/renderer/features/message-editor/suggestions/getSuggestions.ts @@ -1,6 +1,7 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; import { CODE_COMMANDS } from "@features/message-editor/commands"; import { getAvailableCommandsForTask } from "@features/sessions/stores/sessionStore"; +import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { fetchRepoFiles, pathToFileItem, @@ -67,28 +68,48 @@ export async function getFileSuggestions( sessionId: string, query: string, ): Promise { - const repoPath = useDraftStore.getState().contexts[sessionId]?.repoPath; + const context = useDraftStore.getState().contexts[sessionId]; + const repoPath = context?.repoPath; const absoluteMatch = getAbsolutePathSuggestion(query); if (!repoPath) { return absoluteMatch ? [absoluteMatch] : []; } - const { files, fzf } = await fetchRepoFiles(repoPath); - const matched = searchFiles(fzf, files, query); - - const results: FileSuggestionItem[] = matched.map((file) => ({ - id: file.path, - label: parentDirLabel(file.dir, file.name), - description: file.dir || undefined, - filename: file.name, - path: file.path, - })); + // Collect all repo paths: primary + additional workspaces for multi-repo tasks + const repoPaths = [repoPath]; + if (context?.taskId) { + const taskWorkspaces = await workspaceApi.getTaskWorkspaces(context.taskId); + for (const ws of taskWorkspaces.slice(1)) { + const wsPath = ws.worktreePath ?? ws.folderPath; + if (wsPath && wsPath !== repoPath) { + repoPaths.push(wsPath); + } + } + } - if ( - absoluteMatch && - !results.some((r) => `${repoPath}/${r.id}` === absoluteMatch.id) - ) { + // Search files across all repo paths in parallel + const allRepoResults = await Promise.all( + repoPaths.map(async (path) => { + const { files, fzf } = await fetchRepoFiles(path); + const matched = searchFiles(fzf, files, query); + const repoName = path.split("/").pop() ?? path; + return matched.map((file) => ({ + id: file.path, + label: parentDirLabel(file.dir, file.name), + description: + repoPaths.length > 1 + ? `${repoName}/${file.dir || ""}` + : file.dir || undefined, + filename: file.name, + path: file.path, + })); + }), + ); + + const results: FileSuggestionItem[] = allRepoResults.flat(); + + if (absoluteMatch && !results.some((r) => r.id === absoluteMatch.id)) { results.unshift(absoluteMatch); } diff --git a/apps/code/src/renderer/features/panels/components/DraggableTab.tsx b/apps/code/src/renderer/features/panels/components/DraggableTab.tsx index 120342b52..196039209 100644 --- a/apps/code/src/renderer/features/panels/components/DraggableTab.tsx +++ b/apps/code/src/renderer/features/panels/components/DraggableTab.tsx @@ -95,13 +95,15 @@ export const DraggableTab: React.FC = ({ if (filePath) { const repoPath = tabData.type === "file" ? tabData.repoPath : undefined; - const workspaces = await workspaceApi.getAll(); + const allWorkspaces = await workspaceApi.getAll(); const workspace = repoPath - ? (Object.values(workspaces).find( - (ws) => - ws?.worktreePath === repoPath || - ws?.folderPath === repoPath, - ) ?? null) + ? (Object.values(allWorkspaces) + .flat() + .find( + (ws) => + ws?.worktreePath === repoPath || + ws?.folderPath === repoPath, + ) ?? null) : null; await handleExternalAppAction( diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 4d97a7868..ea9f8d1de 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -70,6 +70,8 @@ interface AuthCredentials { export interface ConnectParams { task: Task; repoPath: string; + /** Additional repo paths for multi-repo tasks (passed as additionalDirectories to agent). */ + additionalRepoPaths?: string[]; initialPrompt?: ContentBlock[]; executionMode?: ExecutionMode; adapter?: "claude" | "codex"; @@ -178,6 +180,7 @@ export class SessionService { const { task, repoPath, + additionalRepoPaths, initialPrompt, executionMode, adapter, @@ -282,6 +285,7 @@ export class SessionService { adapter, model, reasoningLevel, + additionalRepoPaths, ); } } catch (error) { @@ -537,6 +541,7 @@ export class SessionService { adapter?: "claude" | "codex", model?: string, reasoningLevel?: string, + additionalRepoPaths?: string[], ): Promise { const { client } = auth; if (!client) { @@ -564,6 +569,7 @@ export class SessionService { ? (reasoningLevel as EffortLevel) : undefined, model: preferredModel, + additionalDirectories: additionalRepoPaths, }); const session = this.createBaseSession(taskRun.id, taskId, taskTitle); diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index fd63c5d73..4a894ec33 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -107,6 +107,7 @@ function TaskRow({ isPinned={task.isPinned} needsPermission={task.needsPermission} taskRunStatus={task.taskRunStatus} + additionalRepositories={task.additionalRepositories} timestamp={timestamp} onClick={onClick} onDoubleClick={onDoubleClick} diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index ca1e88037..59b7a341b 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -15,6 +15,11 @@ import { selectIsFocusedOnWorktree, useFocusStore } from "@stores/focusStore"; import { useCallback, useEffect, useRef, useState } from "react"; import { SidebarItem } from "../SidebarItem"; +interface AdditionalRepo { + fullPath: string; + name: string; +} + interface TaskItemProps { depth?: number; taskId: string; @@ -35,6 +40,8 @@ interface TaskItemProps { | "cancelled"; timestamp?: number; isEditing?: boolean; + /** Additional repos for multi-repo tasks (renders +N badge). */ + additionalRepositories?: AdditionalRepo[]; onClick: () => void; onDoubleClick?: () => void; onContextMenu: (e: React.MouseEvent) => void; @@ -186,6 +193,7 @@ export function TaskItem({ taskRunStatus, timestamp, isEditing = false, + additionalRepositories, onClick, onDoubleClick, onContextMenu, @@ -254,6 +262,18 @@ export function TaskItem({ ) : null; + const multiRepoBadge = + additionalRepositories && additionalRepositories.length > 0 ? ( + r.name).join(", ")}`} + side="right" + > + + +{additionalRepositories.length} + + + ) : null; + const toolbar = onArchive || onTogglePin ? ( + {multiRepoBadge} {timestampNode} {toolbar} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index 24a582062..98c81b621 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -2,8 +2,15 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; import { useSessions } from "@features/sessions/stores/sessionStore"; import { useSuspendedTaskIds } from "@features/suspension/hooks/useSuspendedTaskIds"; import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; -import { getTaskRepository, parseRepository } from "@renderer/utils/repository"; +import { + useAllWorkspaces, + useWorkspaces, +} from "@features/workspace/hooks/useWorkspace"; +import type { Workspace } from "@main/services/workspace/schemas"; +import { + getTaskRepositories, + parseRepository, +} from "@renderer/utils/repository"; import type { Task } from "@shared/types"; import { useEffect, useMemo, useRef } from "react"; import { useSidebarStore } from "../stores/sidebarStore"; @@ -26,6 +33,8 @@ export interface TaskData { isPinned: boolean; needsPermission: boolean; repository: TaskRepositoryInfo | null; + /** Additional repositories beyond the primary (for multi-repo tasks). */ + additionalRepositories: TaskRepositoryInfo[]; isSuspended: boolean; folderId?: string; taskRunStatus?: @@ -74,26 +83,50 @@ interface UseSidebarDataProps { activeView: ViewState; } -function getRepositoryInfo( +function repoStringToInfo(repo: string): TaskRepositoryInfo { + const parsed = parseRepository(repo); + return { + fullPath: repo, + name: parsed?.repoName ?? repo, + }; +} + +function getRepositoryInfos( task: Task, folderPath?: string, -): TaskRepositoryInfo | null { - const repository = getTaskRepository(task); - if (repository) { - const parsed = parseRepository(repository); - return { - fullPath: repository, - name: parsed?.repoName ?? repository, - }; + allWorkspacesForTask?: Workspace[], +): { primary: TaskRepositoryInfo | null; additional: TaskRepositoryInfo[] } { + const allRepos = getTaskRepositories(task); + + if (allRepos.length > 0) { + const primary = repoStringToInfo(allRepos[0]); + const additional = allRepos.slice(1).map(repoStringToInfo); + return { primary, additional }; } + if (folderPath) { const name = folderPath.split("/").pop() ?? folderPath; - return { - fullPath: folderPath, - name, - }; + const primary: TaskRepositoryInfo = { fullPath: folderPath, name }; + + // Derive additional repos from local workspace data when the API + // doesn't have a repositories array yet + const additional: TaskRepositoryInfo[] = []; + if (allWorkspacesForTask && allWorkspacesForTask.length > 1) { + for (const ws of allWorkspacesForTask.slice(1)) { + const wsPath = ws.folderPath; + if (wsPath) { + additional.push({ + fullPath: wsPath, + name: wsPath.split("/").pop() ?? wsPath, + }); + } + } + } + + return { primary, additional }; } - return null; + + return { primary: null, additional: [] }; } function getSortValue(task: TaskData, sortMode: SortMode): number { @@ -150,6 +183,7 @@ export function useSidebarData({ showAllUsers, }); const { data: workspaces, isFetched: isWorkspacesFetched } = useWorkspaces(); + const { data: allWorkspacesMap } = useAllWorkspaces(); const archivedTaskIds = useArchivedTaskIds(); const suspendedTaskIds = useSuspendedTaskIds(); const isLoading = isLoadingTasks || !isWorkspacesFetched; @@ -208,6 +242,12 @@ export function useSidebarData({ const isUnread = taskLastViewedAt != null && lastActivityAt > taskLastViewedAt; + const { primary, additional } = getRepositoryInfos( + task, + workspace?.folderPath, + allWorkspacesMap?.[task.id], + ); + return { id: task.id, title: task.title, @@ -218,7 +258,8 @@ export function useSidebarData({ isPinned: pinnedTaskIds.has(task.id), isSuspended: suspendedTaskIds.has(task.id), needsPermission: (session?.pendingPermissions?.size ?? 0) > 0, - repository: getRepositoryInfo(task, workspace?.folderPath), + repository: primary, + additionalRepositories: additional, folderId: workspace?.folderId || undefined, taskRunStatus: task.latest_run?.status, taskRunEnvironment: task.latest_run?.environment, @@ -231,6 +272,7 @@ export function useSidebarData({ suspendedTaskIds, sessionByTaskId, workspaces, + allWorkspacesMap, ]); const pinnedTasks = useMemo(() => { diff --git a/apps/code/src/renderer/features/task-detail/components/AdditionalRepoRow.tsx b/apps/code/src/renderer/features/task-detail/components/AdditionalRepoRow.tsx new file mode 100644 index 000000000..93f088a22 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/components/AdditionalRepoRow.tsx @@ -0,0 +1,70 @@ +import { FolderPicker } from "@features/folder-picker/components/FolderPicker"; +import { BranchSelector } from "@features/git-interaction/components/BranchSelector"; +import { useGitQueries } from "@features/git-interaction/hooks/useGitQueries"; +import { X } from "@phosphor-icons/react"; +import { Flex } from "@radix-ui/themes"; +import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; + +export interface AdditionalRepoConfig { + id: string; + directory: string; + mode: WorkspaceMode; + branch: string | null; +} + +interface AdditionalRepoRowProps { + config: AdditionalRepoConfig; + onChange: (config: AdditionalRepoConfig) => void; + onRemove: () => void; + disabled?: boolean; +} + +export function AdditionalRepoRow({ + config, + onChange, + onRemove, + disabled, +}: AdditionalRepoRowProps) { + const { currentBranch, branchLoading, defaultBranch } = useGitQueries( + config.directory, + ); + + return ( + + + onChange({ ...config, directory: dir, branch: null }) + } + placeholder="Add repository…" + size="1" + /> + onChange({ ...config, mode })} + size="1" + overrideModes={["worktree", "local"]} + /> + {config.directory && ( + onChange({ ...config, branch })} + /> + )} + + + ); +} diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 4cf514e0c..81f04bf55 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -33,6 +33,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; +import { + type AdditionalRepoConfig, + AdditionalRepoRow, +} from "./AdditionalRepoRow"; import { TaskInputEditor } from "./TaskInputEditor"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; @@ -84,6 +88,9 @@ export function TaskInput({ ); const [selectedDirectory, setSelectedDirectory] = useState(""); + const [additionalRepos, setAdditionalRepos] = useState< + AdditionalRepoConfig[] + >([]); const workspaceMode = lastUsedWorkspaceMode || "local"; const adapter = lastUsedAdapter; @@ -275,6 +282,7 @@ export function TaskInput({ effectiveWorkspaceMode === "cloud" && selectedCloudEnvId ? selectedCloudEnvId : undefined, + additionalRepos: additionalRepos.length > 0 ? additionalRepos : undefined, }); const handleCycleMode = useCallback(() => { @@ -413,75 +421,113 @@ export function TaskInput({ zIndex: 1, }} > - - {workspaceMode === "cloud" ? ( - + + {workspaceMode === "cloud" ? ( + + ) : ( + + )} + - ) : ( - - )} - - - {workspaceMode === "worktree" && ( - + )} + {cloudRegion === "dev" && ( + + + + Dev + + + )} + + {additionalRepos.map((repo) => ( + + setAdditionalRepos((prev) => + prev.map((r) => (r.id === repo.id ? updated : r)), + ) + } + onRemove={() => + setAdditionalRepos((prev) => + prev.filter((r) => r.id !== repo.id), + ) + } disabled={isCreatingTask} /> - )} - {cloudRegion === "dev" && ( - - - - Dev - - + ))} + {workspaceMode !== "cloud" && selectedDirectory && ( + )} diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index cf8fba1aa..26dfda787 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -20,6 +20,12 @@ import type { TaskCreationInput, TaskService } from "../service/service"; const log = logger.scope("task-creation"); +interface AdditionalRepoOption { + directory: string; + mode: WorkspaceMode; + branch: string | null; +} + interface UseTaskCreationOptions { editorRef: React.RefObject; selectedDirectory: string; @@ -34,6 +40,7 @@ interface UseTaskCreationOptions { reasoningLevel?: string; environmentId?: string | null; sandboxEnvironmentId?: string; + additionalRepos?: AdditionalRepoOption[]; onTaskCreated?: (task: Task) => void; } @@ -57,8 +64,18 @@ function prepareTaskInput( reasoningLevel?: string; environmentId?: string | null; sandboxEnvironmentId?: string; + additionalRepos?: AdditionalRepoOption[]; }, ): TaskCreationInput { + const validAdditionalRepos = options.additionalRepos + ?.filter((r) => r.directory) + .map((r) => ({ + repoPath: r.directory, + mode: r.mode, + branch: r.branch, + label: r.directory.split("/").pop(), + })); + return { content: contentToXml(content).trim(), filePaths: extractFilePaths(content), @@ -73,6 +90,10 @@ function prepareTaskInput( reasoningLevel: options.reasoningLevel, environmentId: options.environmentId ?? undefined, sandboxEnvironmentId: options.sandboxEnvironmentId, + additionalRepos: + validAdditionalRepos && validAdditionalRepos.length > 0 + ? validAdditionalRepos + : undefined, }; } @@ -101,6 +122,7 @@ export function useTaskCreation({ reasoningLevel, environmentId, sandboxEnvironmentId, + additionalRepos, onTaskCreated, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); @@ -149,6 +171,7 @@ export function useTaskCreation({ reasoningLevel, environmentId, sandboxEnvironmentId, + additionalRepos, }); if (executionMode) { @@ -197,6 +220,7 @@ export function useTaskCreation({ reasoningLevel, environmentId, sandboxEnvironmentId, + additionalRepos, invalidateTasks, navigateToTask, onTaskCreated, diff --git a/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts b/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts index d4f52225f..53bf191dd 100644 --- a/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts +++ b/apps/code/src/renderer/features/workspace/hooks/useWorkspace.ts @@ -36,22 +36,62 @@ function useInvalidateWorkspaceCaches() { ); } +/** + * Returns all workspaces grouped by task ID. + * Each task may have multiple workspaces (one per repo in multi-repo setups). + */ +export function useAllWorkspaces(): { + data: Record | undefined; + isFetched: boolean; +} { + const query = useWorkspacesQuery(); + return { data: query.data, isFetched: query.isFetched }; +} + +/** + * Returns the first workspace per task for backward-compatible consumers + * that expect a single workspace per task. + */ export function useWorkspaces(): { data: Record | undefined; isFetched: boolean; } { const query = useWorkspacesQuery(); - return { data: query.data, isFetched: query.isFetched }; + const data = useMemo(() => { + if (!query.data) return undefined; + const result: Record = {}; + for (const [taskId, workspaceArr] of Object.entries(query.data)) { + if (workspaceArr.length > 0) { + result[taskId] = workspaceArr[0]; + } + } + return result; + }, [query.data]); + return { data, isFetched: query.isFetched }; } -export function useWorkspace(taskId: string | undefined): Workspace | null { - const { data: workspaces } = useWorkspacesQuery(); +/** + * Returns all workspaces for a specific task. + */ +export function useTaskWorkspaces(taskId: string | undefined): Workspace[] { + const { data: allWorkspaces } = useWorkspacesQuery(); return useMemo( - () => workspaces?.[taskId ?? ""] ?? null, - [workspaces, taskId], + () => allWorkspaces?.[taskId ?? ""] ?? [], + [allWorkspaces, taskId], ); } +/** + * Returns the first workspace for a task (backward compatible). + */ +export function useWorkspace(taskId: string | undefined): Workspace | null { + const { data: allWorkspaces } = useWorkspacesQuery(); + return useMemo(() => { + const arr = allWorkspaces?.[taskId ?? ""]; + return arr?.[0] ?? null; + }, [allWorkspaces, taskId]); +} + export function useWorkspaceLoaded(): boolean { const { isFetched } = useWorkspacesQuery(); return isFetched; @@ -118,8 +158,8 @@ export function useEnsureWorkspace(): { const existing = queryClient.getQueryData( trpcReact.workspace.getAll.queryKey(), )?.[taskId]; - if (existing) { - return existing; + if (existing && existing.length > 0) { + return existing[0]; } const result = await createMutation.mutateAsync({ @@ -131,16 +171,15 @@ export function useEnsureWorkspace(): { branch: branch ?? undefined, }); - if (!result) { + if (!result || result.length === 0) { throw new Error("Failed to create workspace"); } await invalidateCaches(repoPath); - return ( - queryClient.getQueryData(trpcReact.workspace.getAll.queryKey())?.[ - taskId - ] ?? null - ); + const cached = queryClient.getQueryData( + trpcReact.workspace.getAll.queryKey(), + )?.[taskId]; + return cached?.[0] ?? null; }, [createMutation, queryClient, trpcReact, invalidateCaches], ); @@ -152,13 +191,21 @@ export function useEnsureWorkspace(): { } export const workspaceApi = { - async getAll(): Promise> { + async getAll(): Promise> { return (await trpcClient.workspace.getAll.query()) ?? {}; }, + /** Returns the first workspace for a task, or null. */ async get(taskId: string): Promise { const workspaces = await trpcClient.workspace.getAll.query(); - return workspaces?.[taskId] ?? null; + const arr = workspaces?.[taskId]; + return arr?.[0] ?? null; + }, + + /** Returns all workspaces for a task. */ + async getTaskWorkspaces(taskId: string): Promise { + const workspaces = await trpcClient.workspace.getAll.query(); + return workspaces?.[taskId] ?? []; }, async create(options: { @@ -168,6 +215,7 @@ export const workspaceApi = { folderPath: string; mode: WorkspaceMode; branch?: string; + label?: string; }) { return trpcClient.workspace.create.mutate(options); }, diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 79293799f..a9e8f8292 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -55,6 +55,13 @@ const sagaLogger: SagaLogger = { warn: (message, data) => log.warn(message, data), }; +export interface AdditionalRepoInput { + repoPath: string; + mode: WorkspaceMode; + branch?: string | null; + label?: string; +} + export interface TaskCreationInput { // For opening existing task taskId?: string; @@ -73,6 +80,8 @@ export interface TaskCreationInput { environmentId?: string; sandboxEnvironmentId?: string; signalReportId?: string; + /** Additional repos for multi-repo tasks. */ + additionalRepos?: AdditionalRepoInput[]; } export interface TaskCreationOutput { @@ -160,7 +169,35 @@ export class TaskCreationSaga extends Saga< this.resolveFolder(repoPath), ); - const workspaceInfo = await this.step({ + // Resolve additional repo folders in parallel + let additionalRepoConfigs: + | { + mainRepoPath: string; + folderId: string; + folderPath: string; + mode: WorkspaceMode; + branch?: string; + label?: string; + }[] + | undefined; + if (input.additionalRepos && input.additionalRepos.length > 0) { + const resolvedFolders = await Promise.all( + input.additionalRepos.map(async (repo) => ({ + folder: await this.resolveFolder(repo.repoPath), + repo, + })), + ); + additionalRepoConfigs = resolvedFolders.map(({ folder: f, repo }) => ({ + mainRepoPath: repo.repoPath, + folderId: f.id, + folderPath: repo.repoPath, + mode: (repo.mode ?? workspaceMode) as WorkspaceMode, + branch: repo.branch ?? undefined, + label: repo.label ?? repo.repoPath.split("/").pop(), + })); + } + + const workspaceInfos = await this.step({ name: "workspace_creation", execute: async () => { return trpcClient.workspace.create.mutate({ @@ -170,6 +207,10 @@ export class TaskCreationSaga extends Saga< folderPath: repoPath, mode: workspaceMode, branch: branch ?? undefined, + label: additionalRepoConfigs + ? repoPath.split("/").pop() + : undefined, + additionalRepos: additionalRepoConfigs, }); }, rollback: async () => { @@ -181,18 +222,22 @@ export class TaskCreationSaga extends Saga< }, }); - workspace = { - taskId: task.id, - folderId: folder.id, - folderPath: repoPath, - mode: workspaceMode, - worktreePath: workspaceInfo.worktree?.worktreePath ?? null, - worktreeName: workspaceInfo.worktree?.worktreeName ?? null, - branchName: workspaceInfo.worktree?.branchName ?? null, - baseBranch: workspaceInfo.worktree?.baseBranch ?? null, - createdAt: - workspaceInfo.worktree?.createdAt ?? new Date().toISOString(), - }; + // Use first workspace info for backward compat (single-repo tasks) + const workspaceInfo = workspaceInfos[0]; + workspace = workspaceInfo + ? { + taskId: task.id, + folderId: folder.id, + folderPath: repoPath, + mode: workspaceMode, + worktreePath: workspaceInfo.worktree?.worktreePath ?? null, + worktreeName: workspaceInfo.worktree?.worktreeName ?? null, + branchName: workspaceInfo.worktree?.branchName ?? null, + baseBranch: workspaceInfo.worktree?.baseBranch ?? null, + createdAt: + workspaceInfo.worktree?.createdAt ?? new Date().toISOString(), + } + : null; } else if (workspaceMode === "cloud") { await this.step({ name: "cloud_workspace_creation", @@ -221,7 +266,7 @@ export class TaskCreationSaga extends Saga< taskId: task.id, folderId: "", folderPath: "", - mode: "cloud", + mode: "cloud" as const, worktreePath: null, worktreeName: null, branchName: null, @@ -297,15 +342,28 @@ export class TaskCreationSaga extends Saga< ) : undefined; + // Resolve additional repo paths from multi-repo workspaces + let additionalRepoPaths: string[] | undefined; + if (!input.taskId) { + // New task: resolve from workspace creation results + const allWorkspaces = await trpcClient.workspace.getInfo.query({ + taskId: task.id, + }); + if (allWorkspaces.length > 1) { + additionalRepoPaths = allWorkspaces + .slice(1) + .map((ws) => ws.worktree?.worktreePath) + .filter((p): p is string => !!p); + } + } + await this.step({ name: "agent_session", execute: async () => { - // Fire-and-forget for both open and create paths. - // The UI handles "connecting" state with a spinner (TaskLogsPanel), - // so we don't need to block the saga on the full reconnect chain. const connectParams: ConnectParams = { task, repoPath: agentCwd ?? "", + additionalRepoPaths, }; if (initialPrompt) connectParams.initialPrompt = initialPrompt; if (input.executionMode) @@ -385,12 +443,37 @@ export class TaskCreationSaga extends Saga< } } + // Detect additional repos for multi-repo tasks + const additionalRepoNames: string[] = []; + if (input.additionalRepos) { + for (const repo of input.additionalRepos) { + const detected = await trpcClient.git.detectRepo.query({ + directoryPath: repo.repoPath, + }); + if (detected) { + additionalRepoNames.push( + `${detected.organization}/${detected.repository}`, + ); + } + } + } + + // Use repositories array when we have multiple repos + const hasMultipleRepos = repository && additionalRepoNames.length > 0; + return this.step({ name: "task_creation", execute: async () => { const result = await this.deps.posthogClient.createTask({ description: input.content ?? "", - repository: repository ?? undefined, + // Send repositories array for multi-repo, legacy field for single-repo + repositories: hasMultipleRepos + ? [ + { repository: repository! }, + ...additionalRepoNames.map((r) => ({ repository: r })), + ] + : undefined, + repository: hasMultipleRepos ? undefined : (repository ?? undefined), github_integration: input.workspaceMode === "cloud" ? input.githubIntegrationId diff --git a/apps/code/src/renderer/utils/repository.ts b/apps/code/src/renderer/utils/repository.ts index 60fd1029a..a109d0c38 100644 --- a/apps/code/src/renderer/utils/repository.ts +++ b/apps/code/src/renderer/utils/repository.ts @@ -12,6 +12,21 @@ export const parseRepository = ( return { organization: result[0], repoName: result[1] }; }; +/** Returns the first repository for a task (backward compat). */ export function getTaskRepository(task: Task): string | null { + if (task.repositories && task.repositories.length > 0) { + return task.repositories[0].repository; + } return task.repository ?? null; } + +/** Returns all repositories for a task. */ +export function getTaskRepositories(task: Task): string[] { + if (task.repositories && task.repositories.length > 0) { + return task.repositories.map((r) => r.repository); + } + if (task.repository) { + return [task.repository]; + } + return []; +} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 87a9f3619..db8df4e53 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -24,6 +24,11 @@ interface UserBasic { is_email_verified?: boolean | null; } +export interface TaskRepositoryConfig { + repository: string; // Format: "organization/repository" (e.g., "posthog/posthog-js") + github_integration?: number | null; +} + export interface Task { id: string; task_number: number | null; @@ -35,7 +40,10 @@ export interface Task { updated_at: string; created_by?: UserBasic | null; origin_product: string; - repository?: string | null; // Format: "organization/repository" (e.g., "posthog/posthog-js") + /** @deprecated Use `repositories` instead. Kept for backward compat with API. */ + repository?: string | null; + /** All repositories associated with this task. */ + repositories?: TaskRepositoryConfig[]; github_integration?: number | null; json_schema?: Record | null; signal_report?: string | null;