Integrate OpenHandoff factory workspace (#212)

This commit is contained in:
Nathan Flurry 2026-03-09 14:00:20 -07:00 committed by GitHub
parent 3d9476ed0b
commit bf282199b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
251 changed files with 42824 additions and 692 deletions

View file

@ -0,0 +1,916 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useNavigate } from "@tanstack/react-router";
import { DiffContent } from "./mock-layout/diff-content";
import { MessageList } from "./mock-layout/message-list";
import { PromptComposer } from "./mock-layout/prompt-composer";
import { RightSidebar } from "./mock-layout/right-sidebar";
import { Sidebar } from "./mock-layout/sidebar";
import { TabStrip } from "./mock-layout/tab-strip";
import { TranscriptHeader } from "./mock-layout/transcript-header";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
import {
buildDisplayMessages,
buildHistoryEvents,
diffPath,
diffTabId,
formatThinkingDuration,
isDiffTab,
type Handoff,
type HistoryEvent,
type LineAttachment,
type Message,
type ModelId,
} from "./mock-layout/view-model";
import { handoffWorkbenchClient } from "../lib/workbench";
function firstAgentTabId(handoff: Handoff): string | null {
return handoff.tabs[0]?.id ?? null;
}
function sanitizeOpenDiffs(handoff: Handoff, paths: string[] | undefined): string[] {
if (!paths) {
return [];
}
return paths.filter((path) => handoff.diffs[path] != null);
}
function sanitizeLastAgentTabId(handoff: Handoff, tabId: string | null | undefined): string | null {
if (tabId && handoff.tabs.some((tab) => tab.id === tabId)) {
return tabId;
}
return firstAgentTabId(handoff);
}
function sanitizeActiveTabId(
handoff: Handoff,
tabId: string | null | undefined,
openDiffs: string[],
lastAgentTabId: string | null,
): string | null {
if (tabId) {
if (handoff.tabs.some((tab) => tab.id === tabId)) {
return tabId;
}
if (isDiffTab(tabId) && openDiffs.includes(diffPath(tabId))) {
return tabId;
}
}
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
}
const TranscriptPanel = memo(function TranscriptPanel({
handoff,
activeTabId,
lastAgentTabId,
openDiffs,
onSyncRouteSession,
onSetActiveTabId,
onSetLastAgentTabId,
onSetOpenDiffs,
}: {
handoff: Handoff;
activeTabId: string | null;
lastAgentTabId: string | null;
openDiffs: string[];
onSyncRouteSession: (handoffId: string, sessionId: string | null, replace?: boolean) => void;
onSetActiveTabId: (tabId: string | null) => void;
onSetLastAgentTabId: (tabId: string | null) => void;
onSetOpenDiffs: (paths: string[]) => void;
}) {
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4");
const [editingField, setEditingField] = useState<"title" | "branch" | null>(null);
const [editValue, setEditValue] = useState("");
const [editingSessionTabId, setEditingSessionTabId] = useState<string | null>(null);
const [editingSessionName, setEditingSessionName] = useState("");
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef(new Map<string, HTMLDivElement>());
const activeDiff = activeTabId && isDiffTab(activeTabId) ? diffPath(activeTabId) : null;
const activeAgentTab = activeDiff ? null : (handoff.tabs.find((candidate) => candidate.id === activeTabId) ?? handoff.tabs[0] ?? null);
const promptTab = handoff.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? handoff.tabs[0] ?? null;
const isTerminal = handoff.status === "archived";
const historyEvents = useMemo(() => buildHistoryEvents(handoff.tabs), [handoff.tabs]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]);
const draft = promptTab?.draft.text ?? "";
const attachments = promptTab?.draft.attachments ?? [];
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [activeMessages.length]);
useEffect(() => {
textareaRef.current?.focus();
}, [activeTabId, handoff.id]);
useEffect(() => {
setEditingSessionTabId(null);
setEditingSessionName("");
}, [handoff.id]);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
textarea.style.height = `${PROMPT_TEXTAREA_MIN_HEIGHT}px`;
const nextHeight = Math.min(textarea.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT);
textarea.style.height = `${Math.max(PROMPT_TEXTAREA_MIN_HEIGHT, nextHeight)}px`;
textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden";
}, [draft, activeTabId, handoff.id]);
useEffect(() => {
if (!pendingHistoryTarget || activeTabId !== pendingHistoryTarget.tabId) {
return;
}
const targetNode = messageRefs.current.get(pendingHistoryTarget.messageId);
if (!targetNode) {
return;
}
targetNode.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingHistoryTarget(null);
}, [activeMessages.length, activeTabId, pendingHistoryTarget]);
useEffect(() => {
if (!copiedMessageId) {
return;
}
const timer = setTimeout(() => {
setCopiedMessageId(null);
}, 1_200);
return () => clearTimeout(timer);
}, [copiedMessageId]);
useEffect(() => {
if (!activeAgentTab || activeAgentTab.status !== "running" || activeAgentTab.thinkingSinceMs === null) {
return;
}
setTimerNowMs(Date.now());
const timer = window.setInterval(() => {
setTimerNowMs(Date.now());
}, 1_000);
return () => window.clearInterval(timer);
}, [activeAgentTab?.id, activeAgentTab?.status, activeAgentTab?.thinkingSinceMs]);
useEffect(() => {
if (!activeAgentTab?.unread) {
return;
}
void handoffWorkbenchClient.setSessionUnread({
handoffId: handoff.id,
tabId: activeAgentTab.id,
unread: false,
});
}, [activeAgentTab?.id, activeAgentTab?.unread, handoff.id]);
const startEditingField = useCallback((field: "title" | "branch", value: string) => {
setEditingField(field);
setEditValue(value);
}, []);
const cancelEditingField = useCallback(() => {
setEditingField(null);
}, []);
const commitEditingField = useCallback(
(field: "title" | "branch") => {
const value = editValue.trim();
if (!value) {
setEditingField(null);
return;
}
if (field === "title") {
void handoffWorkbenchClient.renameHandoff({ handoffId: handoff.id, value });
} else {
void handoffWorkbenchClient.renameBranch({ handoffId: handoff.id, value });
}
setEditingField(null);
},
[editValue, handoff.id],
);
const updateDraft = useCallback(
(nextText: string, nextAttachments: LineAttachment[]) => {
if (!promptTab) {
return;
}
void handoffWorkbenchClient.updateDraft({
handoffId: handoff.id,
tabId: promptTab.id,
text: nextText,
attachments: nextAttachments,
});
},
[handoff.id, promptTab],
);
const sendMessage = useCallback(() => {
const text = draft.trim();
if (!text || !promptTab) {
return;
}
onSetActiveTabId(promptTab.id);
onSetLastAgentTabId(promptTab.id);
void handoffWorkbenchClient.sendMessage({
handoffId: handoff.id,
tabId: promptTab.id,
text,
attachments,
});
}, [attachments, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
const stopAgent = useCallback(() => {
if (!promptTab) {
return;
}
void handoffWorkbenchClient.stopAgent({
handoffId: handoff.id,
tabId: promptTab.id,
});
}, [handoff.id, promptTab]);
const switchTab = useCallback(
(tabId: string) => {
onSetActiveTabId(tabId);
if (!isDiffTab(tabId)) {
onSetLastAgentTabId(tabId);
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (tab?.unread) {
void handoffWorkbenchClient.setSessionUnread({
handoffId: handoff.id,
tabId,
unread: false,
});
}
onSyncRouteSession(handoff.id, tabId);
}
},
[handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
);
const setTabUnread = useCallback(
(tabId: string, unread: boolean) => {
void handoffWorkbenchClient.setSessionUnread({ handoffId: handoff.id, tabId, unread });
},
[handoff.id],
);
const startRenamingTab = useCallback(
(tabId: string) => {
const targetTab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (!targetTab) {
throw new Error(`Unable to rename missing session tab ${tabId}`);
}
setEditingSessionTabId(tabId);
setEditingSessionName(targetTab.sessionName);
},
[handoff.tabs],
);
const cancelTabRename = useCallback(() => {
setEditingSessionTabId(null);
setEditingSessionName("");
}, []);
const commitTabRename = useCallback(() => {
if (!editingSessionTabId) {
return;
}
const trimmedName = editingSessionName.trim();
if (!trimmedName) {
cancelTabRename();
return;
}
void handoffWorkbenchClient.renameSession({
handoffId: handoff.id,
tabId: editingSessionTabId,
title: trimmedName,
});
cancelTabRename();
}, [cancelTabRename, editingSessionName, editingSessionTabId, handoff.id]);
const closeTab = useCallback(
(tabId: string) => {
const remainingTabs = handoff.tabs.filter((candidate) => candidate.id !== tabId);
const nextTabId = remainingTabs[0]?.id ?? null;
if (activeTabId === tabId) {
onSetActiveTabId(nextTabId);
}
if (lastAgentTabId === tabId) {
onSetLastAgentTabId(nextTabId);
}
onSyncRouteSession(handoff.id, nextTabId);
void handoffWorkbenchClient.closeTab({ handoffId: handoff.id, tabId });
},
[activeTabId, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
);
const closeDiffTab = useCallback(
(path: string) => {
const nextOpenDiffs = openDiffs.filter((candidate) => candidate !== path);
onSetOpenDiffs(nextOpenDiffs);
if (activeTabId === diffTabId(path)) {
onSetActiveTabId(
nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(handoff)),
);
}
},
[activeTabId, handoff, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs],
);
const addTab = useCallback(() => {
void (async () => {
const { tabId } = await handoffWorkbenchClient.addTab({ handoffId: handoff.id });
onSetLastAgentTabId(tabId);
onSetActiveTabId(tabId);
onSyncRouteSession(handoff.id, tabId);
})();
}, [handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
const changeModel = useCallback(
(model: ModelId) => {
if (!promptTab) {
throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`);
}
void handoffWorkbenchClient.changeModel({
handoffId: handoff.id,
tabId: promptTab.id,
model,
});
},
[handoff.id, promptTab],
);
const addAttachment = useCallback(
(filePath: string, lineNumber: number, lineContent: string) => {
if (!promptTab) {
return;
}
const nextAttachment = { id: `${filePath}:${lineNumber}`, filePath, lineNumber, lineContent };
if (attachments.some((attachment) => attachment.filePath === filePath && attachment.lineNumber === lineNumber)) {
return;
}
updateDraft(draft, [...attachments, nextAttachment]);
},
[attachments, draft, promptTab, updateDraft],
);
const removeAttachment = useCallback(
(id: string) => {
updateDraft(
draft,
attachments.filter((attachment) => attachment.id !== id),
);
},
[attachments, draft, updateDraft],
);
const jumpToHistoryEvent = useCallback(
(event: HistoryEvent) => {
setPendingHistoryTarget({ messageId: event.messageId, tabId: event.tabId });
if (activeTabId !== event.tabId) {
switchTab(event.tabId);
return;
}
const targetNode = messageRefs.current.get(event.messageId);
if (targetNode) {
targetNode.scrollIntoView({ behavior: "smooth", block: "center" });
setPendingHistoryTarget(null);
}
},
[activeTabId, switchTab],
);
const copyMessage = useCallback(async (message: Message) => {
try {
if (!window.navigator.clipboard) {
throw new Error("Clipboard API unavailable in mock layout");
}
await window.navigator.clipboard.writeText(message.text);
setCopiedMessageId(message.id);
} catch (error) {
console.error("Failed to copy transcript message", error);
}
}, []);
const thinkingTimerLabel =
activeAgentTab?.status === "running" && activeAgentTab.thinkingSinceMs !== null
? formatThinkingDuration(timerNowMs - activeAgentTab.thinkingSinceMs)
: null;
return (
<SPanel>
<TranscriptHeader
handoff={handoff}
activeTab={activeAgentTab}
editingField={editingField}
editValue={editValue}
onEditValueChange={setEditValue}
onStartEditingField={startEditingField}
onCommitEditingField={commitEditingField}
onCancelEditingField={cancelEditingField}
onSetActiveTabUnread={(unread) => {
if (activeAgentTab) {
setTabUnread(activeAgentTab.id, unread);
}
}}
/>
<TabStrip
handoff={handoff}
activeTabId={activeTabId}
openDiffs={openDiffs}
editingSessionTabId={editingSessionTabId}
editingSessionName={editingSessionName}
onEditingSessionNameChange={setEditingSessionName}
onSwitchTab={switchTab}
onStartRenamingTab={startRenamingTab}
onCommitSessionRename={commitTabRename}
onCancelSessionRename={cancelTabRename}
onSetTabUnread={setTabUnread}
onCloseTab={closeTab}
onCloseDiffTab={closeDiffTab}
onAddTab={addTab}
/>
{activeDiff ? (
<DiffContent
filePath={activeDiff}
file={handoff.fileChanges.find((file) => file.path === activeDiff)}
diff={handoff.diffs[activeDiff]}
onAddAttachment={addAttachment}
/>
) : handoff.tabs.length === 0 ? (
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Sessions are where you chat with the agent. Start one now to send the first prompt on this handoff.
</p>
<button
type="button"
onClick={addTab}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: "#ff4f00",
color: "#fff",
cursor: "pointer",
fontWeight: 600,
}}
>
New session
</button>
</div>
</div>
</ScrollBody>
) : (
<ScrollBody>
<MessageList
tab={activeAgentTab}
scrollRef={scrollRef}
messageRefs={messageRefs}
historyEvents={historyEvents}
onSelectHistoryEvent={jumpToHistoryEvent}
copiedMessageId={copiedMessageId}
onCopyMessage={(message) => {
void copyMessage(message);
}}
thinkingTimerLabel={thinkingTimerLabel}
/>
</ScrollBody>
)}
{!isTerminal && promptTab ? (
<PromptComposer
draft={draft}
textareaRef={textareaRef}
placeholder={!promptTab.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
defaultModel={defaultModel}
model={promptTab.model}
isRunning={promptTab.status === "running"}
onDraftChange={(value) => updateDraft(value, attachments)}
onSend={sendMessage}
onStop={stopAgent}
onRemoveAttachment={removeAttachment}
onChangeModel={changeModel}
onSetDefaultModel={setDefaultModel}
/>
) : null}
</SPanel>
);
});
interface MockLayoutProps {
workspaceId: string;
selectedHandoffId?: string | null;
selectedSessionId?: string | null;
}
export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) {
const navigate = useNavigate();
const viewModel = useSyncExternalStore(
handoffWorkbenchClient.subscribe.bind(handoffWorkbenchClient),
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
);
const handoffs = viewModel.handoffs ?? [];
const projects = viewModel.projects ?? [];
const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState<Record<string, string | null>>({});
const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState<Record<string, string | null>>({});
const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState<Record<string, string[]>>({});
const activeHandoff = useMemo(
() => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null,
[handoffs, selectedHandoffId],
);
useEffect(() => {
if (activeHandoff) {
return;
}
const fallbackHandoffId = handoffs[0]?.id;
if (!fallbackHandoffId) {
return;
}
const fallbackHandoff = handoffs.find((handoff) => handoff.id === fallbackHandoffId) ?? null;
void navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId: fallbackHandoffId,
},
search: { sessionId: fallbackHandoff?.tabs[0]?.id ?? undefined },
replace: true,
});
}, [activeHandoff, handoffs, navigate, workspaceId]);
const openDiffs = activeHandoff ? sanitizeOpenDiffs(activeHandoff, openDiffsByHandoff[activeHandoff.id]) : [];
const lastAgentTabId = activeHandoff ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) : null;
const activeTabId = activeHandoff
? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId)
: null;
const syncRouteSession = useCallback(
(handoffId: string, sessionId: string | null, replace = false) => {
void navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId,
},
search: { sessionId: sessionId ?? undefined },
...(replace ? { replace: true } : {}),
});
},
[navigate, workspaceId],
);
useEffect(() => {
if (!activeHandoff) {
return;
}
const resolvedRouteSessionId = sanitizeLastAgentTabId(activeHandoff, selectedSessionId);
if (!resolvedRouteSessionId) {
return;
}
if (selectedSessionId !== resolvedRouteSessionId) {
syncRouteSession(activeHandoff.id, resolvedRouteSessionId, true);
return;
}
if (lastAgentTabIdByHandoff[activeHandoff.id] === resolvedRouteSessionId) {
return;
}
setLastAgentTabIdByHandoff((current) => ({
...current,
[activeHandoff.id]: resolvedRouteSessionId,
}));
setActiveTabIdByHandoff((current) => {
const currentActive = current[activeHandoff.id];
if (currentActive && isDiffTab(currentActive)) {
return current;
}
return {
...current,
[activeHandoff.id]: resolvedRouteSessionId,
};
});
}, [activeHandoff, lastAgentTabIdByHandoff, selectedSessionId, syncRouteSession]);
const createHandoff = useCallback(() => {
void (async () => {
const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? "";
if (!repoId) {
throw new Error("Cannot create a handoff without an available repo");
}
const task = window.prompt("Describe the handoff task", "Investigate and implement the requested change");
if (!task) {
return;
}
const title = window.prompt("Optional handoff title", "")?.trim() || undefined;
const branch = window.prompt("Optional branch name", "")?.trim() || undefined;
const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({
repoId,
task,
model: "gpt-4o",
...(title ? { title } : {}),
...(branch ? { branch } : {}),
});
await navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId,
},
search: { sessionId: tabId ?? undefined },
});
})();
}, [activeHandoff?.repoId, navigate, viewModel.repos, workspaceId]);
const openDiffTab = useCallback(
(path: string) => {
if (!activeHandoff) {
throw new Error("Cannot open a diff tab without an active handoff");
}
setOpenDiffsByHandoff((current) => {
const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]);
if (existing.includes(path)) {
return current;
}
return {
...current,
[activeHandoff.id]: [...existing, path],
};
});
setActiveTabIdByHandoff((current) => ({
...current,
[activeHandoff.id]: diffTabId(path),
}));
},
[activeHandoff],
);
const selectHandoff = useCallback(
(id: string) => {
const handoff = handoffs.find((candidate) => candidate.id === id) ?? null;
void navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
params: {
workspaceId,
handoffId: id,
},
search: { sessionId: handoff?.tabs[0]?.id ?? undefined },
});
},
[handoffs, navigate, workspaceId],
);
const markHandoffUnread = useCallback((id: string) => {
void handoffWorkbenchClient.markHandoffUnread({ handoffId: id });
}, []);
const renameHandoff = useCallback(
(id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) {
throw new Error(`Unable to rename missing handoff ${id}`);
}
const nextTitle = window.prompt("Rename handoff", currentHandoff.title);
if (nextTitle === null) {
return;
}
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle) {
return;
}
void handoffWorkbenchClient.renameHandoff({ handoffId: id, value: trimmedTitle });
},
[handoffs],
);
const renameBranch = useCallback(
(id: string) => {
const currentHandoff = handoffs.find((handoff) => handoff.id === id);
if (!currentHandoff) {
throw new Error(`Unable to rename missing handoff ${id}`);
}
const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? "");
if (nextBranch === null) {
return;
}
const trimmedBranch = nextBranch.trim();
if (!trimmedBranch) {
return;
}
void handoffWorkbenchClient.renameBranch({ handoffId: id, value: trimmedBranch });
},
[handoffs],
);
const archiveHandoff = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot archive without an active handoff");
}
void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id });
}, [activeHandoff]);
const publishPr = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot publish PR without an active handoff");
}
void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id });
}, [activeHandoff]);
const revertFile = useCallback(
(path: string) => {
if (!activeHandoff) {
throw new Error("Cannot revert a file without an active handoff");
}
setOpenDiffsByHandoff((current) => ({
...current,
[activeHandoff.id]: sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]).filter((candidate) => candidate !== path),
}));
setActiveTabIdByHandoff((current) => ({
...current,
[activeHandoff.id]:
current[activeHandoff.id] === diffTabId(path)
? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id])
: current[activeHandoff.id] ?? null,
}));
void handoffWorkbenchClient.revertFile({
handoffId: activeHandoff.id,
path,
});
},
[activeHandoff, lastAgentTabIdByHandoff],
);
if (!activeHandoff) {
return (
<Shell>
<Sidebar
projects={projects}
activeId=""
onSelect={selectHandoff}
onCreate={createHandoff}
onMarkUnread={markHandoffUnread}
onRenameHandoff={renameHandoff}
onRenameBranch={renameBranch}
/>
<SPanel>
<ScrollBody>
<div
style={{
minHeight: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "32px",
}}
>
<div
style={{
maxWidth: "420px",
textAlign: "center",
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first 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}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: viewModel.repos.length > 0 ? "#ff4f00" : "#444",
color: "#fff",
cursor: viewModel.repos.length > 0 ? "pointer" : "not-allowed",
fontWeight: 600,
}}
>
New handoff
</button>
</div>
</div>
</ScrollBody>
</SPanel>
<SPanel />
</Shell>
);
}
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>
);
}

View file

@ -0,0 +1,77 @@
import { memo, useMemo } from "react";
import { FileCode, Plus } from "lucide-react";
import { ScrollBody } from "./ui";
import { parseDiffLines, type FileChange } from "./view-model";
export const DiffContent = memo(function DiffContent({
filePath,
file,
diff,
onAddAttachment,
}: {
filePath: string;
file?: FileChange;
diff?: string;
onAddAttachment?: (filePath: string, lineNumber: number, lineContent: string) => void;
}) {
const diffLines = useMemo(() => (diff ? parseDiffLines(diff) : []), [diff]);
return (
<>
<div className="mock-diff-header">
<FileCode size={14} color="#71717a" />
<div className="mock-diff-path">{filePath}</div>
{file ? (
<div className="mock-diff-stats">
<span className="mock-diff-added">+{file.added}</span>
<span className="mock-diff-removed">&minus;{file.removed}</span>
</div>
) : null}
</div>
<ScrollBody>
{diff ? (
<div className="mock-diff-body">
{diffLines.map((line) => {
const isHunk = line.kind === "hunk";
return (
<div
key={`${line.lineNumber}-${line.text}`}
className="mock-diff-row"
data-kind={line.kind}
style={!isHunk && onAddAttachment ? { cursor: "pointer" } : undefined}
onClick={!isHunk && onAddAttachment ? () => onAddAttachment(filePath, line.lineNumber, line.text) : undefined}
>
<div className="mock-diff-gutter">
{!isHunk && onAddAttachment ? (
<button
type="button"
aria-label={`Attach line ${line.lineNumber}`}
tabIndex={-1}
className="mock-diff-attach-button"
onClick={(event) => {
event.stopPropagation();
onAddAttachment(filePath, line.lineNumber, line.text);
}}
>
<Plus size={13} />
</button>
) : null}
<span className="mock-diff-line-number">{isHunk ? "" : line.lineNumber}</span>
</div>
<div data-selectable className="mock-diff-line-text">
{line.text}
</div>
</div>
);
})}
</div>
) : (
<div className="mock-diff-empty">
<div className="mock-diff-empty-copy">No diff data available for this file</div>
</div>
)}
</ScrollBody>
</>
);
});

View file

@ -0,0 +1,134 @@
import { memo, useEffect, useState } from "react";
import { useStyletron } from "baseui";
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;
}) {
const [css, theme] = useStyletron();
const [open, setOpen] = useState(false);
const [activeEventId, setActiveEventId] = useState<string | null>(events[events.length - 1]?.id ?? null);
useEffect(() => {
if (!events.some((event) => event.id === activeEventId)) {
setActiveEventId(events[events.length - 1]?.id ?? null);
}
}, [activeEventId, events]);
if (events.length === 0) {
return null;
}
return (
<div
className={css({
position: "absolute",
top: "20px",
right: "16px",
zIndex: 3,
display: "flex",
alignItems: "flex-start",
gap: "12px",
})}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
{open ? (
<div
className={css({
width: "220px",
maxHeight: "320px",
overflowY: "auto",
})}
>
<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
</LabelXSmall>
<LabelXSmall color={theme.colors.contentTertiary}>{events.length}</LabelXSmall>
</div>
<div className={css({ display: "flex", flexDirection: "column", gap: "6px" })}>
{events.map((event) => {
const isActive = event.id === activeEventId;
return (
<button
key={event.id}
type="button"
onMouseEnter={() => setActiveEventId(event.id)}
onFocus={() => setActiveEventId(event.id)}
onClick={() => onSelect(event)}
className={css({
all: "unset",
display: "grid",
gridTemplateColumns: "1fr auto",
gap: "10px",
alignItems: "center",
padding: "9px 10px",
borderRadius: "12px",
cursor: "pointer",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.08)" : "transparent",
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "background 160ms ease, color 160ms ease",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.08)",
color: theme.colors.contentPrimary,
},
})}
>
<div className={css({ minWidth: 0, display: "flex", flexDirection: "column", gap: "4px" })}>
<div
className={css({
fontSize: "12px",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{event.preview}
</div>
<LabelXSmall color={theme.colors.contentTertiary}>{event.sessionName}</LabelXSmall>
</div>
<LabelXSmall color={theme.colors.contentTertiary}>{formatMessageTimestamp(event.createdAtMs)}</LabelXSmall>
</button>
);
})}
</div>
</div>
) : null}
<div
className={css({
width: "18px",
padding: "4px 0",
display: "flex",
flexDirection: "column",
gap: "5px",
alignItems: "stretch",
})}
>
{events.map((event) => {
const isActive = event.id === activeEventId;
return (
<div
key={event.id}
className={css({
height: "3px",
borderRadius: "999px",
backgroundColor: isActive ? "#ff4f00" : "rgba(255, 255, 255, 0.22)",
opacity: isActive ? 1 : 0.75,
transition: "background 160ms ease, opacity 160ms ease",
})}
/>
);
})}
</div>
</div>
);
});

View file

@ -0,0 +1,197 @@
import { memo, type MutableRefObject, type Ref } from "react";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import { Copy } from "lucide-react";
import { HistoryMinimap } from "./history-minimap";
import { SpinnerDot } from "./ui";
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model";
export const MessageList = memo(function MessageList({
tab,
scrollRef,
messageRefs,
historyEvents,
onSelectHistoryEvent,
copiedMessageId,
onCopyMessage,
thinkingTimerLabel,
}: {
tab: AgentTab | null | undefined;
scrollRef: Ref<HTMLDivElement>;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
historyEvents: HistoryEvent[];
onSelectHistoryEvent: (event: HistoryEvent) => void;
copiedMessageId: string | null;
onCopyMessage: (message: Message) => void;
thinkingTimerLabel: string | null;
}) {
const [css, theme] = useStyletron();
const messages = buildDisplayMessages(tab);
return (
<>
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
<div
ref={scrollRef}
className={css({
padding: "16px 220px 16px 44px",
display: "flex",
flexDirection: "column",
gap: "12px",
flex: 1,
minHeight: 0,
overflowY: "auto",
})}
>
{tab && messages.length === 0 ? (
<div
className={css({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
flex: 1,
minHeight: "200px",
gap: "8px",
})}
>
<LabelSmall color={theme.colors.contentTertiary}>
{!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;
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" }}
>
{displayFooter}
</LabelXSmall>
) : 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>
</div>
</div>
);
})}
{tab && tab.status === "running" && messages.length > 0 ? (
<div className={css({ display: "flex", alignItems: "center", gap: "8px", padding: "4px 0" })}>
<SpinnerDot size={12} />
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>Agent is thinking</span>
{thinkingTimerLabel ? (
<span
className={css({
padding: "2px 7px",
borderRadius: "999px",
backgroundColor: "rgba(255, 79, 0, 0.12)",
border: "1px solid rgba(255, 79, 0, 0.2)",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "10px",
letterSpacing: "0.04em",
})}
>
{thinkingTimerLabel}
</span>
) : null}
</LabelXSmall>
</div>
) : null}
</div>
</>
);
});

View file

@ -0,0 +1,163 @@
import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronDown, Star } from "lucide-react";
import { AgentIcon } from "./ui";
import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model";
const ModelPickerContent = memo(function ModelPickerContent({
value,
defaultModel,
onChange,
onSetDefault,
close,
}: {
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
onSetDefault: (id: ModelId) => void;
close: () => void;
}) {
const [css, theme] = useStyletron();
const [hoveredId, setHoveredId] = useState<ModelId | null>(null);
return (
<div className={css({ minWidth: "200px", padding: "4px 0" })}>
{MODEL_GROUPS.map((group) => (
<div key={group.provider}>
<div
className={css({
padding: "6px 12px",
fontSize: "10px",
fontWeight: 700,
color: theme.colors.contentTertiary,
textTransform: "uppercase",
letterSpacing: "0.05em",
})}
>
{group.provider}
</div>
{group.models.map((model) => {
const isActive = model.id === value;
const isDefault = model.id === defaultModel;
const isHovered = model.id === hoveredId;
const agent = providerAgent(group.provider);
return (
<div
key={model.id}
onMouseEnter={() => setHoveredId(model.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => {
onChange(model.id);
close();
}}
className={css({
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 12px",
cursor: "pointer",
fontSize: "12px",
fontWeight: isActive ? 600 : 400,
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
>
<AgentIcon agent={agent} size={12} />
<span className={css({ flex: 1 })}>{model.label}</span>
{isDefault ? <Star size={11} fill="#ff4f00" color="#ff4f00" /> : null}
{!isDefault && isHovered ? (
<Star
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", ":hover": { color: "#ff4f00" } })}
onClick={(event) => {
event.stopPropagation();
onSetDefault(model.id);
}}
/>
) : null}
</div>
);
})}
</div>
))}
</div>
);
});
export const ModelPicker = memo(function ModelPicker({
value,
defaultModel,
onChange,
onSetDefault,
}: {
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
onSetDefault: (id: ModelId) => void;
}) {
const [css, theme] = useStyletron();
return (
<StatefulPopover
placement={PLACEMENT.topLeft}
triggerType="click"
autoFocus={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)",
zIndex: 100,
},
},
Inner: {
style: {
backgroundColor: "transparent",
padding: "0",
},
},
}}
content={({ close }) => (
<ModelPickerContent
value={value}
defaultModel={defaultModel}
onChange={onChange}
onSetDefault={onSetDefault}
close={close}
/>
)}
>
<div className={css({ display: "inline-flex" })}>
<button
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "4px",
cursor: "pointer",
padding: "4px 8px",
borderRadius: "6px",
fontSize: "12px",
fontWeight: 500,
color: theme.colors.contentSecondary,
backgroundColor: theme.colors.backgroundTertiary,
border: `1px solid ${theme.colors.borderOpaque}`,
":hover": { color: theme.colors.contentPrimary },
})}
>
{modelLabel(value)}
<ChevronDown size={11} />
</button>
</div>
</StatefulPopover>
);
});

View file

@ -0,0 +1,181 @@
import { memo, type Ref } from "react";
import { useStyletron } from "baseui";
import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react";
import { ModelPicker } from "./model-picker";
import { PROMPT_TEXTAREA_MIN_HEIGHT, PROMPT_TEXTAREA_MAX_HEIGHT } from "./ui";
import { fileName, type LineAttachment, type ModelId } from "./view-model";
export const PromptComposer = memo(function PromptComposer({
draft,
textareaRef,
placeholder,
attachments,
defaultModel,
model,
isRunning,
onDraftChange,
onSend,
onStop,
onRemoveAttachment,
onChangeModel,
onSetDefaultModel,
}: {
draft: string;
textareaRef: Ref<HTMLTextAreaElement>;
placeholder: string;
attachments: LineAttachment[];
defaultModel: ModelId;
model: ModelId;
isRunning: boolean;
onDraftChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
onRemoveAttachment: (id: string) => void;
onChangeModel: (model: ModelId) => void;
onSetDefaultModel: (model: ModelId) => void;
}) {
const [css, theme] = useStyletron();
return (
<div
className={css({
padding: "12px 16px",
borderTop: `1px solid ${theme.colors.borderOpaque}`,
flexShrink: 0,
display: "flex",
flexDirection: "column",
gap: "8px",
})}
>
{attachments.length > 0 ? (
<div className={css({ display: "flex", flexWrap: "wrap", gap: "4px" })}>
{attachments.map((attachment) => (
<div
key={attachment.id}
className={css({
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "4px",
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: "1px solid rgba(255, 255, 255, 0.14)",
fontSize: "11px",
fontFamily: '"IBM Plex Mono", monospace',
color: theme.colors.contentSecondary,
})}
>
<FileCode size={11} />
<span>
{fileName(attachment.filePath)}:{attachment.lineNumber}
</span>
<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();
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>
)}
</div>
<ModelPicker
value={model}
defaultModel={defaultModel}
onChange={onChangeModel}
onSetDefault={onSetDefaultModel}
/>
</div>
);
});

View file

@ -0,0 +1,387 @@
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 { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { type FileTreeNode, type Handoff, diffTabId } from "./view-model";
const FileTree = memo(function FileTree({
nodes,
depth,
onSelectFile,
onFileContextMenu,
changedPaths,
}: {
nodes: FileTreeNode[];
depth: number;
onSelectFile: (path: string) => void;
onFileContextMenu: (event: MouseEvent, path: string) => void;
changedPaths: Set<string>;
}) {
const [css, theme] = useStyletron();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
return (
<>
{nodes.map((node) => {
const isCollapsed = collapsed.has(node.path);
const isChanged = changedPaths.has(node.path);
return (
<div key={node.path}>
<div
onClick={() => {
if (node.isDir) {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(node.path)) {
next.delete(node.path);
} else {
next.add(node.path);
}
return next;
});
return;
}
onSelectFile(node.path);
}}
onContextMenu={node.isDir ? undefined : (event) => onFileContextMenu(event, node.path)}
className={css({
display: "flex",
alignItems: "center",
gap: "4px",
padding: "3px 10px",
paddingLeft: `${10 + depth * 16}px`,
cursor: "pointer",
fontSize: "12px",
fontFamily: '"IBM Plex Mono", monospace',
color: isChanged ? theme.colors.contentPrimary : theme.colors.contentTertiary,
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
>
{node.isDir ? (
<>
<ChevronRight
size={12}
className={css({
transform: isCollapsed ? undefined : "rotate(90deg)",
transition: "transform 0.1s",
})}
/>
<FolderOpen size={13} />
</>
) : (
<FileCode size={13} color={isChanged ? theme.colors.contentPrimary : undefined} style={{ marginLeft: "16px" }} />
)}
<span>{node.name}</span>
</div>
{node.isDir && !isCollapsed && node.children ? (
<FileTree
nodes={node.children}
depth={depth + 1}
onSelectFile={onSelectFile}
onFileContextMenu={onFileContextMenu}
changedPaths={changedPaths}
/>
) : null}
</div>
);
})}
</>
);
});
export const RightSidebar = memo(function RightSidebar({
handoff,
activeTabId,
onOpenDiff,
onArchive,
onRevertFile,
onPublishPr,
}: {
handoff: Handoff;
activeTabId: string | null;
onOpenDiff: (path: string) => void;
onArchive: () => void;
onRevertFile: (path: string) => void;
onPublishPr: () => void;
}) {
const [css, theme] = useStyletron();
const [rightTab, setRightTab] = useState<"changes" | "files">("changes");
const contextMenu = useContextMenu();
const changedPaths = useMemo(() => new Set(handoff.fileChanges.map((file) => file.path)), [handoff.fileChanges]);
const isTerminal = handoff.status === "archived";
const pullRequestUrl = handoff.pullRequest != null ? `https://github.com/${handoff.repoName}/pull/${handoff.pullRequest.number}` : null;
const copyFilePath = useCallback(async (path: string) => {
try {
if (!window.navigator.clipboard) {
throw new Error("Clipboard API unavailable in mock layout");
}
await window.navigator.clipboard.writeText(path);
} catch (error) {
console.error("Failed to copy file path", error);
}
}, []);
const openFileMenu = useCallback(
(event: MouseEvent, path: string) => {
const items: ContextMenuItem[] = [];
if (changedPaths.has(path)) {
items.push({ label: "Revert", onClick: () => onRevertFile(path) });
}
items.push({ label: "Copy Path", onClick: () => void copyFilePath(path) });
contextMenu.open(event, items);
},
[changedPaths, contextMenu, copyFilePath, onRevertFile],
);
return (
<SPanel>
<PanelHeaderBar>
<div className={css({ flex: 1 })} />
{!isTerminal ? (
<div className={css({ display: "flex", alignItems: "center", gap: "4px" })}>
<button
onClick={() => {
if (pullRequestUrl) {
window.open(pullRequestUrl, "_blank", "noopener,noreferrer");
return;
}
onPublishPr();
}}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<GitPullRequest size={12} />
{pullRequestUrl ? "Open PR" : "Publish PR"}
</button>
<button
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<ArrowUpFromLine size={12} /> Push
</button>
<button
onClick={onArchive}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<Archive size={12} /> 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")}
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" },
})}
>
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>
) : 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>
<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>
)}
</ScrollBody>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);
});

View file

@ -0,0 +1,225 @@
import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react";
import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model";
import {
ContextMenuOverlay,
HandoffIndicator,
PanelHeaderBar,
SPanel,
ScrollBody,
useContextMenu,
} from "./ui";
export const Sidebar = memo(function Sidebar({
projects,
activeId,
onSelect,
onCreate,
onMarkUnread,
onRenameHandoff,
onRenameBranch,
}: {
projects: ProjectSection[];
activeId: string;
onSelect: (id: string) => void;
onCreate: () => void;
onMarkUnread: (id: string) => void;
onRenameHandoff: (id: string) => void;
onRenameBranch: (id: string) => void;
}) {
const [css, theme] = useStyletron();
const contextMenu = useContextMenu();
const [expandedProjects, setExpandedProjects] = useState<Record<string, boolean>>({});
return (
<SPanel>
<PanelHeaderBar>
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, flex: 1, fontSize: "13px" }}>
Handoffs
</LabelSmall>
<button
onClick={onCreate}
className={css({
all: "unset",
width: "24px",
height: "24px",
borderRadius: "4px",
backgroundColor: "#ff4f00",
color: "#ffffff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
":hover": { backgroundColor: "#ff6a00" },
})}
>
<Plus size={14} />
</button>
</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);
return (
<div key={project.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 8px 4px",
gap: "8px",
})}
>
<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>
) : 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}
</div>
);
})}
</div>
</ScrollBody>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);
});

