mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 16:01:05 +00:00
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:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
1277
foundry/packages/frontend/src/components/mock-layout.tsx
Normal file
1277
foundry/packages/frontend/src/components/mock-layout.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,77 @@
|
|||
import { memo, useMemo } from "react";
|
||||
import { FileCode, Plus } from "lucide-react";
|
||||
|
||||
import { ScrollBody } from "./ui";
|
||||
import { parseDiffLines, type FileChange } from "./view-model";
|
||||
|
||||
export const DiffContent = memo(function DiffContent({
|
||||
filePath,
|
||||
file,
|
||||
diff,
|
||||
onAddAttachment,
|
||||
}: {
|
||||
filePath: string;
|
||||
file?: FileChange;
|
||||
diff?: string;
|
||||
onAddAttachment?: (filePath: string, lineNumber: number, lineContent: string) => void;
|
||||
}) {
|
||||
const diffLines = useMemo(() => (diff ? parseDiffLines(diff) : []), [diff]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mock-diff-header">
|
||||
<FileCode size={14} color="#71717a" />
|
||||
<div className="mock-diff-path">{filePath}</div>
|
||||
{file ? (
|
||||
<div className="mock-diff-stats">
|
||||
<span className="mock-diff-added">+{file.added}</span>
|
||||
<span className="mock-diff-removed">−{file.removed}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ScrollBody>
|
||||
{diff ? (
|
||||
<div className="mock-diff-body">
|
||||
{diffLines.map((line) => {
|
||||
const isHunk = line.kind === "hunk";
|
||||
return (
|
||||
<div
|
||||
key={`${line.lineNumber}-${line.text}`}
|
||||
className="mock-diff-row"
|
||||
data-kind={line.kind}
|
||||
style={!isHunk && onAddAttachment ? { cursor: "pointer" } : undefined}
|
||||
onClick={!isHunk && onAddAttachment ? () => onAddAttachment(filePath, line.lineNumber, line.text) : undefined}
|
||||
>
|
||||
<div className="mock-diff-gutter">
|
||||
{!isHunk && onAddAttachment ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Attach line ${line.lineNumber}`}
|
||||
tabIndex={-1}
|
||||
className="mock-diff-attach-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAddAttachment(filePath, line.lineNumber, line.text);
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="mock-diff-line-number">{isHunk ? "" : line.lineNumber}</span>
|
||||
</div>
|
||||
<div data-selectable className="mock-diff-line-text">
|
||||
{line.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mock-diff-empty">
|
||||
<div className="mock-diff-empty-copy">No diff data available for this file</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollBody>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
294
foundry/packages/frontend/src/components/mock-layout/sidebar.tsx
Normal file
294
foundry/packages/frontend/src/components/mock-layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
208
foundry/packages/frontend/src/components/mock-layout/ui.tsx
Normal file
208
foundry/packages/frontend/src/components/mock-layout/ui.tsx
Normal 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,
|
||||
}));
|
||||
|
|
@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
969
foundry/packages/frontend/src/components/mock-onboarding.tsx
Normal file
969
foundry/packages/frontend/src/components/mock-onboarding.tsx
Normal 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",
|
||||
};
|
||||
}
|
||||
1926
foundry/packages/frontend/src/components/workspace-dashboard.tsx
Normal file
1926
foundry/packages/frontend/src/components/workspace-dashboard.tsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue