Skip to content

Commit a9cb857

Browse files
kitlangtonShamirSecret
authored andcommitted
fix(tui): preserve live parts during session hydration (anomalyco#30300)
1 parent 637a2c7 commit a9cb857

2 files changed

Lines changed: 361 additions & 22 deletions

File tree

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 83 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
113113
const kv = useKV()
114114

115115
const fullSyncedSessions = new Set<string>()
116+
const syncingSessions = new Map<string, Promise<void>>()
117+
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
118+
const touchMessage = (sessionID: string, messageID: string) => {
119+
hydratingSessions.get(sessionID)?.messages.add(messageID)
120+
}
121+
const touchPart = (sessionID: string, partID: string) => {
122+
hydratingSessions.get(sessionID)?.parts.add(partID)
123+
}
116124

117125
function sessionListQuery(): { scope?: "project"; path?: string } {
118126
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
@@ -252,6 +260,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
252260
}
253261

254262
case "message.updated": {
263+
touchMessage(event.properties.info.sessionID, event.properties.info.id)
255264
const messages = store.message[event.properties.info.sessionID]
256265
if (!messages) {
257266
setStore("message", event.properties.info.sessionID, [event.properties.info])
@@ -291,6 +300,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
291300
break
292301
}
293302
case "message.removed": {
303+
touchMessage(event.properties.sessionID, event.properties.messageID)
294304
const messages = store.message[event.properties.sessionID]
295305
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
296306
if (result.found) {
@@ -305,6 +315,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
305315
break
306316
}
307317
case "message.part.updated": {
318+
touchPart(event.properties.part.sessionID, event.properties.part.id)
308319
const parts = store.part[event.properties.part.messageID]
309320
if (!parts) {
310321
setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -330,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
330341
if (!parts) break
331342
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
332343
if (!result.found) break
344+
touchPart(event.properties.sessionID, event.properties.partID)
333345
setStore(
334346
"part",
335347
event.properties.messageID,
@@ -344,6 +356,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
344356
}
345357

346358
case "message.part.removed": {
359+
touchPart(event.properties.sessionID, event.properties.partID)
347360
const parts = store.part[event.properties.messageID]
348361
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
349362
if (result.found) {
@@ -521,28 +534,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
521534
},
522535
async sync(sessionID: string) {
523536
if (fullSyncedSessions.has(sessionID)) return
524-
const [session, messages, todo, diff] = await Promise.all([
525-
sdk.client.session.get({ sessionID }, { throwOnError: true }),
526-
sdk.client.session.messages({ sessionID, limit: 100 }),
527-
sdk.client.session.todo({ sessionID }),
528-
sdk.client.session.diff({ sessionID }),
529-
])
530-
setStore(
531-
produce((draft) => {
532-
const match = Binary.search(draft.session, sessionID, (s) => s.id)
533-
if (match.found) draft.session[match.index] = session.data!
534-
if (!match.found) draft.session.splice(match.index, 0, session.data!)
535-
draft.todo[sessionID] = todo.data ?? []
536-
const infos: (typeof draft.message)[string] = []
537-
for (const message of messages.data ?? []) {
538-
infos.push(message.info)
539-
draft.part[message.info.id] = message.parts
540-
}
541-
draft.message[sessionID] = infos
542-
draft.session_diff[sessionID] = diff.data ?? []
543-
}),
544-
)
545-
fullSyncedSessions.add(sessionID)
537+
const syncing = syncingSessions.get(sessionID)
538+
if (syncing) return syncing
539+
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
540+
hydratingSessions.set(sessionID, tracker)
541+
const task = (async () => {
542+
const [session, messages, todo, diff] = await Promise.all([
543+
sdk.client.session.get({ sessionID }, { throwOnError: true }),
544+
sdk.client.session.messages({ sessionID, limit: 100 }),
545+
sdk.client.session.todo({ sessionID }),
546+
sdk.client.session.diff({ sessionID }),
547+
])
548+
setStore(
549+
produce((draft) => {
550+
const match = Binary.search(draft.session, sessionID, (s) => s.id)
551+
if (match.found) draft.session[match.index] = session.data!
552+
if (!match.found) draft.session.splice(match.index, 0, session.data!)
553+
draft.todo[sessionID] = todo.data ?? []
554+
const currentMessages = draft.message[sessionID] ?? []
555+
const infos = (messages.data ?? []).flatMap((message) => {
556+
if (!tracker.messages.has(message.info.id)) return [message.info]
557+
const current = currentMessages.find((item) => item.id === message.info.id)
558+
return current ? [current] : []
559+
})
560+
infos.push(
561+
...currentMessages.filter(
562+
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
563+
),
564+
)
565+
const removed = infos.slice(0, -100)
566+
const visible = infos.slice(-100)
567+
const visibleIDs = new Set(visible.map((message) => message.id))
568+
for (const message of messages.data ?? []) {
569+
if (!visibleIDs.has(message.info.id)) {
570+
delete draft.part[message.info.id]
571+
continue
572+
}
573+
const currentParts = draft.part[message.info.id] ?? []
574+
const parts = message.parts.flatMap((part) => {
575+
const current = currentParts.find((item) => item.id === part.id)
576+
if (tracker.parts.has(part.id)) return current ? [current] : []
577+
if (
578+
current &&
579+
(part.type === "text" || part.type === "reasoning") &&
580+
(current.type === "text" || current.type === "reasoning") &&
581+
part.text.length === 0 &&
582+
current.text.length > 0
583+
) {
584+
return [current]
585+
}
586+
return [part]
587+
})
588+
parts.push(
589+
...currentParts.filter(
590+
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
591+
),
592+
)
593+
draft.part[message.info.id] = parts
594+
}
595+
for (const message of removed) delete draft.part[message.id]
596+
draft.message[sessionID] = visible
597+
draft.session_diff[sessionID] = diff.data ?? []
598+
}),
599+
)
600+
fullSyncedSessions.add(sessionID)
601+
})().finally(() => {
602+
syncingSessions.delete(sessionID)
603+
hydratingSessions.delete(sessionID)
604+
})
605+
syncingSessions.set(sessionID, task)
606+
return task
546607
},
547608
},
548609
bootstrap,

0 commit comments

Comments
 (0)