Merge remote-tracking branch 'origin/main' into test-dev-webhooks-flow

# Conflicts:
#	factory/packages/backend/src/actors/project/actions.ts
#	factory/packages/backend/src/actors/workspace/actions.ts
#	factory/packages/frontend/src/components/mock-layout.tsx
This commit is contained in:
Nathan Flurry 2026-03-11 11:14:04 -07:00
commit c8a095b69f
302 changed files with 11419 additions and 9952 deletions

View file

@ -9,8 +9,9 @@
}
</script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenHandoff</title>
<title>Foundry</title>
</head>
<body>
<div id="root"></div>

View file

@ -10,6 +10,7 @@
"test": "vitest run"
},
"dependencies": {
"@sandbox-agent/react": "workspace:*",
"@openhandoff/client": "workspace:*",
"@openhandoff/frontend-errors": "workspace:*",
"@openhandoff/shared": "workspace:*",
@ -19,6 +20,7 @@
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sandbox-agent": "workspace:*",
"styletron-engine-atomic": "^1.6.2",
"styletron-react": "^6.1.1"
},

View file

@ -0,0 +1,5 @@
<svg width="130" height="128" viewBox="0 0 130 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="1" width="126" height="126" rx="44" fill="#0F0F0F"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M88.0429 44.2658C89.3803 43.625 90.8907 44.1955 91.5731 45.3776C92.2556 46.5596 91.9945 48.1529 90.7709 48.9907L72.3923 62.885C71.8013 63.2262 71.4248 63.7062 71.1029 64.2861C70.781 64.8659 70.5554 65.3922 70.5443 66.0553L67.7403 88.9495C67.521 90.3894 66.4114 91.423 64.9867 91.4576C63.5619 91.4922 62.3731 90.3429 62.24 88.9751L59.3859 66.0642C59.3971 65.4011 59.2126 64.8489 58.8714 64.2579C58.5302 63.6669 58.1442 63.231 57.5643 62.9091L39.15 48.9819C38.032 48.1828 37.6311 46.5786 38.3734 45.362C39.1157 44.1454 40.5656 43.7013 41.9223 44.2314L63.1512 53.2502C63.731 53.5721 64.2996 53.6398 64.9627 53.651C65.6259 53.6622 66.2298 53.5761 66.8208 53.2349L88.0429 44.2658Z" fill="white"/>
<rect x="19.25" y="18.25" width="91.5" height="91.5" rx="25.75" stroke="#F0F0F0" stroke-width="8.5"/>
</svg>

After

Width:  |  Height:  |  Size: 1,018 B

View file

