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>
This commit is contained in:
Nathan Flurry 2026-03-16 17:04:26 -07:00
parent b1b785ae79
commit 3684e2e5f5
18 changed files with 647 additions and 11 deletions

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