blob: f9b6b497c00d594c4c0369dc1882231bd7aa76f6 [file]
import type { ShellRunUpdate, StoredMessage } from '@maka/core';
import type { LiveTurnProjection } from './live-turn-projection.js';
import {
applyShellRunOverlayEntry,
foldShellRunUpdates,
materializeTurns,
overlayLiveTurn,
projectTurnTools,
type ShellRunOverlayEntry,
type ToolActivityItem,
type TurnViewModel,
} from './materialize.js';
/**
* Incremental transcript projection (issue #2030).
*
* The runtime event stream is already incremental — every delta names the turn
* it belongs to — but flattening it into a `messages` snapshot throws that away,
* and the old composition tried to guess it back downstream with `memo`'s
* reference comparison over three chained pure derivations. Two of those
* derivations were not idempotent, so the guess was wrong on every token for
* any session with background-command history.
*
* This layer owns the derived state instead of re-deriving it: it remembers the
* settled turns and hands the previous object back for any turn a message
* refresh did not actually change, so "what changed" is decided by value here
* rather than guessed downstream from reference equality.
*
* Contract (mirrors `parseMarkdownIncremental`'s in `@astryxdesign/core`):
* a turn keeps its object identity unless its projected value changed, and
* projecting the same inputs twice returns the same result array, so calling
* this during render stays safe under double-invocation.
*/
export interface TranscriptProjectionInput {
/**
* Releases the owned state when it changes, so one session's turns and tools
* are not retained behind another's. Hygiene, not correctness: a switch that
* kept the state would still project the new session correctly, because a
* turn is only reused when its value matches.
*/
sessionId?: string;
messages: readonly StoredMessage[];
liveTurn?: LiveTurnProjection;
shellRunUpdates?: readonly ShellRunUpdate[];
}
export interface TranscriptProjection {
project(input: TranscriptProjectionInput): readonly TurnViewModel[];
}
const NO_UPDATES: readonly ShellRunUpdate[] = [];
const NO_TURNS: readonly TurnViewModel[] = [];
export function createTranscriptProjection(): TranscriptProjection {
let sessionId: string | undefined;
let hasProjected = false;
// Stage inputs, remembered so a stage only reruns when its own input moved.
let lastMessages: readonly StoredMessage[] | undefined;
let lastLiveTurn: LiveTurnProjection | undefined;
let lastUpdates: readonly ShellRunUpdate[] | undefined;
// Stage outputs.
let settledTurns: readonly TurnViewModel[] = NO_TURNS;
let liveTurns: readonly TurnViewModel[] = NO_TURNS;
// Tracked separately from `lastMessages` because a refresh can leave the
// settled projection untouched, which must not force the live overlay to run.
let liveTurnsFrom: readonly TurnViewModel[] | undefined;
let overlayEntries: ReadonlyMap<string, ShellRunOverlayEntry> = new Map();
let lastTurns: readonly TurnViewModel[] = NO_TURNS;
function reset(): void {
hasProjected = false;
lastMessages = undefined;
lastLiveTurn = undefined;
lastUpdates = undefined;
settledTurns = NO_TURNS;
liveTurns = NO_TURNS;
liveTurnsFrom = undefined;
overlayEntries = new Map();
lastTurns = NO_TURNS;
}
function project(input: TranscriptProjectionInput): readonly TurnViewModel[] {
if (hasProjected && input.sessionId !== sessionId) reset();
sessionId = input.sessionId;
const updates = input.shellRunUpdates ?? NO_UPDATES;
// Compared element-wise against a copy we own, not by array identity: the
// store publishes a new ShellRunUpdate object for every change it accepts,
// so this holds even if a caller hands us the same array again after
// mutating it in place.
const updatesMoved = lastUpdates === undefined
|| lastUpdates.length !== updates.length
|| updates.some((update, index) => update !== lastUpdates![index]);
// Same inputs, same answer, without advancing any owned state — which is
// what makes projecting during render safe under double invocation.
if (
hasProjected
&& input.messages === lastMessages
&& input.liveTurn === lastLiveTurn
&& !updatesMoved
) {
return lastTurns;
}
if (input.messages !== lastMessages) {
settledTurns = reconcileTurnIdentities(settledTurns, materializeTurns(input.messages));
lastMessages = input.messages;
}
if (liveTurnsFrom !== settledTurns || input.liveTurn !== lastLiveTurn) {
liveTurns = overlayLiveTurn(settledTurns, input.liveTurn);
liveTurnsFrom = settledTurns;
lastLiveTurn = input.liveTurn;
}
if (updatesMoved) {
overlayEntries = foldShellRunUpdates(updates);
lastUpdates = [...updates];
}
// Final identity reconciliation against what we last published: a stage
// that rewrote a turn without changing its value hands the previous object
// back, so identity moves only when the value did.
const overlaid = applyShellRunOverlay(liveTurns);
lastTurns = hasProjected ? reconcileTurnIdentities(lastTurns, overlaid) : overlaid;
hasProjected = true;
return lastTurns;
}
/**
* Apply the folded shell-run updates to the turns' canonical tools.
*
* A durable revision that permanently leads the persisted `tool_result`
* snapshot makes `applyShellRunOverlayEntry` allocate a fresh tool every
* time, so this pass cannot preserve identity on its own — the final
* `reconcileTurnIdentities` above is what hands the previous turn back.
*/
function applyShellRunOverlay(turns: readonly TurnViewModel[]): readonly TurnViewModel[] {
if (overlayEntries.size === 0) return turns;
// Scoped per turn: only a turn that actually holds an overlaid tool is
// rebuilt, so the reconciliation above has a smaller set to walk.
let anyTurnMoved = false;
const overlaid = turns.map((turn) => {
let turnMoved = false;
const projected: ToolActivityItem[] = [];
for (const tool of turn.tools) {
const entry = overlayEntries.get(tool.toolUseId);
const out = entry ? applyShellRunOverlayEntry(tool, entry) : tool;
if (out !== tool) turnMoved = true;
projected.push(out);
}
if (!turnMoved) return turn;
anyTurnMoved = true;
return projectTurnTools([turn], projected)[0]!;
});
return anyTurnMoved ? overlaid : turns;
}
return { project };
}
/**
* Keep the previous object for every turn whose projected value is unchanged.
* A message refresh rebuilds the whole snapshot from freshly deserialized IPC
* rows, so nothing upstream can carry identity — the equality check here is
* what narrows a refresh to the turns whose messages actually changed.
*/
export function reconcileTurnIdentities(
previous: readonly TurnViewModel[],
next: readonly TurnViewModel[],
): readonly TurnViewModel[] {
if (previous.length === 0) return next;
const previousById = new Map(previous.map((turn) => [turn.turnId, turn]));
const reconciled = next.map((turn) => {
const prior = previousById.get(turn.turnId);
return prior && valuesEqual(prior, turn) ? prior : turn;
});
return reconciled.length === previous.length && reconciled.every((turn, index) => turn === previous[index])
? previous
: reconciled;
}
/**
* Structural equality over projected view data. Everything a turn holds is
* plain JSON-shaped data (`args` and tool results included), so a value walk is
* the whole comparison — with an identity short circuit that makes the common
* "only the tail turn moved" case cheap.
*
* Fail-closed outside that shape: anything that is not a plain object or an
* array (a Set, a Map, a Date, a class instance) compares unequal unless it is
* the same reference, because walking its own enumerable keys would silently
* call two different values equal. Reporting "changed" costs a re-render;
* reporting "unchanged" freezes the UI on a stale value.
*
* This is the sole judge of whether a turn keeps its object identity, and that
* identity is load-bearing beyond this module: the shell caches each turn's
* derived presentation in a `WeakMap` keyed on the turn object itself. Relaxing
* this comparison — skipping a field to save a walk — therefore fails twice
* over: the changed turn keeps its identity AND hits a stale presentation
* entry, and the UI freezes on the old value instead of merely re-rendering.
*/
export function valuesEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
return a.every((item, index) => valuesEqual(item, b[index]));
}
if (!isPlainObject(a) || !isPlainObject(b)) return false;
const left = a as Record<string, unknown>;
const right = b as Record<string, unknown>;
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const key of keys) {
if (!valuesEqual(left[key], right[key])) return false;
}
return true;
}
function isPlainObject(value: object): boolean {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}