View file

@ -0,0 +1,213 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelXSmall } from "baseui/typography";
import { FileCode, Plus, X } from "lucide-react";
import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui";
import { diffTabId, fileName, type Handoff } from "./view-model";
export const TabStrip = memo(function TabStrip({
handoff,
activeTabId,
openDiffs,
editingSessionTabId,
editingSessionName,
onEditingSessionNameChange,
onSwitchTab,
onStartRenamingTab,
onCommitSessionRename,
onCancelSessionRename,
onSetTabUnread,
onCloseTab,
onCloseDiffTab,
onAddTab,
}: {
handoff: Handoff;
activeTabId: string | null;
openDiffs: string[];
editingSessionTabId: string | null;
editingSessionName: string;
onEditingSessionNameChange: (value: string) => void;
onSwitchTab: (tabId: string) => void;
onStartRenamingTab: (tabId: string) => void;
onCommitSessionRename: () => void;
onCancelSessionRename: () => void;
onSetTabUnread: (tabId: string, unread: boolean) => void;
onCloseTab: (tabId: string) => void;
onCloseDiffTab: (path: string) => void;
onAddTab: () => void;
}) {
const [css, theme] = useStyletron();
const contextMenu = useContextMenu();
return (
<>
<div
className={css({
display: "flex",
alignItems: "stretch",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundSecondary,
height: "41px",
minHeight: "41px",
overflowX: "auto",
scrollbarWidth: "none",
flexShrink: 0,
"::-webkit-scrollbar": { display: "none" },
})}
>
{handoff.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
onClick={() => onSwitchTab(tab.id)}
onDoubleClick={() => onStartRenamingTab(tab.id)}
onMouseDown={(event) => {
if (event.button === 1 && handoff.tabs.length > 1) {
event.preventDefault();
onCloseTab(tab.id);
}
}}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Rename session", onClick: () => onStartRenamingTab(tab.id) },
{
label: tab.unread ? "Mark as read" : "Mark as unread",
onClick: () => onSetTabUnread(tab.id, !tab.unread),
},
...(handoff.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
])
}
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "0 14px",
borderBottom: isActive ? "2px solid #ff4f00" : "2px solid transparent",
cursor: "pointer",
transition: "color 200ms ease, border-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7" },
})}
>
<div
className={css({
width: "14px",
minWidth: "14px",
height: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<TabAvatar tab={tab} />
</div>
{editingSessionTabId === tab.id ? (
<input
autoFocus
value={editingSessionName}
onChange={(event) => onEditingSessionNameChange(event.target.value)}
onBlur={onCommitSessionRename}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitSessionRename();
} else if (event.key === "Escape") {
onCancelSessionRename();
}
}}
className={css({
all: "unset",
minWidth: "72px",
maxWidth: "180px",
fontSize: "11px",
fontWeight: 600,
color: theme.colors.contentPrimary,
borderBottom: "1px solid rgba(255, 255, 255, 0.3)",
})}
/>
) : (
<LabelXSmall color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
{tab.sessionName}
</LabelXSmall>
)}
{handoff.tabs.length > 1 ? (
<X
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", opacity: 0.5, ":hover": { opacity: 1 } })}
onClick={(event) => {
event.stopPropagation();
onCloseTab(tab.id);
}}
/>
) : null}
</div>
);
})}
{openDiffs.map((path) => {
const tabId = diffTabId(path);
const isActive = tabId === activeTabId;
return (
<div
key={tabId}
onClick={() => onSwitchTab(tabId)}
onMouseDown={(event) => {
if (event.button === 1) {
event.preventDefault();
onCloseDiffTab(path);
}
}}
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "0 14px",
borderBottom: "2px solid transparent",
cursor: "pointer",
transition: "color 200ms ease, border-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7" },
})}
>
<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' }}
>
{fileName(path)}
</LabelXSmall>
<X
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", opacity: 0.5, ":hover": { opacity: 1 } })}
onClick={(event) => {
event.stopPropagation();
onCloseDiffTab(path);
}}
/>
</div>
);
})}
<div
onClick={onAddTab}
className={css({
display: "flex",
alignItems: "center",
padding: "0 10px",
cursor: "pointer",
opacity: 0.4,
":hover": { opacity: 0.7 },
flexShrink: 0,
})}
>
<Plus size={14} color={theme.colors.contentTertiary} />
</div>
</div>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</>
);
});

