chore: storybook tweaks
This commit is contained in:
@@ -425,13 +425,60 @@ const TOOL_SAMPLES = {
|
|||||||
// Fake data generators
|
// Fake data generators
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const SESSION_ID = "playground-session"
|
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()
|
const id = uid()
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
id,
|
id,
|
||||||
sessionID: SESSION_ID,
|
sessionID,
|
||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: "code",
|
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 {
|
return {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
sessionID: SESSION_ID,
|
sessionID,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
time: { created: Date.now(), completed: Date.now() + 3000 },
|
time: { created: Date.now(), completed: Date.now() + 3000 },
|
||||||
parentID,
|
parentID,
|
||||||
@@ -1010,12 +1057,16 @@ function Playground() {
|
|||||||
messages: [],
|
messages: [],
|
||||||
parts: {},
|
parts: {},
|
||||||
})
|
})
|
||||||
|
const [session, setSession] = createSignal({ ...DEFAULT_SESSION })
|
||||||
|
const [loaded, setLoaded] = createSignal("")
|
||||||
|
const [issue, setIssue] = createSignal("")
|
||||||
|
|
||||||
// ---- CSS overrides ----
|
// ---- CSS overrides ----
|
||||||
const [css, setCss] = createStore<Record<string, string>>({})
|
const [css, setCss] = createStore<Record<string, string>>({})
|
||||||
const [defaults, setDefaults] = createStore<Record<string, string>>({})
|
const [defaults, setDefaults] = createStore<Record<string, string>>({})
|
||||||
let styleEl: HTMLStyleElement | undefined
|
let styleEl: HTMLStyleElement | undefined
|
||||||
let previewRef: HTMLDivElement | undefined
|
let previewRef: HTMLDivElement | undefined
|
||||||
|
let pick: HTMLInputElement | undefined
|
||||||
|
|
||||||
/** Read computed styles from the DOM to seed slider defaults */
|
/** Read computed styles from the DOM to seed slider defaults */
|
||||||
const readDefaults = () => {
|
const readDefaults = () => {
|
||||||
@@ -1074,10 +1125,10 @@ function Playground() {
|
|||||||
const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user"))
|
const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user"))
|
||||||
|
|
||||||
const data = createMemo(() => ({
|
const data = createMemo(() => ({
|
||||||
session: [{ id: SESSION_ID }],
|
session: [session()],
|
||||||
session_status: {},
|
session_status: {},
|
||||||
session_diff: {},
|
session_diff: {},
|
||||||
message: { [SESSION_ID]: state.messages },
|
message: { [session().id]: state.messages },
|
||||||
part: state.parts,
|
part: state.parts,
|
||||||
provider: {
|
provider: {
|
||||||
all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
|
all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }],
|
||||||
@@ -1109,8 +1160,8 @@ function Playground() {
|
|||||||
const id = lastAssistantID()
|
const id = lastAssistantID()
|
||||||
if (id) return id
|
if (id) return id
|
||||||
// Create a minimal placeholder turn
|
// Create a minimal placeholder turn
|
||||||
const user = mkUser("...")
|
const user = mkUser("...", [], session().id)
|
||||||
const asst = mkAssistant(user.message.id)
|
const asst = mkAssistant(user.message.id, session().id)
|
||||||
setState(
|
setState(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.messages.push(user.message)
|
draft.messages.push(user.message)
|
||||||
@@ -1136,8 +1187,8 @@ function Playground() {
|
|||||||
// ---- User message helpers ----
|
// ---- User message helpers ----
|
||||||
const addUser = (variant: keyof typeof USER_VARIANTS) => {
|
const addUser = (variant: keyof typeof USER_VARIANTS) => {
|
||||||
const v = USER_VARIANTS[variant]
|
const v = USER_VARIANTS[variant]
|
||||||
const user = mkUser(v.text, v.parts)
|
const user = mkUser(v.text, v.parts, session().id)
|
||||||
const asst = mkAssistant(user.message.id)
|
const asst = mkAssistant(user.message.id, session().id)
|
||||||
setState(
|
setState(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.messages.push(user.message)
|
draft.messages.push(user.message)
|
||||||
@@ -1164,8 +1215,8 @@ function Playground() {
|
|||||||
|
|
||||||
// ---- Composite helpers (create full turns with user + assistant) ----
|
// ---- Composite helpers (create full turns with user + assistant) ----
|
||||||
const addFullTurn = (userText: string, parts: Part[]) => {
|
const addFullTurn = (userText: string, parts: Part[]) => {
|
||||||
const user = mkUser(userText)
|
const user = mkUser(userText, [], session().id)
|
||||||
const asst = mkAssistant(user.message.id)
|
const asst = mkAssistant(user.message.id, session().id)
|
||||||
setState(
|
setState(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.messages.push(user.message)
|
draft.messages.push(user.message)
|
||||||
@@ -1222,9 +1273,91 @@ function Playground() {
|
|||||||
addReasoningFullTurn()
|
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 = () => {
|
const clearAll = () => {
|
||||||
setState({ messages: [], parts: {} })
|
batch(() => {
|
||||||
seq = 0
|
setState({ messages: [], parts: {} })
|
||||||
|
setSession({ ...DEFAULT_SESSION })
|
||||||
|
setLoaded("")
|
||||||
|
setIssue("")
|
||||||
|
seq = 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- CSS export ----
|
// ---- CSS export ----
|
||||||
@@ -1393,6 +1526,35 @@ function Playground() {
|
|||||||
</button>
|
</button>
|
||||||
<Show when={panels.generators}>
|
<Show when={panels.generators}>
|
||||||
<div style={{ padding: "0 12px 12px", display: "flex", "flex-direction": "column", gap: "6px" }}>
|
<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 ---- */}
|
{/* ---- User messages ---- */}
|
||||||
<div style={sectionLabel}>User messages</div>
|
<div style={sectionLabel}>User messages</div>
|
||||||
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
|
<div style={{ "font-size": "10px", color: "var(--text-weaker)", "margin-bottom": "2px" }}>
|
||||||
@@ -1407,6 +1569,19 @@ function Playground() {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</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 ---- */}
|
{/* ---- Text and reasoning blocks ---- */}
|
||||||
<div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
|
<div style={{ ...sectionLabel, "margin-top": "8px" }}>Text and reasoning blocks</div>
|
||||||
@@ -1716,7 +1891,7 @@ function Playground() {
|
|||||||
"font-size": "14px",
|
"font-size": "14px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Click a generator button to add messages
|
Click a generator button or import a session
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1729,7 +1904,7 @@ function Playground() {
|
|||||||
{(msg) => (
|
{(msg) => (
|
||||||
<div style={{ width: "100%" }}>
|
<div style={{ width: "100%" }}>
|
||||||
<SessionTurn
|
<SessionTurn
|
||||||
sessionID={SESSION_ID}
|
sessionID={session().id}
|
||||||
messageID={msg.id}
|
messageID={msg.id}
|
||||||
messages={state.messages}
|
messages={state.messages}
|
||||||
active={false}
|
active={false}
|
||||||
|
|||||||
Reference in New Issue
Block a user