@@ -425,13 +425,60 @@ const TOOL_SAMPLES = {
425425// Fake data generators
426426// ---------------------------------------------------------------------------
427427const 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