View file

@ -0,0 +1,139 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography";
import { MailOpen } from "lucide-react";
import { PanelHeaderBar } from "./ui";
import { type AgentTab, type Handoff } from "./view-model";
export const TranscriptHeader = memo(function TranscriptHeader({
handoff,
activeTab,
editingField,
editValue,
onEditValueChange,
onStartEditingField,
onCommitEditingField,
onCancelEditingField,
onSetActiveTabUnread,
}: {
handoff: Handoff;
activeTab: AgentTab | null | undefined;
editingField: "title" | "branch" | null;
editValue: string;
onEditValueChange: (value: string) => void;
onStartEditingField: (field: "title" | "branch", value: string) => void;
onCommitEditingField: (field: "title" | "branch") => void;
onCancelEditingField: () => void;
onSetActiveTabUnread: (unread: boolean) => void;
}) {
const [css, theme] = useStyletron();
return (
<PanelHeaderBar>
{editingField === "title" ? (
<input
autoFocus
value={editValue}
onChange={(event) => onEditValueChange(event.target.value)}
onBlur={() => onCommitEditingField("title")}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitEditingField("title");
} else if (event.key === "Escape") {
onCancelEditingField();
}
}}
className={css({
all: "unset",
fontWeight: 600,
fontSize: "14px",
color: theme.colors.contentPrimary,
borderBottom: "1px solid rgba(255, 255, 255, 0.3)",
minWidth: "80px",
maxWidth: "300px",
})}
/>
) : (
<LabelSmall
title="Rename"
color={theme.colors.contentPrimary}
$style={{ fontWeight: 600, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
onClick={() => onStartEditingField("title", handoff.title)}
>
{handoff.title}
</LabelSmall>
)}
{handoff.branch ? (
editingField === "branch" ? (
<input
autoFocus
value={editValue}
onChange={(event) => onEditValueChange(event.target.value)}
onBlur={() => onCommitEditingField("branch")}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitEditingField("branch");
} else if (event.key === "Escape") {
onCancelEditingField();
}
}}
className={css({
all: "unset",
padding: "2px 8px",
borderRadius: "999px",
border: "1px solid rgba(255, 255, 255, 0.3)",
backgroundColor: "rgba(255, 255, 255, 0.03)",
color: "#e4e4e7",
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
minWidth: "60px",
})}
/>
) : (
<span
title="Rename"
onClick={() => onStartEditingField("branch", handoff.branch ?? "")}
className={css({
padding: "2px 8px",
borderRadius: "999px",
border: "1px solid rgba(255, 255, 255, 0.14)",
backgroundColor: "rgba(255, 255, 255, 0.03)",
color: "#e4e4e7",
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
cursor: "pointer",
":hover": { borderColor: "rgba(255, 255, 255, 0.3)" },
})}
>
{handoff.branch}
</span>
)
) : null}
<div className={css({ flex: 1 })} />
{activeTab ? (
<button
onClick={() => onSetActiveTabUnread(!activeTab.unread)}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "5px",
padding: "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
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"}
</button>
) : null}
</PanelHeaderBar>
);
});

