Rename Foundry handoffs to tasks (#239)

* Restore foundry onboarding stack

* Consolidate foundry rename

* Create foundry tasks without prompts

* Rename Foundry handoffs to tasks
This commit is contained in:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

View file

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

View file

@ -0,0 +1,132 @@
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" }}>
Task 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({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
display: "grid",
gridTemplateColumns: "1fr auto",
gap: "10px",
alignItems: "center",
padding: "9px 10px",
borderRadius: "12px",
cursor: "pointer",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.08)" : "transparent",
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "background 160ms ease, color 160ms ease",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.08)",
color: theme.colors.contentPrimary,
},
})}
>
<div className={css({ minWidth: 0, display: "flex", flexDirection: "column", gap: "4px" })}>
<div
className={css({
fontSize: "12px",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{event.preview}
</div>
<LabelXSmall color={theme.colors.contentTertiary}>{event.sessionName}</LabelXSmall>
</div>
<LabelXSmall color={theme.colors.contentTertiary}>{formatMessageTimestamp(event.createdAtMs)}</LabelXSmall>
</button>
);
})}
</div>
</div>
) : null}
<div
className={css({
width: "18px",
padding: "4px 0",
display: "flex",
flexDirection: "column",
gap: "5px",
alignItems: "stretch",
})}
>
{events.map((event) => {
const isActive = event.id === activeEventId;
return (
<div
key={event.id}
className={css({
height: "3px",
borderRadius: "999px",
backgroundColor: isActive ? "#ff4f00" : "rgba(255, 255, 255, 0.22)",
opacity: isActive ? 1 : 0.75,
transition: "background 160ms ease, opacity 160ms ease",
})}
/>
);
})}
</div>
</div>
);
});

View file

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

View file

@ -0,0 +1,165 @@
import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronDown, ChevronUp, 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: "220px", padding: "6px 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,
borderRadius: "6px",
marginLeft: "4px",
marginRight: "4px",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
})}
>
<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();
const [isOpen, setIsOpen] = useState(false);
return (
<StatefulPopover
placement={PLACEMENT.topLeft}
triggerType="click"
autoFocus={false}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
overrides={{
Body: {
style: {
backgroundColor: "rgba(32, 32, 32, 0.98)",
backdropFilter: "blur(12px)",
borderTopLeftRadius: "10px",
borderTopRightRadius: "10px",
borderBottomLeftRadius: "10px",
borderBottomRightRadius: "10px",
border: "1px solid rgba(255, 255, 255, 0.10)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04)",
zIndex: 100,
},
},
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({
appearance: "none",
WebkitAppearance: "none",
background: "none",
margin: "0",
display: "flex",
alignItems: "center",
gap: "4px",
cursor: "pointer",
padding: "4px 8px",
borderRadius: "6px",
fontSize: "12px",
fontWeight: 500,
color: theme.colors.contentSecondary,
backgroundColor: "rgba(255, 255, 255, 0.10)",
border: "1px solid rgba(255, 255, 255, 0.14)",
":hover": { color: theme.colors.contentPrimary, backgroundColor: "rgba(255, 255, 255, 0.14)" },
})}
>
{modelLabel(value)}
{isOpen ? <ChevronDown size={11} /> : <ChevronUp size={11} />}
</button>
</div>
</StatefulPopover>
);
});

View file

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

View file

