summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-25 10:20:12 -0500
committerAdam <[email protected]>2026-03-25 10:20:19 -0500
commitb746aec49316d8f83d40aa34fa55bf7cff81c036 (patch)
treedae1472fd736592660eb1548126e32e5081a26b9 /packages/ui/src
parentad40b65b0b8546b84f0312925fcec516bfdf4eb3 (diff)
downloadopencode-b746aec49316d8f83d40aa34fa55bf7cff81c036.tar.gz
opencode-b746aec49316d8f83d40aa34fa55bf7cff81c036.zip
chore: storybook tweaks
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/timeline-playground.stories.tsx207
1 files changed, 191 insertions, 16 deletions
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx
index aa20ba940..00d458fa0 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<string, unknown> {
+ 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<string, Part[]>()
+ 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<Record<string, string>>({})
const [defaults, setDefaults] = createStore<Record<string, string>>({})
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() {
</button>
<Show when={panels.generators}>
<div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
+ {/* ---- Session import ---- */}
+ <div style={sectionLabel}>Import session</div>
+ <div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
+ Replaces the current timeline with an `opencode export` JSON file
+ </div>
+ <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+ <button style={btnAccent} onClick={() => pick?.click()}>
+ Import session
+ </button>
+ <input
+ ref={pick!}
+ type="file"
+ accept=".json,application/json"
+ onChange={importFile}
+ style={{ display: "none" }}
+ />
+ </div>
+ <Show when={loaded()}>
+ <div style={{ "font-size": "10px", color: "var(--text-weaker)", "line-height": "1.4" }}>
+ {loaded()} • {session().title || session().id} • {state.messages.length} message
+ {state.messages.length === 1 ? "" : "s"}
+ </div>
+ </Show>
+ <Show when={issue()}>
+ <div style={{ "font-size": "10px", color: "var(--text-on-critical-base)", "line-height": "1.4" }}>
+ {issue()}
+ </div>
+ </Show>
+
{/* ---- User messages ---- */}
<div style={sectionLabel}>User messages</div>
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
@@ -1407,6 +1569,19 @@ function Playground() {
)}
</For>
</div>
+ <div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
+ <button
+ style={{
+ ...btnDanger,
+ opacity: userMessages().length === 0 ? "0.5" : "1",
+ cursor: userMessages().length === 0 ? "not-allowed" : "pointer",
+ }}
+ disabled={userMessages().length === 0}
+ onClick={interrupt}
+ >
+ Interrupt last
+ </button>
+ </div>
{/* ---- Text and reasoning blocks ---- */}
<div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
@@ -1716,7 +1891,7 @@ function Playground() {
"font-size": "14px",
}}
>
- Click a generator button to add messages
+ Click a generator button or import a session
</div>
}
>
@@ -1729,7 +1904,7 @@ function Playground() {
{(msg) => (
<div style={{ width: "100%" }}>
<SessionTurn
- sessionID={SESSION_ID}
+ sessionID={session().id}
messageID={msg.id}
messages={state.messages}
active={false}