View file

@ -0,0 +1,211 @@
import { memo, useCallback, useEffect, useState, type MouseEvent } from "react";
import { styled, useStyletron } from "baseui";
import { GitPullRequest, GitPullRequestDraft } from "lucide-react";
import type { AgentKind, AgentTab } from "./view-model";
export interface ContextMenuItem {
label: string;
onClick: () => void;
}
export function useContextMenu() {
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
useEffect(() => {
if (!menu) {
return;
}
const close = () => setMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [menu]);
const open = useCallback((event: MouseEvent, items: ContextMenuItem[]) => {
event.preventDefault();
event.stopPropagation();
setMenu({ x: event.clientX, y: event.clientY, items });
}, []);
return { menu, open, close: useCallback(() => setMenu(null), []) };
}
export const ContextMenuOverlay = memo(function ContextMenuOverlay({
menu,
onClose,
}: {
menu: { x: number; y: number; items: ContextMenuItem[] };
onClose: () => void;
}) {
const [css] = useStyletron();
return (
<div
className={css({
position: "fixed",
zIndex: 9999,
top: `${menu.y}px`,
left: `${menu.x}px`,
backgroundColor: "#1a1a1d",
border: "1px solid rgba(255, 255, 255, 0.18)",
borderRadius: "8px",
padding: "4px 0",
minWidth: "160px",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)",
})}
>
{menu.items.map((item, index) => (
<div
key={index}
onClick={() => {
item.onClick();
onClose();
}}
className={css({
padding: "8px 14px",
fontSize: "12px",
color: "#e4e4e7",
cursor: "pointer",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
})}
>
{item.label}
</div>
))}
</div>
);
});
export const SpinnerDot = memo(function SpinnerDot({ size = 10 }: { size?: number }) {
return (
<div
style={{
width: size,
height: size,
borderRadius: "50%",
border: "2px solid rgba(255, 79, 0, 0.25)",
borderTopColor: "#ff4f00",
animation: "hf-spin 0.8s linear infinite",
flexShrink: 0,
}}
/>
);
});
export const UnreadDot = memo(function UnreadDot() {
return (
<div
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: "#ff4f00",
flexShrink: 0,
}}
/>
);
});
export const HandoffIndicator = memo(function HandoffIndicator({
isRunning,
hasUnread,
isDraft,
}: {
isRunning: boolean;
hasUnread: boolean;
isDraft: boolean;
}) {
if (isRunning) return <SpinnerDot size={8} />;
if (hasUnread) return <UnreadDot />;
if (isDraft) return <GitPullRequestDraft size={12} color="#a1a1aa" />;
return <GitPullRequest size={12} color="#7ee787" />;
});
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" />
</svg>
);
});
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" />
</svg>
);
});
const CursorIcon = memo(function CursorIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<rect x="3" y="3" width="18" height="18" rx="4" stroke="#A1A1AA" strokeWidth="1.5" />
<path d="M8 12h8M12 8v8" stroke="#A1A1AA" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
});
export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent: AgentKind; size?: number }) {
switch (agent) {
case "Claude":
return <ClaudeIcon size={size} />;
case "Codex":
return <OpenAIIcon size={size} />;
case "Cursor":
return <CursorIcon size={size} />;
}
});
export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) {
if (tab.status === "running") return <SpinnerDot size={8} />;
if (tab.unread) return <UnreadDot />;
return <AgentIcon agent={tab.agent} size={13} />;
});
export const Shell = styled("div", ({ $theme }) => ({
display: "grid",
gap: "1px",
height: "100dvh",
backgroundColor: $theme.colors.borderOpaque,
gridTemplateColumns: "280px minmax(0, 1fr) 380px",
overflow: "hidden",
}));
export const SPanel = styled("section", ({ $theme }) => ({
minHeight: 0,
display: "flex",
flexDirection: "column" as const,
backgroundColor: $theme.colors.backgroundSecondary,
overflow: "hidden",
}));
export const ScrollBody = styled("div", () => ({
minHeight: 0,
flex: 1,
position: "relative" as const,
overflowY: "auto" as const,
display: "flex",
flexDirection: "column" as const,
}));
export const HEADER_HEIGHT = "42px";
export const PROMPT_TEXTAREA_MIN_HEIGHT = 56;
export const PROMPT_TEXTAREA_MAX_HEIGHT = 100;
export const PanelHeaderBar = styled("div", ({ $theme }) => ({
display: "flex",
alignItems: "center",
minHeight: HEADER_HEIGHT,
maxHeight: HEADER_HEIGHT,
padding: "0 14px",
borderBottom: `1px solid ${$theme.colors.borderOpaque}`,
backgroundColor: $theme.colors.backgroundTertiary,
gap: "8px",
flexShrink: 0,
}));