@ -0,0 +1,402 @@
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 Task, 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({
task,
activeTabId,
onOpenDiff,
onArchive,
onRevertFile,
onPublishPr,
}: {
task: Task;
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(task.fileChanges.map((file) => file.path)), [task.fileChanges]);
const isTerminal = task.status === "archived";
const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.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 $style={{ backgroundColor: "#09090b" }}>
<PanelHeaderBar $style={{ backgroundColor: "#0f0f11", borderBottom: "none" }}>
<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({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
{pullRequestUrl ? "Open PR" : "Publish PR"}
</button>
<button
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<ArrowUpFromLine size={12} style={{ flexShrink: 0 }} /> Push
</button>
<button
onClick={onArchive}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<Archive size={12} style={{ flexShrink: 0 }} /> Archive
</button>
</div>
) : null}
</PanelHeaderBar>
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", borderTop: "1px solid rgba(255, 255, 255, 0.10)" }}>
<div
className={css({
display: "flex",
alignItems: "stretch",
gap: "4px",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: "#09090b",
height: "41px",
minHeight: "41px",
flexShrink: 0,
})}
>
<button
onClick={() => setRightTab("changes")}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
marginLeft: "6px",
borderRadius: "8px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
color: rightTab === "changes" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
backgroundColor: rightTab === "changes" ? "rgba(255, 255, 255, 0.06)" : "transparent",
transitionProperty: "color, background-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7", backgroundColor: rightTab === "changes" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
Changes
{task.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",
})}
>
{task.fileChanges.length}
</span>
) : null}
</button>
<button
onClick={() => setRightTab("files")}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
color: rightTab === "files" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
backgroundColor: rightTab === "files" ? "rgba(255, 255, 255, 0.06)" : "transparent",
transitionProperty: "color, background-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7", backgroundColor: rightTab === "files" ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
All Files
</button>
</div>
<ScrollBody>
{rightTab === "changes" ? (
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
{task.fileChanges.length === 0 ? (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No changes yet</LabelSmall>
</div>
) : null}
{task.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" })}>
{task.fileTree.length > 0 ? (
<FileTree nodes={task.fileTree} depth={0} onSelectFile={onOpenDiff} onFileContextMenu={openFileMenu} changedPaths={changedPaths} />
) : (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No files yet</LabelSmall>
</div>
)}
</div>
)}
</ScrollBody>
</div>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);
});

View file

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

View file

