mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 18:03:56 +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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">−{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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
225
factory/packages/frontend/src/components/mock-layout/sidebar.tsx
Normal file
225
factory/packages/frontend/src/components/mock-layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
211
factory/packages/frontend/src/components/mock-layout/ui.tsx
Normal file
211
factory/packages/frontend/src/components/mock-layout/ui.tsx
Normal 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,
|
||||
}));
|
||||
|
|
@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
1975
factory/packages/frontend/src/components/workspace-dashboard.tsx
Normal file
1975
factory/packages/frontend/src/components/workspace-dashboard.tsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue