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

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