From 3684e2e5f5120078bb5d8977cb02ad26c40f123a Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Mon, 16 Mar 2026 17:04:26 -0700 Subject: [PATCH] feat(foundry): add manual task owner change via UI dropdown Add an owner dropdown to the Overview tab that lets users reassign task ownership to any organization member. The owner's GitHub credentials are used for git operations in the sandbox. Full-stack implementation: - Backend: changeTaskOwnerManually action on task actor, routed through org actor's changeWorkspaceTaskOwner action, with primaryUser schema columns on both task and org index tables - Client: changeOwner method on workspace client (mock + remote) - Frontend: owner dropdown in right sidebar Overview tab showing org members, with avatar and role display - Shared: TaskWorkspaceChangeOwnerInput type and primaryUser fields on workspace snapshot types Co-Authored-By: Claude Opus 4.6 --- .../organization/actions/task-mutations.ts | 4 + .../src/actors/organization/actions/tasks.ts | 11 + .../src/actors/organization/db/migrations.ts | 10 + .../src/actors/organization/db/schema.ts | 2 + .../backend/src/actors/task/db/migrations.ts | 16 ++ .../backend/src/actors/task/db/schema.ts | 18 ++ .../backend/src/actors/task/workflow/index.ts | 11 + .../backend/src/actors/task/workspace.ts | 210 +++++++++++++- foundry/packages/client/src/backend-client.ts | 7 + .../client/src/mock/backend-client.ts | 11 + .../client/src/mock/workspace-client.ts | 13 +- .../client/src/remote/workspace-client.ts | 6 + .../packages/client/src/workspace-client.ts | 2 + .../client/test/subscription-manager.test.ts | 4 + .../frontend/src/components/mock-layout.tsx | 30 +- .../components/mock-layout/right-sidebar.tsx | 266 +++++++++++++++++- .../src/components/mock-layout/sidebar.tsx | 17 ++ foundry/packages/shared/src/workspace.ts | 20 ++ 18 files changed, 647 insertions(+), 11 deletions(-) diff --git a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts index 73abea2..3049bb4 100644 --- a/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts +++ b/foundry/packages/backend/src/actors/organization/actions/task-mutations.ts @@ -64,6 +64,8 @@ function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) { branch: taskSummary.branch, pullRequestJson: JSON.stringify(taskSummary.pullRequest), sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), + primaryUserLogin: taskSummary.primaryUserLogin ?? null, + primaryUserAvatarUrl: taskSummary.primaryUserAvatarUrl ?? null, }; } @@ -78,6 +80,8 @@ export function taskSummaryFromRow(repoId: string, row: any): WorkspaceTaskSumma branch: row.branch ?? null, pullRequest: parseJsonValue(row.pullRequestJson, null), sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), + primaryUserLogin: row.primaryUserLogin ?? null, + primaryUserAvatarUrl: row.primaryUserAvatarUrl ?? null, }; } diff --git a/foundry/packages/backend/src/actors/organization/actions/tasks.ts b/foundry/packages/backend/src/actors/organization/actions/tasks.ts index 118ff15..c3794e1 100644 --- a/foundry/packages/backend/src/actors/organization/actions/tasks.ts +++ b/foundry/packages/backend/src/actors/organization/actions/tasks.ts @@ -10,6 +10,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceDiffInput, TaskWorkspaceRenameInput, @@ -217,6 +218,16 @@ export const organizationTaskActions = { void task.publishPr({}).catch(() => {}); }, + async changeWorkspaceTaskOwner(c: any, input: TaskWorkspaceChangeOwnerInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.changeOwner({ + primaryUserId: input.targetUserId, + primaryGithubLogin: input.targetUserName, + primaryGithubEmail: input.targetUserEmail, + primaryGithubAvatarUrl: null, + }); + }, + async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise { const task = await requireWorkspaceTask(c, input.repoId, input.taskId); void task.revertFile(input).catch(() => {}); diff --git a/foundry/packages/backend/src/actors/organization/db/migrations.ts b/foundry/packages/backend/src/actors/organization/db/migrations.ts index a7e8abc..6a19075 100644 --- a/foundry/packages/backend/src/actors/organization/db/migrations.ts +++ b/foundry/packages/backend/src/actors/organization/db/migrations.ts @@ -16,6 +16,12 @@ const journal = { tag: "0001_add_auth_and_task_tables", breakpoints: true, }, + { + idx: 2, + when: 1773984000000, + tag: "0002_add_task_owner_columns", + breakpoints: true, + }, ], } as const; @@ -165,6 +171,10 @@ CREATE TABLE \`task_summaries\` ( \`pull_request_json\` text, \`sessions_summary_json\` text DEFAULT '[]' NOT NULL ); +`, + m0002: `ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_login\` text; +--> statement-breakpoint +ALTER TABLE \`task_summaries\` ADD COLUMN \`primary_user_avatar_url\` text; `, } as const, }; diff --git a/foundry/packages/backend/src/actors/organization/db/schema.ts b/foundry/packages/backend/src/actors/organization/db/schema.ts index 5071a25..3978a5f 100644 --- a/foundry/packages/backend/src/actors/organization/db/schema.ts +++ b/foundry/packages/backend/src/actors/organization/db/schema.ts @@ -40,6 +40,8 @@ export const taskSummaries = sqliteTable("task_summaries", { branch: text("branch"), pullRequestJson: text("pull_request_json"), sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), + primaryUserLogin: text("primary_user_login"), + primaryUserAvatarUrl: text("primary_user_avatar_url"), }); export const organizationProfile = sqliteTable( diff --git a/foundry/packages/backend/src/actors/task/db/migrations.ts b/foundry/packages/backend/src/actors/task/db/migrations.ts index 1e6ff76..61b0dff 100644 --- a/foundry/packages/backend/src/actors/task/db/migrations.ts +++ b/foundry/packages/backend/src/actors/task/db/migrations.ts @@ -10,6 +10,12 @@ const journal = { tag: "0000_charming_maestro", breakpoints: true, }, + { + idx: 1, + when: 1773984000000, + tag: "0001_add_task_owner", + breakpoints: true, + }, ], } as const; @@ -65,6 +71,16 @@ CREATE TABLE \`task_workspace_sessions\` ( \`created_at\` integer NOT NULL, \`updated_at\` integer NOT NULL ); +`, + m0001: `CREATE TABLE \`task_owner\` ( + \`id\` integer PRIMARY KEY NOT NULL, + \`primary_user_id\` text, + \`primary_github_login\` text, + \`primary_github_email\` text, + \`primary_github_avatar_url\` text, + \`updated_at\` integer NOT NULL, + CONSTRAINT "task_owner_singleton_id_check" CHECK("task_owner"."id" = 1) +); `, } as const, }; diff --git a/foundry/packages/backend/src/actors/task/db/schema.ts b/foundry/packages/backend/src/actors/task/db/schema.ts index 651ff76..bdb7cf7 100644 --- a/foundry/packages/backend/src/actors/task/db/schema.ts +++ b/foundry/packages/backend/src/actors/task/db/schema.ts @@ -47,6 +47,24 @@ export const taskSandboxes = sqliteTable("task_sandboxes", { updatedAt: integer("updated_at").notNull(), }); +/** + * Single-row table tracking the primary user (owner) of this task. + * The owner's GitHub OAuth credentials are injected into the sandbox + * for git operations. Updated when a different user sends a message. + */ +export const taskOwner = sqliteTable( + "task_owner", + { + id: integer("id").primaryKey(), + primaryUserId: text("primary_user_id"), + primaryGithubLogin: text("primary_github_login"), + primaryGithubEmail: text("primary_github_email"), + primaryGithubAvatarUrl: text("primary_github_avatar_url"), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [check("task_owner_singleton_id_check", sql`${table.id} = 1`)], +); + /** * Coordinator index of workspace sessions within this task. * The task actor is the coordinator for sessions. Each row holds session diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index 69004ee..de25813 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -12,6 +12,7 @@ import { killWriteDbActivity, } from "./commands.js"; import { + changeTaskOwnerManually, changeWorkspaceModel, closeWorkspaceSession, createWorkspaceSession, @@ -176,6 +177,16 @@ export const taskCommandActions = { return { ok: true }; }, + async changeOwner(c: any, body: any) { + await changeTaskOwnerManually(c, { + primaryUserId: body.primaryUserId, + primaryGithubLogin: body.primaryGithubLogin, + primaryGithubEmail: body.primaryGithubEmail, + primaryGithubAvatarUrl: body.primaryGithubAvatarUrl ?? null, + }); + return { ok: true }; + }, + async createSession(c: any, body: any) { return await createWorkspaceSession(c, body?.model, body?.authSessionId); }, diff --git a/foundry/packages/backend/src/actors/task/workspace.ts b/foundry/packages/backend/src/actors/task/workspace.ts index 7505d01..ac2430f 100644 --- a/foundry/packages/backend/src/actors/task/workspace.ts +++ b/foundry/packages/backend/src/actors/task/workspace.ts @@ -19,7 +19,7 @@ import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; import { githubRepoFullNameFromRemote } from "../../services/repo.js"; // organization actions called directly (no queue) -import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; +import { task as taskTable, taskOwner, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; function emptyGitState() { @@ -123,6 +123,193 @@ function parseGitState(value: string | null | undefined): { fileChanges: Array { + const row = await c.db.select().from(taskOwner).where(eq(taskOwner.id, 1)).get(); + if (!row) { + return null; + } + return { + primaryUserId: row.primaryUserId ?? null, + primaryGithubLogin: row.primaryGithubLogin ?? null, + primaryGithubEmail: row.primaryGithubEmail ?? null, + primaryGithubAvatarUrl: row.primaryGithubAvatarUrl ?? null, + }; +} + +async function upsertTaskOwner( + c: any, + owner: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + const now = Date.now(); + await c.db + .insert(taskOwner) + .values({ + id: 1, + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: taskOwner.id, + set: { + primaryUserId: owner.primaryUserId, + primaryGithubLogin: owner.primaryGithubLogin, + primaryGithubEmail: owner.primaryGithubEmail, + primaryGithubAvatarUrl: owner.primaryGithubAvatarUrl, + updatedAt: now, + }, + }) + .run(); +} + +/** + * Inject the user's GitHub OAuth token into the sandbox as a git credential store file. + * Also configures git user.name and user.email so commits are attributed correctly. + * The credential file is overwritten on each owner swap. + * + * Race condition note: If User A sends a message and the agent starts a long git operation, + * then User B triggers an owner swap, the in-flight git process still has User A's credentials + * (already read from the credential store). The next git operation uses User B's credentials. + */ +async function injectGitCredentials(sandbox: any, login: string, email: string, token: string): Promise { + const script = [ + "set -euo pipefail", + `git config --global user.name ${JSON.stringify(login)}`, + `git config --global user.email ${JSON.stringify(email)}`, + `git config --global credential.helper 'store --file=/home/user/.git-token'`, + `printf '%s\\n' ${JSON.stringify(`https://${login}:${token}@github.com`)} > /home/user/.git-token`, + `chmod 600 /home/user/.git-token`, + ]; + const result = await sandbox.runProcess({ + command: "bash", + args: ["-lc", script.join("; ")], + cwd: "/", + timeoutMs: 30_000, + }); + if ((result.exitCode ?? 0) !== 0) { + logActorWarning("task", "git credential injection failed", { + exitCode: result.exitCode, + output: [result.stdout, result.stderr].filter(Boolean).join(""), + }); + } +} + +/** + * Resolves the current user's GitHub identity from their auth session. + * Returns null if the session is invalid or the user has no GitHub account. + */ +async function resolveGithubIdentity(authSessionId: string): Promise<{ + userId: string; + login: string; + email: string; + avatarUrl: string | null; + accessToken: string; +} | null> { + const authService = getBetterAuthService(); + const authState = await authService.getAuthState(authSessionId); + if (!authState?.user?.id) { + return null; + } + + const tokenResult = await authService.getAccessTokenForSession(authSessionId); + if (!tokenResult?.accessToken) { + return null; + } + + const githubAccount = authState.accounts?.find((account: any) => account.providerId === "github"); + if (!githubAccount) { + return null; + } + + // Resolve the GitHub login from the API since Better Auth only stores the + // numeric account ID, not the login username. + let login = authState.user.name ?? "unknown"; + let avatarUrl = authState.user.image ?? null; + try { + const resp = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${tokenResult.accessToken}`, + Accept: "application/vnd.github+json", + }, + }); + if (resp.ok) { + const ghUser = (await resp.json()) as { login?: string; avatar_url?: string }; + if (ghUser.login) { + login = ghUser.login; + } + if (ghUser.avatar_url) { + avatarUrl = ghUser.avatar_url; + } + } + } catch (error) { + console.warn("resolveGithubIdentity: failed to fetch GitHub user", error); + } + + return { + userId: authState.user.id, + login, + email: authState.user.email ?? `${githubAccount.accountId}@users.noreply.github.com`, + avatarUrl, + accessToken: tokenResult.accessToken, + }; +} + +/** + * Check if the task owner needs to swap, and if so, update the owner record + * and inject new git credentials into the sandbox. + * Returns true if an owner swap occurred. + */ +async function maybeSwapTaskOwner(c: any, authSessionId: string | null | undefined, sandbox: any | null): Promise { + if (!authSessionId) { + return false; + } + + const identity = await resolveGithubIdentity(authSessionId); + if (!identity) { + return false; + } + + const currentOwner = await readTaskOwner(c); + if (currentOwner?.primaryUserId === identity.userId) { + return false; + } + + await upsertTaskOwner(c, { + primaryUserId: identity.userId, + primaryGithubLogin: identity.login, + primaryGithubEmail: identity.email, + primaryGithubAvatarUrl: identity.avatarUrl, + }); + + if (sandbox) { + await injectGitCredentials(sandbox, identity.login, identity.email, identity.accessToken); + } + + return true; +} + +/** + * Manually change the task owner. Updates the owner record and broadcasts the + * change to subscribers. Git credentials are NOT injected here — they will be + * injected the next time the target user sends a message (auto-swap path). + */ +export async function changeTaskOwnerManually( + c: any, + input: { primaryUserId: string; primaryGithubLogin: string; primaryGithubEmail: string; primaryGithubAvatarUrl: string | null }, +): Promise { + await upsertTaskOwner(c, input); + await broadcastTaskUpdate(c); +} + export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean { if (status === "running") { return false; @@ -443,7 +630,7 @@ async function getTaskSandboxRuntime( */ let sandboxRepoPrepared = false; -async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean }): Promise { +async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean; authSessionId?: string | null }): Promise { if (!record.branchName) { throw new Error("cannot prepare a sandbox repo before the task branch exists"); } @@ -489,6 +676,12 @@ async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { ski throw new Error(`sandbox repo preparation failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`); } + // On first repo preparation, inject the task owner's git credentials into the sandbox + // so that push/commit operations are authenticated and attributed to the correct user. + if (!sandboxRepoPrepared && opts?.authSessionId) { + await maybeSwapTaskOwner(c, opts.authSessionId, sandbox); + } + sandboxRepoPrepared = true; } @@ -862,6 +1055,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P const activeSessionId = userTaskState.activeSessionId && sessions.some((meta) => meta.sessionId === userTaskState.activeSessionId) ? userTaskState.activeSessionId : null; + const owner = await readTaskOwner(c); + return { id: c.state.taskId, repoId: c.state.repoId, @@ -873,6 +1068,8 @@ export async function buildTaskSummary(c: any, authSessionId?: string | null): P pullRequest: record.pullRequest ?? null, activeSessionId, sessionsSummary: sessions.map((meta) => buildSessionSummary(meta, userTaskState.bySessionId.get(meta.sessionId))), + primaryUserLogin: owner?.primaryGithubLogin ?? null, + primaryUserAvatarUrl: owner?.primaryGithubAvatarUrl ?? null, }; } @@ -1212,7 +1409,14 @@ export async function sendWorkspaceMessage(c: any, sessionId: string, text: stri const runtime = await getTaskSandboxRuntime(c, record); // Skip git fetch on subsequent messages — the repo was already prepared during session // creation. This avoids a 5-30s network round-trip to GitHub on every prompt. - await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true }); + await ensureSandboxRepo(c, runtime.sandbox, record, { skipFetchIfPrepared: true, authSessionId }); + + // Check if the task owner needs to swap. If a different user is sending this message, + // update the owner record and inject their git credentials into the sandbox. + const ownerSwapped = await maybeSwapTaskOwner(c, authSessionId, runtime.sandbox); + if (ownerSwapped) { + await broadcastTaskUpdate(c); + } const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)].filter( Boolean, ); diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index 0903aa8..c2222cc 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -12,6 +12,7 @@ import type { TaskRecord, TaskSummary, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -110,6 +111,7 @@ interface OrganizationHandle { stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise; publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise; + changeWorkspaceTaskOwner(input: TaskWorkspaceChangeOwnerInput & AuthSessionScopedInput): Promise; revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(input: { repoId: string }): Promise; @@ -304,6 +306,7 @@ export interface BackendClient { stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise; + changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise; revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise; adminReloadGithubOrganization(organizationId: string): Promise; adminReloadGithubRepository(organizationId: string, repoId: string): Promise; @@ -1282,6 +1285,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien await (await organization(organizationId)).publishWorkspacePr(await withAuthSessionInput(input)); }, + async changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise { + await (await organization(organizationId)).changeWorkspaceTaskOwner(await withAuthSessionInput(input)); + }, + async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise { await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input)); }, diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index fc6470c..5ef675d 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -188,6 +188,8 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back unread: tab.unread, created: tab.created, })), + primaryUserLogin: null, + primaryUserAvatarUrl: null, }); const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({ @@ -750,6 +752,15 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back emitTaskUpdate(input.taskId); }, + async changeWorkspaceTaskOwner( + _organizationId: string, + input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }, + ): Promise { + await workspace.changeOwner(input); + emitOrganizationSnapshot(); + emitTaskUpdate(input.taskId); + }, + async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise { await workspace.revertFile(input); emitOrganizationSnapshot(); diff --git a/foundry/packages/client/src/mock/workspace-client.ts b/foundry/packages/client/src/mock/workspace-client.ts index c51b2e8..7983e0f 100644 --- a/foundry/packages/client/src/mock/workspace-client.ts +++ b/foundry/packages/client/src/mock/workspace-client.ts @@ -349,7 +349,10 @@ class MockWorkspaceStore implements TaskWorkspaceClient { return { ...currentTask, - activeSessionId: currentTask.activeSessionId === input.sessionId ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) : currentTask.activeSessionId, + activeSessionId: + currentTask.activeSessionId === input.sessionId + ? (currentTask.sessions.find((candidate) => candidate.id !== input.sessionId)?.id ?? null) + : currentTask.activeSessionId, sessions: currentTask.sessions.filter((candidate) => candidate.id !== input.sessionId), }; }); @@ -396,6 +399,14 @@ class MockWorkspaceStore implements TaskWorkspaceClient { })); } + async changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise { + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + primaryUserLogin: input.targetUserName, + primaryUserAvatarUrl: null, + })); + } + private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void { const nextSnapshot = updater(this.snapshot); this.snapshot = { diff --git a/foundry/packages/client/src/remote/workspace-client.ts b/foundry/packages/client/src/remote/workspace-client.ts index 1b6bc8e..2a11f51 100644 --- a/foundry/packages/client/src/remote/workspace-client.ts +++ b/foundry/packages/client/src/remote/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -140,6 +141,11 @@ class RemoteWorkspaceStore implements TaskWorkspaceClient { await this.refresh(); } + async changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise { + await this.backend.changeWorkspaceTaskOwner(this.organizationId, input); + await this.refresh(); + } + private ensureStarted(): void { if (!this.unsubscribeWorkspace) { this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => { diff --git a/foundry/packages/client/src/workspace-client.ts b/foundry/packages/client/src/workspace-client.ts index c3293a0..6662352 100644 --- a/foundry/packages/client/src/workspace-client.ts +++ b/foundry/packages/client/src/workspace-client.ts @@ -1,6 +1,7 @@ import type { TaskWorkspaceAddSessionResponse, TaskWorkspaceChangeModelInput, + TaskWorkspaceChangeOwnerInput, TaskWorkspaceCreateTaskInput, TaskWorkspaceCreateTaskResponse, TaskWorkspaceDiffInput, @@ -43,6 +44,7 @@ export interface TaskWorkspaceClient { closeSession(input: TaskWorkspaceSessionInput): Promise; addSession(input: TaskWorkspaceSelectInput): Promise; changeModel(input: TaskWorkspaceChangeModelInput): Promise; + changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise; } export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient { diff --git a/foundry/packages/client/test/subscription-manager.test.ts b/foundry/packages/client/test/subscription-manager.test.ts index c064606..f0a29c2 100644 --- a/foundry/packages/client/test/subscription-manager.test.ts +++ b/foundry/packages/client/test/subscription-manager.test.ts @@ -77,6 +77,8 @@ function organizationSnapshot(): OrganizationSummarySnapshot { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }; @@ -159,6 +161,8 @@ describe("RemoteSubscriptionManager", () => { pullRequest: null, activeSessionId: null, sessionsSummary: [], + primaryUserLogin: null, + primaryUserAvatarUrl: null, }, ], }, diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index 042b5a4..797b650 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -42,7 +42,7 @@ import { type Message, type ModelId, } from "./mock-layout/view-model"; -import { activeMockOrganization, activeMockUser, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; +import { activeMockOrganization, activeMockUser, getMockOrganizationById, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; import { backendClient } from "../lib/backend"; import { subscriptionManager } from "../lib/subscription"; import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status"; @@ -188,6 +188,8 @@ function toTaskModel( fileTree: detail?.fileTree ?? [], minutesUsed: detail?.minutesUsed ?? 0, activeSandboxId: detail?.activeSandboxId ?? null, + primaryUserLogin: detail?.primaryUserLogin ?? summary.primaryUserLogin ?? null, + primaryUserAvatarUrl: detail?.primaryUserAvatarUrl ?? summary.primaryUserAvatarUrl ?? null, }; } @@ -264,6 +266,7 @@ interface WorkspaceActions { closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise; addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>; changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise; + changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise; adminReloadGithubOrganization(): Promise; adminReloadGithubRepository(repoId: string): Promise; } @@ -1069,6 +1072,8 @@ const RightRail = memo(function RightRail({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { organizationId: string; @@ -1078,6 +1083,8 @@ const RightRail = memo(function RightRail({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); @@ -1170,6 +1177,8 @@ const RightRail = memo(function RightRail({ onArchive={onArchive} onRevertFile={onRevertFile} onPublishPr={onPublishPr} + onChangeOwner={onChangeOwner} + members={members} onToggleSidebar={onToggleSidebar} /> @@ -1311,6 +1320,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input), addSession: (input) => backendClient.createWorkspaceSession(organizationId, input), changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input), + changeOwner: (input) => backendClient.changeWorkspaceTaskOwner(organizationId, input), adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId), adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId), }), @@ -1741,6 +1751,22 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } [tasks], ); + const changeOwner = useCallback( + (member: { id: string; name: string; email: string }) => { + if (!activeTask) { + throw new Error("Cannot change owner without an active task"); + } + void taskWorkspaceClient.changeOwner({ + repoId: activeTask.repoId, + taskId: activeTask.id, + targetUserId: member.id, + targetUserName: member.name, + targetUserEmail: member.email, + }); + }, + [activeTask], + ); + const archiveTask = useCallback(() => { if (!activeTask) { throw new Error("Cannot archive without an active task"); @@ -2167,6 +2193,8 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } onArchive={archiveTask} onRevertFile={revertFile} onPublishPr={publishPr} + onChangeOwner={changeOwner} + members={getMockOrganizationById(appSnapshot, organizationId)?.members ?? []} onToggleSidebar={() => setRightSidebarOpen(false)} /> diff --git a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx index cd4c33a..ca7b580 100644 --- a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -1,7 +1,20 @@ -import { memo, useCallback, useMemo, useState, type MouseEvent } from "react"; +import { memo, useCallback, useMemo, useRef, useState, type MouseEvent } from "react"; import { useStyletron } from "baseui"; -import { LabelSmall } from "baseui/typography"; -import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react"; +import { LabelSmall, LabelXSmall } from "baseui/typography"; +import { + Archive, + ArrowUpFromLine, + ChevronDown, + ChevronRight, + FileCode, + FilePlus, + FileX, + FolderOpen, + GitBranch, + GitPullRequest, + PanelRight, + User, +} from "lucide-react"; import { useFoundryTokens } from "../../app/theme"; import { createErrorContext } from "@sandbox-agent/foundry-shared"; @@ -99,6 +112,8 @@ export const RightSidebar = memo(function RightSidebar({ onArchive, onRevertFile, onPublishPr, + onChangeOwner, + members, onToggleSidebar, }: { task: Task; @@ -107,11 +122,13 @@ export const RightSidebar = memo(function RightSidebar({ onArchive: () => void; onRevertFile: (path: string) => void; onPublishPr: () => void; + onChangeOwner: (member: { id: string; name: string; email: string }) => void; + members: Array<{ id: string; name: string; email: string }>; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); - const [rightTab, setRightTab] = useState<"changes" | "files">("changes"); + const [rightTab, setRightTab] = useState<"overview" | "changes" | "files">("changes"); const contextMenu = useContextMenu(); const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]); const isTerminal = task.status === "archived"; @@ -125,6 +142,8 @@ export const RightSidebar = memo(function RightSidebar({ }); observer.observe(node); }, []); + const [ownerDropdownOpen, setOwnerDropdownOpen] = useState(false); + const ownerDropdownRef = useRef(null); const pullRequestUrl = task.pullRequest?.url ?? null; const copyFilePath = useCallback(async (path: string) => { @@ -310,7 +329,7 @@ export const RightSidebar = memo(function RightSidebar({ })} > +