sandbox-agent/factory/packages/frontend/src/components/mock-layout.tsx
Nathan Flurry b00c0109d0 Merge remote-tracking branch 'origin/main' into foundry-terminal-pane
# Conflicts:
#	factory/packages/backend/src/driver.ts
#	factory/packages/backend/src/integrations/sandbox-agent/client.ts
#	factory/packages/backend/test/helpers/test-driver.ts
#	factory/packages/frontend/src/components/mock-layout.tsx
#	pnpm-lock.yaml
#	sdks/react/src/ProcessTerminal.tsx
2026-03-10 23:59:58 -07:00

1211 lines
37 KiB
TypeScript

import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import { DiffContent } from "./mock-layout/diff-content";
import { MessageList } from "./mock-layout/message-list";
import { PromptComposer } from "./mock-layout/prompt-composer";
import { RightSidebar } from "./mock-layout/right-sidebar";
import { Sidebar } from "./mock-layout/sidebar";
import { TabStrip } from "./mock-layout/tab-strip";
import { TerminalPane } from "./mock-layout/terminal-pane";
import { TranscriptHeader } from "./mock-layout/transcript-header";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
import {
buildDisplayMessages,
buildHistoryEvents,
diffPath,
diffTabId,
formatThinkingDuration,
isDiffTab,
type Handoff,
type HistoryEvent,
type LineAttachment,
type Message,
type ModelId,
} from "./mock-layout/view-model";
import { backendClient } from "../lib/backend";
import { handoffWorkbenchClient } from "../lib/workbench";
const STAR_SANDBOX_AGENT_REPO_STORAGE_KEY = "hf.onboarding.starSandboxAgentRepo";
function firstAgentTabId(handoff: Handoff): string | null {
return handoff.tabs[0]?.id ?? null;
}
function sanitizeOpenDiffs(handoff: Handoff, paths: string[] | undefined): string[] {
if (!paths) {
return [];
}
return paths.filter((path) => handoff.diffs[path] != null);
}
function sanitizeLastAgentTabId(handoff: Handoff, tabId: string | null | undefined): string | null {
if (tabId && handoff.tabs.some((tab) => tab.id === tabId)) {
return tabId;
}
return firstAgentTabId(handoff);
}
function sanitizeActiveTabId(handoff: Handoff, tabId: string | null | undefined, openDiffs: string[], lastAgentTabId: string | null): string | null {
if (tabId) {
if (handoff.tabs.some((tab) => tab.id === tabId)) {
return tabId;
}
if (isDiffTab(tabId) && openDiffs.includes(diffPath(tabId))) {
return tabId;
}
}
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
}
const TranscriptPanel = memo(function TranscriptPanel({
handoff,
activeTabId,
lastAgentTabId,
openDiffs,
onSyncRouteSession,
onSetActiveTabId,
onSetLastAgentTabId,
onSetOpenDiffs,
}: {
handoff: Handoff;
activeTabId: string | null;
lastAgentTabId: string | null;
openDiffs: string[];
onSyncRouteSession: (handoffId: string, sessionId: string | null, replace?: boolean) => void;
onSetActiveTabId: (tabId: string | null) => void;
onSetLastAgentTabId: (tabId: string | null) => void;
onSetOpenDiffs: (paths: string[]) => void;
}) {
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4");
const [editingField, setEditingField] = useState<"title" | "branch" | null>(null);
const [editValue, setEditValue] = useState("");
const [editingSessionTabId, setEditingSessionTabId] = useState<string | null>(null);
const [editingSessionName, setEditingSessionName] = useState("");
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef(new Map<string, HTMLDivElement>());
const activeDiff = activeTabId && isDiffTab(activeTabId) ? diffPath(activeTabId) : null;
const activeAgentTab = activeDiff ? null : (handoff.tabs.find((candidate) => candidate.id === activeTabId) ?? handoff.tabs[0] ?? null);
const promptTab = handoff.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? handoff.tabs[0] ?? null;
const isTerminal = handoff.status === "archived";
const historyEvents = useMemo(() => buildHistoryEvents(handoff.tabs), [handoff.tabs]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]);
const draft = promptTab?.draft.text ?? "";
const attachments = promptTab?.draft.attachments ?? [];
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [activeMessages.length]);
useEffect(() => {
textareaRef.current?.focus();
}, [activeTabId, handoff.id]);
useEffect(() => {
setEditingSessionTabId(null);
setEditingSessionName("");
}, [handoff.id]);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.style.height = `${PROMPT_TEXTAREA_MIN_HEIGHT}px`;
const nextHeight = Math.min(textarea.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT);
textarea.style.height = `${Math.max(PROMPT_TEXTAREA_MIN_HEIGHT, nextHeight)}px`;
textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden";
}, [draft, activeTabId, handoff.id]);
useEffect(() => {
if (!pendingHistoryTarget || activeTabId !== pendingHistoryTarget.tabId) {
return;
}
const targetNode = messageRefs.current.get(pendingHistoryTarget.messageId);
if (!targetNode) {
return;
}
targetNode.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingHistoryTarget(null);
}, [activeMessages.length, activeTabId, pendingHistoryTarget]);
useEffect(() => {
if (!copiedMessageId) {
return;
}
const timer = setTimeout(() => {
setCopiedMessageId(null);
}, 1_200);
return () => clearTimeout(timer);
}, [copiedMessageId]);
useEffect(() => {
if (!activeAgentTab || activeAgentTab.status !== "running" || activeAgentTab.thinkingSinceMs === null) {
return;
}
setTimerNowMs(Date.now());
const timer = window.setInterval(() => {
setTimerNowMs(Date.now());
}, 1_000);
return () => window.clearInterval(timer);
}, [activeAgentTab?.id, activeAgentTab?.status, activeAgentTab?.thinkingSinceMs]);
useEffect(() => {
if (!activeAgentTab?.unread) {
return;
}
void handoffWorkbenchClient.setSessionUnread({
handoffId: handoff.id,
tabId: activeAgentTab.id,
unread: false,
});
}, [activeAgentTab?.id, activeAgentTab?.unread, handoff.id]);
const startEditingField = useCallback((field: "title" | "branch", value: string) => {
setEditingField(field);
setEditValue(value);
}, []);
const cancelEditingField = useCallback(() => {
setEditingField(null);
}, []);
const commitEditingField = useCallback(
(field: "title" | "branch") => {
const value = editValue.trim();
if (!value) {
setEditingField(null);
return;
}
if (field === "title") {
void handoffWorkbenchClient.renameHandoff({ handoffId: handoff.id, value });
} else {
void handoffWorkbenchClient.renameBranch({ handoffId: handoff.id, value });
}
setEditingField(null);
},
[editValue, handoff.id],
);
const updateDraft = useCallback(
(nextText: string, nextAttachments: LineAttachment[]) => {
if (!promptTab) {
return;
}
void handoffWorkbenchClient.updateDraft({
handoffId: handoff.id,
tabId: promptTab.id,
text: nextText,
attachments: nextAttachments,
});
},
[handoff.id, promptTab],
);
const sendMessage = useCallback(() => {
const text = draft.trim();
if (!text || !promptTab) {
return;
}
onSetActiveTabId(promptTab.id);
onSetLastAgentTabId(promptTab.id);
void handoffWorkbenchClient.sendMessage({
handoffId: handoff.id,
tabId: promptTab.id,
text,
attachments,
});
}, [attachments, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
const stopAgent = useCallback(() => {
if (!promptTab) {
return;
}
void handoffWorkbenchClient.stopAgent({
handoffId: handoff.id,
tabId: promptTab.id,
});
}, [handoff.id, promptTab]);
const switchTab = useCallback(
(tabId: string) => {
onSetActiveTabId(tabId);
if (!isDiffTab(tabId)) {
onSetLastAgentTabId(tabId);
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (tab?.unread) {
void handoffWorkbenchClient.setSessionUnread({
handoffId: handoff.id,
tabId,
unread: false,
});
}
onSyncRouteSession(handoff.id, tabId);
}
},
[handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
);
const setTabUnread = useCallback(
(tabId: string, unread: boolean) => {
void handoffWorkbenchClient.setSessionUnread({ handoffId: handoff.id, tabId, unread });
},
[handoff.id],
);
const startRenamingTab = useCallback(
(tabId: string) => {
const targetTab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (!targetTab) {
throw new Error(`Unable to rename missing session tab ${tabId}`);
}
setEditingSessionTabId(tabId);
setEditingSessionName(targetTab.sessionName);
},
[handoff.tabs],
);
const cancelTabRename = useCallback(() => {
setEditingSessionTabId(null);
setEditingSessionName("");
}, []);
const commitTabRename = useCallback(() => {
if (!editingSessionTabId) {
return;
}
const trimmedName = editingSessionName.trim();
if (!trimmedName) {
cancelTabRename();
return;
}
void handoffWorkbenchClient.renameSession({
handoffId: handoff.id,
tabId: editingSessionTabId,
title: trimmedName,
});
cancelTabRename();
}, [cancelTabRename, editingSessionName, editingSessionTabId, handoff.id]);
const closeTab = useCallback(
(tabId: string) => {
const remainingTabs = handoff.tabs.filter((candidate) => candidate.id !== tabId);
const nextTabId = remainingTabs[0]?.id ?? null;
if (activeTabId === tabId) {
onSetActiveTabId(nextTabId);
}
if (lastAgentTabId === tabId) {
onSetLastAgentTabId(nextTabId);
}
onSyncRouteSession(handoff.id, nextTabId);
void handoffWorkbenchClient.closeTab({ handoffId: handoff.id, tabId });
},
[activeTabId, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
);
const closeDiffTab = useCallback(
(path: string) => {
const nextOpenDiffs = openDiffs.filter((candidate) => candidate !== path);
onSetOpenDiffs(nextOpenDiffs);
if (activeTabId === diffTabId(path)) {
onSetActiveTabId(nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(handoff)));
}
},
[activeTabId, handoff, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs],
);
const addTab = useCallback(() => {
void (async () => {
const { tabId } = await handoffWorkbenchClient.addTab({ handoffId: handoff.id });
onSetLastAgentTabId(tabId);
onSetActiveTabId(tabId);
onSyncRouteSession(handoff.id, tabId);
})();
}, [handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
const changeModel = useCallback(
(model: ModelId) => {
if (!promptTab) {
throw new Error(`Unable to change model for task ${handoff.id} without an active prompt tab`);
}
void handoffWorkbenchClient.changeModel({
handoffId: handoff.id,
tabId: promptTab.id,
model,
});
},
[handoff.id, promptTab],
);
const addAttachment = useCallback(
(filePath: string, lineNumber: number, lineContent: string) => {
if (!promptTab) {
return;
}
const nextAttachment = { id: `${filePath}:${lineNumber}`, filePath, lineNumber, lineContent };
if (attachments.some((attachment) => attachment.filePath === filePath && attachment.lineNumber === lineNumber)) {
return;
}
updateDraft(draft, [...attachments, nextAttachment]);
},
[attachments, draft, promptTab, updateDraft],
);
const removeAttachment = useCallback(
(id: string) => {
updateDraft(
draft,
attachments.filter((attachment) => attachment.id !== id),
);
},
[attachments, draft, updateDraft],
);
const jumpToHistoryEvent = useCallback(
(event: HistoryEvent) => {
setPendingHistoryTarget({ messageId: event.messageId, tabId: event.tabId });
if (activeTabId !== event.tabId) {
switchTab(event.tabId);
return;
}
const targetNode = messageRefs.current.get(event.messageId);
if (targetNode) {
targetNode.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingHistoryTarget(null);
}
},
[activeTabId, switchTab],
);
const copyMessage = useCallback(async (message: Message) => {
try {
if (!window.navigator.clipboard) {
throw new Error("Clipboard API unavailable in mock layout");
}
await window.navigator.clipboard.writeText(message.text);
setCopiedMessageId(message.id);
} catch (error) {
console.error("Failed to copy transcript message", error);
}
}, []);
const thinkingTimerLabel =
activeAgentTab?.status === "running" && activeAgentTab.thinkingSinceMs !== null
? formatThinkingDuration(timerNowMs - activeAgentTab.thinkingSinceMs)
: null;
return (
<SPanel>
<TranscriptHeader
handoff={handoff}
activeTab={activeAgentTab}
editingField={editingField}
editValue={editValue}
onEditValueChange={setEditValue}
onStartEditingField={startEditingField}
onCommitEditingField={commitEditingField}
onCancelEditingField={cancelEditingField}
onSetActiveTabUnread={(unread) => {
if (activeAgentTab) {
setTabUnread(activeAgentTab.id, unread);
}
}}
/>
<TabStrip
handoff={handoff}
activeTabId={activeTabId}
openDiffs={openDiffs}
editingSessionTabId={editingSessionTabId}
editingSessionName={editingSessionName}
onEditingSessionNameChange={setEditingSessionName}
onSwitchTab={switchTab}
onStartRenamingTab={startRenamingTab}
onCommitSessionRename={commitTabRename}
onCancelSessionRename={cancelTabRename}
onSetTabUnread={setTabUnread}
onCloseTab={closeTab}
onCloseDiffTab={closeDiffTab}
onAddTab={addTab}
/>
{activeDiff ? (
<DiffContent
filePath={activeDiff}
file={handoff.fileChanges.find((file) => file.path === activeDiff)}
diff={handoff.diffs[activeDiff]}
onAddAttachment={addAttachment}
/>
) : handoff.tabs.length === 0 ? (
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Sessions are where you chat with the agent. Start one now to send the first prompt on this task.
</p>
<button
type="button"
onClick={addTab}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: "#ff4f00",
color: "#fff",
cursor: "pointer",
fontWeight: 600,
}}
>
New session
</button>
</div>
</div>
</ScrollBody>
) : (
<ScrollBody>
<MessageList
tab={activeAgentTab}
scrollRef={scrollRef}
messageRefs={messageRefs}
historyEvents={historyEvents}
onSelectHistoryEvent={jumpToHistoryEvent}
copiedMessageId={copiedMessageId}
onCopyMessage={(message) => {
void copyMessage(message);
}}
thinkingTimerLabel={thinkingTimerLabel}
/>
</ScrollBody>
)}
{!isTerminal && promptTab ? (
<PromptComposer
draft={draft}
textareaRef={textareaRef}
placeholder={!promptTab.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
defaultModel={defaultModel}
model={promptTab.model}
isRunning={promptTab.status === "running"}
onDraftChange={(value) => updateDraft(value, attachments)}
onSend={sendMessage}
onStop={stopAgent}
onRemoveAttachment={removeAttachment}
onChangeModel={changeModel}
onSetDefaultModel={setDefaultModel}
/>
) : null}
</SPanel>
);
});
const RIGHT_RAIL_MIN_SECTION_HEIGHT = 180;
const RIGHT_RAIL_SPLITTER_HEIGHT = 10;
const DEFAULT_TERMINAL_HEIGHT = 320;
const TERMINAL_HEIGHT_STORAGE_KEY = "openhandoff:foundry-terminal-height";
const RightRail = memo(function RightRail({
workspaceId,
handoff,
activeTabId,
onOpenDiff,
onArchive,
onRevertFile,
onPublishPr,
}: {
workspaceId: string;
handoff: Handoff;
activeTabId: string | null;
onOpenDiff: (path: string) => void;
onArchive: () => void;
onRevertFile: (path: string) => void;
onPublishPr: () => void;
}) {
const [css] = useStyletron();
const railRef = useRef<HTMLDivElement>(null);
const [terminalHeight, setTerminalHeight] = useState(() => {
if (typeof window === "undefined") {
return DEFAULT_TERMINAL_HEIGHT;
}
const stored = window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY);
const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN;
return Number.isFinite(parsed) ? parsed : DEFAULT_TERMINAL_HEIGHT;
});
const clampTerminalHeight = useCallback((nextHeight: number) => {
const railHeight = railRef.current?.getBoundingClientRect().height ?? 0;
const maxHeight = Math.max(
RIGHT_RAIL_MIN_SECTION_HEIGHT,
railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT,
);
return Math.min(Math.max(nextHeight, RIGHT_RAIL_MIN_SECTION_HEIGHT), maxHeight);
}, []);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight));
}, [terminalHeight]);
useEffect(() => {
const handleResize = () => {
setTerminalHeight((current) => clampTerminalHeight(current));
};
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener("resize", handleResize);
}, [clampTerminalHeight]);
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startY = event.clientY;
const startHeight = terminalHeight;
document.body.style.cursor = "ns-resize";
const handlePointerMove = (moveEvent: PointerEvent) => {
const deltaY = moveEvent.clientY - startY;
setTerminalHeight(clampTerminalHeight(startHeight - deltaY));
};
const stopResize = () => {
document.body.style.cursor = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize, { once: true });
},
[clampTerminalHeight, terminalHeight],
);
return (
<div
ref={railRef}
className={css({
minHeight: 0,
display: "flex",
flexDirection: "column",
backgroundColor: "#090607",
})}
>
<div
className={css({
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
flex: 1,
minWidth: 0,
})}
>
<RightSidebar
handoff={handoff}
activeTabId={activeTabId}
onOpenDiff={onOpenDiff}
onArchive={onArchive}
onRevertFile={onRevertFile}
onPublishPr={onPublishPr}
/>
</div>
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize terminal panel"
onPointerDown={startResize}
className={css({
height: `${RIGHT_RAIL_SPLITTER_HEIGHT}px`,
flexShrink: 0,
cursor: "ns-resize",
position: "relative",
backgroundColor: "#050505",
":before": {
content: '""',
position: "absolute",
left: "50%",
top: "50%",
width: "42px",
height: "4px",
borderRadius: "999px",
transform: "translate(-50%, -50%)",
backgroundColor: "rgba(255, 255, 255, 0.14)",
},
})}
/>
<div
className={css({
height: `${terminalHeight}px`,
minHeight: `${RIGHT_RAIL_MIN_SECTION_HEIGHT}px`,
backgroundColor: "#080506",
overflow: "hidden",
})}
>
<TerminalPane workspaceId={workspaceId} handoffId={handoff.id} />
</div>
</div>
);
});
interface MockLayoutProps {
workspaceId: string;
selectedHandoffId?: string | null;
selectedSessionId?: string | null;
}
export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) {
const navigate = useNavigate();
const viewModel = useSyncExternalStore(
handoffWorkbenchClient.subscribe.bind(handoffWorkbenchClient),
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
);
const handoffs = viewModel.handoffs ?? [];
const projects = viewModel.projects ?? [];
const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState<Record<string, string | null>>({});
const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState<Record<string, string | null>>({});
const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState<Record<string, string[]>>({});
const [starRepoPromptOpen, setStarRepoPromptOpen] = useState(false);
const [starRepoPending, setStarRepoPending] = useState(false);
const [starRepoError, setStarRepoError] = useState<string | null>(null);
const activeHandoff = useMemo(() => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, [handoffs, selectedHandoffId]);
useEffect(() => {
try {
const status = globalThis.localStorage?.getItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY);
if (status !== "completed" && status !== "dismissed") {
setStarRepoPromptOpen(true);
}
} catch {
setStarRepoPromptOpen(true);
}
}, []);
useEffect(() => {
if (activeHandoff) {
return;
}
const fallbackHandoffId = handoffs[0]?.id;
if (!fallbackHandoffId) {
return;
}
const fallbackHandoff = handoffs.find((handoff) => handoff.id === fallbackHandoffId) ?? null;
void navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId: fallbackHandoffId,
},
search: { sessionId: fallbackHandoff?.tabs[0]?.id ?? undefined },
replace: true,
});
}, [activeHandoff, handoffs, navigate, workspaceId]);
const openDiffs = activeHandoff ? sanitizeOpenDiffs(activeHandoff, openDiffsByHandoff[activeHandoff.id]) : [];
const lastAgentTabId = activeHandoff ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) : null;
const activeTabId = activeHandoff ? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId) : null;
const syncRouteSession = useCallback(
(handoffId: string, sessionId: string | null, replace = false) => {
void navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId,
},
search: { sessionId: sessionId ?? undefined },
...(replace ? { replace: true } : {}),
});
},
[navigate, workspaceId],
);
useEffect(() => {
if (!activeHandoff) {
return;
}
const resolvedRouteSessionId = sanitizeLastAgentTabId(activeHandoff, selectedSessionId);
if (!resolvedRouteSessionId) {
return;
}
if (selectedSessionId !== resolvedRouteSessionId) {
syncRouteSession(activeHandoff.id, resolvedRouteSessionId, true);
return;
}
if (lastAgentTabIdByHandoff[activeHandoff.id] === resolvedRouteSessionId) {
return;
}
setLastAgentTabIdByHandoff((current) => ({
...current,
[activeHandoff.id]: resolvedRouteSessionId,
}));
setActiveTabIdByHandoff((current) => {
const currentActive = current[activeHandoff.id];
if (currentActive && isDiffTab(currentActive)) {
return current;
}
return {
...current,
[activeHandoff.id]: resolvedRouteSessionId,
};
});
}, [activeHandoff, lastAgentTabIdByHandoff, selectedSessionId, syncRouteSession]);
const createHandoff = useCallback(() => {
void (async () => {
const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? "";
if (!repoId) {
throw new Error("Cannot create a task without an available repo");
}
const task = window.prompt("Describe the task", "Investigate and implement the requested change");
if (!task) {
return;
}
const title = window.prompt("Optional task title", "")?.trim() || undefined;
const branch = window.prompt("Optional branch name", "")?.trim() || undefined;
const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({
repoId,
task,
model: "gpt-4o",
...(title ? { title } : {}),
...(branch ? { branch } : {}),
});
await navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId,
},
search: { sessionId: tabId ?? undefined },
});
})();
}, [activeHandoff?.repoId, navigate, viewModel.repos, workspaceId]);
const openDiffTab = useCallback(
(path: string) => {
if (!activeHandoff) {
throw new Error("Cannot open a diff tab without an active task");
}
setOpenDiffsByHandoff((current) => {
const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]);
if (existing.includes(path)) {
return current;
}
return {
...current,
[activeHandoff.id]: [...existing, path],
};
});
setActiveTabIdByHandoff((current) => ({
...current,
[activeHandoff.id]: diffTabId(path),
}));
},
[activeHandoff],
);
const selectHandoff = useCallback(
(id: string) => {
const handoff = handoffs.find((candidate) => candidate.id === id) ?? null;
void navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId: id,
},
search: { sessionId: handoff?.tabs[0]?.id ?? undefined },
});
},
[handoffs, navigate, workspaceId],
);
const markHandoffUnread = useCallback((id: string) => {
void handoffWorkbenchClient.markHandoffUnread({ handoffId: id });
}, []);
const renameHandoff = useCallback(
(id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) {
throw new Error(`Unable to rename missing task ${id}`);
}
const nextTitle = window.prompt("Rename task", currentHandoff.title);
if (nextTitle === null) {
return;
}
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle) {
return;
}
void handoffWorkbenchClient.renameHandoff({ handoffId: id, value: trimmedTitle });
},
[handoffs],
);
const renameBranch = useCallback(
(id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) {
throw new Error(`Unable to rename missing task ${id}`);
}
const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? "");
if (nextBranch === null) {
return;
}
const trimmedBranch = nextBranch.trim();
if (!trimmedBranch) {
return;
}
void handoffWorkbenchClient.renameBranch({ handoffId: id, value: trimmedBranch });
},
[handoffs],
);
const archiveHandoff = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot archive without an active task");
}
void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id });
}, [activeHandoff]);
const publishPr = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot publish PR without an active task");
}
void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id });
}, [activeHandoff]);
const revertFile = useCallback(
(path: string) => {
if (!activeHandoff) {
throw new Error("Cannot revert a file without an active task");
}
setOpenDiffsByHandoff((current) => ({
...current,
[activeHandoff.id]: sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]).filter((candidate) => candidate !== path),
}));
setActiveTabIdByHandoff((current) => ({
...current,
[activeHandoff.id]:
current[activeHandoff.id] === diffTabId(path)
? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id])
: (current[activeHandoff.id] ?? null),
}));
void handoffWorkbenchClient.revertFile({
handoffId: activeHandoff.id,
path,
});
},
[activeHandoff, lastAgentTabIdByHandoff],
);
const dismissStarRepoPrompt = useCallback(() => {
setStarRepoError(null);
try {
globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "dismissed");
} catch {
// ignore storage failures
}
setStarRepoPromptOpen(false);
}, []);
const starSandboxAgentRepo = useCallback(() => {
setStarRepoPending(true);
setStarRepoError(null);
void backendClient
.starSandboxAgentRepo(workspaceId)
.then(() => {
try {
globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "completed");
} catch {
// ignore storage failures
}
setStarRepoPromptOpen(false);
})
.catch((error) => {
setStarRepoError(error instanceof Error ? error.message : String(error));
})
.finally(() => {
setStarRepoPending(false);
});
}, [workspaceId]);
const starRepoPrompt = starRepoPromptOpen ? (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 10000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
background: "rgba(0, 0, 0, 0.68)",
}}
data-testid="onboarding-star-repo-modal"
>
<div
style={{
width: "min(520px, 100%)",
border: "1px solid rgba(255, 255, 255, 0.14)",
borderRadius: "18px",
background: "#111113",
boxShadow: "0 32px 80px rgba(0, 0, 0, 0.45)",
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ fontSize: "12px", letterSpacing: "0.08em", textTransform: "uppercase", color: "rgba(255, 255, 255, 0.5)" }}>Onboarding</div>
<h2 style={{ margin: 0, fontSize: "24px", lineHeight: 1.1 }}>Give us support for sandbox agent</h2>
<p style={{ margin: 0, color: "rgba(255, 255, 255, 0.72)", lineHeight: 1.5 }}>
Before you keep going, give us support for sandbox agent and star the repo right here in the app.
</p>
</div>
{starRepoError ? (
<div
style={{
borderRadius: "12px",
border: "1px solid rgba(255, 110, 110, 0.32)",
background: "rgba(255, 110, 110, 0.08)",
padding: "12px 14px",
color: "#ffb4b4",
fontSize: "13px",
}}
data-testid="onboarding-star-repo-error"
>
{starRepoError}
</div>
) : null}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "10px" }}>
<button
type="button"
onClick={dismissStarRepoPrompt}
style={{
border: "1px solid rgba(255, 255, 255, 0.14)",
borderRadius: "999px",
padding: "10px 16px",
background: "transparent",
color: "#e4e4e7",
cursor: "pointer",
fontWeight: 600,
}}
>
Maybe later
</button>
<button
type="button"
onClick={starSandboxAgentRepo}
disabled={starRepoPending}
style={{
border: 0,
borderRadius: "999px",
padding: "10px 16px",
background: starRepoPending ? "#7f5539" : "#ff4f00",
color: "#fff",
cursor: starRepoPending ? "progress" : "pointer",
fontWeight: 700,
}}
data-testid="onboarding-star-repo-submit"
>
{starRepoPending ? "Starring..." : "Star the sandbox agent repo"}
</button>
</div>
</div>
</div>
) : null;
if (!activeHandoff) {
return (
<>
<Shell>
<Sidebar
projects={projects}
activeId=""
onSelect={selectHandoff}
onCreate={createHandoff}
onMarkUnread={markHandoffUnread}
onRenameHandoff={renameHandoff}
onRenameBranch={renameBranch}
/>
<SPanel>
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
{viewModel.repos.length > 0
? "Start from the sidebar to create a task on the first available repo."
: "No repos are available in this workspace yet."}
</p>
<button
type="button"
onClick={createHandoff}
disabled={viewModel.repos.length === 0}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: viewModel.repos.length > 0 ? "#ff4f00" : "#444",
color: "#fff",
cursor: viewModel.repos.length > 0 ? "pointer" : "not-allowed",
fontWeight: 600,
}}
>
New task
</button>
</div>
</div>
</ScrollBody>
</SPanel>
<SPanel />
</Shell>
{starRepoPrompt}
</>
);
}
return (
<>
<Shell>
<Sidebar
projects={projects}
activeId={activeHandoff.id}
onSelect={selectHandoff}
onCreate={createHandoff}
onMarkUnread={markHandoffUnread}
onRenameHandoff={renameHandoff}
onRenameBranch={renameBranch}
/>
<TranscriptPanel
handoff={activeHandoff}
activeTabId={activeTabId}
lastAgentTabId={lastAgentTabId}
openDiffs={openDiffs}
onSyncRouteSession={syncRouteSession}
onSetActiveTabId={(tabId) => {
setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId }));
}}
onSetLastAgentTabId={(tabId) => {
setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId }));
}}
onSetOpenDiffs={(paths) => {
setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths }));
}}
/>
<RightRail
workspaceId={workspaceId}
handoff={activeHandoff}
activeTabId={activeTabId}
onOpenDiff={openDiffTab}
onArchive={archiveHandoff}
onRevertFile={revertFile}
onPublishPr={publishPr}
/>
</Shell>
{starRepoPrompt}
</>
);
}