@ -1,13 +1,6 @@
import { useEffect } from "react";
import { setFrontendErrorContext } from "@openhandoff/frontend-errors/client";
import {
Navigate,
Outlet,
createRootRoute,
createRoute,
createRouter,
useRouterState,
} from "@tanstack/react-router";
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
import { MockLayout } from "../components/mock-layout";
import { defaultWorkspaceId } from "../lib/env";
import { handoffWorkbenchClient } from "../lib/workbench";
@ -19,13 +12,7 @@ const rootRoute = createRootRoute({
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => (
<Navigate
to="/workspaces/$workspaceId"
params={{ workspaceId: defaultWorkspaceId }}
replace
/>
),
component: () => <Navigate to="/workspaces/$workspaceId" params={{ workspaceId: defaultWorkspaceId }} replace />,
});
const workspaceRoute = createRoute({
@ -55,10 +42,7 @@ const repoRoute = createRoute({
component: RepoRoute,
});
const routeTree = rootRoute.addChildren([
indexRoute,
workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute]),
]);
const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute])]);
export const router = createRouter({ routeTree });
@ -105,17 +89,9 @@ function RepoRoute() {
repoId,
});
}, [repoId, workspaceId]);
const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find(
(handoff) => handoff.repoId === repoId,
)?.id;
const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find((handoff) => handoff.repoId === repoId)?.id;
if (!activeHandoffId) {
return (
<Navigate
to="/workspaces/$workspaceId"
params={{ workspaceId }}
replace
/>
);
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
}
return (
<Navigate

View file

@ -2,17 +2,17 @@ import { createDarkTheme, type Theme } from "baseui";
export const appTheme: Theme = createDarkTheme({
colors: {
primary: "#e4e4e7", // zinc-200
accent: "#ff4f00", // orange accent (inspector)
backgroundPrimary: "#000000", // pure black (inspector --bg)
backgroundSecondary: "#0a0a0b", // near-black panels (inspector --bg-panel)
backgroundTertiary: "#0a0a0b", // same as panel (border provides separation)
primary: "#e4e4e7", // zinc-200
accent: "#ff4f00", // orange accent (inspector)
backgroundPrimary: "#09090b", // darkest — chat center panel
backgroundSecondary: "#0f0f11", // slightly lighter — sidebars
backgroundTertiary: "#0c0c0e", // center + right panel headers
backgroundInversePrimary: "#fafafa",
contentPrimary: "#ffffff", // white (inspector --text)
contentSecondary: "#a1a1aa", // zinc-400 (inspector --muted)
contentTertiary: "#71717a", // zinc-500
contentPrimary: "#ffffff", // white (inspector --text)
contentSecondary: "#a1a1aa", // zinc-400 (inspector --muted)
contentTertiary: "#71717a", // zinc-500
contentInversePrimary: "#000000",
borderOpaque: "rgba(255, 255, 255, 0.18)", // inspector --border
borderTransparent: "rgba(255, 255, 255, 0.14)", // inspector --border-2
borderOpaque: "rgba(255, 255, 255, 0.10)", // inspector --border
borderTransparent: "rgba(255, 255, 255, 0.07)", // inspector --border-2
},
});

View file

@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
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";
@ -7,6 +8,7 @@ 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 {
@ -22,8 +24,11 @@ import {
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;
}
@ -44,12 +49,7 @@ function sanitizeLastAgentTabId(handoff: Handoff, tabId: string | null | undefin
return firstAgentTabId(handoff);
}
function sanitizeActiveTabId(
handoff: Handoff,
tabId: string | null | undefined,
openDiffs: string[],
lastAgentTabId: string | null,
): string | null {
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;
@ -336,9 +336,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
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)),
);
onSetActiveTabId(nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(handoff)));
}
},
[activeTabId, handoff, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs],
@ -356,7 +354,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
const changeModel = useCallback(
(model: ModelId) => {
if (!promptTab) {
throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`);
throw new Error(`Unable to change model for task ${handoff.id} without an active prompt tab`);
}
void handoffWorkbenchClient.changeModel({
@ -447,109 +445,338 @@ const TranscriptPanel = memo(function TranscriptPanel({
}
}}
/>
<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}
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
backgroundColor: "#09090b",
overflow: "hidden",
borderTopLeftRadius: "12px",
borderLeft: "1px solid rgba(255, 255, 255, 0.10)",
borderTop: "1px solid rgba(255, 255, 255, 0.10)",
}}
>
<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}
/>
) : handoff.tabs.length === 0 ? (
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
{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={{
maxWidth: "420px",
textAlign: "center",
minHeight: "100%",
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<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 handoff.
</p>
<button
type="button"
onClick={addTab}
<div
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: "#ff4f00",
color: "#fff",
cursor: "pointer",
fontWeight: 600,
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
New session
</button>
<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: "rgba(255, 255, 255, 0.12)",
color: "#e4e4e7",
cursor: "pointer",
fontWeight: 600,
}}
>
New session
</button>
</div>
</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>
) : (
<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}
/>
</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}
) : null}
</div>
</SPanel>
);
});
const LEFT_SIDEBAR_DEFAULT_WIDTH = 340;
const RIGHT_SIDEBAR_DEFAULT_WIDTH = 380;
const SIDEBAR_MIN_WIDTH = 220;
const SIDEBAR_MAX_WIDTH = 600;
const RESIZE_HANDLE_WIDTH = 1;
const LEFT_WIDTH_STORAGE_KEY = "openhandoff:foundry-left-sidebar-width";
const RIGHT_WIDTH_STORAGE_KEY = "openhandoff:foundry-right-sidebar-width";
function readStoredWidth(key: string, fallback: number): number {
if (typeof window === "undefined") return fallback;
const stored = window.localStorage.getItem(key);
const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN;
return Number.isFinite(parsed) ? Math.min(Math.max(parsed, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH) : fallback;
}
const PanelResizeHandle = memo(function PanelResizeHandle({ onResizeStart, onResize }: { onResizeStart: () => void; onResize: (deltaX: number) => void }) {
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
onResizeStart();
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const handlePointerMove = (moveEvent: PointerEvent) => {
onResize(moveEvent.clientX - startX);
};
const stopResize = () => {
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", stopResize);
};
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", stopResize, { once: true });
},
[onResize, onResizeStart],
);
return (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={handlePointerDown}
style={{
width: `${RESIZE_HANDLE_WIDTH}px`,
flexShrink: 0,
cursor: "col-resize",
backgroundColor: "transparent",
position: "relative",
zIndex: 1,
}}
>
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
left: "-3px",
right: "-3px",
}}
/>
</div>
);
});
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,
flex: 1,
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;
@ -564,15 +791,78 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
);
const handoffs = viewModel.handoffs ?? [];
const projects = viewModel.projects ?? [];
const rawProjects = viewModel.projects ?? [];
const [projectOrder, setProjectOrder] = useState<string[] | null>(null);
const projects = useMemo(() => {
if (!projectOrder) return rawProjects;
const byId = new Map(rawProjects.map((p) => [p.id, p]));
const ordered = projectOrder.map((id) => byId.get(id)).filter(Boolean) as typeof rawProjects;
for (const p of rawProjects) {
if (!projectOrder.includes(p.id)) ordered.push(p);
}
return ordered;
}, [rawProjects, projectOrder]);
const reorderProjects = useCallback(
(fromIndex: number, toIndex: number) => {
const ids = projects.map((p) => p.id);
const [moved] = ids.splice(fromIndex, 1);
ids.splice(toIndex, 0, moved!);
setProjectOrder(ids);
},
[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 [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH));
const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH));
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const activeHandoff = useMemo(
() => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null,
[handoffs, selectedHandoffId],
);
useEffect(() => {
leftWidthRef.current = leftWidth;
window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth));
}, [leftWidth]);
useEffect(() => {
rightWidthRef.current = rightWidth;
window.localStorage.setItem(RIGHT_WIDTH_STORAGE_KEY, String(rightWidth));
}, [rightWidth]);
const startLeftRef = useRef(leftWidth);
const startRightRef = useRef(rightWidth);
const onLeftResize = useCallback((deltaX: number) => {
setLeftWidth(Math.min(Math.max(startLeftRef.current + deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH));
}, []);
const onLeftResizeStart = useCallback(() => {
startLeftRef.current = leftWidthRef.current;
}, []);
const onRightResize = useCallback((deltaX: number) => {
setRightWidth(Math.min(Math.max(startRightRef.current - deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH));
}, []);
const onRightResizeStart = useCallback(() => {
startRightRef.current = rightWidthRef.current;
}, []);
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) {
@ -599,9 +889,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
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 activeTabId = activeHandoff ? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId) : null;
const syncRouteSession = useCallback(
(handoffId: string, sessionId: string | null, replace = false) => {
@ -658,7 +946,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
void (async () => {
const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? "";
if (!repoId) {
throw new Error("Cannot create a handoff without an available repo");
throw new Error("Cannot create a task without an available repo");
}
const task = "New task";
@ -683,7 +971,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
const openDiffTab = useCallback(
(path: string) => {
if (!activeHandoff) {
throw new Error("Cannot open a diff tab without an active handoff");
throw new Error("Cannot open a diff tab without an active task");
}
setOpenDiffsByHandoff((current) => {
const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]);
@ -727,10 +1015,10 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
(id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) {
throw new Error(`Unable to rename missing handoff ${id}`);
throw new Error(`Unable to rename missing task ${id}`);
}
const nextTitle = window.prompt("Rename handoff", currentHandoff.title);
const nextTitle = window.prompt("Rename task", currentHandoff.title);
if (nextTitle === null) {
return;
}
@ -749,7 +1037,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
(id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) {
throw new Error(`Unable to rename missing handoff ${id}`);
throw new Error(`Unable to rename missing task ${id}`);
}
const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? "");
@ -769,14 +1057,14 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
const archiveHandoff = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot archive without an active handoff");
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 handoff");
throw new Error("Cannot publish PR without an active task");
}
void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id });
}, [activeHandoff]);
@ -784,7 +1072,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
const revertFile = useCallback(
(path: string) => {
if (!activeHandoff) {
throw new Error("Cannot revert a file without an active handoff");
throw new Error("Cannot revert a file without an active task");
}
setOpenDiffsByHandoff((current) => ({
...current,
@ -795,7 +1083,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
[activeHandoff.id]:
current[activeHandoff.id] === diffTabId(path)
? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id])
: current[activeHandoff.id] ?? null,
: (current[activeHandoff.id] ?? null),
}));
void handoffWorkbenchClient.revertFile({
@ -806,105 +1094,255 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
[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(440px, 100%)",
border: "1px solid rgba(255, 255, 255, 0.10)",
borderRadius: "12px",
background: "rgba(24, 24, 27, 0.98)",
backdropFilter: "blur(16px)",
boxShadow: "0 24px 64px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04)",
padding: "28px",
display: "flex",
flexDirection: "column",
gap: "20px",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div style={{ fontSize: "11px", letterSpacing: "0.06em", textTransform: "uppercase", fontWeight: 600, color: "rgba(255, 255, 255, 0.4)" }}>
Welcome to Foundry
</div>
<h2 style={{ margin: 0, fontSize: "18px", fontWeight: 500, lineHeight: 1.3 }}>Support Sandbox Agent</h2>
<p style={{ margin: 0, color: "rgba(255, 255, 255, 0.55)", fontSize: "13px", lineHeight: 1.6 }}>
Star the repo to help us grow and stay up to date with new releases.
</p>
</div>
{starRepoError ? (
<div
style={{
borderRadius: "8px",
border: "1px solid rgba(255, 110, 110, 0.24)",
background: "rgba(255, 110, 110, 0.06)",
padding: "10px 12px",
color: "#ff9b9b",
fontSize: "12px",
}}
data-testid="onboarding-star-repo-error"
>
{starRepoError}
</div>
) : null}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button
type="button"
onClick={dismissStarRepoPrompt}
style={{
border: "1px solid rgba(255, 255, 255, 0.10)",
borderRadius: "6px",
padding: "8px 14px",
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
transition: "all 160ms ease",
}}
>
Maybe later
</button>
<button
type="button"
onClick={starSandboxAgentRepo}
disabled={starRepoPending}
style={{
border: 0,
borderRadius: "6px",
padding: "8px 14px",
background: starRepoPending ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.12)",
color: "#e4e4e7",
cursor: starRepoPending ? "progress" : "pointer",
fontSize: "12px",
fontWeight: 600,
transition: "all 160ms ease",
}}
data-testid="onboarding-star-repo-submit"
>
{starRepoPending ? "Starring..." : "Star the 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",
}}
>
<>
<Shell>
<div style={{ width: `${leftWidth}px`, flexShrink: 0, minWidth: 0, display: "flex", flexDirection: "column" }}>
<Sidebar
projects={projects}
activeId=""
onSelect={selectHandoff}
onCreate={createHandoff}
onMarkUnread={markHandoffUnread}
onRenameHandoff={renameHandoff}
onRenameBranch={renameBranch}
onReorderProjects={reorderProjects}
/>
</div>
<PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} />
<SPanel $style={{ backgroundColor: "#09090b", flex: 1, minWidth: 0 }}>
<ScrollBody>
<div
style={{
maxWidth: "420px",
textAlign: "center",
minHeight: "100%",
display: "flex",
flexDirection: "column",
gap: "12px",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first handoff</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
{viewModel.repos.length > 0
? "Start from the sidebar to create a handoff on the first available repo."
: "No repos are available in this workspace yet."}
</p>
<button
type="button"
onClick={createHandoff}
disabled={viewModel.repos.length === 0}
<div
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,
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
New handoff
</button>
<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 ? "rgba(255, 255, 255, 0.12)" : "#444",
color: "#e4e4e7",
cursor: viewModel.repos.length > 0 ? "pointer" : "not-allowed",
fontWeight: 600,
}}
>
New task
</button>
</div>
</div>
</div>
</ScrollBody>
</SPanel>
<SPanel />
</Shell>
</ScrollBody>
</SPanel>
<PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} />
<div style={{ width: `${rightWidth}px`, flexShrink: 0, minWidth: 0, display: "flex", flexDirection: "column" }}>
<SPanel />
</div>
</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 }));
}}
/>
<RightSidebar
handoff={activeHandoff}
activeTabId={activeTabId}
onOpenDiff={openDiffTab}
onArchive={archiveHandoff}
onRevertFile={revertFile}
onPublishPr={publishPr}
/>
</Shell>
<>
<Shell>
<div style={{ width: `${leftWidth}px`, flexShrink: 0, minWidth: 0, display: "flex", flexDirection: "column" }}>
<Sidebar
projects={projects}
activeId={activeHandoff.id}
onSelect={selectHandoff}
onCreate={createHandoff}
onMarkUnread={markHandoffUnread}
onRenameHandoff={renameHandoff}
onRenameBranch={renameBranch}
onReorderProjects={reorderProjects}
/>
</div>
<PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} />
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<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 }));
}}
/>
</div>
<PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} />
<div style={{ width: `${rightWidth}px`, flexShrink: 0, minWidth: 0, display: "flex", flexDirection: "column" }}>
<RightRail
workspaceId={workspaceId}
handoff={activeHandoff}
activeTabId={activeTabId}
onOpenDiff={openDiffTab}
onArchive={archiveHandoff}
onRevertFile={revertFile}
onPublishPr={publishPr}
/>
</div>
</Shell>
{starRepoPrompt}
</>
);
}

View file

@ -4,13 +4,7 @@ import { LabelXSmall } from "baseui/typography";
import { formatMessageTimestamp, type HistoryEvent } from "./view-model";
export const HistoryMinimap = memo(function HistoryMinimap({
events,
onSelect,
}: {
events: HistoryEvent[];
onSelect: (event: HistoryEvent) => void;
}) {
export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }: { events: HistoryEvent[]; onSelect: (event: HistoryEvent) => void }) {
const [css, theme] = useStyletron();
const [open, setOpen] = useState(false);
const [activeEventId, setActiveEventId] = useState<string | null>(events[events.length - 1]?.id ?? null);
@ -49,7 +43,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({
>
<div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" })}>
<LabelXSmall color={theme.colors.contentTertiary} $style={{ letterSpacing: "0.08em", textTransform: "uppercase" }}>
Handoff Events
Task Events
</LabelXSmall>
<LabelXSmall color={theme.colors.contentTertiary}>{events.length}</LabelXSmall>
</div>
@ -64,7 +58,11 @@ export const HistoryMinimap = memo(function HistoryMinimap({
onFocus={() => setActiveEventId(event.id)}
onClick={() => onSelect(event)}
className={css({
all: "unset",
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
display: "grid",
gridTemplateColumns: "1fr auto",
gap: "10px",

View file

@ -1,4 +1,5 @@
import { memo, type MutableRefObject, type Ref } from "react";
import { AgentTranscript, type AgentTranscriptClassNames, type TranscriptEntry } from "@sandbox-agent/react";
import { memo, useMemo, type MutableRefObject, type Ref } from "react";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import { Copy } from "lucide-react";
@ -7,6 +8,117 @@ import { HistoryMinimap } from "./history-minimap";
import { SpinnerDot } from "./ui";
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model";
const TranscriptMessageBody = memo(function TranscriptMessageBody({
message,
messageRefs,
copiedMessageId,
onCopyMessage,
}: {
message: Message;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
copiedMessageId: string | null;
onCopyMessage: (message: Message) => void;
}) {
const [css, theme] = useStyletron();
const isUser = message.sender === "client";
const isCopied = copiedMessageId === message.id;
const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
const displayFooter = isUser ? messageTimestamp : message.durationMs ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` : null;
return (
<div
ref={(node) => {
if (node) {
messageRefs.current.set(message.id, node);
} else {
messageRefs.current.delete(message.id);
}
}}
className={css({
display: "flex",
flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start",
gap: "6px",
})}
>
<div
className={css({
maxWidth: "100%",
padding: "12px 16px",
borderTopLeftRadius: "16px",
borderTopRightRadius: "16px",
...(isUser
? {
backgroundColor: "rgba(255, 255, 255, 0.10)",
color: "#e4e4e7",
borderBottomLeftRadius: "16px",
borderBottomRightRadius: "4px",
}
: {
backgroundColor: "transparent",
border: "none",
color: "#e4e4e7",
borderRadius: "0",
padding: "0",
}),
})}
>
<div
data-selectable
className={css({
fontSize: "13px",
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
})}
>
{message.text}
</div>
</div>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "10px",
justifyContent: isUser ? "flex-end" : "flex-start",
minHeight: "16px",
paddingLeft: isUser ? undefined : "2px",
})}
>
{displayFooter ? (
<LabelXSmall color={theme.colors.contentTertiary} $style={{ fontFamily: '"IBM Plex Mono", monospace', letterSpacing: "0.01em" }}>
{displayFooter}
</LabelXSmall>
) : null}
<button
type="button"
data-copy-action="true"
onClick={() => onCopyMessage(message)}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
padding: "0",
margin: "0",
display: "inline-flex",
alignItems: "center",
gap: "5px",
fontSize: "11px",
cursor: "pointer",
color: isCopied ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "color 160ms ease",
":hover": { color: theme.colors.contentPrimary },
})}
>
<Copy size={11} />
{isCopied ? "Copied" : null}
</button>
</div>
</div>
);
});
export const MessageList = memo(function MessageList({
tab,
scrollRef,
@ -27,10 +139,64 @@ export const MessageList = memo(function MessageList({
thinkingTimerLabel: string | null;
}) {
const [css, theme] = useStyletron();
const messages = buildDisplayMessages(tab);
const messages = useMemo(() => buildDisplayMessages(tab), [tab]);
const messagesById = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]);
const transcriptEntries = useMemo<TranscriptEntry[]>(
() =>
messages.map((message) => ({
id: message.id,
eventId: message.id,
kind: "message",
time: new Date(message.createdAtMs).toISOString(),
role: message.sender === "client" ? "user" : "assistant",
text: message.text,
})),
[messages],
);
const messageContentClass = css({
maxWidth: "80%",
display: "flex",
flexDirection: "column",
});
const transcriptClassNames: Partial<AgentTranscriptClassNames> = {
root: css({
display: "flex",
flexDirection: "column",
gap: "12px",
}),
message: css({
display: "flex",
}),
messageContent: messageContentClass,
messageText: css({
width: "100%",
}),
thinkingRow: css({
display: "flex",
alignItems: "center",
gap: "8px",
padding: "4px 0",
}),
thinkingIndicator: css({
display: "flex",
alignItems: "center",
gap: "8px",
color: "#ff4f00",
fontSize: "11px",
fontFamily: '"IBM Plex Mono", monospace',
letterSpacing: "0.01em",
}),
};
return (
<>
<style>{`
[data-variant="user"] > [data-slot="message-content"] {
margin-left: auto;
}
`}</style>
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
<div
ref={scrollRef}
@ -38,13 +204,12 @@ export const MessageList = memo(function MessageList({
padding: "16px 220px 16px 44px",
display: "flex",
flexDirection: "column",
gap: "12px",
flex: 1,
minHeight: 0,
overflowY: "auto",
})}
>
{tab && messages.length === 0 ? (
{tab && transcriptEntries.length === 0 ? (
<div
className={css({
display: "flex",
@ -60,137 +225,44 @@ export const MessageList = memo(function MessageList({
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
</LabelSmall>
</div>
) : null}
{messages.map((message) => {
const isUser = message.sender === "client";
const isCopied = copiedMessageId === message.id;
const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
const displayFooter = isUser
? messageTimestamp
: message.durationMs
? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}`
: null;
) : (
<AgentTranscript
entries={transcriptEntries}
classNames={transcriptClassNames}
renderMessageText={(entry) => {
const message = messagesById.get(entry.id);
if (!message) {
return null;
}
return (
<div
key={message.id}
ref={(node) => {
if (node) {
messageRefs.current.set(message.id, node);
} else {
messageRefs.current.delete(message.id);
}
}}
className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })}
>
<div
className={css({
maxWidth: "80%",
display: "flex",
flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start",
gap: "6px",
})}
>
<div
className={css({
maxWidth: "100%",
padding: "12px 16px",
borderTopLeftRadius: "16px",
borderTopRightRadius: "16px",
...(isUser
? {
backgroundColor: "#ffffff",
color: "#000000",
borderBottomLeftRadius: "16px",
borderBottomRightRadius: "4px",
}
: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
color: "#e4e4e7",
borderBottomLeftRadius: "4px",
borderBottomRightRadius: "16px",
}),
})}
>
<div
data-selectable
className={css({
fontSize: "13px",
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
})}
>
{message.text}
</div>
</div>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "10px",
justifyContent: isUser ? "flex-end" : "flex-start",
minHeight: "16px",
paddingLeft: isUser ? undefined : "2px",
})}
>
{displayFooter ? (
<LabelXSmall
color={theme.colors.contentTertiary}
$style={{ fontFamily: '"IBM Plex Mono", monospace', letterSpacing: "0.01em" }}
return <TranscriptMessageBody message={message} messageRefs={messageRefs} copiedMessageId={copiedMessageId} onCopyMessage={onCopyMessage} />;
}}
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
renderThinkingState={() => (
<div className={transcriptClassNames.thinkingRow}>
<SpinnerDot size={12} />
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>Agent is thinking</span>
{thinkingTimerLabel ? (
<span
className={css({
padding: "2px 7px",
borderRadius: "999px",
backgroundColor: "rgba(255, 79, 0, 0.12)",
border: "1px solid rgba(255, 79, 0, 0.2)",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "10px",
letterSpacing: "0.04em",
})}
>
{displayFooter}
</LabelXSmall>
{thinkingTimerLabel}
</span>
) : null}
<button
type="button"
data-copy-action="true"
onClick={() => onCopyMessage(message)}
className={css({
all: "unset",
display: "inline-flex",
alignItems: "center",
gap: "5px",
fontSize: "11px",
cursor: "pointer",
color: isCopied ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "color 160ms ease",
":hover": { color: theme.colors.contentPrimary },
})}
>
<Copy size={11} />
{isCopied ? "Copied" : "Copy"}
</button>
</div>
</LabelXSmall>
</div>
</div>
);
})}
{tab && tab.status === "running" && messages.length > 0 ? (
<div className={css({ display: "flex", alignItems: "center", gap: "8px", padding: "4px 0" })}>
<SpinnerDot size={12} />
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>Agent is thinking</span>
{thinkingTimerLabel ? (
<span
className={css({
padding: "2px 7px",
borderRadius: "999px",
backgroundColor: "rgba(255, 79, 0, 0.12)",
border: "1px solid rgba(255, 79, 0, 0.2)",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "10px",
letterSpacing: "0.04em",
})}
>
{thinkingTimerLabel}
</span>
) : null}
</LabelXSmall>
</div>
) : null}
)}
/>
)}
</div>
</>
);

View file

@ -1,7 +1,7 @@
import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronDown, Star } from "lucide-react";
import { ChevronDown, ChevronUp, Star } from "lucide-react";
import { AgentIcon } from "./ui";
import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model";
@ -23,7 +23,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
const [hoveredId, setHoveredId] = useState<ModelId | null>(null);
return (
<div className={css({ minWidth: "200px", padding: "4px 0" })}>
<div className={css({ minWidth: "220px", padding: "6px 0" })}>
{MODEL_GROUPS.map((group) => (
<div key={group.provider}>
<div
@ -62,7 +62,10 @@ const ModelPickerContent = memo(function ModelPickerContent({
fontSize: "12px",
fontWeight: isActive ? 600 : 400,
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
borderRadius: "6px",
marginLeft: "4px",
marginRight: "4px",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
})}
>
<AgentIcon agent={agent} size={12} />
@ -100,22 +103,26 @@ export const ModelPicker = memo(function ModelPicker({
onSetDefault: (id: ModelId) => void;
}) {
const [css, theme] = useStyletron();
const [isOpen, setIsOpen] = useState(false);
return (
<StatefulPopover
placement={PLACEMENT.topLeft}
triggerType="click"
autoFocus={false}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
overrides={{
Body: {
style: {
backgroundColor: "#000000",
borderTopLeftRadius: "8px",
borderTopRightRadius: "8px",
borderBottomLeftRadius: "8px",
borderBottomRightRadius: "8px",
border: `1px solid ${theme.colors.borderOpaque}`,
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)",
backgroundColor: "rgba(32, 32, 32, 0.98)",
backdropFilter: "blur(12px)",
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
borderBottomLeftRadius: "10px",
borderBottomRightRadius: "10px",
border: "1px solid rgba(255, 255, 255, 0.10)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04)",
zIndex: 100,
},
},
@ -126,20 +133,15 @@ export const ModelPicker = memo(function ModelPicker({
},
},
}}
content={({ close }) => (
<ModelPickerContent
value={value}
defaultModel={defaultModel}
onChange={onChange}
onSetDefault={onSetDefault}
close={close}
/>
)}
content={({ close }) => <ModelPickerContent value={value} defaultModel={defaultModel} onChange={onChange} onSetDefault={onSetDefault} close={close} />}
>
<div className={css({ display: "inline-flex" })}>
<button
className={css({
all: "unset",
appearance: "none",
WebkitAppearance: "none",
background: "none",
margin: "0",
display: "flex",
alignItems: "center",
gap: "4px",
@ -149,13 +151,13 @@ export const ModelPicker = memo(function ModelPicker({
fontSize: "12px",
fontWeight: 500,
color: theme.colors.contentSecondary,
backgroundColor: theme.colors.backgroundTertiary,
border: `1px solid ${theme.colors.borderOpaque}`,
":hover": { color: theme.colors.contentPrimary },
backgroundColor: "rgba(255, 255, 255, 0.10)",
border: "1px solid rgba(255, 255, 255, 0.14)",
":hover": { color: theme.colors.contentPrimary, backgroundColor: "rgba(255, 255, 255, 0.14)" },
})}
>
{modelLabel(value)}
<ChevronDown size={11} />
{isOpen ? <ChevronDown size={11} /> : <ChevronUp size={11} />}
</button>
</div>
</StatefulPopover>

View file

@ -1,9 +1,10 @@
import { memo, type Ref } from "react";
import { useStyletron } from "baseui";
import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react";
import { ChatComposer, type ChatComposerClassNames } from "@sandbox-agent/react";
import { FileCode, SendHorizonal, Square, X } from "lucide-react";
import { ModelPicker } from "./model-picker";
import { PROMPT_TEXTAREA_MIN_HEIGHT, PROMPT_TEXTAREA_MAX_HEIGHT } from "./ui";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT } from "./ui";
import { fileName, type LineAttachment, type ModelId } from "./view-model";
export const PromptComposer = memo(function PromptComposer({
@ -36,12 +37,83 @@ export const PromptComposer = memo(function PromptComposer({
onSetDefaultModel: (model: ModelId) => void;
}) {
const [css, theme] = useStyletron();
const composerClassNames: Partial<ChatComposerClassNames> = {
form: css({
position: "relative",
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
borderRadius: "16px",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT + 36}px`,
transition: "border-color 200ms ease",
":focus-within": { borderColor: "rgba(255, 255, 255, 0.15)" },
display: "flex",
flexDirection: "column",
}),
input: css({
display: "block",
width: "100%",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT + 20}px`,
padding: "14px 58px 8px 14px",
background: "transparent",
border: "none",
borderRadius: "16px 16px 0 0",
color: theme.colors.contentPrimary,
fontSize: "13px",
fontFamily: "inherit",
resize: "none",
outline: "none",
lineHeight: "1.4",
maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT + 40}px`,
boxSizing: "border-box",
overflowY: "hidden",
"::placeholder": { color: theme.colors.contentSecondary },
}),
submit: css({
appearance: "none",
WebkitAppearance: "none",
boxSizing: "border-box",
width: "32px",
height: "32px",
padding: "0",
margin: "0",
border: "none",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 0,
fontSize: 0,
color: theme.colors.contentPrimary,
transition: "background 200ms ease",
backgroundColor: isRunning ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.12)",
":hover": {
backgroundColor: isRunning ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.20)",
},
":disabled": {
cursor: "not-allowed",
opacity: 0.45,
},
}),
submitContent: css({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
lineHeight: 0,
color: isRunning ? theme.colors.contentPrimary : "#ffffff",
}),
};
return (
<div
className={css({
padding: "12px 16px",
borderTop: `1px solid ${theme.colors.borderOpaque}`,
borderTop: "none",
flexShrink: 0,
display: "flex",
flexDirection: "column",
@ -70,111 +142,37 @@ export const PromptComposer = memo(function PromptComposer({
<span>
{fileName(attachment.filePath)}:{attachment.lineNumber}
</span>
<X
size={10}
className={css({ cursor: "pointer", opacity: 0.6, ":hover": { opacity: 1 } })}
onClick={() => onRemoveAttachment(attachment.id)}
/>
<X size={10} className={css({ cursor: "pointer", opacity: 0.6, ":hover": { opacity: 1 } })} onClick={() => onRemoveAttachment(attachment.id)} />
</div>
))}
</div>
) : null}
<div
className={css({
position: "relative",
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
borderRadius: "16px",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
transition: "border-color 200ms ease",
":focus-within": { borderColor: "rgba(255, 255, 255, 0.3)" },
})}
>
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
<ChatComposer
message={draft}
onMessageChange={onDraftChange}
onSubmit={isRunning ? onStop : onSend}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (isRunning) {
onStop();
} else {
onSend();
}
}}
placeholder={placeholder}
rows={1}
className={css({
display: "block",
width: "100%",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
padding: "12px 58px 12px 14px",
background: "transparent",
border: "none",
borderRadius: "16px",
color: theme.colors.contentPrimary,
fontSize: "13px",
fontFamily: "inherit",
resize: "none",
outline: "none",
lineHeight: "1.4",
maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT}px`,
boxSizing: "border-box",
overflowY: "hidden",
"::placeholder": { color: theme.colors.contentSecondary },
})}
/>
{isRunning ? (
<button
onClick={onStop}
className={css({
all: "unset",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 0.06)",
color: theme.colors.contentPrimary,
transition: "background 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.12)" },
})}
>
<Square size={16} />
</button>
) : (
<button
onClick={onSend}
className={css({
all: "unset",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#ff4f00",
color: "#ffffff",
transition: "background 200ms ease",
":hover": { backgroundColor: "#ff6a00" },
})}
>
<ArrowUpFromLine size={16} />
</button>
}
}}
placeholder={placeholder}
inputRef={textareaRef}
rows={2}
allowEmptySubmit={isRunning}
submitLabel={isRunning ? "Stop" : "Send"}
classNames={composerClassNames}
renderSubmitContent={() => (isRunning ? <Square size={16} style={{ display: "block" }} /> : <SendHorizonal size={16} style={{ display: "block" }} />)}
renderFooter={() => (
<div className={css({ padding: "0 10px 8px" })}>
<ModelPicker value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
</div>
)}
</div>
<ModelPicker
value={model}
defaultModel={defaultModel}
onChange={onChangeModel}
onSetDefault={onSetDefaultModel}
/>
</div>
);

View file

@ -1,16 +1,7 @@
import { memo, useCallback, useMemo, useState, type MouseEvent } from "react";
import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography";
import {
Archive,
ArrowUpFromLine,
ChevronRight,
FileCode,
FilePlus,
FileX,
FolderOpen,
GitPullRequest,
} from "lucide-react";
import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest } from "lucide-react";
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { type FileTreeNode, type Handoff, diffTabId } from "./view-model";
@ -86,13 +77,7 @@ const FileTree = memo(function FileTree({
<span>{node.name}</span>
</div>
{node.isDir && !isCollapsed && node.children ? (
<FileTree
nodes={node.children}
depth={depth + 1}
onSelectFile={onSelectFile}
onFileContextMenu={onFileContextMenu}
changedPaths={changedPaths}
/>
<FileTree nodes={node.children} depth={depth + 1} onSelectFile={onSelectFile} onFileContextMenu={onFileContextMenu} changedPaths={changedPaths} />
) : null}
</div>
);
@ -150,8 +135,8 @@ export const RightSidebar = memo(function RightSidebar({
);
return (
<SPanel>
<PanelHeaderBar>
<SPanel $style={{ backgroundColor: "#09090b" }}>
<PanelHeaderBar $style={{ backgroundColor: "#0f0f11", borderBottom: "none" }}>
<div className={css({ flex: 1 })} />
{!isTerminal ? (
<div className={css({ display: "flex", alignItems: "center", gap: "4px" })}>
@ -165,222 +150,252 @@ export const RightSidebar = memo(function RightSidebar({
onPublishPr();
}}
className={css({
all: "unset",
display: "flex",
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<GitPullRequest size={12} />
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
{pullRequestUrl ? "Open PR" : "Publish PR"}
</button>
<button
className={css({
all: "unset",
display: "flex",
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<ArrowUpFromLine size={12} /> Push
<ArrowUpFromLine size={12} style={{ flexShrink: 0 }} /> Push
</button>
<button
onClick={onArchive}
className={css({
all: "unset",
display: "flex",
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<Archive size={12} /> Archive
<Archive size={12} style={{ flexShrink: 0 }} /> Archive
</button>
</div>
) : null}
</PanelHeaderBar>
<div
className={css({
display: "flex",
alignItems: "stretch",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundSecondary,
height: "41px",
minHeight: "41px",
flexShrink: 0,
})}
>
<button
onClick={() => setRightTab("changes")}
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", borderTop: "1px solid rgba(255, 255, 255, 0.10)" }}>
<div
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
height: "100%",
padding: "0 16px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 600,
whiteSpace: "nowrap",
color: rightTab === "changes" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
borderBottom: `2px solid ${rightTab === "changes" ? "#ff4f00" : "transparent"}`,
marginBottom: "-1px",
transitionProperty: "color, border-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7" },
alignItems: "stretch",
gap: "4px",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: "#09090b",
height: "41px",
minHeight: "41px",
flexShrink: 0,
})}
>
Changes
{handoff.fileChanges.length > 0 ? (
<span
className={css({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: "16px",
height: "16px",
padding: "0 5px",
background: "#3f3f46",
color: "#a1a1aa",
fontSize: "9px",
fontWeight: 700,
borderRadius: "8px",
})}
>
{handoff.fileChanges.length}
</span>
) : null}
</button>
<button
onClick={() => setRightTab("files")}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
height: "100%",
padding: "0 16px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 600,
whiteSpace: "nowrap",
color: rightTab === "files" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
borderBottom: `2px solid ${rightTab === "files" ? "#ff4f00" : "transparent"}`,
marginBottom: "-1px",
transitionProperty: "color, border-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7" },
})}
>
All Files
</button>
</div>
<ScrollBody>
{rightTab === "changes" ? (
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
{handoff.fileChanges.length === 0 ? (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No changes yet</LabelSmall>
</div>
<button
onClick={() => setRightTab("changes")}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
marginLeft: "6px",
borderRadius: "8px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
color: rightTab === "changes" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
backgroundColor: rightTab === "changes" ? "rgba(255, 255, 255, 0.06)" : "transparent",
transitionProperty: "color, background-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7", backgroundColor: rightTab === "changes" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
Changes
{handoff.fileChanges.length > 0 ? (
<span
className={css({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: "16px",
height: "16px",
padding: "0 5px",
background: "#3f3f46",
color: "#a1a1aa",
fontSize: "9px",
fontWeight: 700,
borderRadius: "8px",
})}
>
{handoff.fileChanges.length}
</span>
) : null}
{handoff.fileChanges.map((file) => {
const isActive = activeTabId === diffTabId(file.path);
const TypeIcon = file.type === "A" ? FilePlus : file.type === "D" ? FileX : FileCode;
const iconColor = file.type === "A" ? "#7ee787" : file.type === "D" ? "#ffa198" : theme.colors.contentTertiary;
return (
<div
key={file.path}
onClick={() => onOpenDiff(file.path)}
onContextMenu={(event) => openFileMenu(event, file.path)}
className={css({
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 10px",
borderRadius: "6px",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
>
<TypeIcon size={14} color={iconColor} style={{ flexShrink: 0 }} />
<div
className={css({
flex: 1,
minWidth: 0,
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "12px",
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{file.path}
</div>
</button>
<button
onClick={() => setRightTab("files")}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
color: rightTab === "files" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
backgroundColor: rightTab === "files" ? "rgba(255, 255, 255, 0.06)" : "transparent",
transitionProperty: "color, background-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7", backgroundColor: rightTab === "files" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
All Files
</button>
</div>
<ScrollBody>
{rightTab === "changes" ? (
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
{handoff.fileChanges.length === 0 ? (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No changes yet</LabelSmall>
</div>
) : null}
{handoff.fileChanges.map((file) => {
const isActive = activeTabId === diffTabId(file.path);
const TypeIcon = file.type === "A" ? FilePlus : file.type === "D" ? FileX : FileCode;
const iconColor = file.type === "A" ? "#7ee787" : file.type === "D" ? "#ffa198" : theme.colors.contentTertiary;
return (
<div
key={file.path}
onClick={() => onOpenDiff(file.path)}
onContextMenu={(event) => openFileMenu(event, file.path)}
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
flexShrink: 0,
fontSize: "11px",
fontFamily: '"IBM Plex Mono", monospace',
gap: "8px",
padding: "6px 10px",
borderRadius: "6px",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
>
<span className={css({ color: "#7ee787" })}>+{file.added}</span>
<span className={css({ color: "#ffa198" })}>-{file.removed}</span>
<span className={css({ color: iconColor, fontWeight: 600, width: "10px", textAlign: "center" })}>{file.type}</span>
<TypeIcon size={14} color={iconColor} style={{ flexShrink: 0 }} />
<div
className={css({
flex: 1,
minWidth: 0,
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "12px",
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{file.path}
</div>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
flexShrink: 0,
fontSize: "11px",
fontFamily: '"IBM Plex Mono", monospace',
})}
>
<span className={css({ color: "#7ee787" })}>+{file.added}</span>
<span className={css({ color: "#ffa198" })}>-{file.removed}</span>
<span className={css({ color: iconColor, fontWeight: 600, width: "10px", textAlign: "center" })}>{file.type}</span>
</div>
</div>
);
})}
</div>
) : (
<div className={css({ padding: "6px 0" })}>
{handoff.fileTree.length > 0 ? (
<FileTree nodes={handoff.fileTree} depth={0} onSelectFile={onOpenDiff} onFileContextMenu={openFileMenu} changedPaths={changedPaths} />
) : (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No files yet</LabelSmall>
</div>
);
})}
</div>
) : (
<div className={css({ padding: "6px 0" })}>
{handoff.fileTree.length > 0 ? (
<FileTree
nodes={handoff.fileTree}
depth={0}
onSelectFile={onOpenDiff}
onFileContextMenu={openFileMenu}
changedPaths={changedPaths}
/>
) : (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No files yet</LabelSmall>
</div>
)}
</div>
)}
</ScrollBody>
)}
</div>
)}
</ScrollBody>
</div>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);

View file

@ -1,17 +1,26 @@
import { memo, useState } from "react";
import { memo, useRef, useState } from "react";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react";
import { ChevronDown, ChevronUp, CloudUpload, GitPullRequestDraft, ListChecks, Plus } from "lucide-react";
import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model";
import {
ContextMenuOverlay,
HandoffIndicator,
PanelHeaderBar,
SPanel,
ScrollBody,
useContextMenu,
} from "./ui";
import { ContextMenuOverlay, HandoffIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
function projectInitial(label: string): string {
const parts = label.split("/");
const name = parts[parts.length - 1] ?? label;
return name.charAt(0).toUpperCase();
}
function projectIconColor(label: string): string {
let hash = 0;
for (let i = 0; i < label.length; i++) {
hash = (hash * 31 + label.charCodeAt(i)) | 0;
}
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
}
export const Sidebar = memo(function Sidebar({
projects,
@ -21,6 +30,7 @@ export const Sidebar = memo(function Sidebar({
onMarkUnread,
onRenameHandoff,
onRenameBranch,
onReorderProjects,
}: {
projects: ProjectSection[];
activeId: string;
@ -29,191 +39,250 @@ export const Sidebar = memo(function Sidebar({
onMarkUnread: (id: string) => void;
onRenameHandoff: (id: string) => void;
onRenameBranch: (id: string) => void;
onReorderProjects: (fromIndex: number, toIndex: number) => void;
}) {
const [css, theme] = useStyletron();
const contextMenu = useContextMenu();
const [expandedProjects, setExpandedProjects] = useState<Record<string, boolean>>({});
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
const dragIndexRef = useRef<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
return (
<SPanel>
<PanelHeaderBar>
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, flex: 1, fontSize: "13px" }}>
Handoffs
<style>{`
[data-project-header]:hover [data-chevron] {
display: inline-flex !important;
}
[data-project-header]:hover [data-project-icon] {
display: none !important;
}
`}</style>
<PanelHeaderBar $style={{ backgroundColor: "transparent", borderBottom: "none" }}>
<LabelSmall
color={theme.colors.contentPrimary}
$style={{ fontWeight: 500, flex: 1, fontSize: "13px", display: "flex", alignItems: "center", gap: "6px", lineHeight: 1 }}
>
<ListChecks size={14} />
Tasks
</LabelSmall>
<button
<div
role="button"
tabIndex={0}
onClick={onCreate}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") onCreate();
}}
className={css({
all: "unset",
width: "24px",
height: "24px",
borderRadius: "4px",
backgroundColor: "#ff4f00",
color: "#ffffff",
width: "26px",
height: "26px",
borderRadius: "8px",
backgroundColor: "rgba(255, 255, 255, 0.12)",
color: "#e4e4e7",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
":hover": { backgroundColor: "#ff6a00" },
flexShrink: 0,
":hover": { backgroundColor: "rgba(255, 255, 255, 0.20)" },
})}
>
<Plus size={14} />
</button>
<Plus size={14} style={{ display: "block" }} />
</div>
</PanelHeaderBar>
<ScrollBody>
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
{projects.map((project) => {
const visibleCount = expandedProjects[project.id] ? project.handoffs.length : Math.min(project.handoffs.length, 5);
const hiddenCount = Math.max(0, project.handoffs.length - visibleCount);
{projects.map((project, projectIndex) => {
const isCollapsed = collapsedProjects[project.id] === true;
const isDragOver = dragOverIndex === projectIndex && dragIndexRef.current !== projectIndex;
return (
<div key={project.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
<div
key={project.id}
draggable
onDragStart={(event) => {
dragIndexRef.current = projectIndex;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", String(projectIndex));
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDragOverIndex(projectIndex);
}}
onDragLeave={() => {
setDragOverIndex((current) => (current === projectIndex ? null : current));
}}
onDrop={(event) => {
event.preventDefault();
const fromIndex = dragIndexRef.current;
if (fromIndex != null && fromIndex !== projectIndex) {
onReorderProjects(fromIndex, projectIndex);
}
dragIndexRef.current = null;
setDragOverIndex(null);
}}
onDragEnd={() => {
dragIndexRef.current = null;
setDragOverIndex(null);
}}
className={css({
display: "flex",
flexDirection: "column",
gap: "4px",
borderTop: isDragOver ? "2px solid #ff4f00" : "2px solid transparent",
transition: "border-color 150ms ease",
})}
>
<div
onClick={() =>
setCollapsedProjects((current) => ({
...current,
[project.id]: !current[project.id],
}))
}
data-project-header
className={css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 8px 4px",
gap: "8px",
cursor: "grab",
userSelect: "none",
":hover": { opacity: 0.8 },
})}
>
<LabelSmall
color={theme.colors.contentSecondary}
$style={{
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{project.label}
</LabelSmall>
<LabelXSmall color={theme.colors.contentTertiary}>
{formatRelativeAge(project.updatedAtMs)}
</LabelXSmall>
</div>
{project.handoffs.slice(0, visibleCount).map((handoff) => {
const isActive = handoff.id === activeId;
const isDim = handoff.status === "archived";
const isRunning = handoff.tabs.some((tab) => tab.status === "running");
const hasUnread = handoff.tabs.some((tab) => tab.unread);
const isDraft = handoff.pullRequest == null || handoff.pullRequest.status === "draft";
const totalAdded = handoff.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = handoff.fileChanges.reduce((sum, file) => sum + file.removed, 0);
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
return (
<div
key={handoff.id}
onClick={() => onSelect(handoff.id)}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Rename handoff", onClick: () => onRenameHandoff(handoff.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(handoff.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) },
])
}
className={css({
padding: "12px",
borderRadius: "8px",
border: isActive ? "1px solid rgba(255, 255, 255, 0.2)" : "1px solid transparent",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
transition: "all 200ms ease",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)",
borderColor: theme.colors.borderOpaque,
},
})}
>
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
<div
className={css({
width: "14px",
minWidth: "14px",
height: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<HandoffIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<LabelSmall
$style={{
fontWeight: 600,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
color={isDim ? theme.colors.contentSecondary : theme.colors.contentPrimary}
>
{handoff.title}
</LabelSmall>
{hasDiffs ? (
<div className={css({ display: "flex", gap: "4px", flexShrink: 0 })}>
<span className={css({ fontSize: "11px", color: "#7ee787" })}>+{totalAdded}</span>
<span className={css({ fontSize: "11px", color: "#ffa198" })}>-{totalRemoved}</span>
<div className={css({ display: "flex", alignItems: "center", gap: "4px", overflow: "hidden" })}>
<div className={css({ position: "relative", width: "14px", height: "14px", flexShrink: 0 })}>
<span
className={css({
position: "absolute",
inset: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "3px",
fontSize: "9px",
fontWeight: 700,
lineHeight: 1,
color: "#fff",
backgroundColor: projectIconColor(project.label),
})}
data-project-icon
>
{projectInitial(project.label)}
</span>
<span className={css({ position: "absolute", inset: 0, display: "none", alignItems: "center", justifyContent: "center" })} data-chevron>
{isCollapsed ? (
<ChevronDown size={12} color={theme.colors.contentTertiary} />
) : (
<ChevronUp size={12} color={theme.colors.contentTertiary} />
)}
</span>
</div>
) : null}
<LabelSmall
color={theme.colors.contentSecondary}
$style={{
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{project.label}
</LabelSmall>
</div>
{isCollapsed ? <LabelXSmall color={theme.colors.contentTertiary}>{formatRelativeAge(project.updatedAtMs)}</LabelXSmall> : null}
</div>
<div className={css({ display: "flex", alignItems: "center", marginTop: "4px", gap: "6px" })}>
<LabelXSmall
color={theme.colors.contentTertiary}
$style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flexShrink: 1,
}}
>
{handoff.repoName}
</LabelXSmall>
{handoff.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
#{handoff.pullRequest.number}
</LabelXSmall>
{handoff.pullRequest.status === "draft" ? <CloudUpload size={11} color="#ff4f00" /> : null}
</span>
) : (
<GitPullRequestDraft size={11} color={theme.colors.contentTertiary} />
)}
<LabelXSmall color={theme.colors.contentTertiary} $style={{ marginLeft: "auto", flexShrink: 0 }}>
{formatRelativeAge(handoff.updatedAtMs)}
</LabelXSmall>
</div>
</div>
);
})}
{hiddenCount > 0 ? (
<button
type="button"
onClick={() =>
setExpandedProjects((current) => ({
...current,
[project.id]: true,
}))
}
className={css({
all: "unset",
padding: "8px 12px 10px 34px",
color: theme.colors.contentSecondary,
fontSize: "12px",
cursor: "pointer",
":hover": { color: theme.colors.contentPrimary },
})}
>
Show {hiddenCount} more
</button>
) : null}
{!isCollapsed &&
project.handoffs.map((handoff) => {
const isActive = handoff.id === activeId;
const isDim = handoff.status === "archived";
const isRunning = handoff.tabs.some((tab) => tab.status === "running");
const hasUnread = handoff.tabs.some((tab) => tab.unread);
const isDraft = handoff.pullRequest == null || handoff.pullRequest.status === "draft";
const totalAdded = handoff.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = handoff.fileChanges.reduce((sum, file) => sum + file.removed, 0);
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
return (
<div
key={handoff.id}
onClick={() => onSelect(handoff.id)}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Rename task", onClick: () => onRenameHandoff(handoff.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(handoff.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) },
])
}
className={css({
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid transparent",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
transition: "all 200ms ease",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)",
},
})}
>
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
<div
className={css({
width: "14px",
minWidth: "14px",
height: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<HandoffIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<LabelSmall
$style={{
fontWeight: hasUnread ? 600 : 400,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minWidth: 0,
flexShrink: 1,
}}
color={hasUnread ? "#ffffff" : theme.colors.contentSecondary}
>
{handoff.title}
</LabelSmall>
{handoff.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
#{handoff.pullRequest.number}
</LabelXSmall>
{handoff.pullRequest.status === "draft" ? <CloudUpload size={11} color="#ff4f00" /> : null}
</span>
) : (
<GitPullRequestDraft size={11} color={theme.colors.contentTertiary} />
)}
{hasDiffs ? (
<div className={css({ display: "flex", gap: "4px", flexShrink: 0, marginLeft: "auto" })}>
<span className={css({ fontSize: "11px", color: "#7ee787" })}>+{totalAdded}</span>
<span className={css({ fontSize: "11px", color: "#ffa198" })}>-{totalRemoved}</span>
</div>
) : null}
<LabelXSmall color={theme.colors.contentTertiary} $style={{ flexShrink: 0, marginLeft: hasDiffs ? undefined : "auto" }}>
{formatRelativeAge(handoff.updatedAtMs)}
</LabelXSmall>
</div>
</div>
);
})}
</div>
);
})}

View file

@ -42,12 +42,18 @@ export const TabStrip = memo(function TabStrip({
return (
<>
<style>{`
[data-tab]:hover [data-tab-close] { opacity: 0.5 !important; }
[data-tab]:hover [data-tab-close]:hover { opacity: 1 !important; }
`}</style>
<div
className={css({
display: "flex",
alignItems: "stretch",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundSecondary,
gap: "4px",
backgroundColor: "#09090b",
paddingLeft: "6px",
height: "41px",
minHeight: "41px",
overflowX: "auto",
@ -79,16 +85,20 @@ export const TabStrip = memo(function TabStrip({
...(handoff.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
])
}
data-tab
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "0 14px",
borderBottom: isActive ? "2px solid #ff4f00" : "2px solid transparent",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
transition: "color 200ms ease, border-color 200ms ease",
transition: "color 200ms ease, background-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7" },
":hover": { color: "#e4e4e7", backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
<div
@ -120,7 +130,13 @@ export const TabStrip = memo(function TabStrip({
}
}}
className={css({
all: "unset",
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
padding: "0",
margin: "0",
outline: "none",
minWidth: "72px",
maxWidth: "180px",
fontSize: "11px",
@ -130,7 +146,7 @@ export const TabStrip = memo(function TabStrip({
})}
/>
) : (
<LabelXSmall color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
<LabelXSmall color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary} $style={{ fontWeight: 500 }}>
{tab.sessionName}
</LabelXSmall>
)}
@ -138,7 +154,8 @@ export const TabStrip = memo(function TabStrip({
<X
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", opacity: 0.5, ":hover": { opacity: 1 } })}
data-tab-close
className={css({ cursor: "pointer", opacity: 0 })}
onClick={(event) => {
event.stopPropagation();
onCloseTab(tab.id);
@ -161,29 +178,34 @@ export const TabStrip = memo(function TabStrip({
onCloseDiffTab(path);
}
}}
data-tab
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "0 14px",
borderBottom: "2px solid transparent",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
transition: "color 200ms ease, border-color 200ms ease",
transition: "color 200ms ease, background-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7" },
":hover": { color: "#e4e4e7", backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
<FileCode size={12} color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary} />
<LabelXSmall
color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary}
$style={{ fontWeight: 600, fontFamily: '"IBM Plex Mono", monospace' }}
$style={{ fontWeight: 500, fontFamily: '"IBM Plex Mono", monospace' }}
>
{fileName(path)}
</LabelXSmall>
<X
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", opacity: 0.5, ":hover": { opacity: 1 } })}
data-tab-close
className={css({ cursor: "pointer", opacity: 0 })}
onClick={(event) => {
event.stopPropagation();
onCloseDiffTab(path);
@ -200,6 +222,7 @@ export const TabStrip = memo(function TabStrip({
padding: "0 10px",
cursor: "pointer",
opacity: 0.4,
lineHeight: 0,
":hover": { opacity: 0.7 },
flexShrink: 0,
})}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography";
import { MailOpen } from "lucide-react";
import { Clock, MailOpen } from "lucide-react";
import { PanelHeaderBar } from "./ui";
import { type AgentTab, type Handoff } from "./view-model";
@ -30,7 +30,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
const [css, theme] = useStyletron();
return (
<PanelHeaderBar>
<PanelHeaderBar $style={{ backgroundColor: "#0f0f11", borderBottom: "none" }}>
{editingField === "title" ? (
<input
autoFocus
@ -45,8 +45,14 @@ export const TranscriptHeader = memo(function TranscriptHeader({
}
}}
className={css({
all: "unset",
fontWeight: 600,
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
padding: "0",
margin: "0",
outline: "none",
fontWeight: 500,
fontSize: "14px",
color: theme.colors.contentPrimary,
borderBottom: "1px solid rgba(255, 255, 255, 0.3)",
@ -58,7 +64,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
<LabelSmall
title="Rename"
color={theme.colors.contentPrimary}
$style={{ fontWeight: 600, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
$style={{ fontWeight: 400, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
onClick={() => onStartEditingField("title", handoff.title)}
>
{handoff.title}
@ -79,7 +85,11 @@ export const TranscriptHeader = memo(function TranscriptHeader({
}
}}
className={css({
all: "unset",
appearance: "none",
WebkitAppearance: "none",
background: "none",
margin: "0",
outline: "none",
padding: "2px 8px",
borderRadius: "999px",
border: "1px solid rgba(255, 255, 255, 0.3)",
@ -113,25 +123,50 @@ export const TranscriptHeader = memo(function TranscriptHeader({
)
) : null}
<div className={css({ flex: 1 })} />
<div
className={css({
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "3px 10px",
borderRadius: "6px",
backgroundColor: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.08)",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
color: theme.colors.contentSecondary,
whiteSpace: "nowrap",
})}
>
<Clock size={11} style={{ flexShrink: 0 }} />
<span>847 min used</span>
</div>
{activeTab ? (
<button
onClick={() => onSetActiveTabUnread(!activeTab.unread)}
className={css({
all: "unset",
display: "flex",
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
color: theme.colors.contentSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: theme.colors.contentPrimary },
})}
>
<MailOpen size={12} /> {activeTab.unread ? "Mark read" : "Mark unread"}
<MailOpen size={12} style={{ flexShrink: 0 }} /> {activeTab.unread ? "Mark read" : "Mark unread"}
</button>
) : null}
</PanelHeaderBar>

View file

@ -129,7 +129,10 @@ export const HandoffIndicator = memo(function HandoffIndicator({
const ClaudeIcon = memo(function ClaudeIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 1200 1200" fill="none" style={{ flexShrink: 0 }}>
<path fill="#D97757" d="M 233.96 800.21 L 468.64 668.54 L 472.59 657.1 L 468.64 650.74 L 457.21 650.74 L 417.99 648.32 L 283.89 644.7 L 167.6 639.87 L 54.93 633.83 L 26.58 627.79 L 0 592.75 L 2.74 575.28 L 26.58 559.25 L 60.72 562.23 L 136.19 567.38 L 249.42 575.19 L 331.57 580.03 L 453.26 592.67 L 472.59 592.67 L 475.33 584.86 L 468.72 580.03 L 463.57 575.19 L 346.39 495.79 L 219.54 411.87 L 153.1 363.54 L 117.18 339.06 L 99.06 316.11 L 91.25 266.01 L 123.87 230.09 L 167.68 233.07 L 178.87 236.05 L 223.25 270.2 L 318.04 343.57 L 441.83 434.74 L 459.95 449.8 L 467.19 444.64 L 468.08 441.02 L 459.95 427.41 L 392.62 305.72 L 320.78 181.93 L 288.81 130.63 L 280.35 99.87 C 277.37 87.22 275.19 76.59 275.19 63.62 L 312.32 13.21 L 332.86 6.6 L 382.39 13.21 L 403.25 31.33 L 434.01 101.72 L 483.87 212.54 L 561.18 363.22 L 583.81 407.92 L 595.89 449.32 L 600.4 461.96 L 608.21 461.96 L 608.21 454.71 L 614.58 369.83 L 626.34 265.61 L 637.77 131.52 L 641.72 93.75 L 660.4 48.48 L 697.53 24 L 726.52 37.85 L 750.36 72 L 747.06 94.07 L 732.89 186.2 L 705.1 330.52 L 686.98 427.17 L 697.53 427.17 L 709.61 415.09 L 758.5 350.17 L 840.64 247.49 L 876.89 206.74 L 919.17 161.72 L 946.31 140.3 L 997.61 140.3 L 1035.38 196.43 L 1018.47 254.42 L 965.64 321.42 L 921.83 378.2 L 859.01 462.77 L 819.79 530.42 L 823.41 535.81 L 832.75 534.93 L 974.66 504.72 L 1051.33 490.87 L 1142.82 475.17 L 1184.21 494.5 L 1188.72 514.15 L 1172.46 554.34 L 1074.6 578.5 L 959.84 601.45 L 788.94 641.88 L 786.85 643.41 L 789.26 646.39 L 866.26 653.64 L 899.19 655.41 L 979.81 655.41 L 1129.93 666.6 L 1169.15 692.54 L 1192.67 724.27 L 1188.72 748.43 L 1128.32 779.19 L 1046.82 759.87 L 856.59 714.6 L 791.36 698.34 L 782.34 698.34 L 782.34 703.73 L 836.7 756.89 L 936.32 846.85 L 1061.07 962.82 L 1067.44 991.49 L 1051.41 1014.12 L 1034.5 1011.7 L 924.89 929.23 L 882.6 892.11 L 786.85 811.49 L 780.48 811.49 L 780.48 819.95 L 802.55 852.24 L 919.09 1027.41 L 925.13 1081.13 L 916.67 1098.6 L 886.47 1109.15 L 853.29 1103.11 L 785.07 1007.36 L 714.68 899.52 L 657.91 802.87 L 650.98 806.82 L 617.48 1167.7 L 601.77 1186.15 L 565.53 1200 L 535.33 1177.05 L 519.3 1139.92 L 535.33 1066.55 L 554.66 970.79 L 570.36 894.68 L 584.54 800.13 L 592.99 768.72 L 592.43 766.63 L 585.5 767.52 L 514.23 865.37 L 405.83 1011.87 L 320.05 1103.68 L 299.52 1111.81 L 263.92 1093.37 L 267.22 1060.43 L 287.11 1031.11 L 405.83 880.11 L 477.42 786.52 L 523.65 732.48 L 523.33 724.67 L 520.59 724.67 L 205.29 929.4 L 149.15 936.64 L 124.99 914.01 L 127.97 876.89 L 139.41 864.81 L 234.2 799.57 Z" />
<path
fill="#D97757"
d="M 233.96 800.21 L 468.64 668.54 L 472.59 657.1 L 468.64 650.74 L 457.21 650.74 L 417.99 648.32 L 283.89 644.7 L 167.6 639.87 L 54.93 633.83 L 26.58 627.79 L 0 592.75 L 2.74 575.28 L 26.58 559.25 L 60.72 562.23 L 136.19 567.38 L 249.42 575.19 L 331.57 580.03 L 453.26 592.67 L 472.59 592.67 L 475.33 584.86 L 468.72 580.03 L 463.57 575.19 L 346.39 495.79 L 219.54 411.87 L 153.1 363.54 L 117.18 339.06 L 99.06 316.11 L 91.25 266.01 L 123.87 230.09 L 167.68 233.07 L 178.87 236.05 L 223.25 270.2 L 318.04 343.57 L 441.83 434.74 L 459.95 449.8 L 467.19 444.64 L 468.08 441.02 L 459.95 427.41 L 392.62 305.72 L 320.78 181.93 L 288.81 130.63 L 280.35 99.87 C 277.37 87.22 275.19 76.59 275.19 63.62 L 312.32 13.21 L 332.86 6.6 L 382.39 13.21 L 403.25 31.33 L 434.01 101.72 L 483.87 212.54 L 561.18 363.22 L 583.81 407.92 L 595.89 449.32 L 600.4 461.96 L 608.21 461.96 L 608.21 454.71 L 614.58 369.83 L 626.34 265.61 L 637.77 131.52 L 641.72 93.75 L 660.4 48.48 L 697.53 24 L 726.52 37.85 L 750.36 72 L 747.06 94.07 L 732.89 186.2 L 705.1 330.52 L 686.98 427.17 L 697.53 427.17 L 709.61 415.09 L 758.5 350.17 L 840.64 247.49 L 876.89 206.74 L 919.17 161.72 L 946.31 140.3 L 997.61 140.3 L 1035.38 196.43 L 1018.47 254.42 L 965.64 321.42 L 921.83 378.2 L 859.01 462.77 L 819.79 530.42 L 823.41 535.81 L 832.75 534.93 L 974.66 504.72 L 1051.33 490.87 L 1142.82 475.17 L 1184.21 494.5 L 1188.72 514.15 L 1172.46 554.34 L 1074.6 578.5 L 959.84 601.45 L 788.94 641.88 L 786.85 643.41 L 789.26 646.39 L 866.26 653.64 L 899.19 655.41 L 979.81 655.41 L 1129.93 666.6 L 1169.15 692.54 L 1192.67 724.27 L 1188.72 748.43 L 1128.32 779.19 L 1046.82 759.87 L 856.59 714.6 L 791.36 698.34 L 782.34 698.34 L 782.34 703.73 L 836.7 756.89 L 936.32 846.85 L 1061.07 962.82 L 1067.44 991.49 L 1051.41 1014.12 L 1034.5 1011.7 L 924.89 929.23 L 882.6 892.11 L 786.85 811.49 L 780.48 811.49 L 780.48 819.95 L 802.55 852.24 L 919.09 1027.41 L 925.13 1081.13 L 916.67 1098.6 L 886.47 1109.15 L 853.29 1103.11 L 785.07 1007.36 L 714.68 899.52 L 657.91 802.87 L 650.98 806.82 L 617.48 1167.7 L 601.77 1186.15 L 565.53 1200 L 535.33 1177.05 L 519.3 1139.92 L 535.33 1066.55 L 554.66 970.79 L 570.36 894.68 L 584.54 800.13 L 592.99 768.72 L 592.43 766.63 L 585.5 767.52 L 514.23 865.37 L 405.83 1011.87 L 320.05 1103.68 L 299.52 1111.81 L 263.92 1093.37 L 267.22 1060.43 L 287.11 1031.11 L 405.83 880.11 L 477.42 786.52 L 523.65 732.48 L 523.33 724.67 L 520.59 724.67 L 205.29 929.4 L 149.15 936.64 L 124.99 914.01 L 127.97 876.89 L 139.41 864.81 L 234.2 799.57 Z"
/>
</svg>
);
});
@ -137,7 +140,10 @@ const ClaudeIcon = memo(function ClaudeIcon({ size = 14 }: { size?: number }) {
const OpenAIIcon = memo(function OpenAIIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0153-1.1639a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="#ffffff" />
<path
d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0153-1.1639a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"
fill="#ffffff"
/>
</svg>
);
});
@ -169,16 +175,15 @@ export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) {
});
export const Shell = styled("div", ({ $theme }) => ({
display: "grid",
gap: "1px",
display: "flex",
height: "100dvh",
backgroundColor: $theme.colors.borderOpaque,
gridTemplateColumns: "280px minmax(0, 1fr) 380px",
backgroundColor: $theme.colors.backgroundSecondary,
overflow: "hidden",
}));
export const SPanel = styled("section", ({ $theme }) => ({
minHeight: 0,
flex: 1,
display: "flex",
flexDirection: "column" as const,
backgroundColor: $theme.colors.backgroundSecondary,

View file

@ -86,7 +86,7 @@ const DetailRail = styled("aside", ({ $theme }) => ({
const FILTER_OPTIONS: SelectItem[] = [
{ id: "active", label: "Active + Unmapped" },
{ id: "archived", label: "Archived Handoffs" },
{ id: "archived", label: "Archived Tasks" },
{ id: "unmapped", label: "Unmapped Only" },
{ id: "all", label: "All Branches" },
];
@ -137,7 +137,7 @@ function branchTestIdToken(value: string): string {
function useSessionEvents(
handoff: HandoffRecord | null,
sessionId: string | null
sessionId: string | null,
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
return useQuery({
queryKey: ["workspace", handoff?.workspaceId ?? "", "session", handoff?.handoffId ?? "", sessionId ?? ""],
@ -147,15 +147,10 @@ function useSessionEvents(
if (!handoff?.activeSandboxId || !sessionId) {
return { items: [] };
}
return backendClient.listSandboxSessionEvents(
handoff.workspaceId,
handoff.providerId,
handoff.activeSandboxId,
{
sessionId,
limit: 120,
}
);
return backendClient.listSandboxSessionEvents(handoff.workspaceId, handoff.providerId, handoff.activeSandboxId, {
sessionId,
limit: 120,
});
},
});
}
@ -343,19 +338,11 @@ function MetaRow({ label, value, mono = false }: { label: string; value: string;
>
<LabelXSmall color="contentSecondary">{label}</LabelXSmall>
{mono ? (
<MonoLabelSmall
marginTop="0"
marginBottom="0"
overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}
>
<MonoLabelSmall marginTop="0" marginBottom="0" overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}>
{value}
</MonoLabelSmall>
) : (
<LabelSmall
marginTop="0"
marginBottom="0"
overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}
>
<LabelSmall marginTop="0" marginBottom="0" overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}>
{value}
</LabelSmall>
)}
@ -407,7 +394,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
refetchInterval: 2_500,
queryFn: async () => {
if (!selectedHandoffId) {
throw new Error("No handoff");
throw new Error("No task selected");
}
return backendClient.getHandoff(workspaceId, selectedHandoffId);
},
@ -483,17 +470,14 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
});
}, [repos, rows]);
const selectedSummary = useMemo(
() => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null,
[rows, selectedHandoffId]
);
const selectedSummary = useMemo(() => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null, [rows, selectedHandoffId]);
const selectedForSession = repoOverviewMode ? null : (handoffDetailQuery.data ?? null);
const activeSandbox = useMemo(() => {
if (!selectedForSession) return null;
const byActive = selectedForSession.activeSandboxId
? selectedForSession.sandboxes.find((sandbox) => sandbox.sandboxId === selectedForSession.activeSandboxId) ?? null
? (selectedForSession.sandboxes.find((sandbox) => sandbox.sandboxId === selectedForSession.activeSandboxId) ?? null)
: null;
return byActive ?? selectedForSession.sandboxes[0] ?? null;
}, [selectedForSession]);
@ -539,7 +523,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
handoffSessionId: selectedForSession?.activeSessionId ?? null,
sessions: sessionRows,
}),
[activeSessionId, selectedForSession?.activeSessionId, sessionRows]
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
);
const resolvedSessionId = sessionSelection.sessionId;
const staleSessionId = sessionSelection.staleSessionId;
@ -548,7 +532,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this handoff");
throw new Error("No sandbox is available for this task");
}
return backendClient.createSandboxSession({
workspaceId,
@ -581,7 +565,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const sendPrompt = useMutation({
mutationFn: async (prompt: string) => {
if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this handoff");
throw new Error("No sandbox is available for this task");
}
const sessionId = await ensureSessionForPrompt();
await backendClient.sendSandboxPrompt({
@ -716,17 +700,14 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
const selectedAgentOption = useMemo(
() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!),
[newAgentType]
);
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
const selectedFilterOption = useMemo(
() => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!),
[overviewFilter]
[overviewFilter],
);
const sessionOptions = useMemo(
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.id} (${session.status ?? "running"})` })),
[sessionRows]
[sessionRows],
);
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;
@ -746,11 +727,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
if (!selectedOverviewBranch) {
return filteredOverviewBranches[0] ?? null;
}
return (
filteredOverviewBranches.find((row) => row.branchName === selectedOverviewBranch) ??
filteredOverviewBranches[0] ??
null
);
return filteredOverviewBranches.find((row) => row.branchName === selectedOverviewBranch) ?? filteredOverviewBranches[0] ?? null;
}, [filteredOverviewBranches, selectedOverviewBranch]);
useEffect(() => {
@ -799,7 +776,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
},
},
}),
[theme.colors.backgroundSecondary, theme.colors.borderOpaque]
[theme.colors.backgroundSecondary, theme.colors.borderOpaque],
);
return (
@ -815,14 +792,14 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
gap: theme.sizing.scale400,
})}
>
<div
className={css({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "2px",
})}
>
<div
className={css({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "2px",
})}
>
<LabelXSmall color="contentTertiary">Workspace</LabelXSmall>
<div
className={css({
@ -857,7 +834,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
borderTop: `1px solid ${theme.colors.borderOpaque}`,
})}
>
<LabelXSmall color="contentSecondary">Handoffs</LabelXSmall>
<LabelXSmall color="contentSecondary">Tasks</LabelXSmall>
</div>
</PanelHeader>
@ -869,7 +846,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
) : null}
{!handoffsQuery.isLoading && repoGroups.length === 0 ? (
<EmptyState>No repos or handoffs yet. Add a repo to start a workspace.</EmptyState>
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
) : null}
{repoGroups.map((group) => (
@ -985,7 +962,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}}
data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`}
>
Create Handoff
Create Task
</Button>
</div>
</section>
@ -1198,7 +1175,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
{formatRelativeAge(branch.updatedAt)}
</ParagraphSmall>
<StatusPill kind={branch.handoffId ? "positive" : "warning"}>
{branch.handoffId ? "handoff" : "unmapped"}
{branch.handoffId ? "task" : "unmapped"}
</StatusPill>
{branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null}
</div>
@ -1291,13 +1268,11 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}}
data-testid={`repo-overview-create-${branchToken}`}
>
Create Handoff
Create Task
</Button>
) : null}
<StatusPill kind={branchKind(branch)}>
{branch.conflictsWithMain ? "conflict" : "ok"}
</StatusPill>
<StatusPill kind={branchKind(branch)}>{branch.conflictsWithMain ? "conflict" : "ok"}</StatusPill>
</div>
</div>
</div>
@ -1331,11 +1306,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
>
<Bot size={16} />
<HeadingXSmall marginTop="0" marginBottom="0">
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No handoff selected"}
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No task selected"}
</HeadingXSmall>
{selectedForSession ? (
<StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill>
) : null}
{selectedForSession ? <StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill> : null}
</div>
{selectedForSession && !resolvedSessionId ? (
@ -1364,7 +1337,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
})}
>
{!selectedForSession ? (
<EmptyState>Select a handoff from the left sidebar.</EmptyState>
<EmptyState>Select a task from the left sidebar.</EmptyState>
) : (
<>
<div
@ -1440,12 +1413,12 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
: "This handoff is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
? "No transcript events yet. Send a prompt to start this session."
: "No active session for this handoff."}
: "This task is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
? "No transcript events yet. Send a prompt to start this session."
: "No active session for this task."}
</EmptyState>
) : null}
@ -1462,14 +1435,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
key={entry.id}
data-testid="session-transcript-entry"
className={css({
borderLeft: `2px solid ${
entry.sender === "agent" ? "rgba(29, 111, 95, 0.45)" : "rgba(32, 108, 176, 0.45)"
}`,
border: `1px solid ${
entry.sender === "agent" ? "rgba(29, 111, 95, 0.22)" : "rgba(32, 108, 176, 0.22)"
}`,
backgroundColor:
entry.sender === "agent" ? "rgba(29, 111, 95, 0.07)" : "rgba(32, 108, 176, 0.07)",
borderLeft: `2px solid ${entry.sender === "agent" ? "rgba(29, 111, 95, 0.45)" : "rgba(32, 108, 176, 0.45)"}`,
border: `1px solid ${entry.sender === "agent" ? "rgba(29, 111, 95, 0.22)" : "rgba(32, 108, 176, 0.22)"}`,
backgroundColor: entry.sender === "agent" ? "rgba(29, 111, 95, 0.07)" : "rgba(32, 108, 176, 0.07)",
padding: `12px ${theme.sizing.scale400}`,
})}
>
@ -1535,11 +1503,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
void sendPrompt.mutateAsync(prompt);
}}
disabled={
sendPrompt.isPending ||
createSession.isPending ||
!selectedForSession ||
!activeSandbox?.sandboxId ||
draft.trim().length === 0
sendPrompt.isPending || createSession.isPending || !selectedForSession || !activeSandbox?.sandboxId || draft.trim().length === 0
}
>
<span
@ -1565,7 +1529,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<DetailRail>
<PanelHeader>
<HeadingSmall marginTop="0" marginBottom="0">
{repoOverviewMode ? "Repo Details" : "Handoff Details"}
{repoOverviewMode ? "Repo Details" : "Task Details"}
</HeadingSmall>
</PanelHeader>
@ -1618,7 +1582,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} />
<MetaRow
label="Handoff"
label="Task"
value={selectedBranchOverview.handoffTitle ?? selectedBranchOverview.handoffId ?? "-"}
/>
</div>
@ -1628,7 +1592,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
)
) : !selectedForSession ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
No handoff selected.
No task selected.
</ParagraphSmall>
) : (
<>
@ -1644,7 +1608,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Handoff" value={selectedForSession.handoffId} mono />
<MetaRow label="Task" value={selectedForSession.handoffId} mono />
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
</div>
@ -1711,9 +1675,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
</LabelSmall>
</div>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{selectedForSession.statusMessage
? selectedForSession.statusMessage
: "Open transcript in the center panel for details."}
{selectedForSession.statusMessage ? selectedForSession.statusMessage : "Open transcript in the center panel for details."}
</ParagraphSmall>
</div>
) : null}
@ -1773,7 +1735,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}}
overrides={modalOverrides}
>
<ModalHeader>Create Handoff</ModalHeader>
<ModalHeader>Create Task</ModalHeader>
<ModalBody>
<div
className={css({
@ -1783,7 +1745,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
})}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
Pick a repo, describe the task, and the backend will create a handoff.
Pick a repo, describe the task, and the backend will create a task.
</ParagraphSmall>
<div>
@ -1921,7 +1883,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}}
data-testid="handoff-create-submit"
>
Create Handoff
Create Task
</Button>
</ModalFooter>
</Modal>

View file

@ -24,7 +24,7 @@ const base: HandoffRecord = {
cwd: null,
createdAt: 10,
updatedAt: 10,
}
},
],
agentType: null,
prSubmitted: false,

View file

@ -4,9 +4,7 @@ import { buildTranscript, extractEventText, resolveSessionSelection } from "./mo
describe("extractEventText", () => {
it("extracts prompt text arrays", () => {
expect(
extractEventText({ params: { prompt: [{ type: "text", text: "hello" }] } })
).toBe("hello");
expect(extractEventText({ params: { prompt: [{ type: "text", text: "hello" }] } })).toBe("hello");
});
it("falls back to method name", () => {
@ -17,9 +15,9 @@ describe("extractEventText", () => {
expect(
extractEventText({
result: {
text: "agent output"
}
})
text: "agent output",
},
}),
).toBe("agent output");
});
@ -31,11 +29,11 @@ describe("extractEventText", () => {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "chunk"
}
}
}
})
text: "chunk",
},
},
},
}),
).toBe("chunk");
});
});
@ -50,7 +48,7 @@ describe("buildTranscript", () => {
createdAt: 1000,
connectionId: "conn-1",
sender: "client",
payload: { params: { prompt: [{ type: "text", text: "hello" }] } }
payload: { params: { prompt: [{ type: "text", text: "hello" }] } },
},
{
id: "evt-2",
@ -59,8 +57,8 @@ describe("buildTranscript", () => {
createdAt: 2000,
connectionId: "conn-1",
sender: "agent",
payload: { params: { text: "world" } }
}
payload: { params: { text: "world" } },
},
]);
expect(rows).toEqual([
@ -68,37 +66,38 @@ describe("buildTranscript", () => {
id: "evt-1",
sender: "client",
text: "hello",
createdAt: 1000
createdAt: 1000,
},
{
id: "evt-2",
sender: "agent",
text: "world",
createdAt: 2000
}
createdAt: 2000,
},
]);
});
});
describe("resolveSessionSelection", () => {
const session = (id: string, status: "running" | "idle" | "error" = "running"): SandboxSessionRecord => ({
id,
agentSessionId: `agent-${id}`,
lastConnectionId: `conn-${id}`,
createdAt: 1,
status
} as SandboxSessionRecord);
const session = (id: string, status: "running" | "idle" | "error" = "running"): SandboxSessionRecord =>
({
id,
agentSessionId: `agent-${id}`,
lastConnectionId: `conn-${id}`,
createdAt: 1,
status,
}) as SandboxSessionRecord;
it("prefers explicit selection when present in session list", () => {
const resolved = resolveSessionSelection({
explicitSessionId: "session-2",
handoffSessionId: "session-1",
sessions: [session("session-1"), session("session-2")]
sessions: [session("session-1"), session("session-2")],
});
expect(resolved).toEqual({
sessionId: "session-2",
staleSessionId: null
staleSessionId: null,
});
});
@ -106,12 +105,12 @@ describe("resolveSessionSelection", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
handoffSessionId: "session-1",
sessions: [session("session-1")]
sessions: [session("session-1")],
});
expect(resolved).toEqual({
sessionId: "session-1",
staleSessionId: null
staleSessionId: null,
});
});
@ -119,12 +118,12 @@ describe("resolveSessionSelection", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
handoffSessionId: "session-stale",
sessions: [session("session-fresh")]
sessions: [session("session-fresh")],
});
expect(resolved).toEqual({
sessionId: "session-fresh",
staleSessionId: null
staleSessionId: null,
});
});
@ -132,12 +131,12 @@ describe("resolveSessionSelection", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
handoffSessionId: "session-stale",
sessions: []
sessions: [],
});
expect(resolved).toEqual({
sessionId: null,
staleSessionId: "session-stale"
staleSessionId: "session-stale",
});
});
});

View file

@ -105,11 +105,7 @@ export function buildTranscript(events: SandboxSessionEventRecord[]): Array<{
}));
}
export function resolveSessionSelection(input: {
explicitSessionId: string | null;
handoffSessionId: string | null;
sessions: SandboxSessionRecord[];
}): {
export function resolveSessionSelection(input: { explicitSessionId: string | null; handoffSessionId: string | null; sessions: SandboxSessionRecord[] }): {
sessionId: string | null;
staleSessionId: string | null;
} {

View file

@ -1,7 +1,8 @@
import { createBackendClient } from "@openhandoff/client";
import { backendEndpoint, defaultWorkspaceId } from "./env";
import { backendEndpoint, defaultWorkspaceId, frontendClientMode } from "./env";
export const backendClient = createBackendClient({
endpoint: backendEndpoint,
defaultWorkspaceId,
mode: frontendClientMode,
});

View file

@ -11,8 +11,7 @@ type FrontendImportMetaEnv = ImportMetaEnv & {
const frontendEnv = import.meta.env as FrontendImportMetaEnv;
export const backendEndpoint =
import.meta.env.VITE_HF_BACKEND_ENDPOINT?.trim() || resolveDefaultBackendEndpoint();
export const backendEndpoint = import.meta.env.VITE_HF_BACKEND_ENDPOINT?.trim() || resolveDefaultBackendEndpoint();
export const defaultWorkspaceId = import.meta.env.VITE_HF_WORKSPACE?.trim() || "default";
@ -24,9 +23,7 @@ function resolveFrontendClientMode(): "mock" | "remote" {
if (raw === "remote" || raw === "" || raw === undefined) {
return "remote";
}
throw new Error(
`Unsupported OPENHANDOFF_FRONTEND_CLIENT_MODE value "${frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
);
throw new Error(`Unsupported OPENHANDOFF_FRONTEND_CLIENT_MODE value "${frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`);
}
export const frontendClientMode = resolveFrontendClientMode();

View file

@ -29,5 +29,5 @@ createRoot(document.getElementById("root")!).render(
</QueryClientProvider>
</BaseProvider>
</StyletronProvider>
</StrictMode>
</StrictMode>,
);

View file

@ -39,7 +39,9 @@ a {
}
@keyframes hf-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
button,
@ -102,7 +104,7 @@ pre {
}
.mock-diff-row[data-kind="remove"] {
background: rgba(248, 81, 73, 0.10);
background: rgba(248, 81, 73, 0.1);
}
.mock-diff-row[data-kind="hunk"] {

View file

@ -6,9 +6,7 @@ const backendProxyTarget = process.env.HF_BACKEND_HTTP?.trim() || "http://127.0.
const cacheDir = process.env.HF_VITE_CACHE_DIR?.trim() || undefined;
export default defineConfig({
define: {
"import.meta.env.OPENHANDOFF_FRONTEND_CLIENT_MODE": JSON.stringify(
process.env.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim() || "remote",
),
"import.meta.env.OPENHANDOFF_FRONTEND_CLIENT_MODE": JSON.stringify(process.env.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim() || "remote"),
},
plugins: [react(), frontendErrorCollectorVitePlugin()],
cacheDir,