mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 00:02:48 +00:00
Integrate OpenHandoff factory workspace (#212)
This commit is contained in:
parent
3d9476ed0b
commit
bf282199b5
251 changed files with 42824 additions and 692 deletions
916
factory/packages/frontend/src/components/mock-layout.tsx
Normal file
916
factory/packages/frontend/src/components/mock-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue