Skip to content

Commit 7a94f0b

Browse files
kitlangton0xLLLLH
authored andcommitted
fix(tui): preserve live parts during session hydration (anomalyco#30300)
1 parent 5c7c865 commit 7a94f0b

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" }
@@ -251,6 +259,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
251259
}
252260

253261
case "message.updated": {
262+
touchMessage(event.properties.info.sessionID, event.properties.info.id)
254263
const messages = store.message[event.properties.info.sessionID]
255264
if (!messages) {
256265
setStore("message", event.properties.info.sessionID, [event.properties.info])
@@ -290,6 +299,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
290299
break
291300
}
292301
case "message.removed": {
302+
touchMessage(event.properties.sessionID, event.properties.messageID)
293303
const messages = store.message[event.properties.sessionID]
294304
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
295305
if (result.found) {
@@ -304,6 +314,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
304314
break
305315
}
306316
case "message.part.updated": {
317+
touchPart(event.properties.part.sessionID, event.properties.part.id)
307318
const parts = store.part[event.properties.part.messageID]
308319
if (!parts) {
309320
setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -329,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
329340
if (!parts) break
330341
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
331342
if (!result.found) break
343+
touchPart(event.properties.sessionID, event.properties.partID)
332344
setStore(
333345
"part",
334346
event.properties.messageID,
@@ -343,6 +355,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
343355
}
344356

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

0 commit comments

Comments
 (0)