mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 07:04:29 +00:00
# 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
1211 lines
37 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|