@ -0,0 +1,236 @@
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 Task } from "./view-model";
export const TabStrip = memo(function TabStrip({
task,
activeTabId,
openDiffs,
editingSessionTabId,
editingSessionName,
onEditingSessionNameChange,
onSwitchTab,
onStartRenamingTab,
onCommitSessionRename,
onCancelSessionRename,
onSetTabUnread,
onCloseTab,
onCloseDiffTab,
onAddTab,
}: {
task: Task;
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 (
<>
<style>{`
[data-tab]:hover [data-tab-close] { opacity: 0.5 !important; }
[data-tab]:hover [data-tab-close]:hover { opacity: 1 !important; }
`}</style>
<div
className={css({
display: "flex",
alignItems: "stretch",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
gap: "4px",
backgroundColor: "#09090b",
paddingLeft: "6px",
height: "41px",
minHeight: "41px",
overflowX: "auto",
scrollbarWidth: "none",
flexShrink: 0,
"::-webkit-scrollbar": { display: "none" },
})}
>
{task.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 && task.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),
},
...(task.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
])
}
data-tab
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
transition: "color 200ms ease, background-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7", backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
<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({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
padding: "0",
margin: "0",
outline: "none",
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: 500 }}>
{tab.sessionName}
</LabelXSmall>
)}
{task.tabs.length > 1 ? (
<X
size={11}
color={theme.colors.contentTertiary}
data-tab-close
className={css({ cursor: "pointer", opacity: 0 })}
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);
}
}}
data-tab
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
transition: "color 200ms ease, background-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7", backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.04)" },
})}
>
<FileCode size={12} color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary} />
<LabelXSmall
color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary}
$style={{ fontWeight: 500, fontFamily: '"IBM Plex Mono", monospace' }}
>
{fileName(path)}
</LabelXSmall>
<X
size={11}
color={theme.colors.contentTertiary}
data-tab-close
className={css({ cursor: "pointer", opacity: 0 })}
onClick={(event) => {
event.stopPropagation();
onCloseDiffTab(path);
}}
/>
</div>
);
})}
<div
onClick={onAddTab}
className={css({
display: "flex",
alignItems: "center",
padding: "0 10px",
cursor: "pointer",
opacity: 0.4,
lineHeight: 0,
":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}
</>
);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,174 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography";
import { Clock, MailOpen } from "lucide-react";
import { PanelHeaderBar } from "./ui";
import { type AgentTab, type Task } from "./view-model";
export const TranscriptHeader = memo(function TranscriptHeader({
task,
activeTab,
editingField,
editValue,
onEditValueChange,
onStartEditingField,
onCommitEditingField,
onCancelEditingField,
onSetActiveTabUnread,
}: {
task: Task;
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 $style={{ backgroundColor: "#0f0f11", borderBottom: "none" }}>
{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({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
padding: "0",
margin: "0",
outline: "none",
fontWeight: 500,
fontSize: "14px",
color: theme.colors.contentPrimary,
borderBottom: "1px solid rgba(255, 255, 255, 0.3)",
minWidth: "80px",
maxWidth: "300px",
})}
/>
) : (
<LabelSmall
title="Rename"
color={theme.colors.contentPrimary}
$style={{ fontWeight: 400, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
onClick={() => onStartEditingField("title", task.title)}
>
{task.title}
</LabelSmall>
)}
{task.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({
appearance: "none",
WebkitAppearance: "none",
background: "none",
margin: "0",
outline: "none",
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", task.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)" },
})}
>
{task.branch}
</span>
)
) : null}
<div className={css({ flex: 1 })} />
<div
className={css({
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "3px 10px",
borderRadius: "6px",
backgroundColor: "rgba(255, 255, 255, 0.05)",
border: "1px solid rgba(255, 255, 255, 0.08)",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
color: theme.colors.contentSecondary,
whiteSpace: "nowrap",
})}
>
<Clock size={11} style={{ flexShrink: 0 }} />
<span>847 min used</span>
</div>
{activeTab ? (
<button
onClick={() => onSetActiveTabUnread(!activeTab.unread)}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
color: theme.colors.contentSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: theme.colors.contentPrimary },
})}
>
<MailOpen size={12} style={{ flexShrink: 0 }} /> {activeTab.unread ? "Mark read" : "Mark unread"}
</button>
) : null}
</PanelHeaderBar>
);
});

View file

@ -0,0 +1,208 @@
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 TaskIndicator = memo(function TaskIndicator({ 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: "flex",
height: "100dvh",
backgroundColor: $theme.colors.backgroundSecondary,
overflow: "hidden",
}));
export const SPanel = styled("section", ({ $theme }) => ({
minHeight: 0,
flex: 1,
display: "flex",
flexDirection: "column" as const,
backgroundColor: $theme.colors.backgroundSecondary,
overflow: "hidden",
}));
export const ScrollBody = styled("div", () => ({
minHeight: 0,
flex: 1,
position: "relative" as const,
overflowY: "auto" as const,
display: "flex",
flexDirection: "column" as const,
}));
export const HEADER_HEIGHT = "42px";
export const PROMPT_TEXTAREA_MIN_HEIGHT = 56;
export const PROMPT_TEXTAREA_MAX_HEIGHT = 100;
export const PanelHeaderBar = styled("div", ({ $theme }) => ({
display: "flex",
alignItems: "center",
minHeight: HEADER_HEIGHT,
maxHeight: HEADER_HEIGHT,
padding: "0 14px",
borderBottom: `1px solid ${$theme.colors.borderOpaque}`,
backgroundColor: $theme.colors.backgroundTertiary,
gap: "8px",
flexShrink: 0,
}));

View file

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

View file

@ -0,0 +1,339 @@
import type {
WorkbenchAgentKind as AgentKind,
WorkbenchAgentTab as AgentTab,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileChange as FileChange,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchTask as Task,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchLineAttachment as LineAttachment,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchProjectSection as ProjectSection,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-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,
Task,
HistoryEvent,
LineAttachment,
ModelGroup,
ModelId,
ParsedDiffLine,
TranscriptEvent,
};