Skip to content

Commit b746aec

Browse files
committed
chore: storybook tweaks
1 parent ad40b65 commit b746aec

1 file changed

Lines changed: 191 additions & 16 deletions

File tree

packages/ui/src/components/timeline-playground.stories.tsx

Lines changed: 191 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -425,13 +425,60 @@ const TOOL_SAMPLES = {
425425
// Fake data generators
426426
// ---------------------------------------------------------------------------
427427
const SESSION_ID = "playground-session"
428+
const DEFAULT_SESSION = { id: SESSION_ID, title: "Timeline Playground" }
428429

429-
function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } {
430+
function record(value: unknown): value is Record<string, unknown> {
431+
return !!value && typeof value === "object" && !Array.isArray(value)
432+
}
433+
434+
function normalize(raw: unknown) {
435+
if (Array.isArray(raw)) {
436+
const info = raw.find((row) => record(row) && row.type === "session" && record(row.data))?.data
437+
if (!record(info) || typeof info.id !== "string") {
438+
throw new Error("No session found in JSON")
439+
}
440+
441+
const part = new Map<string, Part[]>()
442+
const messages = raw.flatMap((row) => {
443+
if (!record(row) || !record(row.data)) return []
444+
if (row.type === "part" && typeof row.data.messageID === "string") {
445+
const list = part.get(row.data.messageID) ?? []
446+
list.push(row.data as Part)
447+
part.set(row.data.messageID, list)
448+
return []
449+
}
450+
if (row.type !== "message" || typeof row.data.id !== "string") return []
451+
return [{ info: row.data as Message, parts: [] as Part[] }]
452+
})
453+
454+
return {
455+
info,
456+
messages: messages.map((msg) => ({
457+
info: msg.info,
458+
parts: part.get(msg.info.id) ?? [],
459+
})),
460+
}
461+
}
462+
463+
if (!record(raw) || !record(raw.info) || typeof raw.info.id !== "string" || !Array.isArray(raw.messages)) {
464+
throw new Error("Expected an `opencode export` JSON file")
465+
}
466+
467+
return {
468+
info: raw.info,
469+
messages: raw.messages.flatMap((row) => {
470+
if (!record(row) || !record(row.info) || typeof row.info.id !== "string") return []
471+
return [{ info: row.info as Message, parts: Array.isArray(row.parts) ? (row.parts as Part[]) : [] }]
472+
}),
473+
}
474+
}
475+
476+
function mkUser(text: string, extra: Part[] = [], sessionID = SESSION_ID): { message: UserMessage; parts: Part[] } {
430477
const id = uid()
431478
return {
432479
message: {
433480
id,
434-
sessionID: SESSION_ID,
481+
sessionID,
435482
role: "user",
436483
time: { created: Date.now() },
437484
agent: "code",
@@ -445,10 +492,10 @@ function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts
445492
}
446493
}
447494

448-
function mkAssistant(parentID: string): AssistantMessage {
495+
function mkAssistant(parentID: string, sessionID = SESSION_ID): AssistantMessage {
449496
return {
450497
id: uid(),
451-
sessionID: SESSION_ID,
498+
sessionID,
452499
role: "assistant",
453500
time: { created: Date.now(), completed: Date.now() + 3000 },
454501
parentID,
@@ -1010,12 +1057,16 @@ function Playground() {
10101057
messages: [],
10111058
parts: {},
10121059
})
1060+
const [session, setSession] = createSignal({ ...DEFAULT_SESSION })
1061+
const [loaded, setLoaded] = createSignal("")
1062+
const [issue, setIssue] = createSignal("")
10131063

10141064
// ---- CSS overrides ----
10151065
const [css, setCss] = createStore<Record<string, string>>({})
10161066
const [defaults, setDefaults] = createStore<Record<string, string>>({})
10171067
let styleEl: HTMLStyleElement | undefined
10181068
let previewRef: HTMLDivElement | undefined
1069+
let pick: HTMLInputElement | undefined
10191070

10201071
/** Read computed styles from the DOM to seed slider defaults */
10211072
const readDefaults = () => {
@@ -1074,10 +1125,10 @@ function Playground() {
10741125
const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user"))
10751126

10761127
const data = createMemo(() => ({
1077-
session: [{ id: SESSION_ID }],
1128+
session: [session()],
10781129
session_status: {},
10791130
session_diff: {},
1080-
message: { [SESSION_ID]: state.messages },
1131+
message: { [session().id]: state.messages },
10811132
part: state.parts,
10821133
provider: {
10831134
all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
@@ -1109,8 +1160,8 @@ function Playground() {
11091160
const id = lastAssistantID()
11101161
if (id) return id
11111162
// Create a minimal placeholder turn
1112-
const user = mkUser("...")
1113-
const asst = mkAssistant(user.message.id)
1163+
const user = mkUser("...", [], session().id)
1164+
const asst = mkAssistant(user.message.id, session().id)
11141165
setState(
11151166
produce((draft) => {
11161167
draft.messages.push(user.message)
@@ -1136,8 +1187,8 @@ function Playground() {
11361187
// ---- User message helpers ----
11371188
const addUser = (variant: keyof typeof USER_VARIANTS) => {
11381189
const v = USER_VARIANTS[variant]
1139-
const user = mkUser(v.text, v.parts)
1140-
const asst = mkAssistant(user.message.id)
1190+
const user = mkUser(v.text, v.parts, session().id)
1191+
const asst = mkAssistant(user.message.id, session().id)
11411192
setState(
11421193
produce((draft) => {
11431194
draft.messages.push(user.message)
@@ -1164,8 +1215,8 @@ function Playground() {
11641215

11651216
// ---- Composite helpers (create full turns with user + assistant) ----
11661217
const addFullTurn = (userText: string, parts: Part[]) => {
1167-
const user = mkUser(userText)
1168-
const asst = mkAssistant(user.message.id)
1218+
const user = mkUser(userText, [], session().id)
1219+
const asst = mkAssistant(user.message.id, session().id)
11691220
setState(
11701221
produce((draft) => {
11711222
draft.messages.push(user.message)
@@ -1222,9 +1273,91 @@ function Playground() {
12221273
addReasoningFullTurn()
12231274
}
12241275

1276+
const interrupt = () => {
1277+
const user = userMessages().at(-1)
1278+
if (!user) return
1279+
const now = Date.now()
1280+
1281+
setState(
1282+
produce((draft) => {
1283+
const msg = draft.messages.findLast(
1284+
(item): item is AssistantMessage => item.role === "assistant" && item.parentID === user.id,
1285+
)
1286+
1287+
if (msg) {
1288+
const time = msg.time ?? { created: now }
1289+
msg.time = { ...time, completed: time.completed ?? now }
1290+
msg.error = { name: "MessageAbortedError", message: "Interrupted" }
1291+
return
1292+
}
1293+
1294+
const asst = mkAssistant(user.id, session().id)
1295+
asst.time = { created: now, completed: now }
1296+
asst.error = { name: "MessageAbortedError", message: "Interrupted" }
1297+
draft.messages.push(asst)
1298+
draft.parts[asst.id] = []
1299+
}),
1300+
)
1301+
}
1302+
1303+
const load = (raw: unknown, name: string) => {
1304+
const next = normalize(raw)
1305+
const id = typeof next.info.id === "string" && next.info.id ? next.info.id : SESSION_ID
1306+
const messages = next.messages.map((msg) => ({
1307+
...msg.info,
1308+
sessionID: typeof msg.info.sessionID === "string" ? msg.info.sessionID : id,
1309+
}))
1310+
const parts = Object.fromEntries(
1311+
next.messages.map((msg, idx) => {
1312+
const info = messages[idx]
1313+
return [
1314+
info.id,
1315+
msg.parts.map((part) => ({
1316+
...part,
1317+
messageID: typeof part.messageID === "string" ? part.messageID : info.id,
1318+
sessionID: typeof part.sessionID === "string" ? part.sessionID : info.sessionID,
1319+
})),
1320+
]
1321+
}),
1322+
)
1323+
1324+
batch(() => {
1325+
setSession({
1326+
...DEFAULT_SESSION,
1327+
...next.info,
1328+
id,
1329+
title: typeof next.info.title === "string" && next.info.title ? next.info.title : name,
1330+
})
1331+
setState({ messages, parts })
1332+
setLoaded(name)
1333+
setIssue("")
1334+
})
1335+
}
1336+
1337+
const importFile = async (event: Event) => {
1338+
const input = event.currentTarget as HTMLInputElement
1339+
const file = input.files?.[0]
1340+
if (!file) return
1341+
1342+
setIssue("")
1343+
1344+
try {
1345+
load(JSON.parse(await file.text()), file.name)
1346+
} catch (err) {
1347+
setIssue(err instanceof Error ? err.message : String(err))
1348+
} finally {
1349+
input.value = ""
1350+
}
1351+
}
1352+
12251353
const clearAll = () => {
1226-
setState({ messages: [], parts: {} })
1227-
seq = 0
1354+
batch(() => {
1355+
setState({ messages: [], parts: {} })
1356+
setSession({ ...DEFAULT_SESSION })
1357+
setLoaded("")
1358+
setIssue("")
1359+
seq = 0
1360+
})
12281361
}
12291362

12301363
// ---- CSS export ----
@@ -1393,6 +1526,35 @@ function Playground() {
13931526
</button>
13941527
<Show when={panels.generators}>
13951528
<div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
1529+
{/* ---- Session import ---- */}
1530+
<div style={sectionLabel}>Import session</div>
1531+
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
1532+
Replaces the current timeline with an `opencode export` JSON file
1533+
</div>
1534+
<div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
1535+
<button style={btnAccent} onClick={() => pick?.click()}>
1536+
Import session
1537+
</button>
1538+
<input
1539+
ref={pick!}
1540+
type="file"
1541+
accept=".json,application/json"
1542+
onChange={importFile}
1543+
style={{ display: "none" }}
1544+
/>
1545+
</div>
1546+
<Show when={loaded()}>
1547+
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "line-height": "1.4" }}>
1548+
{loaded()}{session().title || session().id}{state.messages.length} message
1549+
{state.messages.length === 1 ? "" : "s"}
1550+
</div>
1551+
</Show>
1552+
<Show when={issue()}>
1553+
<div style={{ "font-size": "10px", color: "var(--text-on-critical-base)", "line-height": "1.4" }}>
1554+
{issue()}
1555+
</div>
1556+
</Show>
1557+
13961558
{/* ---- User messages ---- */}
13971559
<div style={sectionLabel}>User messages</div>
13981560
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
@@ -1407,6 +1569,19 @@ function Playground() {
14071569
)}
14081570
</For>
14091571
</div>
1572+
<div style={{ display: "flex", "flex-wrap": "wrap", gap: "4px" }}>
1573+
<button
1574+
style={{
1575+
...btnDanger,
1576+
opacity: userMessages().length === 0 ? "0.5" : "1",
1577+
cursor: userMessages().length === 0 ? "not-allowed" : "pointer",
1578+
}}
1579+
disabled={userMessages().length === 0}
1580+
onClick={interrupt}
1581+
>
1582+
Interrupt last
1583+
</button>
1584+
</div>
14101585

14111586
{/* ---- Text and reasoning blocks ---- */}
14121587
<div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
@@ -1716,7 +1891,7 @@ function Playground() {
17161891
"font-size": "14px",
17171892
}}
17181893
>
1719-
Click a generator button to add messages
1894+
Click a generator button or import a session
17201895
</div>
17211896
}
17221897
>
@@ -1729,7 +1904,7 @@ function Playground() {
17291904
{(msg) => (
17301905
<div style={{ width: "100%" }}>
17311906
<SessionTurn
1732-
sessionID={SESSION_ID}
1907+
sessionID={session().id}
17331908
messageID={msg.id}
17341909
messages={state.messages}
17351910
active={false}

0 commit comments

Comments
 (0)