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

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