summaryrefslogtreecommitdiffhomepage
path: root/src/core/chunks/selectors.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/chunks/selectors.ts')
-rw-r--r--src/core/chunks/selectors.ts83
1 files changed, 78 insertions, 5 deletions
diff --git a/src/core/chunks/selectors.ts b/src/core/chunks/selectors.ts
index 8b265a5..d32a628 100644
--- a/src/core/chunks/selectors.ts
+++ b/src/core/chunks/selectors.ts
@@ -1,12 +1,83 @@
-import type { ChatMessage, Chunk, TurnProviderRetryEvent } from "@dispatch/wire";
-import type { RenderedChunk, TranscriptState } from "./types";
+import type { ChatMessage, Chunk, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire";
+import type { ProvisionalChunk, RenderedChunk, TranscriptState } from "./types";
+
+/**
+ * Whether a provisional chunk is the same content as a committed chunk at the
+ * same position of the same turn (identity for de-dup, not deep equality).
+ * Tool chunks match by `toolCallId`; text-bearing chunks by exact text.
+ */
+function sameChunk(p: ProvisionalChunk, c: StoredChunk): boolean {
+ if (p.role !== c.role || p.chunk.type !== c.chunk.type) return false;
+ const a = p.chunk;
+ const b = c.chunk;
+ switch (a.type) {
+ case "tool-call":
+ case "tool-result":
+ return a.toolCallId === (b as typeof a).toolCallId;
+ case "text":
+ case "thinking":
+ case "system":
+ return a.text === (b as typeof a).text;
+ case "error":
+ return a.message === (b as typeof a).message;
+ case "image":
+ return true;
+ }
+}
+
+/**
+ * How many LEADING provisional chunks are already backed by committed chunks.
+ *
+ * The backend persists each step of an in-flight turn as it completes (CR-6),
+ * so a client that opens a conversation mid-turn gets those steps from history
+ * AND again from the late-join event replay (as provisional chunks). Both
+ * carry the turn's chunks in the same order, so the provisional run of turn T
+ * is aligned position-by-position with the committed run of turn T. Skip the
+ * aligned prefix; stop at the first mismatch (fail-safe: a misalignment shows
+ * a duplicate, never hides content).
+ *
+ * Also reports whether the ACCUMULATING chunk is already committed (the step
+ * completed + history synced, but the next live event hasn't flushed it yet).
+ */
+function committedOverlap(state: TranscriptState): { skip: number; hideAccumulating: boolean } {
+ const first = state.provisional[0];
+ const turnId = first?.turnId ?? state.accumulating?.turnId;
+ if (turnId === undefined) return { skip: 0, hideAccumulating: false };
+ // The committed run for this turn (contiguous at the tail of `committed`).
+ let start = state.committed.length;
+ while (start > 0 && state.committed[start - 1]?.turnId === turnId) start--;
+ const run = state.committed.length - start;
+ if (run === 0) return { skip: 0, hideAccumulating: false };
+
+ let skip = 0;
+ while (skip < state.provisional.length && skip < run) {
+ const p = state.provisional[skip];
+ const c = state.committed[start + skip];
+ if (p === undefined || c === undefined || p.turnId !== turnId || !sameChunk(p, c)) break;
+ skip++;
+ }
+
+ let hideAccumulating = false;
+ const acc = state.accumulating;
+ if (acc !== null && skip === state.provisional.length && skip < run) {
+ const c = state.committed[start + skip];
+ hideAccumulating =
+ c !== undefined &&
+ c.role === "assistant" &&
+ c.chunk.type === acc.kind &&
+ c.chunk.text === acc.text;
+ }
+ return { skip, hideAccumulating };
+}
/**
* Select all chunks for rendering: committed first (seq order),
- * then provisional (seq: null).
+ * then provisional (seq: null) — minus provisional chunks already backed by
+ * committed history (see `committedOverlap`).
*/
export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
const result: RenderedChunk[] = [];
+ const { skip, hideAccumulating } = committedOverlap(state);
for (const c of state.committed) {
result.push(
c.turnId !== undefined
@@ -14,7 +85,9 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
: { seq: c.seq, role: c.role, chunk: c.chunk, provisional: false },
);
}
- for (const p of state.provisional) {
+ for (let i = skip; i < state.provisional.length; i++) {
+ const p = state.provisional[i];
+ if (p === undefined) continue;
result.push(
p.turnId !== undefined
? { seq: null, role: p.role, chunk: p.chunk, provisional: true, turnId: p.turnId }
@@ -22,7 +95,7 @@ export function selectChunks(state: TranscriptState): readonly RenderedChunk[] {
);
}
const acc = state.accumulating;
- if (acc !== null) {
+ if (acc !== null && !hideAccumulating) {
const chunk: Chunk =
acc.kind === "text" ? { type: "text", text: acc.text } : { type: "thinking", text: acc.text };
result.push({