mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 01:04:42 +00:00
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:
parent
b1b785ae79
commit
3684e2e5f5
18 changed files with 647 additions and 11 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" })}>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue