feat(foundry): task owner git auth + manual owner change UI (#263)

* Add task owner git auth proposal and sandbox architecture docs

- Add proposal for primary user per task with OAuth token injection
  for sandbox git operations (.context/proposal-task-owner-git-auth.md)
- Document sandbox architecture constraints in CLAUDE.md: single sandbox
  per task assumption, OAuth token security implications, git auto-auth
  requirement, and git error surfacing rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add proposals for reverting to queues and rivetkit sandbox resilience

- proposal-revert-actions-to-queues.md: Detailed plan for reverting the
  actions-only pattern back to queues/workflows now that the RivetKit
  queue.iter() bug is fixed. Lists what to keep (lazy tasks, resolveTaskRepoId,
  sync override threading, E2B fixes, frontend fixes) vs what to revert
  (communication pattern only).

- proposal-rivetkit-sandbox-resilience.md: Rivetkit sandbox actor changes for
  handling destroyed/paused sandboxes, keep-alive, and the UNIQUE constraint
  crash fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 17:05:11 -07:00 committed by GitHub
parent 167712ace7
commit 4111aebfce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1114 additions and 11 deletions

View file

@ -136,6 +136,14 @@ The client subscribes to `app` always, `organization` when entering an organizat
- Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent organization actor, which broadcasts to organization subscribers.
- Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone.
## Sandbox Architecture
- Structurally, the system supports multiple sandboxes per task, but in practice there is exactly one active sandbox per task. Design features assuming one sandbox per task. If multi-sandbox is needed in the future, extend at that time.
- Each task has a **primary user** (owner) whose GitHub OAuth credentials are injected into the sandbox for git operations. The owner swaps when a different user sends a message. See `.context/proposal-task-owner-git-auth.md` for the full design.
- **Security: OAuth token scope.** The user's GitHub OAuth token has `repo` scope, granting full control of all private repositories the user has access to. When the user is the active task owner, their token is injected into the sandbox. This means the agent can read/write ANY repo the user has access to, not just the task's target repo. This is the standard trade-off for OAuth-based git integrations (same as GitHub Codespaces, Gitpod). The user consents to `repo` scope at sign-in time. Credential files in the sandbox are `chmod 600` and overwritten on owner swap.
- All git operations in the sandbox must be auto-authenticated. Never configure git to prompt for credentials (no interactive `GIT_ASKPASS` prompts). Use a credential store file that is pre-populated with the active owner's token.
- All git operation errors (push 401, clone failure, branch protection rejection) must surface in the UI with actionable context. Never silently swallow git errors.
## Git State Policy
- The backend stores zero git state. No local clones, no refs, no working trees, and no git-spice.

View file

@ -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<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
sessionsSummary: parseJsonValue<WorkspaceSessionSummary[]>(row.sessionsSummaryJson, []),
primaryUserLogin: row.primaryUserLogin ?? null,
primaryUserAvatarUrl: row.primaryUserAvatarUrl ?? null,
};
}

View file

@ -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<void> {
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<void> {
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
void task.revertFile(input).catch(() => {});

View file

@ -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,
};

View file

@ -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(

View file

@ -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,
};

View file

@ -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

View file

@ -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);
},

View file

