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

File diff suppressed because it is too large Load diff

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,
};

View file

@ -0,0 +1,969 @@
import { useEffect, useMemo, useState } from "react";
import { type FoundryBillingPlanId, type FoundryOrganization, type FoundryOrganizationMember, type FoundryUser } from "@sandbox-agent/foundry-shared";
import { useNavigate } from "@tanstack/react-router";
import { ArrowLeft, BadgeCheck, Building2, CreditCard, Github, ShieldCheck, Star, Users } from "lucide-react";
import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
import { isMockFrontendClient } from "../lib/env";
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
const planCatalog: Record<
FoundryBillingPlanId,
{
label: string;
price: string;
seats: string;
summary: string;
}
> = {
free: {
label: "Free",
price: "$0",
seats: "1 seat included",
summary: "Best for a personal workspace and quick evaluations.",
},
team: {
label: "Team",
price: "$240/mo",
seats: "5 seats included",
summary: "GitHub org onboarding, shared billing, and seat accrual on first prompt.",
},
};
function appSurfaceStyle(): React.CSSProperties {
return {
minHeight: "100dvh",
display: "flex",
flexDirection: "column",
background:
"radial-gradient(circle at top left, rgba(255, 79, 0, 0.16), transparent 28%), radial-gradient(circle at top right, rgba(24, 140, 255, 0.18), transparent 32%), #050505",
color: "#ffffff",
};
}
function topBarStyle(): React.CSSProperties {
return {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "18px 28px",
borderBottom: "1px solid rgba(255, 255, 255, 0.1)",
background: "rgba(0, 0, 0, 0.36)",
backdropFilter: "blur(16px)",
};
}
function contentWrapStyle(): React.CSSProperties {
return {
width: "min(1180px, calc(100vw - 40px))",
margin: "0 auto",
padding: "28px 0 40px",
display: "flex",
flexDirection: "column",
gap: "20px",
};
}
function primaryButtonStyle(): React.CSSProperties {
return {
border: 0,
borderRadius: "999px",
padding: "11px 16px",
background: "#ff4f00",
color: "#ffffff",
fontWeight: 700,
cursor: "pointer",
};
}
function secondaryButtonStyle(): React.CSSProperties {
return {
border: "1px solid rgba(255, 255, 255, 0.16)",
borderRadius: "999px",
padding: "10px 15px",
background: "rgba(255, 255, 255, 0.03)",
color: "#ffffff",
fontWeight: 600,
cursor: "pointer",
};
}
function subtleButtonStyle(): React.CSSProperties {
return {
border: 0,
borderRadius: "999px",
padding: "10px 14px",
background: "rgba(255, 255, 255, 0.05)",
color: "#ffffff",
fontWeight: 600,
cursor: "pointer",
};
}
function cardStyle(): React.CSSProperties {
return {
background: "linear-gradient(180deg, rgba(21, 21, 24, 0.96), rgba(10, 10, 11, 0.98))",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "24px",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.36)",
};
}
function badgeStyle(background: string, color = "#f4f4f5"): React.CSSProperties {
return {
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 10px",
borderRadius: "999px",
background,
color,
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.01em",
};
}
function formatDate(value: string | null): string {
if (!value) {
return "N/A";
}
return dateFormatter.format(new Date(value));
}
function workspacePath(organization: FoundryOrganization): string {
return `/workspaces/${organization.workspaceId}`;
}
function settingsPath(organization: FoundryOrganization): string {
return `/organizations/${organization.id}/settings`;
}
function billingPath(organization: FoundryOrganization): string {
return `/organizations/${organization.id}/billing`;
}
function checkoutPath(organization: FoundryOrganization, planId: FoundryBillingPlanId): string {
return `/organizations/${organization.id}/checkout/${planId}`;
}
function statusBadge(organization: FoundryOrganization) {
if (organization.kind === "personal") {
return <span style={badgeStyle("rgba(24, 140, 255, 0.18)", "#b9d8ff")}>Personal workspace</span>;
}
return <span style={badgeStyle("rgba(255, 79, 0, 0.16)", "#ffd6c7")}>GitHub organization</span>;
}
function githubBadge(organization: FoundryOrganization) {
if (organization.github.installationStatus === "connected") {
return <span style={badgeStyle("rgba(46, 160, 67, 0.16)", "#b7f0c3")}>GitHub connected</span>;
}
if (organization.github.installationStatus === "reconnect_required") {
return <span style={badgeStyle("rgba(255, 193, 7, 0.18)", "#ffe6a6")}>Reconnect required</span>;
}
return <span style={badgeStyle("rgba(255, 255, 255, 0.08)")}>Install GitHub App</span>;
}
function PageShell({
user,
title,
eyebrow,
description,
children,
actions,
onSignOut,
}: {
user: FoundryUser | null;
title: string;
eyebrow: string;
description: string;
children: React.ReactNode;
actions?: React.ReactNode;
onSignOut?: () => void;
}) {
return (
<div style={appSurfaceStyle()}>
<div style={topBarStyle()}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
width: "42px",
height: "42px",
borderRadius: "14px",
background: "linear-gradient(135deg, #ff4f00, #ff7a00)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 800,
letterSpacing: "0.06em",
}}
>
SA
</div>
<div>
<div style={{ fontSize: "12px", fontWeight: 700, textTransform: "uppercase", color: "#a1a1aa" }}>{eyebrow}</div>
<div style={{ fontSize: "24px", fontWeight: 800 }}>{title}</div>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
{actions}
{user ? (
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "13px", fontWeight: 700 }}>{user.name}</div>
<div style={{ fontSize: "12px", color: "#a1a1aa" }}>@{user.githubLogin}</div>
</div>
{onSignOut ? (
<button type="button" onClick={onSignOut} style={secondaryButtonStyle()}>
Sign out
</button>
) : null}
</div>
) : null}
</div>
</div>
<div style={contentWrapStyle()}>
<div style={{ maxWidth: "720px", color: "#d4d4d8", fontSize: "15px", lineHeight: 1.5 }}>{description}</div>
{children}
</div>
</div>
);
}
function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) {
return (
<div
style={{
...cardStyle(),
padding: "18px 20px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div style={{ fontSize: "12px", color: "#a1a1aa", textTransform: "uppercase", letterSpacing: "0.06em" }}>{label}</div>
<div style={{ fontSize: "26px", fontWeight: 800 }}>{value}</div>
<div style={{ fontSize: "13px", color: "#c4c4ca", lineHeight: 1.5 }}>{caption}</div>
</div>
);
}
function MemberRow({ member }: { member: FoundryOrganizationMember }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) 120px",
gap: "12px",
padding: "12px 0",
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
alignItems: "center",
}}
>
<div>
<div style={{ fontWeight: 700 }}>{member.name}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>{member.email}</div>
</div>
<div style={{ color: "#d4d4d8", textTransform: "capitalize" }}>{member.role}</div>
<div>
<span
style={badgeStyle(
member.state === "active" ? "rgba(46, 160, 67, 0.16)" : "rgba(255, 193, 7, 0.18)",
member.state === "active" ? "#b7f0c3" : "#ffe6a6",
)}
>
{member.state}
</span>
</div>
</div>
);
}
export function MockSignInPage() {
const client = useMockAppClient();
const navigate = useNavigate();
const mockAccount = {
name: "Nathan",
email: "nathan@acme.dev",
githubLogin: "nathan",
label: "Mock account for review",
};
return (
<div style={appSurfaceStyle()}>
<div style={{ ...contentWrapStyle(), justifyContent: "center", minHeight: "100dvh" }}>
<div
style={{
...cardStyle(),
padding: "32px",
display: "grid",
gridTemplateColumns: "minmax(0, 1.1fr) minmax(0, 0.9fr)",
gap: "28px",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "18px", justifyContent: "center" }}>
<span style={badgeStyle("rgba(255, 79, 0, 0.18)", "#ffd6c7")}>Mock Better Auth + GitHub OAuth</span>
<div style={{ fontSize: "42px", lineHeight: 1.05, fontWeight: 900, maxWidth: "11ch" }}>Sign in and land directly in the org onboarding funnel.</div>
<div style={{ fontSize: "16px", lineHeight: 1.6, color: "#d4d4d8", maxWidth: "56ch" }}>
{isMockFrontendClient
? "This mock screen stands in for a basic GitHub OAuth sign-in page. After sign-in, the user moves into the separate organization selector and then the rest of the onboarding funnel."
: "GitHub OAuth starts here. After the callback exchange completes, the app restores the signed-in session and continues into organization selection."}
</div>
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
<div style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
<Github size={14} />
GitHub sign-in
</div>
<div style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
<Building2 size={14} />
Org selection
</div>
<div style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
<CreditCard size={14} />
Hosted billing
</div>
</div>
</div>
<div
style={{
...cardStyle(),
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "18px",
justifyContent: "center",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ fontSize: "22px", fontWeight: 800 }}>Continue to Sandbox Agent</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
{isMockFrontendClient
? "This mock sign-in uses a single GitHub account so the org selection step remains the place where the user chooses their workspace."
: "This starts the live GitHub OAuth flow and restores the app session when the callback returns."}
</div>
</div>
<button
type="button"
onClick={() => {
void (async () => {
await client.signInWithGithub(isMockFrontendClient ? "user-nathan" : undefined);
if (isMockFrontendClient) {
await navigate({ to: "/organizations" });
}
})();
}}
style={{
...primaryButtonStyle(),
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
fontSize: "16px",
padding: "14px 18px",
}}
>
<Github size={18} />
Sign in with GitHub
</button>
<div
style={{
borderRadius: "18px",
border: "1px solid rgba(255, 255, 255, 0.08)",
background: "rgba(255, 255, 255, 0.03)",
padding: "16px",
display: "grid",
gap: "8px",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px" }}>
<div>
<div style={{ fontSize: "16px", fontWeight: 800 }}>{mockAccount.name}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>
@{mockAccount.githubLogin} · {mockAccount.email}
</div>
</div>
<span style={badgeStyle("rgba(24, 140, 255, 0.16)", "#b9d8ff")}>{isMockFrontendClient ? mockAccount.label : "Live GitHub identity"}</span>
</div>
<div style={{ color: "#a1a1aa", fontSize: "13px", lineHeight: 1.5 }}>
{isMockFrontendClient
? "Sign-in always lands as this single mock user. Organization choice happens on the next screen."
: "In remote mode this card is replaced by the live GitHub user once the OAuth callback completes."}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export function MockOrganizationSelectorPage() {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const organizations: FoundryOrganization[] = eligibleOrganizations(snapshot);
const navigate = useNavigate();
const starterRepo = snapshot.onboarding.starterRepo;
const starterRepoTarget = organizations.find((organization) => organization.kind === "organization") ?? organizations[0] ?? null;
return (
<PageShell
user={user}
title="Choose an organization"
eyebrow="Onboarding"
description="After GitHub sign-in, choose which personal workspace or GitHub organization to onboard. Organization workspaces simulate GitHub app installation, repository import, and shared billing."
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div
style={{
...cardStyle(),
padding: "22px",
display: "grid",
gap: "16px",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "16px", flexWrap: "wrap" }}>
<div style={{ display: "grid", gap: "8px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Star size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>Starter repo</div>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55, maxWidth: "72ch" }}>
Star <strong>{starterRepo.repoFullName}</strong> before entering the main app, or skip it and continue onboarding. This keeps the starter-repo ask
inside the funnel instead of interrupting the workspace later.
</div>
</div>
{starterRepo.status === "starred" ? (
<span style={badgeStyle("rgba(46, 160, 67, 0.16)", "#b7f0c3")}>Starred</span>
) : starterRepo.status === "skipped" ? (
<span style={badgeStyle("rgba(255, 255, 255, 0.08)")}>Skipped for now</span>
) : (
<span style={badgeStyle("rgba(255, 193, 7, 0.18)", "#ffe6a6")}>Optional</span>
)}
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button
type="button"
onClick={() => {
if (!starterRepoTarget) {
return;
}
void client.starStarterRepo(starterRepoTarget.id);
}}
style={primaryButtonStyle()}
disabled={!starterRepoTarget || starterRepo.status === "starred"}
>
<Star size={15} />
{starterRepo.status === "starred" ? "Repo starred" : "Star the Sandbox Agent repo"}
</button>
<button type="button" onClick={() => void client.skipStarterRepo()} style={secondaryButtonStyle()} disabled={starterRepo.status === "skipped"}>
{starterRepo.status === "skipped" ? "Skipped" : "Maybe later"}
</button>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: "18px" }}>
{organizations.map((organization) => (
<div
key={organization.id}
style={{
...cardStyle(),
padding: "22px",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "16px" }}>
<div>
<div style={{ fontSize: "22px", fontWeight: 800 }}>{organization.settings.displayName}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>
{organization.settings.slug} · {organization.settings.primaryDomain}
</div>
</div>
{statusBadge(organization)}
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{githubBadge(organization)}
<span style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
<CreditCard size={14} />
{planCatalog[organization.billing.planId]!.label}
</span>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55, minHeight: "70px" }}>
{organization.kind === "personal"
? "Personal workspaces skip seat purchasing but still show the same onboarding and billing entry points."
: "Organization onboarding includes GitHub repo import, seat accrual on first prompt, and billing controls for the shared workspace."}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: "10px" }}>
<StatCard
label="Members"
value={`${organization.members.length}`}
caption={`${organization.members.filter((member) => member.state === "active").length} active`}
/>
<StatCard label="Repos" value={`${organization.repoCatalog.length}`} caption={organization.github.lastSyncLabel} />
<StatCard label="Seats" value={`${organization.seatAssignments.length}/${organization.billing.seatsIncluded}`} caption="Accrue on first prompt" />
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button
type="button"
onClick={() => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
}}
style={primaryButtonStyle()}
>
Continue as {organization.settings.displayName}
</button>
<button type="button" onClick={() => void navigate({ to: settingsPath(organization) })} style={secondaryButtonStyle()}>
Organization settings
</button>
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={subtleButtonStyle()}>
Billing
</button>
</div>
</div>
))}
</div>
</PageShell>
);
}
export function MockOrganizationSettingsPage({ organization }: { organization: FoundryOrganization }) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const [displayName, setDisplayName] = useState(organization.settings.displayName);
const [slug, setSlug] = useState(organization.settings.slug);
const [primaryDomain, setPrimaryDomain] = useState(organization.settings.primaryDomain);
const seatCaption = useMemo(
() => `${organization.seatAssignments.length} of ${organization.billing.seatsIncluded} seats already accrued`,
[organization.billing.seatsIncluded, organization.seatAssignments.length],
);
const openWorkspace = () => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
};
useEffect(() => {
setDisplayName(organization.settings.displayName);
setSlug(organization.settings.slug);
setPrimaryDomain(organization.settings.primaryDomain);
}, [organization.id, organization.settings.displayName, organization.settings.slug, organization.settings.primaryDomain]);
return (
<PageShell
user={user}
title={`${organization.settings.displayName} settings`}
eyebrow="Organization"
description={
isMockFrontendClient
? "This mock settings surface covers the org profile, GitHub installation state, background repository sync controls, and the seat-accrual rule from the spec."
: "This settings surface is backed by the app-shell actor and covers organization profile, GitHub installation state, repository sync controls, and seat accrual."
}
actions={
<>
<button type="button" onClick={() => void navigate({ to: "/organizations" })} style={secondaryButtonStyle()}>
<ArrowLeft size={15} />
Orgs
</button>
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={subtleButtonStyle()}>
Billing
</button>
<button type="button" onClick={openWorkspace} style={primaryButtonStyle()}>
Open workspace
</button>
</>
}
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.1fr) minmax(320px, 0.9fr)", gap: "18px" }}>
<div style={{ display: "grid", gap: "18px" }}>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "16px" }}>
<div>
<div style={{ fontSize: "20px", fontWeight: 800 }}>Organization profile</div>
<div style={{ color: "#a1a1aa", fontSize: "14px" }}>
{isMockFrontendClient ? "Mock org state persisted in the client package." : "Organization profile persisted in the app-shell backend."}
</div>
</div>
{statusBadge(organization)}
</div>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Display name</span>
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} style={inputStyle()} />
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "14px" }}>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Slug</span>
<input value={slug} onChange={(event) => setSlug(event.target.value)} style={inputStyle()} />
</label>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Primary domain</span>
<input value={primaryDomain} onChange={(event) => setPrimaryDomain(event.target.value)} style={inputStyle()} />
</label>
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button
type="button"
onClick={() =>
void client.updateOrganizationProfile({
organizationId: organization.id,
displayName,
slug,
primaryDomain,
})
}
style={primaryButtonStyle()}
>
Save settings
</button>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={secondaryButtonStyle()}>
Refresh repo sync
</button>
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Github size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>GitHub access</div>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{githubBadge(organization)}
<span style={badgeStyle("rgba(255, 255, 255, 0.06)")}>{organization.github.connectedAccount}</span>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
{organization.github.importedRepoCount} repos imported. Last sync: {organization.github.lastSyncLabel}
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={secondaryButtonStyle()}>
Reconnect GitHub
</button>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={subtleButtonStyle()}>
Retry sync
</button>
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "8px" }}>
<Users size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>Members and roles</div>
</div>
<div style={{ color: "#a1a1aa", fontSize: "14px", marginBottom: "8px" }}>
{isMockFrontendClient
? "Mock org membership feeds seat accrual and billing previews."
: "Organization membership feeds seat accrual and billing state."}
</div>
{organization.members.map((member) => (
<MemberRow key={member.id} member={member} />
))}
</div>
</div>
<div style={{ display: "grid", gap: "14px" }}>
<StatCard label="Seat policy" value="First prompt" caption="Seats accrue when a member sends their first prompt in the workspace." />
<StatCard label="Seat usage" value={`${organization.seatAssignments.length}`} caption={seatCaption} />
<StatCard
label="Default model"
value={organization.settings.defaultModel}
caption="Shown here to match the expected org-level configuration surface."
/>
</div>
</div>
</PageShell>
);
}
export function MockOrganizationBillingPage({ organization }: { organization: FoundryOrganization }) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const hasStripeCustomer = organization.billing.stripeCustomerId.trim().length > 0;
const effectivePlanId: FoundryBillingPlanId = hasStripeCustomer ? organization.billing.planId : "free";
const effectiveSeatsIncluded = hasStripeCustomer ? organization.billing.seatsIncluded : 1;
const openWorkspace = () => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
};
return (
<PageShell
user={user}
title={`${organization.settings.displayName} billing`}
eyebrow="Stripe Billing"
description={
isMockFrontendClient
? "This mock page covers plan selection, hosted checkout entry, renewal controls, seat usage, and invoice history. It is the reviewable UI surface for Milestone 2 billing without wiring the real Stripe backend yet."
: "This billing surface drives live Stripe checkout, portal management, renewal controls, seat usage, and invoice history from the persisted organization billing model."
}
actions={
<>
<button type="button" onClick={() => void navigate({ to: settingsPath(organization) })} style={secondaryButtonStyle()}>
Org settings
</button>
<button type="button" onClick={openWorkspace} style={primaryButtonStyle()}>
Open workspace
</button>
</>
}
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: "14px" }}>
<StatCard label="Current plan" value={planCatalog[effectivePlanId]!.label} caption={organization.billing.status.replaceAll("_", " ")} />
<StatCard
label="Seats used"
value={`${organization.seatAssignments.length}/${effectiveSeatsIncluded}`}
caption="Seat accrual happens on first prompt in the workspace."
/>
<StatCard label="Renewal" value={formatDate(organization.billing.renewalAt)} caption={`Payment method: ${organization.billing.paymentMethodLabel}`} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: "18px" }}>
{(Object.entries(planCatalog) as Array<[FoundryBillingPlanId, (typeof planCatalog)[FoundryBillingPlanId]]>).map(([planId, plan]) => {
const isCurrent = effectivePlanId === planId;
return (
<div key={planId} style={{ ...cardStyle(), padding: "22px", display: "grid", gap: "14px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" }}>
<div>
<div style={{ fontSize: "22px", fontWeight: 800 }}>{plan.label}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>{plan.seats}</div>
</div>
{isCurrent ? <span style={badgeStyle("rgba(46, 160, 67, 0.16)", "#b7f0c3")}>Current</span> : null}
</div>
<div style={{ fontSize: "34px", fontWeight: 900 }}>{plan.price}</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55, minHeight: "70px" }}>{plan.summary}</div>
<button
type="button"
onClick={() => (isCurrent ? void navigate({ to: billingPath(organization) }) : void navigate({ to: checkoutPath(organization, planId) }))}
style={isCurrent ? secondaryButtonStyle() : primaryButtonStyle()}
>
{isCurrent ? "Current plan" : `Choose ${plan.label}`}
</button>
</div>
);
})}
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 0.9fr) minmax(320px, 1.1fr)", gap: "18px" }}>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<ShieldCheck size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>Subscription controls</div>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
Stripe customer {organization.billing.stripeCustomerId || "pending"}.{" "}
{isMockFrontendClient
? "This mock screen intentionally mirrors a hosted billing portal entry point and the in-product summary beside it."
: hasStripeCustomer
? "Use the portal for payment method management and invoices, while in-product controls keep renewal state visible in the app shell."
: "Complete checkout first, then use the portal and renewal controls once Stripe has created the customer and subscription."}
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
{hasStripeCustomer ? (
organization.billing.status === "scheduled_cancel" ? (
<button type="button" onClick={() => void client.resumeSubscription(organization.id)} style={primaryButtonStyle()}>
Resume subscription
</button>
) : (
<button type="button" onClick={() => void client.cancelScheduledRenewal(organization.id)} style={secondaryButtonStyle()}>
Cancel at period end
</button>
)
) : (
<button type="button" onClick={() => void navigate({ to: checkoutPath(organization, "team") })} style={primaryButtonStyle()}>
Start Team checkout
</button>
)}
<button
type="button"
onClick={() =>
void (isMockFrontendClient
? navigate({ to: checkoutPath(organization, effectivePlanId) })
: hasStripeCustomer
? client.openBillingPortal(organization.id)
: navigate({ to: checkoutPath(organization, "team") }))
}
style={subtleButtonStyle()}
>
{isMockFrontendClient ? "Open hosted checkout mock" : hasStripeCustomer ? "Open Stripe portal" : "Go to checkout"}
</button>
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "8px" }}>
<BadgeCheck size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>Invoices</div>
</div>
<div style={{ color: "#a1a1aa", fontSize: "14px", marginBottom: "8px" }}>Recent hosted billing activity for review.</div>
{organization.billing.invoices.length === 0 ? (
<div style={{ color: "#d4d4d8" }}>No invoices yet.</div>
) : (
organization.billing.invoices.map((invoice) => (
<div
key={invoice.id}
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px 90px",
gap: "12px",
alignItems: "center",
padding: "12px 0",
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<div>
<div style={{ fontWeight: 700 }}>{invoice.label}</div>
<div style={{ fontSize: "13px", color: "#a1a1aa" }}>{invoice.issuedAt}</div>
</div>
<div style={{ fontWeight: 700 }}>${invoice.amountUsd}</div>
<div>
<span
style={badgeStyle(
invoice.status === "paid" ? "rgba(46, 160, 67, 0.16)" : "rgba(255, 193, 7, 0.18)",
invoice.status === "paid" ? "#b7f0c3" : "#ffe6a6",
)}
>
{invoice.status}
</span>
</div>
</div>
))
)}
</div>
</div>
</PageShell>
);
}
export function MockHostedCheckoutPage({ organization, planId }: { organization: FoundryOrganization; planId: FoundryBillingPlanId }) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const plan = planCatalog[planId]!;
return (
<PageShell
user={user}
title={`Checkout ${plan.label}`}
eyebrow="Hosted Checkout"
description={
isMockFrontendClient
? "This is the mock hosted Stripe step. Completing checkout updates the org billing state in the client package and returns the reviewer to the billing screen."
: "This hands off to a live Stripe Checkout session. After payment succeeds, the backend finalizes the session and routes back into the billing screen."
}
actions={
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={secondaryButtonStyle()}>
<ArrowLeft size={15} />
Back to billing
</button>
}
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 0.95fr) minmax(320px, 1.05fr)", gap: "18px" }}>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "14px" }}>
<div style={{ fontSize: "20px", fontWeight: 800 }}>Order summary</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
{organization.settings.displayName} is checking out on the {plan.label} plan.
</div>
<div style={{ display: "grid", gap: "10px" }}>
<CheckoutLine label="Plan" value={plan.label} />
<CheckoutLine label="Price" value={plan.price} />
<CheckoutLine label="Included seats" value={plan.seats} />
<CheckoutLine label="Payment method" value="Visa ending in 4242" />
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
<div style={{ fontSize: "20px", fontWeight: 800 }}>Mock card details</div>
<div style={{ display: "grid", gap: "12px" }}>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Cardholder</span>
<input value={organization.settings.displayName} readOnly style={inputStyle()} />
</label>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Card number</span>
<input value="4242 4242 4242 4242" readOnly style={inputStyle()} />
</label>
</div>
<button
type="button"
onClick={() => {
void (async () => {
await client.completeHostedCheckout(organization.id, planId);
if (isMockFrontendClient) {
await navigate({ to: billingPath(organization), replace: true });
}
})();
}}
style={primaryButtonStyle()}
>
{isMockFrontendClient ? "Complete checkout" : "Continue to Stripe"}
</button>
</div>
</div>
</PageShell>
);
}
function CheckoutLine({ label, value }: { label: string; value: string }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "12px 0",
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<div style={{ color: "#a1a1aa" }}>{label}</div>
<div style={{ fontWeight: 700 }}>{value}</div>
</div>
);
}
function inputStyle(): React.CSSProperties {
return {
width: "100%",
borderRadius: "14px",
border: "1px solid rgba(255, 255, 255, 0.12)",
background: "rgba(255, 255, 255, 0.04)",
color: "#ffffff",
padding: "12px 14px",
outline: "none",
};
}

File diff suppressed because it is too large Load diff