Skip to content

Commit 5d6f2a1

Browse files
authored
fix(ui): use part_text_accum_delta to prevent markdown cutoff during streaming (#26822)
1 parent b1cb718 commit 5d6f2a1

8 files changed

Lines changed: 37 additions & 2 deletions

File tree

packages/app/src/context/global-sync/child-store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ export function createChildStoreManager(input: {
231231
limit: 5,
232232
message: {},
233233
part: {},
234+
part_text_accum_delta: {},
234235
})
235236
children[key] = child
236237
disposers.set(key, dispose)

packages/app/src/context/global-sync/event-reducer.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const baseState = (input: Partial<State> = {}) =>
8181
limit: 10,
8282
message: {},
8383
part: {},
84+
part_text_accum_delta: {},
8485
...input,
8586
}) as State
8687

packages/app/src/context/global-sync/event-reducer.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ export function applyDirectoryEvent(input: {
211211
const result = Binary.search(messages, props.messageID, (m) => m.id)
212212
if (result.found) messages.splice(result.index, 1)
213213
}
214+
const parts = draft.part[props.messageID]
215+
if (parts) {
216+
for (const part of parts) {
217+
delete draft.part_text_accum_delta[part.id]
218+
}
219+
}
214220
delete draft.part[props.messageID]
215221
}),
216222
)
@@ -219,6 +225,11 @@ export function applyDirectoryEvent(input: {
219225
case "message.part.updated": {
220226
const part = (event.properties as { part: Part }).part
221227
if (SKIP_PARTS.has(part.type)) break
228+
input.setStore(
229+
produce((draft) => {
230+
delete draft.part_text_accum_delta[part.id]
231+
}),
232+
)
222233
const parts = input.store.part[part.messageID]
223234
if (!parts) {
224235
input.setStore("part", part.messageID, [part])
@@ -240,6 +251,11 @@ export function applyDirectoryEvent(input: {
240251
}
241252
case "message.part.removed": {
242253
const props = event.properties as { messageID: string; partID: string }
254+
input.setStore(
255+
produce((draft) => {
256+
delete draft.part_text_accum_delta[props.partID]
257+
}),
258+
)
243259
const parts = input.store.part[props.messageID]
244260
if (!parts) break
245261
const result = Binary.search(parts, props.partID, (p) => p.id)
@@ -263,6 +279,7 @@ export function applyDirectoryEvent(input: {
263279
if (!parts) break
264280
const result = Binary.search(parts, props.partID, (p) => p.id)
265281
if (!result.found) break
282+
input.setStore("part_text_accum_delta", props.partID, (existing) => (existing ?? "") + props.delta)
266283
input.setStore(
267284
"part",
268285
props.messageID,

packages/app/src/context/global-sync/session-cache.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ describe("app session cache", () => {
3939
part: Record<string, Part[] | undefined>
4040
permission: Record<string, PermissionRequest[] | undefined>
4141
question: Record<string, QuestionRequest[] | undefined>
42+
part_text_accum_delta: Record<string, string | undefined>
4243
} = {
4344
session_status: { ses_1: { type: "busy" } as SessionStatus },
4445
session_diff: { ses_1: [] },
@@ -47,12 +48,14 @@ describe("app session cache", () => {
4748
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
4849
permission: { ses_1: [] as PermissionRequest[] },
4950
question: { ses_1: [] as QuestionRequest[] },
51+
part_text_accum_delta: { prt_1: "streamed text" },
5052
}
5153

5254
dropSessionCaches(store, ["ses_1"])
5355

5456
expect(store.message.ses_1).toBeUndefined()
5557
expect(store.part.msg_1).toBeUndefined()
58+
expect(store.part_text_accum_delta.prt_1).toBeUndefined()
5659
expect(store.todo.ses_1).toBeUndefined()
5760
expect(store.session_diff.ses_1).toBeUndefined()
5861
expect(store.session_status.ses_1).toBeUndefined()
@@ -70,6 +73,7 @@ describe("app session cache", () => {
7073
part: Record<string, Part[] | undefined>
7174
permission: Record<string, PermissionRequest[] | undefined>
7275
question: Record<string, QuestionRequest[] | undefined>
76+
part_text_accum_delta: Record<string, string | undefined>
7377
} = {
7478
session_status: {},
7579
session_diff: {},
@@ -78,6 +82,7 @@ describe("app session cache", () => {
7882
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
7983
permission: {},
8084
question: {},
85+
part_text_accum_delta: {},
8186
}
8287

8388
dropSessionCaches(store, ["ses_1"])

packages/app/src/context/global-sync/session-cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type SessionCache = {
1818
part: Record<string, Part[] | undefined>
1919
permission: Record<string, PermissionRequest[] | undefined>
2020
question: Record<string, QuestionRequest[] | undefined>
21+
part_text_accum_delta: Record<string, string | undefined>
2122
}
2223

2324
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
@@ -27,6 +28,9 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
2728
for (const key of Object.keys(store.part)) {
2829
const parts = store.part[key]
2930
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
31+
for (const part of parts) {
32+
delete store.part_text_accum_delta[part.id]
33+
}
3034
delete store.part[key]
3135
}
3236

packages/app/src/context/global-sync/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ export type State = {
7272
part: {
7373
[messageID: string]: Part[]
7474
}
75+
part_text_accum_delta: {
76+
[partID: string]: string
77+
}
7578
}
7679

7780
export type VcsCache = {

packages/ui/src/components/message-part.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1461,7 +1461,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
14611461
const streaming = createMemo(
14621462
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
14631463
)
1464-
const text = () => (part().text ?? "").trim()
1464+
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim()
14651465
const isLastTextPart = createMemo(() => {
14661466
const last = (data.store.part?.[props.message.id] ?? [])
14671467
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
@@ -1521,11 +1521,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
15211521
}
15221522

15231523
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
1524+
const data = useData()
15241525
const part = () => props.part as ReasoningPart
15251526
const streaming = createMemo(
15261527
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
15271528
)
1528-
const text = () => part().text.trim()
1529+
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim()
15291530

15301531
return (
15311532
<Show when={text()}>

packages/ui/src/context/data.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ type Data = {
2424
part: {
2525
[messageID: string]: Part[]
2626
}
27+
part_text_accum_delta?: {
28+
[partID: string]: string
29+
}
2730
}
2831

2932
export type NavigateToSessionFn = (sessionID: string) => void

0 commit comments

Comments
 (0)