mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 00:04:54 +00:00
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:
commit
c8a095b69f
302 changed files with 11419 additions and 9952 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
5
factory/packages/frontend/public/favicon.svg
Normal file
5
factory/packages/frontend/public/favicon.svg
Normal 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 |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const base: HandoffRecord = {
|
|||
cwd: null,
|
||||
createdAt: 10,
|
||||
updatedAt: 10,
|
||||
}
|
||||
},
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
} {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -29,5 +29,5 @@ createRoot(document.getElementById("root")!).render(
|
|||
</QueryClientProvider>
|
||||
</BaseProvider>
|
||||
</StyletronProvider>
|
||||
</StrictMode>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue