diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index aa20ba940a..00d458fa02 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -425,13 +425,60 @@ const TOOL_SAMPLES = { // Fake data generators // --------------------------------------------------------------------------- const SESSION_ID = "playground-session" +const DEFAULT_SESSION = { id: SESSION_ID, title: "Timeline Playground" } -function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } { +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function normalize(raw: unknown) { + if (Array.isArray(raw)) { + const info = raw.find((row) => record(row) && row.type === "session" && record(row.data))?.data + if (!record(info) || typeof info.id !== "string") { + throw new Error("No session found in JSON") + } + + const part = new Map() + const messages = raw.flatMap((row) => { + if (!record(row) || !record(row.data)) return [] + if (row.type === "part" && typeof row.data.messageID === "string") { + const list = part.get(row.data.messageID) ?? [] + list.push(row.data as Part) + part.set(row.data.messageID, list) + return [] + } + if (row.type !== "message" || typeof row.data.id !== "string") return [] + return [{ info: row.data as Message, parts: [] as Part[] }] + }) + + return { + info, + messages: messages.map((msg) => ({ + info: msg.info, + parts: part.get(msg.info.id) ?? [], + })), + } + } + + if (!record(raw) || !record(raw.info) || typeof raw.info.id !== "string" || !Array.isArray(raw.messages)) { + throw new Error("Expected an `opencode export` JSON file") + } + + return { + info: raw.info, + messages: raw.messages.flatMap((row) => { + if (!record(row) || !record(row.info) || typeof row.info.id !== "string") return [] + return [{ info: row.info as Message, parts: Array.isArray(row.parts) ? (row.parts as Part[]) : [] }] + }), + } +} + +function mkUser(text: string, extra: Part[] = [], sessionID = SESSION_ID): { message: UserMessage; parts: Part[] } { const id = uid() return { message: { id, - sessionID: SESSION_ID, + sessionID, role: "user", time: { created: Date.now() }, agent: "code", @@ -445,10 +492,10 @@ function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts } } -function mkAssistant(parentID: string): AssistantMessage { +function mkAssistant(parentID: string, sessionID = SESSION_ID): AssistantMessage { return { id: uid(), - sessionID: SESSION_ID, + sessionID, role: "assistant", time: { created: Date.now(), completed: Date.now() + 3000 }, parentID, @@ -1010,12 +1057,16 @@ function Playground() { messages: [], parts: {}, }) + const [session, setSession] = createSignal({ ...DEFAULT_SESSION }) + const [loaded, setLoaded] = createSignal("") + const [issue, setIssue] = createSignal("") // ---- CSS overrides ---- const [css, setCss] = createStore>({}) const [defaults, setDefaults] = createStore>({}) let styleEl: HTMLStyleElement | undefined let previewRef: HTMLDivElement | undefined + let pick: HTMLInputElement | undefined /** Read computed styles from the DOM to seed slider defaults */ const readDefaults = () => { @@ -1074,10 +1125,10 @@ function Playground() { const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user")) const data = createMemo(() => ({ - session: [{ id: SESSION_ID }], + session: [session()], session_status: {}, session_diff: {}, - message: { [SESSION_ID]: state.messages }, + message: { [session().id]: state.messages }, part: state.parts, provider: { all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }], @@ -1109,8 +1160,8 @@ function Playground() { const id = lastAssistantID() if (id) return id // Create a minimal placeholder turn - const user = mkUser("...") - const asst = mkAssistant(user.message.id) + const user = mkUser("...", [], session().id) + const asst = mkAssistant(user.message.id, session().id) setState( produce((draft) => { draft.messages.push(user.message) @@ -1136,8 +1187,8 @@ function Playground() { // ---- User message helpers ---- const addUser = (variant: keyof typeof USER_VARIANTS) => { const v = USER_VARIANTS[variant] - const user = mkUser(v.text, v.parts) - const asst = mkAssistant(user.message.id) + const user = mkUser(v.text, v.parts, session().id) + const asst = mkAssistant(user.message.id, session().id) setState( produce((draft) => { draft.messages.push(user.message) @@ -1164,8 +1215,8 @@ function Playground() { // ---- Composite helpers (create full turns with user + assistant) ---- const addFullTurn = (userText: string, parts: Part[]) => { - const user = mkUser(userText) - const asst = mkAssistant(user.message.id) + const user = mkUser(userText, [], session().id) + const asst = mkAssistant(user.message.id, session().id) setState( produce((draft) => { draft.messages.push(user.message) @@ -1222,9 +1273,91 @@ function Playground() { addReasoningFullTurn() } + const interrupt = () => { + const user = userMessages().at(-1) + if (!user) return + const now = Date.now() + + setState( + produce((draft) => { + const msg = draft.messages.findLast( + (item): item is AssistantMessage => item.role === "assistant" && item.parentID === user.id, + ) + + if (msg) { + const time = msg.time ?? { created: now } + msg.time = { ...time, completed: time.completed ?? now } + msg.error = { name: "MessageAbortedError", message: "Interrupted" } + return + } + + const asst = mkAssistant(user.id, session().id) + asst.time = { created: now, completed: now } + asst.error = { name: "MessageAbortedError", message: "Interrupted" } + draft.messages.push(asst) + draft.parts[asst.id] = [] + }), + ) + } + + const load = (raw: unknown, name: string) => { + const next = normalize(raw) + const id = typeof next.info.id === "string" && next.info.id ? next.info.id : SESSION_ID + const messages = next.messages.map((msg) => ({ + ...msg.info, + sessionID: typeof msg.info.sessionID === "string" ? msg.info.sessionID : id, + })) + const parts = Object.fromEntries( + next.messages.map((msg, idx) => { + const info = messages[idx] + return [ + info.id, + msg.parts.map((part) => ({ + ...part, + messageID: typeof part.messageID === "string" ? part.messageID : info.id, + sessionID: typeof part.sessionID === "string" ? part.sessionID : info.sessionID, + })), + ] + }), + ) + + batch(() => { + setSession({ + ...DEFAULT_SESSION, + ...next.info, + id, + title: typeof next.info.title === "string" && next.info.title ? next.info.title : name, + }) + setState({ messages, parts }) + setLoaded(name) + setIssue("") + }) + } + + const importFile = async (event: Event) => { + const input = event.currentTarget as HTMLInputElement + const file = input.files?.[0] + if (!file) return + + setIssue("") + + try { + load(JSON.parse(await file.text()), file.name) + } catch (err) { + setIssue(err instanceof Error ? err.message : String(err)) + } finally { + input.value = "" + } + } + const clearAll = () => { - setState({ messages: [], parts: {} }) - seq = 0 + batch(() => { + setState({ messages: [], parts: {} }) + setSession({ ...DEFAULT_SESSION }) + setLoaded("") + setIssue("") + seq = 0 + }) } // ---- CSS export ---- @@ -1393,6 +1526,35 @@ function Playground() {
+ {/* ---- Session import ---- */} +
Import session
+
+ Replaces the current timeline with an `opencode export` JSON file +
+
+ + +
+ +
+ {loaded()} • {session().title || session().id} • {state.messages.length} message + {state.messages.length === 1 ? "" : "s"} +
+
+ +
+ {issue()} +
+
+ {/* ---- User messages ---- */}
User messages
@@ -1407,6 +1569,19 @@ function Playground() { )}
+
+ +
{/* ---- Text and reasoning blocks ---- */}
Text and reasoning blocks
@@ -1716,7 +1891,7 @@ function Playground() { "font-size": "14px", }} > - Click a generator button to add messages + Click a generator button or import a session
} > @@ -1729,7 +1904,7 @@ function Playground() { {(msg) => (