View file

@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import type { WorkbenchAgentTab } from "@openhandoff/shared";
import { buildDisplayMessages } from "./view-model";
function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab {
return {
id: "tab-1",
sessionId: "session-1",
sessionName: "Session 1",
agent: "Codex",
model: "gpt-4o",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: {
text: "",
attachments: [],
updatedAtMs: null,
},
transcript,
};
}
describe("buildDisplayMessages", () => {
it("collapses chunked agent output into a single display message", () => {
const messages = buildDisplayMessages(
makeTab([
{
id: "evt-setup",
eventIndex: 0,
sessionId: "session-1",
createdAt: 0,
connectionId: "conn-1",
sender: "client",
payload: {
method: "session/new",
params: {
cwd: "/repo",
},
},
},
{
id: "evt-client",
eventIndex: 1,
sessionId: "session-1",
createdAt: 1,
connectionId: "conn-1",
sender: "client",
payload: {
method: "session/prompt",
params: {
prompt: [{ type: "text", text: "hello" }],
},
},
},
{
id: "evt-config",
eventIndex: 1,
sessionId: "session-1",
createdAt: 1,
connectionId: "conn-1",
sender: "agent",
payload: {
result: {
configOptions: [],
},
},
},
{
id: "evt-chunk-1",
eventIndex: 2,
sessionId: "session-1",
createdAt: 2,
connectionId: "conn-1",
sender: "agent",
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "hel",
},
},
},
},
},
{
id: "evt-chunk-2",
eventIndex: 3,
sessionId: "session-1",
createdAt: 3,
connectionId: "conn-1",
sender: "agent",
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "lo",
},
},
},
},
},
{
id: "evt-stop",
eventIndex: 4,
sessionId: "session-1",
createdAt: 4,
connectionId: "conn-1",
sender: "agent",
payload: {
result: {
stopReason: "end_turn",
},
},
},
]),
);
expect(messages).toEqual([
expect.objectContaining({
id: "evt-client",
sender: "client",
text: "hello",
}),
expect.objectContaining({
id: "evt-chunk-1",
sender: "agent",
text: "hello",
}),
]);
});
it("hides non-message session update envelopes", () => {
const messages = buildDisplayMessages(
makeTab([
{
id: "evt-client",
eventIndex: 1,
sessionId: "session-1",
createdAt: 1,
connectionId: "conn-1",
sender: "client",
payload: {
method: "session/prompt",
params: {
prompt: [{ type: "text", text: "hello" }],
},
},
},
{
id: "evt-update",
eventIndex: 2,
sessionId: "session-1",
createdAt: 2,
connectionId: "conn-1",
sender: "agent",
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "agent_thought",
content: {
type: "text",
text: "thinking",
},
},
},
},
},
{
id: "evt-result",
eventIndex: 3,
sessionId: "session-1",
createdAt: 3,
connectionId: "conn-1",
sender: "agent",
payload: {
result: {
text: "done",
},
},
},
]),
);
expect(messages).toEqual([
expect.objectContaining({
id: "evt-client",
sender: "client",
text: "hello",
}),
expect.objectContaining({
id: "evt-result",
sender: "agent",
text: "done",
}),
]);
});
});

View file

@ -0,0 +1,339 @@
import type {
WorkbenchAgentKind as AgentKind,
WorkbenchAgentTab as AgentTab,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileChange as FileChange,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchHandoff as Handoff,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchLineAttachment as LineAttachment,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchProjectSection as ProjectSection,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
import { extractEventText } from "../../features/sessions/model";
export type { ProjectSection };
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-4o", label: "GPT-4o" },
{ id: "o3", label: "o3" },
],
},
];
export function formatRelativeAge(updatedAtMs: number, nowMs = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((nowMs - updatedAtMs) / 1000));
if (deltaSeconds < 60) return `${deltaSeconds}s`;
const minutes = Math.floor(deltaSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function formatMessageTimestamp(createdAtMs: number, nowMs = Date.now()): string {
const createdAt = new Date(createdAtMs);
const now = new Date(nowMs);
const sameDay = createdAt.toDateString() === now.toDateString();
const timeLabel = createdAt.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
if (sameDay) {
return timeLabel;
}
const deltaDays = Math.floor((nowMs - createdAtMs) / (24 * 60 * 60 * 1000));
if (deltaDays < 7) {
const weekdayLabel = createdAt.toLocaleDateString([], { weekday: "short" });
return `${weekdayLabel} ${timeLabel}`;
}
return createdAt.toLocaleDateString([], {
month: "short",
day: "numeric",
});
}
export function formatThinkingDuration(durationMs: number): string {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export function formatMessageDuration(durationMs: number): string {
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
}
const DIFF_PREFIX = "diff:";
export function isDiffTab(id: string): boolean {
return id.startsWith(DIFF_PREFIX);
}
export function diffPath(id: string): string {
return id.slice(DIFF_PREFIX.length);
}
export function diffTabId(path: string): string {
return `${DIFF_PREFIX}${path}`;
}
export function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function eventOrder(id: string): number {
const match = id.match(/\d+/);
return match ? Number(match[0]) : 0;
}
function historyPreview(event: TranscriptEvent): string {
const content = extractEventText(event.payload).trim() || "Untitled event";
return content.length > 42 ? `${content.slice(0, 39)}...` : content;
}
function historyDetail(event: TranscriptEvent): string {
const content = extractEventText(event.payload).trim();
return content || "Untitled event";
}
export function buildHistoryEvents(tabs: AgentTab[]): HistoryEvent[] {
return tabs
.flatMap((tab) =>
tab.transcript
.filter((event) => event.sender === "client")
.map((event) => ({
id: `history-${tab.id}-${event.id}`,
messageId: event.id,
preview: historyPreview(event),
sessionName: tab.sessionName,
tabId: tab.id,
createdAtMs: event.createdAt,
detail: historyDetail(event),
})),
)
.sort((left, right) => eventOrder(left.messageId) - eventOrder(right.messageId));
}
export interface Message {
id: string;
sender: "client" | "agent";
text: string;
createdAtMs: number;
durationMs?: number;
event: TranscriptEvent;
}
function isAgentChunkEvent(event: TranscriptEvent): string | null {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return null;
}
const params = (payload as { params?: unknown }).params;
if (!params || typeof params !== "object") {
return null;
}
const update = (params as { update?: unknown }).update;
if (!update || typeof update !== "object") {
return null;
}
if ((update as { sessionUpdate?: unknown }).sessionUpdate !== "agent_message_chunk") {
return null;
}
const content = (update as { content?: unknown }).content;
if (!content || typeof content !== "object") {
return null;
}
const text = (content as { text?: unknown }).text;
return typeof text === "string" ? text : null;
}
function isClientPromptEvent(event: TranscriptEvent): boolean {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return false;
}
return (payload as { method?: unknown }).method === "session/prompt";
}
function shouldDisplayEvent(event: TranscriptEvent): boolean {
const payload = event.payload;
if (event.sender === "client") {
return isClientPromptEvent(event) && Boolean(extractEventText(payload).trim());
}
if (!payload || typeof payload !== "object") {
return Boolean(extractEventText(payload).trim());
}
if ((payload as { error?: unknown }).error) {
return true;
}
if (isAgentChunkEvent(event) !== null) {
return false;
}
if ((payload as { method?: unknown }).method === "session/update") {
return false;
}
const result = (payload as { result?: unknown }).result;
if (result && typeof result === "object") {
if (typeof (result as { stopReason?: unknown }).stopReason === "string") {
return false;
}
if (typeof (result as { text?: unknown }).text !== "string") {
return false;
}
}
const params = (payload as { params?: unknown }).params;
if (params && typeof params === "object") {
const update = (params as { update?: unknown }).update;
if (update && typeof update === "object") {
const sessionUpdate = (update as { sessionUpdate?: unknown }).sessionUpdate;
if (
sessionUpdate === "usage_update" ||
sessionUpdate === "available_commands_update" ||
sessionUpdate === "config_options_update" ||
sessionUpdate === "available_modes_update" ||
sessionUpdate === "available_models_update"
) {
return false;
}
}
}
return Boolean(extractEventText(payload).trim());
}
export function buildDisplayMessages(tab: AgentTab | null | undefined): Message[] {
if (!tab) {
return [];
}
const messages: Message[] = [];
let pendingAgentMessage: Message | null = null;
const flushPendingAgentMessage = () => {
if (pendingAgentMessage && pendingAgentMessage.text.length > 0) {
messages.push(pendingAgentMessage);
}
pendingAgentMessage = null;
};
for (const event of tab.transcript) {
const chunkText = isAgentChunkEvent(event);
if (chunkText !== null) {
if (!pendingAgentMessage) {
pendingAgentMessage = {
id: event.id,
sender: "agent",
text: chunkText,
createdAtMs: event.createdAt,
event,
};
} else {
pendingAgentMessage.text += chunkText;
}
continue;
}
flushPendingAgentMessage();
if (!shouldDisplayEvent(event)) {
continue;
}
messages.push({
id: event.id,
sender: event.sender,
text: extractEventText(event.payload),
createdAtMs: event.createdAt,
durationMs:
event.payload && typeof event.payload === "object"
? typeof (event.payload as { result?: { durationMs?: unknown } }).result?.durationMs === "number"
? ((event.payload as { result?: { durationMs?: number } }).result?.durationMs ?? undefined)
: undefined
: undefined,
event,
});
}
flushPendingAgentMessage();
return messages;
}
export function parseDiffLines(diff: string): ParsedDiffLine[] {
return diff.split("\n").map((text, index) => {
if (text.startsWith("@@")) {
return { kind: "hunk", lineNumber: index + 1, text };
}
if (text.startsWith("+")) {
return { kind: "add", lineNumber: index + 1, text };
}
if (text.startsWith("-")) {
return { kind: "remove", lineNumber: index + 1, text };
}
return { kind: "context", lineNumber: index + 1, text };
});
}
export type {
AgentKind,
AgentTab,
DiffLineKind,
FileChange,
FileTreeNode,
Handoff,
HistoryEvent,
LineAttachment,
ModelGroup,
ModelId,
ParsedDiffLine,
TranscriptEvent,
};

File diff suppressed because it is too large Load diff