@ -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<a
}
}
async function readTaskOwner(
c: any,
): Promise<{
primaryUserId: string | null;
primaryGithubLogin: string | null;
primaryGithubEmail: string | null;
primaryGithubAvatarUrl: string | null;
} | null> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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<void> {
async function ensureSandboxRepo(c: any, sandbox: any, record: any, opts?: { skipFetchIfPrepared?: boolean; authSessionId?: string | null }): Promise<void> {
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,
);

View file

@ -12,6 +12,7 @@ import type {
TaskRecord,
TaskSummary,
TaskWorkspaceChangeModelInput,
TaskWorkspaceChangeOwnerInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
@ -110,6 +111,7 @@ interface OrganizationHandle {
stopWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
closeWorkspaceSession(input: TaskWorkspaceSessionInput & AuthSessionScopedInput): Promise<void>;
publishWorkspacePr(input: TaskWorkspaceSelectInput & AuthSessionScopedInput): Promise<void>;
changeWorkspaceTaskOwner(input: TaskWorkspaceChangeOwnerInput & AuthSessionScopedInput): Promise<void>;
revertWorkspaceFile(input: TaskWorkspaceDiffInput & AuthSessionScopedInput): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubRepository(input: { repoId: string }): Promise<void>;
@ -304,6 +306,7 @@ export interface BackendClient {
stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise<void>;
publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
changeWorkspaceTaskOwner(organizationId: string, input: TaskWorkspaceChangeOwnerInput): Promise<void>;
revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void>;
adminReloadGithubOrganization(organizationId: string): Promise<void>;
adminReloadGithubRepository(organizationId: string, repoId: string): Promise<void>;
@ -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<void> {
await (await organization(organizationId)).changeWorkspaceTaskOwner(await withAuthSessionInput(input));
},
async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await (await organization(organizationId)).revertWorkspaceFile(await withAuthSessionInput(input));
},

View file

@ -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<void> {
await workspace.changeOwner(input);
emitOrganizationSnapshot();
emitTaskUpdate(input.taskId);
},
async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise<void> {
await workspace.revertFile(input);
emitOrganizationSnapshot();

View file

@ -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<void> {
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 = {

View file

@ -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<void> {
await this.backend.changeWorkspaceTaskOwner(this.organizationId, input);
await this.refresh();
}
private ensureStarted(): void {
if (!this.unsubscribeWorkspace) {
this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => {

View file

@ -1,6 +1,7 @@
import type {
TaskWorkspaceAddSessionResponse,
TaskWorkspaceChangeModelInput,
TaskWorkspaceChangeOwnerInput,
TaskWorkspaceCreateTaskInput,
TaskWorkspaceCreateTaskResponse,
TaskWorkspaceDiffInput,
@ -43,6 +44,7 @@ export interface TaskWorkspaceClient {
closeSession(input: TaskWorkspaceSessionInput): Promise<void>;
addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse>;
changeModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise<void>;
}
export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient {

View file

@ -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,
},
],
},

View file

@ -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<void>;
addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>;
changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise<void>;
changeOwner(input: { repoId: string; taskId: string; targetUserId: string; targetUserName: string; targetUserEmail: string }): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubRepository(repoId: string): Promise<void>;
}
@ -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}
/>
</div>
@ -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)}
/>
</div>

View file

@ -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<HTMLDivElement>(null);
const pullRequestUrl = task.pullRequest?.url ?? null;
const copyFilePath = useCallback(async (path: string) => {
@ -310,7 +329,7 @@ export const RightSidebar = memo(function RightSidebar({
})}
>
<button
onClick={() => setRightTab("changes")}
onClick={() => setRightTab("overview")}
className={css({
appearance: "none",
WebkitAppearance: "none",
@ -322,6 +341,36 @@ export const RightSidebar = memo(function RightSidebar({
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
padding: "4px 12px",
borderRadius: "8px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
color: rightTab === "overview" ? t.textPrimary : t.textSecondary,
backgroundColor: rightTab === "overview" ? t.interactiveHover : "transparent",
transitionProperty: "color, background-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: t.textPrimary, backgroundColor: rightTab === "overview" ? t.interactiveHover : t.interactiveSubtle },
})}
>
Overview
</button>
<button
onClick={() => setRightTab("changes")}
className={css({
appearance: "none",
WebkitAppearance: "none",
border: "none",
marginTop: "6px",
marginRight: "0",
marginBottom: "6px",
marginLeft: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 12px",
borderRadius: "8px",
@ -392,7 +441,212 @@ export const RightSidebar = memo(function RightSidebar({
</div>
<ScrollBody>
{rightTab === "changes" ? (
{rightTab === "overview" ? (
<div className={css({ padding: "16px 14px", display: "flex", flexDirection: "column", gap: "16px" })}>
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
Owner
</LabelXSmall>
<div ref={ownerDropdownRef} className={css({ position: "relative" })}>
<div
role="button"
tabIndex={0}
onClick={() => setOwnerDropdownOpen((prev) => !prev)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") setOwnerDropdownOpen((prev) => !prev);
}}
className={css({
display: "flex",
alignItems: "center",
gap: "10px",
paddingTop: "4px",
paddingRight: "8px",
paddingBottom: "4px",
paddingLeft: "4px",
borderRadius: "6px",
cursor: "pointer",
":hover": { backgroundColor: t.interactiveHover },
})}
>
{task.primaryUserLogin ? (
<>
{task.primaryUserAvatarUrl ? (
<img
src={task.primaryUserAvatarUrl}
alt={task.primaryUserLogin}
className={css({
width: "28px",
height: "28px",
borderRadius: "50%",
flexShrink: 0,
})}
/>
) : (
<div
className={css({
width: "28px",
height: "28px",
borderRadius: "50%",
backgroundColor: t.surfaceElevated,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<User size={14} color={t.textTertiary} />
</div>
)}
<LabelSmall color={t.textPrimary} $style={{ fontWeight: 500, flex: 1 }}>
{task.primaryUserLogin}
</LabelSmall>
</>
) : (
<>
<div
className={css({
width: "28px",
height: "28px",
borderRadius: "50%",
backgroundColor: t.surfaceElevated,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<User size={14} color={t.textTertiary} />
</div>
<LabelSmall color={t.textTertiary} $style={{ flex: 1 }}>
No owner assigned
</LabelSmall>
</>
)}
<ChevronDown size={12} color={t.textTertiary} style={{ flexShrink: 0 }} />
</div>
{ownerDropdownOpen ? (
<>
<div
onClick={() => setOwnerDropdownOpen(false)}
className={css({ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, zIndex: 99 })}
/>
<div
className={css({
position: "absolute",
top: "100%",
left: 0,
right: 0,
zIndex: 100,
marginTop: "4px",
backgroundColor: t.surfaceElevated,
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
paddingTop: "4px",
paddingBottom: "4px",
maxHeight: "200px",
overflowY: "auto",
})}
>
{members.map((member) => (
<div
key={member.id}
role="button"
tabIndex={0}
onClick={() => {
onChangeOwner(member);
setOwnerDropdownOpen(false);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
onChangeOwner(member);
setOwnerDropdownOpen(false);
}
}}
className={css({
display: "flex",
alignItems: "center",
gap: "8px",
paddingTop: "6px",
paddingRight: "12px",
paddingBottom: "6px",
paddingLeft: "12px",
cursor: "pointer",
fontSize: "12px",
color: t.textPrimary,
":hover": { backgroundColor: t.interactiveHover },
})}
>
<div
className={css({
width: "20px",
height: "20px",
borderRadius: "50%",
backgroundColor: t.surfacePrimary,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<User size={10} color={t.textTertiary} />
</div>
<span>{member.name}</span>
</div>
))}
{members.length === 0 ? (
<div
className={css({
paddingTop: "8px",
paddingRight: "12px",
paddingBottom: "8px",
paddingLeft: "12px",
fontSize: "12px",
color: t.textTertiary,
})}
>
No members
</div>
) : null}
</div>
</>
) : null}
</div>
</div>
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
Branch
</LabelXSmall>
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
<GitBranch size={14} color={t.textTertiary} style={{ flexShrink: 0 }} />
<LabelSmall
color={t.textSecondary}
$style={{ fontFamily: '"IBM Plex Mono", monospace', overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{task.branch ?? "No branch"}
</LabelSmall>
</div>
</div>
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
Repository
</LabelXSmall>
<LabelSmall color={t.textSecondary}>{task.repoName}</LabelSmall>
</div>
{task.pullRequest ? (
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
Pull Request
</LabelXSmall>
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
<GitPullRequest size={14} color={t.textTertiary} style={{ flexShrink: 0 }} />
<LabelSmall color={t.textSecondary}>
#{task.pullRequest.number} {task.pullRequest.title ?? ""}
</LabelSmall>
</div>
</div>
) : null}
</div>
) : rightTab === "changes" ? (
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
{task.fileChanges.length === 0 ? (
<div className={css({ padding: "20px 0", textAlign: "center" })}>

View file

@ -745,6 +745,23 @@ export const Sidebar = memo(function Sidebar({
{task.title}
</LabelSmall>
</div>
{task.primaryUserLogin ? (
<span
className={css({
fontSize: "10px",
fontWeight: 500,
color: t.statusSuccess,
whiteSpace: "nowrap",
flexShrink: 0,
maxWidth: "80px",
overflow: "hidden",
textOverflow: "ellipsis",
})}
title={task.primaryUserLogin}
>
{task.primaryUserLogin}
</span>
) : null}
{task.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>

View file

@ -128,6 +128,10 @@ export interface WorkspaceTaskSummary {
activeSessionId: string | null;
/** Summary of sessions — no transcript content. */
sessionsSummary: WorkspaceSessionSummary[];
/** GitHub login of the current primary user (task owner). */
primaryUserLogin: string | null;
/** Avatar URL of the current primary user. */
primaryUserAvatarUrl: string | null;
}
/** Full task detail — only fetched when viewing a specific task. */
@ -218,6 +222,10 @@ export interface WorkspaceTask {
fileTree: WorkspaceFileTreeNode[];
minutesUsed: number;
activeSandboxId?: string | null;
/** GitHub login of the current primary user (task owner). */
primaryUserLogin?: string | null;
/** Avatar URL of the current primary user. */
primaryUserAvatarUrl?: string | null;
}
export interface WorkspaceRepo {
@ -295,6 +303,18 @@ export interface TaskWorkspaceSetSessionUnreadInput extends TaskWorkspaceSession
unread: boolean;
}
export interface TaskWorkspaceChangeOwnerInput {
repoId: string;
taskId: string;
/** User ID of the target owner (from FoundryOrganizationMember.id). */
targetUserId: string;
/** Display name to use as fallback if GitHub login cannot be resolved. */
targetUserName: string;
/** Email to use as fallback if GitHub email cannot be resolved. */
targetUserEmail: string;
authSessionId?: string;
}
export interface TaskWorkspaceDiffInput {
repoId: string;
taskId: string;