diff --git a/package.json b/package.json index 2144d0a8a3..b7fabec0b0 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "theme:color-audit:test": "node --test scripts/audit-theme-colors.test.mjs scripts/audit-cli-theme-colors.test.mjs", "theme:visual-contract": "node scripts/validate-theme-visual-contract.mjs", "appearance:contract-audit": "node scripts/audit-appearance-contracts.mjs", + "flowchat:log:analyze": "node scripts/diagnostics/analyze-flowchat-log.mjs", + "flowchat:log:analyze:test": "node --test scripts/diagnostics/analyze-flowchat-log.test.mjs", "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs && node scripts/update-models-dev-snapshot.mjs --check", "models-dev:check": "node scripts/update-models-dev-snapshot.mjs --check", "models-dev:update": "node scripts/update-models-dev-snapshot.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 769d91a40a..e37b659f9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,8 +338,8 @@ importers: specifier: ^15.6.6 version: 15.6.6(react@18.3.1) react-virtuoso: - specifier: ^4.14.1 - version: 4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^4.18.11 + version: 4.18.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) rehype-katex: specifier: ^7.0.1 version: 7.0.1 @@ -4666,8 +4666,8 @@ packages: peerDependencies: react: '>= 0.14.0' - react-virtuoso@4.18.1: - resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} + react-virtuoso@4.18.11: + resolution: {integrity: sha512-Qeeq9vqa5seCxACvzbQTUeq3s2/Bu+VlwOMF0jfy3E/ZKHLiXWwJpXBhv2rckqYkvm1XRVFLCBMCmqCU8JNEJg==} peerDependencies: react: '>=16 || >=17 || >= 18 || >= 19' react-dom: '>=16 || >=17 || >= 18 || >=19' @@ -10614,7 +10614,7 @@ snapshots: react: 18.3.1 refractor: 3.6.0 - react-virtuoso@4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-virtuoso@4.18.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) diff --git a/scripts/diagnostics/analyze-flowchat-log.mjs b/scripts/diagnostics/analyze-flowchat-log.mjs index 5cc894753c..081250c44b 100644 --- a/scripts/diagnostics/analyze-flowchat-log.mjs +++ b/scripts/diagnostics/analyze-flowchat-log.mjs @@ -1,16 +1,75 @@ +/** + * Read `flowchat.log` and answer the questions the viewport trail exists for. + * + * The log is JSONL, one object per entry, written by + * `src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts` behind + * `app.logging.flow_chat_diagnostics`. Viewport entries carry the tag + * `viewport`; history paging carries `history-paging`. + * + * The reports here are not a frequency count with extra steps. Each one + * corresponds to a fault that has actually shipped: + * + * - **Placements that did not stick.** A Turn that lands and is dragged away + * and a Turn that never landed leave the same final position, so every + * placement is recorded with what became of it and the drift is what + * separates them. + * - **Fights.** Travel far exceeding net displacement is two writers undoing + * each other — the shape of a snap back reissued 958 times without arriving. + * - **Refusals.** Who was outranked, by whom. A write that never happened is + * invisible in the DOM and in every other log. + * - **Silent declines.** Each writer's reason for not moving. "Nothing + * happened" has been the report more often than "it moved wrongly". + * + * Entries carry a `repeated` summary when the frontend coalesced a run of + * identical events, so every count here weighs an entry by what it stands for + * rather than by one. Ignoring that would under-report exactly the runaway + * loops the log is for. + */ + import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; + +/** Joins the parts of a composite map key; cannot occur inside one. */ +const KEY_SEPARATOR = String.fromCharCode(31); + +const VIEWPORT_TAG = 'viewport'; +const WRITE_LOCATION = 'viewportOwner.write'; +const OUTCOME_SUFFIX = '.outcome'; +const DROPPED_ENTRY_LOCATION = 'FlowChatDiagnosticsRecorder.flush'; + +/** Locations whose whole purpose is to record a movement that did not happen. */ +const DECLINE_LOCATIONS = new Set([ + 'anchor.dropped', + 'anchor.stoodDown', + 'followOutput.deferNewTurn', + 'historyPaging.refused', + 'snapBack.declined', + 'snapBack.notNeeded', + 'turnNavigation.rejected', +]); + +export const DEFAULT_OPTIONS = { + top: 20, + minDrift: 8, + gapMs: 750, + around: null, + radius: 12, + tags: [], +}; function printUsage() { console.log(`Usage: node scripts/diagnostics/analyze-flowchat-log.mjs [options] Options: - --top Maximum rows per summary table (default: 20) - --min-delta Minimum positive reservation jump to show (default: 100) - --around Show a compact event window around a sequence - --radius Sequence radius for --around (default: 8) - --help Show this help`); + --top Maximum rows per table (default: ${DEFAULT_OPTIONS.top}) + --min-drift Report a placement as unstuck past this drift (default: ${DEFAULT_OPTIONS.minDrift}) + --gap Quiet period that ends an episode (default: ${DEFAULT_OPTIONS.gapMs}) + --around Print the raw entries around a sequence number + --radius Sequence radius for --around (default: ${DEFAULT_OPTIONS.radius}) + --tag Only entries with this hypothesis tag; repeatable + --help Show this help`); } function parseNumberOption(args, index, optionName) { @@ -22,7 +81,11 @@ function parseNumberOption(args, index, optionName) { return value; } -function parseArgs(argv) { +export function parseArgs(rawArgv) { + // `npm run … -- ` forwards the separator itself; pnpm forwards it too + // when it is typed. Either way it is not an argument. + const argv = rawArgv[0] === '--' ? rawArgv.slice(1) : rawArgv; + if (argv.includes('--help')) { printUsage(); process.exit(0); @@ -34,21 +97,18 @@ function parseArgs(argv) { throw new Error('A FlowChat JSONL log path is required'); } - const options = { - logPath, - top: 20, - minDelta: 100, - around: null, - radius: 8, - }; + const options = { ...DEFAULT_OPTIONS, tags: [], logPath }; for (let index = 1; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--top') { options.top = Math.max(1, Math.floor(parseNumberOption(argv, index, arg))); index += 1; - } else if (arg === '--min-delta') { - options.minDelta = Math.max(0, parseNumberOption(argv, index, arg)); + } else if (arg === '--min-drift') { + options.minDrift = Math.max(0, parseNumberOption(argv, index, arg)); + index += 1; + } else if (arg === '--gap') { + options.gapMs = Math.max(0, parseNumberOption(argv, index, arg)); index += 1; } else if (arg === '--around') { options.around = Math.floor(parseNumberOption(argv, index, arg)); @@ -56,6 +116,11 @@ function parseArgs(argv) { } else if (arg === '--radius') { options.radius = Math.max(0, Math.floor(parseNumberOption(argv, index, arg))); index += 1; + } else if (arg === '--tag') { + const tag = argv[index + 1]; + if (!tag || tag.startsWith('--')) throw new Error('--tag requires a name'); + options.tags.push(tag); + index += 1; } else { throw new Error(`Unknown option: ${arg}`); } @@ -70,11 +135,26 @@ function finiteNumber(value) { } function round(value) { - return Math.round(finiteNumber(value) * 100) / 100; + return Math.round(finiteNumber(value) * 10) / 10; } -function reservationTotal(reservation) { - return finiteNumber(reservation?.collapse?.px) + finiteNumber(reservation?.pin?.px); +/** + * What one entry stands for. + * + * A coalesced entry is one line describing a run, so counting it as one event + * would report a 300-write fight as a handful of writes. Travel is the same + * question for distance. + */ +export function entryWeight(entry) { + return 1 + Math.max(0, Math.floor(finiteNumber(entry?.data?.repeated?.suppressedCount))); +} + +export function entryTravelPx(entry) { + const data = entry?.data ?? {}; + const own = data.toPx === undefined || data.fromPx === undefined + ? 0 + : Math.abs(finiteNumber(data.toPx) - finiteNumber(data.fromPx)); + return own + Math.abs(finiteNumber(data.repeated?.suppressedTravelPx)); } function compactData(data) { @@ -83,131 +163,330 @@ function compactData(data) { return serialized.length <= 240 ? serialized : `${serialized.slice(0, 237)}...`; } -async function analyze(options) { - const eventCounts = new Map(); - const reservationJumps = []; - const collapseIntents = []; - const sequenceWindow = []; - let lineCount = 0; - let eventCount = 0; - let parseErrorCount = 0; - - const input = createReadStream(options.logPath, { encoding: 'utf8' }); - const lines = createInterface({ input, crlfDelay: Infinity }); +function increment(map, key, by = 1) { + map.set(key, (map.get(key) ?? 0) + by); +} - for await (const line of lines) { - lineCount += 1; - if (!line.trim()) continue; +/** + * Group viewport writes into stretches of activity. + * + * A fault is a period, not an entry: "it flickered for a second when I opened + * the session" is one episode with several owners in it. The gap that ends one + * is wall-clock, because the interesting silence is the reader looking at a + * still transcript. + */ +export function collectEpisodes(writes, gapMs) { + const episodes = []; + let current = null; - let event; - try { - event = JSON.parse(line); - } catch { - parseErrorCount += 1; - continue; + for (const write of writes) { + const atMs = finiteNumber(write.performanceTimeMs); + if (current === null || atMs - current.endMs > gapMs) { + current = { + firstSequence: write.sequence, + lastSequence: write.sequence, + startMs: atMs, + endMs: atMs, + writes: 0, + refusals: 0, + travelPx: 0, + fromPx: null, + toPx: null, + owners: new Map(), + }; + episodes.push(current); } - eventCount += 1; - const countKey = `${event.location ?? ''}\u0000${event.message ?? ''}`; - const existingCount = eventCounts.get(countKey); - if (existingCount) { - existingCount.count += 1; + const weight = entryWeight(write); + const granted = write.data?.granted !== false; + current.lastSequence = write.sequence; + current.endMs = atMs; + current.travelPx += entryTravelPx(write); + if (granted) { + current.writes += weight; + if (current.fromPx === null) current.fromPx = finiteNumber(write.data?.fromPx); + current.toPx = finiteNumber(write.data?.toPx); } else { - eventCounts.set(countKey, { - count: 1, - location: event.location ?? '', - message: event.message ?? '', - }); + current.refusals += weight; } + increment(current.owners, String(write.data?.owner ?? 'unknown'), weight); + } + + return episodes.map(episode => { + const netPx = episode.fromPx === null ? 0 : episode.toPx - episode.fromPx; + return { + sequences: `${episode.firstSequence}-${episode.lastSequence}`, + durationMs: Math.round(episode.endMs - episode.startMs), + writes: episode.writes, + refusals: episode.refusals, + travelPx: round(episode.travelPx), + netPx: round(netPx), + /* + * Distance travelled per pixel of progress. One means a clean move; + * anything large is writers undoing each other, and it is the number to + * sort by when the report is "it shook". + */ + churn: Math.abs(netPx) < 1 + ? round(episode.travelPx) + : round(episode.travelPx / Math.abs(netPx)), + owners: [...episode.owners.entries()] + .sort((left, right) => right[1] - left[1]) + .map(([owner, count]) => `${owner}x${count}`) + .join(' '), + }; + }); +} - if ( - event.location === 'VirtualMessageList.updateBottomReservationState' && - event.data?.before && - event.data?.after - ) { - const before = reservationTotal(event.data.before); - const after = reservationTotal(event.data.after); - const delta = after - before; - if (delta >= options.minDelta) { - reservationJumps.push({ - sequence: event.sequence, - deltaPx: round(delta), - beforePx: round(before), - afterPx: round(after), - collapsePx: round(event.data.after.collapse?.px), - pinPx: round(event.data.after.pin?.px), - coordinatorMode: event.data.coordinatorMode ?? '', - following: event.data.isFollowingOutput === true, - streaming: event.data.isStreamingOutput === true, - }); +/** + * Pair every placement with the sample taken after it settled. + * + * Matched first-in-first-out per location: an outcome lands hundreds of + * milliseconds after its placement, so another placement of the same kind can + * begin in between, and the queue keeps them in order. An outcome with nothing + * pending is reported rather than dropped — it means the log starts mid-flight. + */ +export function joinPlacements(entries) { + const pending = new Map(); + const placements = []; + const orphanedOutcomes = []; + + for (const entry of entries) { + const location = String(entry.location ?? ''); + if (location.endsWith(OUTCOME_SUFFIX)) { + const placedLocation = location.slice(0, -OUTCOME_SUFFIX.length); + const queue = pending.get(placedLocation); + const placement = queue?.shift(); + if (!placement) { + orphanedOutcomes.push({ sequence: entry.sequence, location: placedLocation }); + continue; } + placement.outcome = entry; + continue; } + if (entry.data?.placedPx === undefined) continue; + + const placement = { placed: entry, outcome: null }; + placements.push(placement); + const queue = pending.get(location); + if (queue) queue.push(placement); + else pending.set(location, [placement]); + } + + return { placements, orphanedOutcomes }; +} + +export function summarizePlacements(placements) { + return placements.map(({ placed, outcome }) => { + const data = placed.data ?? {}; + const outcomeData = outcome?.data ?? {}; + return { + sequence: placed.sequence, + location: String(placed.location ?? ''), + branch: String(data.branch ?? data.reason ?? ''), + beforePx: round(data.beforePx), + placedPx: round(data.placedPx), + targetPx: data.targetPx === undefined ? round(data.placedPx) : round(data.targetPx), + settledPx: outcome ? round(outcomeData.settledPx) : null, + driftPx: outcome ? round(outcomeData.driftPx) : null, + /* + * No outcome is not "no drift". The sample is scheduled on a timer, so a + * placement at the end of the log, or one whose view unmounted first, + * never reports — and reading that as a clean landing is how a report + * turns into a false negative. + */ + settled: outcome ? 'yes' : 'unknown', + }; + }); +} - if ( - event.location === 'VirtualMessageList.handleToolCardCollapseIntent' && - event.message === 'Tool card collapse reservation calculated' - ) { - const current = finiteNumber(event.data?.currentTotalCompensationPx); - const provisional = finiteNumber(event.data?.provisionalTotalCompensationPx); - collapseIntents.push({ - sequence: event.sequence, - tool: event.data?.nextIntent?.toolName ?? '', - cardHeightPx: round(event.data?.estimatedShrink), - distancePx: round(event.data?.effectiveDistanceFromBottom), - addedPx: round(provisional - current), - totalPx: round(provisional), - coordinatorMode: event.data?.coordinatorMode ?? '', - }); +export function analyzeEntries(entries, options) { + const settings = { ...DEFAULT_OPTIONS, ...options }; + const tags = new Set(settings.tags ?? []); + const kept = tags.size === 0 + ? entries + : entries.filter(entry => tags.has(String(entry.hypothesis ?? ''))); + + const viewportEntries = kept.filter(entry => entry.hypothesis === VIEWPORT_TAG); + const writes = viewportEntries.filter(entry => entry.location === WRITE_LOCATION); + + const refusals = new Map(); + const ownerActivity = new Map(); + for (const write of writes) { + const weight = entryWeight(write); + const owner = String(write.data?.owner ?? 'unknown'); + const activity = ownerActivity.get(owner) ?? { owner, writes: 0, refusals: 0, travelPx: 0 }; + activity.travelPx += entryTravelPx(write); + if (write.data?.granted === false) { + activity.refusals += weight; + increment(refusals, `${owner}${KEY_SEPARATOR}${String(write.data?.heldBy ?? 'nobody')}`, weight); + } else { + activity.writes += weight; } + ownerActivity.set(owner, activity); + } + + const declines = new Map(); + for (const entry of viewportEntries) { + const location = String(entry.location ?? ''); + if (!DECLINE_LOCATIONS.has(location)) continue; + const reason = String(entry.data?.reason ?? entry.data?.branch ?? ''); + increment(declines, `${location}${KEY_SEPARATOR}${reason}`, entryWeight(entry)); + } + + const frequency = new Map(); + for (const entry of kept) { + const key = `${String(entry.hypothesis ?? '')}${KEY_SEPARATOR}${String(entry.location ?? '')}`; + increment(frequency, key, entryWeight(entry)); + } + + const { placements, orphanedOutcomes } = joinPlacements(viewportEntries); + const summarized = summarizePlacements(placements); - if ( - options.around !== null && - finiteNumber(event.sequence) >= options.around - options.radius && - finiteNumber(event.sequence) <= options.around + options.radius - ) { - sequenceWindow.push({ - sequence: event.sequence, - location: event.location ?? '', - message: event.message ?? '', - data: compactData(event.data), - }); + const droppedEntries = kept + .filter(entry => entry.location === DROPPED_ENTRY_LOCATION) + .reduce((total, entry) => total + finiteNumber(entry.data?.droppedEntries), 0); + + return { + entryCount: kept.length, + viewportEntryCount: viewportEntries.length, + droppedEntries, + sequenceRange: kept.length === 0 + ? null + : { first: kept[0].sequence, last: kept[kept.length - 1].sequence }, + episodes: collectEpisodes(writes, settings.gapMs) + .sort((left, right) => right.churn - left.churn), + unstuckPlacements: summarized + .filter(placement => placement.driftPx !== null + && Math.abs(placement.driftPx) >= settings.minDrift) + .sort((left, right) => Math.abs(right.driftPx) - Math.abs(left.driftPx)), + unsampledPlacements: summarized.filter(placement => placement.settled === 'unknown'), + placementCount: summarized.length, + orphanedOutcomes, + refusals: [...refusals.entries()] + .map(([key, count]) => { + const [owner, heldBy] = key.split(KEY_SEPARATOR); + return { owner, refusedBy: heldBy, count }; + }) + .sort((left, right) => right.count - left.count), + ownerActivity: [...ownerActivity.values()] + .map(activity => ({ ...activity, travelPx: round(activity.travelPx) })) + .sort((left, right) => right.writes + right.refusals - (left.writes + left.refusals)), + declines: [...declines.entries()] + .map(([key, count]) => { + const [location, reason] = key.split(KEY_SEPARATOR); + return { location, reason, count }; + }) + .sort((left, right) => right.count - left.count), + frequency: [...frequency.entries()] + .map(([key, count]) => { + const [tag, location] = key.split(KEY_SEPARATOR); + return { tag, location, count }; + }) + .sort((left, right) => right.count - left.count), + window: settings.around === null ? [] : kept + .filter(entry => finiteNumber(entry.sequence) >= settings.around - settings.radius + && finiteNumber(entry.sequence) <= settings.around + settings.radius) + .map(entry => ({ + sequence: entry.sequence, + tag: String(entry.hypothesis ?? ''), + location: String(entry.location ?? ''), + message: String(entry.message ?? ''), + data: compactData(entry.data), + })), + }; +} + +export async function readEntries(logPath) { + const entries = []; + let lineCount = 0; + let parseErrorCount = 0; + + const input = createReadStream(logPath, { encoding: 'utf8' }); + const lines = createInterface({ input, crlfDelay: Infinity }); + for await (const line of lines) { + lineCount += 1; + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line)); + } catch { + parseErrorCount += 1; } } - console.log(`FlowChat log: ${options.logPath}`); - console.log(`Lines: ${lineCount}, events: ${eventCount}, parse errors: ${parseErrorCount}`); + return { entries, lineCount, parseErrorCount }; +} - console.log('\nMost frequent events'); - console.table( - [...eventCounts.values()] - .sort((left, right) => right.count - left.count) - .slice(0, options.top), +function reportTable(title, rows, top) { + console.log(`\n${title}`); + if (rows.length === 0) { + console.log(' (none)'); + return; + } + console.table(rows.slice(0, top)); + if (rows.length > top) { + console.log(` ... ${rows.length - top} more`); + } +} + +export function printReport(report, options, source) { + console.log(`FlowChat log: ${source.logPath}`); + console.log( + `Lines: ${source.lineCount}, entries: ${report.entryCount}` + + ` (viewport: ${report.viewportEntryCount}), parse errors: ${source.parseErrorCount}`, ); + if (report.sequenceRange) { + console.log(`Sequences: ${report.sequenceRange.first}-${report.sequenceRange.last}`); + } + if (report.droppedEntries > 0) { + // Said loudly: every count below is a lower bound once entries were lost. + console.log( + `WARNING: ${report.droppedEntries} entries were dropped before reaching the log.` + + ' Counts below are lower bounds.', + ); + } - console.log(`\nLargest reservation increases (>= ${options.minDelta}px)`); - console.table( - reservationJumps - .sort((left, right) => right.deltaPx - left.deltaPx) - .slice(0, options.top), + reportTable( + 'Episodes of viewport activity, worst churn first (travel per pixel of progress)', + report.episodes, + options.top, ); + reportTable( + `Placements that did not stick (drift >= ${options.minDrift}px)`, + report.unstuckPlacements, + options.top, + ); + reportTable('Refusals: who was outranked, by whom', report.refusals, options.top); + reportTable('Declines: a writer choosing not to move, and why', report.declines, options.top); + reportTable('Per owner', report.ownerActivity, options.top); + reportTable('Most frequent locations', report.frequency, options.top); - console.log('\nLargest collapse-intent estimates'); - console.table( - collapseIntents - .sort((left, right) => right.addedPx - left.addedPx) - .slice(0, options.top), + console.log( + `\nPlacements: ${report.placementCount},` + + ` never sampled: ${report.unsampledPlacements.length},` + + ` outcomes with no placement in this log: ${report.orphanedOutcomes.length}`, ); if (options.around !== null) { - console.log(`\nEvents around sequence ${options.around} (+/- ${options.radius})`); - console.table(sequenceWindow); + reportTable( + `Entries around sequence ${options.around} (+/- ${options.radius})`, + report.window, + report.window.length, + ); } } -try { +async function main() { const options = parseArgs(process.argv.slice(2)); - await analyze(options); -} catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + const source = await readEntries(options.logPath); + const report = analyzeEntries(source.entries, options); + printReport(report, options, { ...source, logPath: options.logPath }); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + await main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } } diff --git a/scripts/diagnostics/analyze-flowchat-log.test.mjs b/scripts/diagnostics/analyze-flowchat-log.test.mjs new file mode 100644 index 0000000000..152f583596 --- /dev/null +++ b/scripts/diagnostics/analyze-flowchat-log.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + analyzeEntries, + collectEpisodes, + entryTravelPx, + entryWeight, + joinPlacements, + parseArgs, +} from './analyze-flowchat-log.mjs'; + +let nextSequence = 0; + +function entry(location, data, overrides = {}) { + nextSequence += 1; + return { + sequence: nextSequence, + timestamp: '2026-08-11T00:00:00.000Z', + performanceTimeMs: nextSequence * 16, + hypothesis: 'viewport', + location, + message: location, + data, + ...overrides, + }; +} + +function write(owner, fromPx, toPx, overrides = {}) { + return entry('viewportOwner.write', { + owner, + granted: true, + heldBy: null, + fromPx, + toPx, + ...overrides.data, + }, overrides.entry); +} + +test('parseArgs requires a log path and rejects unknown options', () => { + assert.throws(() => parseArgs([]), /log path is required/); + assert.throws(() => parseArgs(['flowchat.log', '--nope']), /Unknown option/); + + const options = parseArgs(['flowchat.log', '--min-drift', '20', '--tag', 'viewport']); + assert.equal(options.logPath, 'flowchat.log'); + assert.equal(options.minDrift, 20); + assert.deepEqual(options.tags, ['viewport']); + + // `npm run … -- ` hands the separator through as an argument. + assert.equal(parseArgs(['--', 'flowchat.log']).logPath, 'flowchat.log'); +}); + +test('a coalesced entry counts as the run it stands for', () => { + const coalesced = write('follow-output', 100, 102, { + data: { repeated: { suppressedCount: 59, suppressedTravelPx: 118, suppressedForMs: 480 } }, + }); + + assert.equal(entryWeight(coalesced), 60); + assert.equal(entryTravelPx(coalesced), 120); + // An entry with no run behind it still stands for itself. + assert.equal(entryWeight(write('follow-output', 0, 10)), 1); +}); + +test('episodes split on a quiet gap and score a fight by its churn', () => { + nextSequence = 0; + const fight = [ + write('snap-back', 1000, 1000.7), + write('anchor-correction', 1000.7, 1000), + write('snap-back', 1000, 1000.7), + write('anchor-correction', 1000.7, 1000), + ]; + const later = write('follow-output', 1000, 2000, { + entry: { performanceTimeMs: 20_000 }, + }); + + const episodes = collectEpisodes([...fight, later], 750); + + assert.equal(episodes.length, 2); + // Four writes that went nowhere: travel without progress is the whole signal. + assert.equal(episodes[0].writes, 4); + assert.equal(episodes[0].netPx, 0); + assert.ok(episodes[0].churn >= 2, `expected churn, got ${episodes[0].churn}`); + assert.equal(episodes[1].netPx, 1000); + assert.equal(episodes[1].churn, 1); +}); + +test('refused writes are counted apart from the ones that moved', () => { + nextSequence = 0; + const episodes = collectEpisodes([ + write('follow-output', 500, 600), + write('anchor-correction', 600, 500, { data: { granted: false, heldBy: 'user-gesture' } }), + ], 750); + + assert.equal(episodes[0].writes, 1); + assert.equal(episodes[0].refusals, 1); + // A refusal moved nothing, so it cannot contribute to net displacement. + assert.equal(episodes[0].netPx, 100); +}); + +test('placements pair with their outcome in order, and a stray outcome is reported', () => { + nextSequence = 0; + const first = entry('turnNavigation.placed', { beforePx: 0, placedPx: 800, targetPx: 800 }); + const second = entry('turnNavigation.placed', { beforePx: 800, placedPx: 1600, targetPx: 1600 }); + const firstOutcome = entry('turnNavigation.placed.outcome', { settledPx: 40, driftPx: -760 }); + const secondOutcome = entry('turnNavigation.placed.outcome', { settledPx: 1600, driftPx: 0 }); + const stray = entry('visibleTask.scrollToTask.outcome', { settledPx: 10, driftPx: -5 }); + + const { placements, orphanedOutcomes } = joinPlacements([ + first, + second, + firstOutcome, + secondOutcome, + stray, + ]); + + assert.equal(placements.length, 2); + assert.equal(placements[0].outcome, firstOutcome); + assert.equal(placements[1].outcome, secondOutcome); + assert.deepEqual(orphanedOutcomes, [ + { sequence: stray.sequence, location: 'visibleTask.scrollToTask' }, + ]); +}); + +test('analyzeEntries reports what did not stick, who was refused, and who declined', () => { + nextSequence = 0; + const report = analyzeEntries([ + entry('visibleTask.scrollToTask', { beforePx: 0, placedPx: 900, targetPx: 900 }), + write('anchor-correction', 900, 120), + entry('visibleTask.scrollToTask.outcome', { settledPx: 120, driftPx: -780 }), + write('follow-output', 120, 200, { data: { granted: false, heldBy: 'user-gesture' } }), + entry('snapBack.declined', { reason: 'follow-correcting' }), + entry('snapBack.declined', { reason: 'follow-correcting' }), + entry('followOutput.deferNewTurn', { turnId: 't-42' }), + { ...entry('history_paging_requested', { direction: 'before' }), hypothesis: 'history-paging' }, + ], { minDrift: 8 }); + + assert.equal(report.unstuckPlacements.length, 1); + assert.equal(report.unstuckPlacements[0].driftPx, -780); + assert.equal(report.unstuckPlacements[0].location, 'visibleTask.scrollToTask'); + + assert.deepEqual(report.refusals, [ + { owner: 'follow-output', refusedBy: 'user-gesture', count: 1 }, + ]); + + const declined = report.declines.find(row => row.location === 'snapBack.declined'); + assert.deepEqual(declined, { + location: 'snapBack.declined', + reason: 'follow-correcting', + count: 2, + }); + assert.ok(report.declines.some(row => row.location === 'followOutput.deferNewTurn')); + + // The paging tag is counted but never mistaken for viewport activity. + assert.equal(report.viewportEntryCount, 7); + assert.ok(report.frequency.some(row => row.tag === 'history-paging')); +}); + +test('a placement whose outcome never arrived is not reported as having stuck', () => { + nextSequence = 0; + const report = analyzeEntries([ + entry('navigation.scrollIntoView', { beforePx: 0, placedPx: 300 }), + ], {}); + + assert.equal(report.placementCount, 1); + assert.equal(report.unstuckPlacements.length, 0); + assert.equal(report.unsampledPlacements.length, 1); + assert.equal(report.unsampledPlacements[0].settled, 'unknown'); +}); + +test('dropped entries are surfaced, because every count becomes a lower bound', () => { + nextSequence = 0; + const report = analyzeEntries([ + { ...entry('FlowChatDiagnosticsRecorder.flush', { droppedEntries: 128 }), hypothesis: 'I' }, + write('follow-output', 0, 100), + ], {}); + + assert.equal(report.droppedEntries, 128); +}); + +test('--tag keeps only the requested stream', () => { + nextSequence = 0; + const report = analyzeEntries([ + write('follow-output', 0, 100), + { ...entry('history_paging_requested', { direction: 'before' }), hypothesis: 'history-paging' }, + ], { tags: ['history-paging'] }); + + assert.equal(report.entryCount, 1); + assert.equal(report.viewportEntryCount, 0); + assert.equal(report.episodes.length, 0); +}); diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 07ffd5d564..906a667ffc 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -13,8 +13,8 @@ use tokio::sync::{broadcast, Mutex}; use bitfun_agent_runtime::sdk::{ AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentEventReceiver, - AgentInputAttachment, AgentLocalCommandTurnRecordRequest, - AgentMessageWorkspaceReferencesRequest, AgentRuntime, AgentSessionCompactionRequest, + AgentInputAttachment, AgentMessageWorkspaceReferencesRequest, AgentRuntime, + AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, @@ -485,17 +485,6 @@ impl ExecAgentRuntimeClient { } } - pub(crate) async fn record_completed_local_command_turn( - &self, - request: AgentLocalCommandTurnRecordRequest, - ) -> Result<()> { - self.embedded_runtime("recording local command turns")? - .record_completed_local_command_turn(request) - .await - .map(|_| ()) - .map_err(|error| anyhow::anyhow!(error.into_message())) - } - pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { *self .approval_policy diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index bd5fc646bb..03b521f71a 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -33,8 +33,8 @@ use bitfun_product_domains::tool_permissions::{ use bitfun_runtime_ports::{ put_agent_workspace_references, AgentContextReloadRequest, AgentDialogSteerRequest, AgentDialogTurnExecution, AgentDialogTurnRequest, AgentInputAttachment, - AgentLocalCommandTurnRecordRequest, AgentMessageWorkspaceReferencesRequest, - AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, + AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, + AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, AgentSessionListRequest, AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, @@ -769,16 +769,6 @@ impl TuiAgentClient { Ok(()) } - pub(crate) async fn record_completed_local_command_turn( - &self, - request: AgentLocalCommandTurnRecordRequest, - ) -> Result<()> { - self.backend - .record_local_command_turn(RecordLocalCommandTurnRequest(request)) - .await?; - Ok(()) - } - pub(crate) fn set_approval_policy(&self, policy: CliApprovalPolicy) { *self .approval_policy diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 82aa7d6513..bba902a043 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -23,10 +23,9 @@ use tokio::sync::broadcast::error::TryRecvError; use bitfun_app_server_protocol::model::{AddModelRequest, UpdateModelRequest}; use bitfun_app_server_protocol::skill::SkillSummary; use bitfun_app_server_protocol::subagent::SubagentSummary; -use bitfun_core_types::SessionUsageReport; use bitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use bitfun_runtime_ports::{ - AgentLocalCommandTurnRecordRequest, AgentSessionComposerUpdate, AgentSessionLineageEntry, + AgentSessionComposerUpdate, AgentSessionLineageEntry, AgentSessionLineageInspection, AgentSessionLineageSnapshot, AgentSessionUsageRequest, AgentTurnCancellationResult, AgentWorkspaceReferenceSearchResult, SessionTranscript, WorkspaceDiffSnapshot, diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 4435319964..d9048ee44f 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -92,20 +92,6 @@ fn apply_agent_mode_feedback( } } -fn usage_report_metadata(report: &SessionUsageReport) -> Result { - let usage_report = serde_json::to_value(report) - .map_err(|error| anyhow!("Failed to serialize usage report: {error}"))?; - Ok(serde_json::json!({ - "localCommandKind": "usage_report", - "reportId": report.report_id, - "schemaVersion": report.schema_version, - "generatedAt": report.generated_at, - "modelVisible": false, - "usageReport": usage_report, - "usageReportStatus": "completed", - })) -} - fn apply_model_selection_feedback( chat_state: &mut ChatState, selected_display_name: &str, @@ -252,6 +238,17 @@ impl ChatMode { .or_else(|| Some(self.agent.workspace_path_string())); let agent = self.agent.clone(); + /* + * Rendered into the conversation view and nowhere else. A report about + * a session is not an event in it, and this used to write one as a + * `local_command` Turn as well — which the desktop then loaded from + * disk and gave a numbered slot in its Turn rail, because the ordinals + * come from the backend catalog and the catalog counts what is stored. + * + * `add_assistant_message` is already the UI-only path: `turn_id: None`, + * never persisted, never in model context. In a terminal the scrollback + * is the record, so nothing here needs to replace what is being removed. + */ let report_result: Result = tokio::task::block_in_place(|| { let session_id = session_id.clone(); @@ -262,39 +259,21 @@ impl ChatMode { .filter(|path| !path.trim().is_empty()) .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; - let report = agent + agent .generate_session_usage_report(AgentSessionUsageRequest { - session_id: session_id.clone(), + session_id, workspace_path: Some(workspace_path), remote_connection_id: None, remote_ssh_host: None, include_hidden_subagents: true, }) - .await?; - - let markdown = render_usage_report_markdown(&report); - let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); - let metadata = usage_report_metadata(&report)?; - agent - .record_completed_local_command_turn(AgentLocalCommandTurnRecordRequest { - session_id, - content: markdown, - turn_id: Some(format!("local-usage-{}", report.report_id)), - timestamp_ms: Some(generated_at), - metadata: metadata.as_object().cloned().ok_or_else(|| { - anyhow!("Usage report metadata must be an object") - })?, - }) - .await?; - - Ok(report) + .await }) }); match report_result { Ok(report) => { - let markdown = render_usage_report_markdown(&report); - chat_state.add_assistant_message(markdown); + chat_state.add_assistant_message(render_usage_report_markdown(&report)); chat_view.set_status(Some("Usage report added to conversation".to_string())); } Err(error) => { @@ -857,10 +836,7 @@ fn session_update_unavailable_message(setting_name: &str, is_processing: bool) - #[cfg(test)] mod usage_metadata_tests { - use super::{ - session_update_allowed, session_update_unavailable_message, usage_report_metadata, - SessionUsageReport, - }; + use super::{session_update_allowed, session_update_unavailable_message}; #[test] fn session_update_is_rechecked_when_an_idle_popup_outlives_turn_start() { @@ -878,21 +854,4 @@ mod usage_metadata_tests { "Agent mode cannot be changed during the current turn." ); } - - #[test] - fn usage_metadata_preserves_the_existing_tui_transcript_schema() { - let mut report = SessionUsageReport::partial_unavailable("session-1", 1_778_347_200_000); - report.report_id = "usage-session-1-1778347200000".to_string(); - - let metadata = usage_report_metadata(&report).expect("usage metadata"); - - assert_eq!(metadata["localCommandKind"], "usage_report"); - assert_eq!(metadata["reportId"], report.report_id); - assert_eq!(metadata["schemaVersion"], report.schema_version); - assert_eq!(metadata["generatedAt"], report.generated_at); - assert_eq!(metadata["modelVisible"], false); - assert_eq!(metadata["usageReportStatus"], "completed"); - assert_eq!(metadata["usageReport"]["sessionId"], "session-1"); - assert_eq!(metadata.as_object().map(serde_json::Map::len), Some(7)); - } } diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 3c2cad6a38..a5af4e4b6c 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -593,12 +593,6 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .remove(&request.tool_id); Ok(RuntimeIpcOperationResult::Unit) } - RuntimeIpcOperation::RecordLocalCommandTurn { request } => self - .runtime - .record_completed_local_command_turn(request) - .await - .map(|record| RuntimeIpcOperationResult::LocalCommandTurnRecorded { record }) - .map_err(runtime_ipc_error), } } diff --git a/src/apps/cli/src/shared_tui_backend.rs b/src/apps/cli/src/shared_tui_backend.rs index fc7061cf86..8c7d3871cf 100644 --- a/src/apps/cli/src/shared_tui_backend.rs +++ b/src/apps/cli/src/shared_tui_backend.rs @@ -502,21 +502,6 @@ impl TuiBackend for SharedTuiBackend { Ok(SubmitUserAnswersResponse {}) } - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - match self - .request(RuntimeIpcOperation::RecordLocalCommandTurn { request: request.0 }) - .await? - { - RuntimeIpcOperationResult::LocalCommandTurnRecorded { record } => { - Ok(RecordLocalCommandTurnResponse(record)) - } - other => Err(unexpected("record_local_command_turn", other)), - } - } - async fn respond_permission( &self, request: RespondPermissionRequest, @@ -1209,7 +1194,6 @@ fn tui_capabilities(management: &AppManagementCapabilities) -> Vec Result; - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result; async fn respond_permission( &self, request: RespondPermissionRequest, @@ -503,13 +499,6 @@ impl TuiBackend for AppServerTuiBackend { map_client(self.client.submit_user_answers(request).await) } - async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - map_client(self.client.record_local_command_turn(request).await) - } - async fn respond_permission( &self, request: RespondPermissionRequest, diff --git a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs index 2f453a9c95..da2cb83a06 100644 --- a/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs +++ b/src/apps/cli/tests/cli_command_contracts/product_assembly_cli.rs @@ -249,10 +249,19 @@ fn peer_session_control_and_usage_persistence_use_runtime_sdk() { "Peer Host session control must route {sdk_operation} through the Runtime SDK" ); } + // Inverted, along with the behaviour it described. `/usage` renders into + // the conversation view and writes nothing: a report about a session is not + // an event in it, and the Turn this used to persist was loaded back by the + // desktop and given a numbered slot in its Turn rail. `add_assistant_message` + // is the UI-only path — `turn_id: None`, never persisted — and in a terminal + // the scrollback is the record. + // + // Source text only. This says the call is absent, not that nothing persists; + // a behavioural guarantee would have to come from the runtime port's own + // tests. assert!( - CHAT_SELECTION.contains("record_completed_local_command_turn") - && !CHAT_SELECTION.contains("append_completed_local_command_turn"), - "TUI usage persistence must use the fixed-semantics Runtime SDK port" + !CHAT_SELECTION.contains("record_completed_local_command_turn"), + "/usage must not write a local_command Turn: it renders into the conversation view and persists nothing" ); for removed_compatibility_method in [ @@ -350,14 +359,13 @@ fn chat_context_reload_uses_the_same_tui_backend_as_session_operations() { } #[test] -fn tui_client_covers_interactive_permission_and_local_turn_operations() { +fn tui_client_covers_interactive_permission_operations() { const TUI_CLIENT: &str = include_str!("../../src/agent/tui_client.rs"); for sdk_operation in [ "subscribe_permission_requests", "pending_permission_requests", "respond_permission", - "record_completed_local_command_turn", ] { assert!( TUI_CLIENT.contains(sdk_operation), diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 0715b1d8b3..2bed249162 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -1384,10 +1384,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ("read_file_content", RemoteWorkspacePolicy::LegacyUnaudited), ("read_mcp_resource", RemoteWorkspacePolicy::LegacyUnaudited), ("record_file_change", RemoteWorkspacePolicy::LegacyUnaudited), - ( - "record_local_command_turn", - RemoteWorkspacePolicy::RemoteRouted, - ), ( "refresh_model_client", RemoteWorkspacePolicy::LegacyUnaudited, diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index abacb34899..17623bea85 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -12,7 +12,7 @@ use bitfun_core::agentic::persistence::{SessionBranchResult, SessionMetadataPage use bitfun_core::service::remote_ssh::normalize_remote_workspace_path; use bitfun_core::service::session::{ DialogTurnData, SessionKind, SessionMetadata, SessionStatus, SessionTranscriptExport, - SessionTranscriptExportOptions, SessionTurnCatalog, + SessionTranscriptExportOptions, }; use bitfun_core::service::session_usage::SessionUsageReport; use bitfun_core::service::workspace::WorkspaceKind; @@ -89,25 +89,6 @@ pub struct SaveSessionTurnRequest { pub remote_ssh_host: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RecordLocalCommandTurnRequest { - pub turn_data: DialogTurnData, - pub workspace_path: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RecordLocalCommandTurnResponse { - pub turn_id: String, - pub storage_turn_index: usize, - pub total_turn_count: usize, - pub turn_catalog: SessionTurnCatalog, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveSessionMetadataRequest { pub metadata: SessionMetadata, @@ -502,36 +483,6 @@ pub async fn save_session_turn( Ok(()) } -#[tauri::command] -pub async fn record_local_command_turn( - request: RecordLocalCommandTurnRequest, - runtime: State<'_, DesktopRuntimeContext>, -) -> Result { - let recorded = runtime - .session_application() - .record_local_command_turn( - desktop_session_scope( - request.workspace_path.clone(), - request.remote_connection_id, - request.remote_ssh_host, - ), - &request.turn_data, - ) - .await - .map_err(|error| format!("Failed to record local command turn: {error}"))?; - - crate::api::remote_connect_api::notify_session_changed( - &request.turn_data.session_id, - &request.workspace_path, - ); - Ok(RecordLocalCommandTurnResponse { - turn_id: recorded.turn_id, - storage_turn_index: recorded.storage_turn_index, - total_turn_count: recorded.total_turn_count, - turn_catalog: recorded.turn_catalog, - }) -} - #[tauri::command] pub async fn save_session_metadata( request: SaveSessionMetadataRequest, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 71737837dc..0eefe56740 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1508,7 +1508,6 @@ pub async fn run() { load_session_turns, get_session_usage_report, save_session_turn, - record_local_command_turn, save_session_metadata, export_session_transcript, delete_persisted_session, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 671bbc3add..5dcc080387 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -131,14 +131,6 @@ pub(crate) struct DesktopSessionViewRestore { pub timings: SessionViewRestoreTiming, } -#[derive(Debug, Clone)] -pub(crate) struct DesktopRecordedLocalCommandTurn { - pub turn_id: String, - pub storage_turn_index: usize, - pub total_turn_count: usize, - pub turn_catalog: SessionTurnCatalog, -} - #[derive(Debug)] pub(crate) struct DesktopSessionWithTurnsRestore { pub session: Session, @@ -517,55 +509,6 @@ impl DesktopSessionApplication { .map_err(desktop_core_session_error) } - pub(crate) async fn record_local_command_turn( - &self, - request: DesktopSessionScopeRequest, - turn: &DialogTurnData, - ) -> DesktopSessionApplicationResult { - let scope = self.resolved_scope(request).await; - self.ensure_runtime_ownership(&scope)?; - let storage_path = self.storage_path(&scope); - self.compatibility - .ensure_session_loaded_from_storage_path(&storage_path, &turn.session_id, false) - .await - .map_err(desktop_core_session_error)?; - let local_command = local_command_turn_record_request(turn)?.ok_or_else(|| { - DesktopSessionApplicationError::Validation( - "record_local_command_turn accepts only local_command Turns".to_string(), - ) - })?; - let recorded = self - .agent_runtime - .record_completed_local_command_turn(local_command) - .await - .map_err(desktop_runtime_session_error)?; - let (_, _, total_turn_count, turn_catalog, _) = self - .compatibility - .restore_session_view_from_storage_path(&storage_path, &turn.session_id, false, Some(1)) - .await - .map_err(desktop_core_session_error)?; - let catalog_entry = turn_catalog - .entries - .iter() - .find(|entry| { - entry.turn_id.as_deref() == Some(recorded.turn_id.as_str()) - && entry.storage_turn_index == recorded.storage_turn_index - }) - .ok_or_else(|| { - DesktopSessionApplicationError::OutcomeUnknown(format!( - "Recorded local command Turn is missing from the authoritative catalog: {}", - recorded.turn_id - )) - })?; - - Ok(DesktopRecordedLocalCommandTurn { - turn_id: recorded.turn_id, - storage_turn_index: catalog_entry.storage_turn_index, - total_turn_count, - turn_catalog, - }) - } - pub(crate) async fn touch_session( &self, request: DesktopSessionScopeRequest, diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index c655c651c4..5dd337d4a9 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -1,7 +1,6 @@ use bitfun_product_domains::tool_permissions::{PermissionReply, PermissionRequest}; use bitfun_runtime_ports::{ AgentContextReloadRequest, AgentDialogSteerRequest, AgentDialogTurnRequest, - AgentLocalCommandTurnRecordRequest, AgentLocalCommandTurnRecordResult, AgentMessageWorkspaceReferencesRequest, AgentSessionCompactionRequest, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, @@ -183,9 +182,6 @@ pub enum RuntimeIpcOperation { SubmitUserAnswers { request: RuntimeUserAnswersRequest, }, - RecordLocalCommandTurn { - request: AgentLocalCommandTurnRecordRequest, - }, } impl RuntimeIpcOperation { @@ -229,7 +225,6 @@ impl RuntimeIpcOperation { Self::PendingPermissions { session_id } | Self::RespondPermission { session_id, .. } => Some(session_id), Self::SubmitUserAnswers { request } => Some(&request.session_id), - Self::RecordLocalCommandTurn { request } => Some(&request.session_id), Self::Health | Self::ListAgentModes { session_id: None } | Self::ListSessions { .. } @@ -280,9 +275,6 @@ impl RuntimeIpcOperation { | Self::SubmitUserAnswers { .. } => { RuntimeIpcOperationRules::new(CurrentController, false, false, true) } - Self::RecordLocalCommandTurn { .. } => { - RuntimeIpcOperationRules::new(CurrentController, true, false, true) - } Self::PendingPermissions { .. } => { RuntimeIpcOperationRules::new(CurrentController, false, false, false) } @@ -404,9 +396,6 @@ pub enum RuntimeIpcOperationResult { WorkspaceDiff { snapshot: WorkspaceDiffSnapshot, }, - LocalCommandTurnRecorded { - record: AgentLocalCommandTurnRecordResult, - }, } #[cfg(test)] diff --git a/src/crates/interfaces/app-server-client/src/lib.rs b/src/crates/interfaces/app-server-client/src/lib.rs index 8b4c59439f..6d558c008b 100644 --- a/src/crates/interfaces/app-server-client/src/lib.rs +++ b/src/crates/interfaces/app-server-client/src/lib.rs @@ -383,14 +383,6 @@ impl AppServerClient { .await } - pub async fn record_local_command_turn( - &self, - request: RecordLocalCommandTurnRequest, - ) -> Result { - self.request_with_timeout(|cx| Ok(cx.send_request(request)), SIDE_EFFECT_TIMEOUT) - .await - } - pub async fn respond_permission( &self, request: RespondPermissionRequest, diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs index f6eb86d78f..c2ad96c846 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/session.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/session.rs @@ -4,8 +4,7 @@ use agent_client_protocol::{JsonRpcRequest, JsonRpcResponse}; use bitfun_core_types::SessionUsageReport; use bitfun_product_domains::tool_permissions::PermissionRequest; use bitfun_runtime_ports::{ - AgentContextReloadRequest, AgentLocalCommandTurnRecordRequest, - AgentLocalCommandTurnRecordResult, AgentSessionCompactionRequest, AgentSessionCompactionResult, + AgentContextReloadRequest, AgentSessionCompactionRequest, AgentSessionCompactionResult, AgentSessionForkBeforeTurnRequest, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionLineageCancellationRequest, AgentSessionLineageInspection, AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionLineageTranscriptRequest, @@ -92,13 +91,6 @@ pub struct ResolveWorkspaceRequest(pub AgentSessionWorkspaceRequest); #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] pub struct ResolveWorkspaceResponse(pub Option); -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] -#[request(method = "session/recordLocalCommandTurn", response = RecordLocalCommandTurnResponse)] -pub struct RecordLocalCommandTurnRequest(pub AgentLocalCommandTurnRecordRequest); - -#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] -pub struct RecordLocalCommandTurnResponse(pub AgentLocalCommandTurnRecordResult); - #[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] #[request(method = "session/rename", response = RenameSessionResponse)] pub struct RenameSessionRequest(pub AgentSessionRenameRequest); diff --git a/src/crates/interfaces/app-server/src/server/handlers/app.rs b/src/crates/interfaces/app-server/src/server/handlers/app.rs index 84b81cb020..21a6abf1fe 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/app.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/app.rs @@ -113,7 +113,6 @@ fn registered_capabilities( "session/sync", "session/readTranscript", "session/resolveWorkspace", - "session/recordLocalCommandTurn", "session/rename", "session/setArchived", "session/updateModel", diff --git a/src/crates/interfaces/app-server/src/server/handlers/session.rs b/src/crates/interfaces/app-server/src/server/handlers/session.rs index 59ce5cb9b4..ed7a249842 100644 --- a/src/crates/interfaces/app-server/src/server/handlers/session.rs +++ b/src/crates/interfaces/app-server/src/server/handlers/session.rs @@ -5,7 +5,7 @@ use bitfun_agent_runtime::sdk::{AgentSessionRestoreRequest, ProcessingPhase, Ses use bitfun_app_server_protocol::session::{ CancelLineageRequest, CancelLineageResponse, CompactSessionRequest, CompactSessionResponse, InspectLineageRequest, InspectLineageResponse, ReadTranscriptRequest, ReadTranscriptResponse, - RecordLocalCommandTurnRequest, RecordLocalCommandTurnResponse, RedoSessionRequest, + RedoSessionRequest, ReloadContextRequest, ReloadContextResponse, ResolveWorkspaceRequest, ResolveWorkspaceResponse, RevertSessionResponse, SessionLineageRequest, SessionLineageResponse, SessionProcessingPhase, SessionRuntimeState, SessionUsageRequest, SessionUsageResponse, SyncSessionRequest, @@ -223,25 +223,7 @@ pub(in crate::server) fn builder( }, agent_client_protocol::on_receive_request!(), ) - .on_receive_request( - { - let runtime = runtime.clone(); - async move |request: RecordLocalCommandTurnRequest, responder, _cx| { - let session_id = request.0.session_id.clone(); - responder.respond_with_result( - runtime - .runtime() - .record_completed_local_command_turn(request.0) - .await - .map(RecordLocalCommandTurnResponse) - .map_err(|error| { - BitfunAppRuntime::session_runtime_error(&session_id, error) - }), - ) - } - }, - agent_client_protocol::on_receive_request!(), - ) + .on_receive_request( { let runtime = runtime.clone(); diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index b17051fa93..1c1ab052e5 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -301,7 +301,6 @@ struct Phase2Provider { steers: Mutex>, shell_commands: Mutex>, answers: Mutex>, - local_commands: Mutex>, compactions: Mutex>, settlements: Mutex>, reloads: Mutex>, @@ -468,7 +467,6 @@ impl ports::AgentLocalCommandTurnPort for Phase2Provider { &self, request: ports::AgentLocalCommandTurnRecordRequest, ) -> PortResult { - self.local_commands.lock().unwrap().push(request.clone()); Ok(ports::AgentLocalCommandTurnRecordResult { turn_id: request .turn_id @@ -860,20 +858,6 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { }) .await .expect("submit user answers"); - let local_turn = client - .record_local_command_turn(protocol_session::RecordLocalCommandTurnRequest( - ports::AgentLocalCommandTurnRecordRequest { - session_id: "session-1".to_string(), - content: "usage: 12 tokens".to_string(), - turn_id: Some("local-turn".to_string()), - timestamp_ms: Some(100), - metadata: serde_json::Map::new(), - }, - )) - .await - .expect("record local command turn"); - assert_eq!(local_turn.0.turn_id, "local-turn"); - client .compact_session(protocol_session::CompactSessionRequest( ports::AgentSessionCompactionRequest { @@ -922,7 +906,6 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { "cargo test" ); assert_eq!(provider.answers.lock().unwrap().len(), 1); - assert_eq!(provider.local_commands.lock().unwrap().len(), 1); assert_eq!(provider.compactions.lock().unwrap().len(), 1); assert_eq!(provider.reloads.lock().unwrap().len(), 1); client.shutdown().await; diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 4ab46996af..510c7dea28 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -62,7 +62,7 @@ "react-i18next": "^16.5.3", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", - "react-virtuoso": "^4.14.1", + "react-virtuoso": "^4.18.11", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 4a5b190778..889bd4b6ff 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -10,6 +10,7 @@ import { NotificationContainer, notificationService } from '../shared/notificati import { NotificationCenter } from '../shared/notification-system/components/NotificationCenter'; import { AnnouncementProvider } from '../shared/announcement-system'; import { ConfirmDialogRenderer } from '../component-library'; +import { SessionUsageModal } from '../flow_chat/components/usage/SessionUsageModal'; import { createLogger } from '@/shared/utils/logger'; import { startupTrace } from '@/shared/utils/startupTrace'; import { isTauriRuntime } from '@/infrastructure/runtime'; @@ -898,6 +899,11 @@ function App() { {/* Confirm dialog */} + {/* Session usage report. Mounted here rather than in a chat view: + the request runs below any component, and the report outlives + whichever session view is on screen. */} + + {/* Announcement / feature-demo / tips system */} diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 796a5578c4..6d4ed693a8 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -3296,11 +3296,10 @@ export const ChatInput: React.FC = ({ noWorkspaceMessage: t('chatInput.usageNoWorkspace'), failedTitle: t('chatInput.usageFailed'), unknownErrorMessage: t('error.unknown'), - loadingMarkdown: t('usage.loading.markdown'), }, ); - if (result.inserted) { + if (result.shown) { dispatchInput({ type: 'DEACTIVATE' }); } } catch (error) { diff --git a/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx b/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx deleted file mode 100644 index 3a41a47c8e..0000000000 --- a/src/web-ui/src/flow_chat/components/FlowItemRenderer.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Streaming item renderer - * Dispatches to the right component by item type - * Uses React.memo to avoid unnecessary re-renders - */ - -import React from 'react'; -import { FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem, FlowUserSteeringItem, type ToolRejectOptions } from '../types/flow-chat'; -import { FlowTextBlock } from './FlowTextBlock'; -import { FlowToolCard } from './FlowToolCard'; -import { ModelThinkingDisplay } from '../tool-cards/ModelThinkingDisplay'; -import { UserSteeringBubble } from './UserSteeringBubble'; - -interface FlowItemRendererProps { - item: FlowItem; - onFileViewRequest?: (filePath: string) => void; - onTabOpen?: (tabInfo: any) => void; - onConfirm?: (toolId: string, permissionOptionId?: string, approve?: boolean) => void; - onReject?: (toolId: string, options?: ToolRejectOptions) => void; - sessionId?: string; -} - -const FlowItemRendererComponent: React.FC = ({ - item, - onFileViewRequest, - onTabOpen, - onConfirm, - onReject, - sessionId -}) => { - if (item.type === 'text') { - const textItem = item as FlowTextItem; - return ; - } - - if (item.type === 'thinking') { - return ; - } - - if (item.type === 'user-steering') { - return ; - } - - if (item.type === 'tool') { - const toolItem = item as FlowToolItem; - return ( -
- -
- ); - } - - return null; -}; - -// Key optimization: React.memo -export const FlowItemRenderer = React.memo( - FlowItemRendererComponent, - (prev, next) => { - // Re-render if ID changes - if (prev.item.id !== next.item.id) return false; - - // Re-render if status changes - if (prev.item.status !== next.item.status) return false; - - // Compare text content for text items - if (prev.item.type === 'text' && next.item.type === 'text') { - const prevText = prev.item as FlowTextItem; - const nextText = next.item as FlowTextItem; - return prevText.content === nextText.content && - prevText.isStreaming === nextText.isStreaming; - } - - // Compare tool results and streaming params for tool items - if (prev.item.type === 'tool' && next.item.type === 'tool') { - const prevTool = prev.item as FlowToolItem; - const nextTool = next.item as FlowToolItem; - // Compare streaming params to re-render when they update - return prevTool.toolResult === nextTool.toolResult && - prevTool.interruptionReason === nextTool.interruptionReason && - prevTool.acpPermission === nextTool.acpPermission && - prevTool.isParamsStreaming === nextTool.isParamsStreaming && - JSON.stringify(prevTool.partialParams) === JSON.stringify(nextTool.partialParams); - } - - return true; - } -); - -FlowItemRenderer.displayName = 'FlowItemRenderer'; diff --git a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx index a008ed8dac..6ebf00d39c 100644 --- a/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/PendingQueuePanel.tsx @@ -7,9 +7,9 @@ * UX notes: * - Click anywhere on the preview text to start editing. * - Cmd/Ctrl+Enter saves the edit; Esc cancels. - * - Clicking "send now" eagerly inserts a UserSteeringBubble into the live - * round so the user sees feedback instantly; the backend confirmation event - * is deduped via `steeringId`. + * - Clicking "send now" eagerly inserts a steering message into the live round + * so the user sees feedback instantly; the backend confirmation event is + * deduped via `steeringId`. */ import { useCallback, useEffect, useMemo, useState } from 'react'; diff --git a/src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.scss b/src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.scss index 11fa36380e..52b95bb3b6 100644 --- a/src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.scss +++ b/src/web-ui/src/flow_chat/components/ScrollToTurnHeaderButton.scss @@ -7,7 +7,7 @@ position: absolute; left: 0; right: 0; - top: 57px; // Keep in sync with `.message-list-header` height. + top: 0; // FlowChatHeader is stacked above the list, so start at the viewport top. z-index: 10; pointer-events: none; @@ -98,7 +98,7 @@ // ========== Responsive tweaks ========== @media (max-width: 768px) { .scroll-to-turn-header-trigger { - top: 57px; + top: 0; height: 64px; &__btn { diff --git a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.appearance.ts b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.appearance.ts deleted file mode 100644 index 3f4bc2d016..0000000000 --- a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.appearance.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; - -export const stickyTaskIndicatorAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'sticky-task-indicator', - parts: [ - { id: 'root' }, { id: 'gradient' }, { id: 'content' }, { id: 'button' }, - { id: 'icon' }, { id: 'label' }, { id: 'arrow' }, - ], - states: [{ id: 'visible', selector: { kind: 'self', suffix: '[data-bf-state~="visible"]' } }], -}; diff --git a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.scss b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.scss deleted file mode 100644 index bd0ee94bbc..0000000000 --- a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.scss +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Sticky task indicator styles. - * Fixed at the top of the flowchat message list with a gradient fade. - */ - -.sticky-task-indicator { - position: absolute; - left: 0; - right: 0; - top: 93px; // Below FlowChatHeader (36px) + ScrollToTurnHeaderButton hover zone (57px). - z-index: 10; - pointer-events: none; - - // Trigger area height: 40px hover zone + 20px gradient. - height: 60px; - - opacity: 0; - transition: opacity 140ms ease; - - // ========== Visible state ========== - &--visible { - opacity: 1; - pointer-events: auto; - } - - // ========== Gradient background layer ========== - &__gradient { - position: absolute; - inset: 0; - pointer-events: none; - - background: linear-gradient( - to bottom, - var(--bf-appearance-token-color-bg-scene) 0%, - color-mix(in srgb, var(--bf-appearance-token-color-bg-scene) 80%, transparent) 40%, - color-mix(in srgb, var(--bf-appearance-token-color-bg-scene) 40%, transparent) 70%, - transparent 100% - ); - } - - // ========== Content layer ========== - &__content { - position: absolute; - top: 8px; - left: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - - transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1); - } - - // ========== Task label button ========== - &__btn { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: min(480px, 70vw); - height: 28px; - padding: 0 10px; - border-radius: 999px; - border: 1px solid var(--bf-appearance-token-border-base); - background: var(--bf-appearance-token-color-bg-scene); - color: var(--bf-appearance-token-color-text-muted); - cursor: pointer; - font-size: var(--bf-appearance-token-flowchat-font-size-xs); - line-height: 1; - white-space: nowrap; - - box-shadow: 0 6px 16px var(--bf-appearance-token-color-overlay-black-15); - transition: - transform 120ms cubic-bezier(0.23, 1, 0.32, 1), - opacity 120ms ease; - - &:hover { - border-color: var(--bf-appearance-token-border-medium); - color: var(--bf-appearance-token-color-text-primary); - transform: translateY(-1px); - } - - &:active { - transform: scale(0.97); - } - - &:focus-visible { - outline: none; - border-color: var(--bf-appearance-token-border-strong); - color: var(--bf-appearance-token-color-text-primary); - box-shadow: 0 0 0 1.5px var(--bf-appearance-token-border-strong); - } - } - - &__icon { - flex-shrink: 0; - opacity: 0.75; - } - - &__label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &__arrow { - flex-shrink: 0; - opacity: 0.6; - } -} - -@media (prefers-reduced-motion: reduce) { - .sticky-task-indicator, - .sticky-task-indicator__content, - .sticky-task-indicator__btn { - transition-duration: 0ms; - } -} - -// ========== Responsive tweaks ========== -@media (max-width: 768px) { - .sticky-task-indicator { - top: 93px; - height: 52px; - - &__btn { - height: 26px; - padding: 0 8px; - max-width: min(320px, 80vw); - } - } -} diff --git a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx b/src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx deleted file mode 100644 index e6dec703a9..0000000000 --- a/src/web-ui/src/flow_chat/components/StickyTaskIndicator.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Sticky task indicator. - * Shows at the top of the message list when the user has scrolled past a Task - * tool card, indicating which Task they are currently reading. - * Clicking the indicator scrolls the Task to the viewport top. - */ - -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Split, ChevronUp } from 'lucide-react'; -import { Tooltip } from '@/component-library'; -import type { VisibleTaskInfo } from '../hooks/useVisibleTaskInfo'; -import './StickyTaskIndicator.scss'; - -interface StickyTaskIndicatorProps { - visible: boolean; - taskInfo: VisibleTaskInfo | null; - onClick: () => void; -} - -export const StickyTaskIndicator: React.FC = ({ - visible, - taskInfo, - onClick, -}) => { - const { t } = useTranslation('flow-chat'); - - const label = taskInfo?.label || t('toolCards.taskTool.defaultAgentKind'); - const tooltip = t('stickyTaskIndicator.tooltip'); - - return ( -
-
-
- - - -
-
- ); -}; - -StickyTaskIndicator.displayName = 'StickyTaskIndicator'; diff --git a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.appearance.ts b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.appearance.ts deleted file mode 100644 index bf4ceb90ae..0000000000 --- a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.appearance.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; - -export const turnHistoryPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'turn-history-panel', - parts: [ - { id: 'root' }, { id: 'loading' }, { id: 'empty' }, { id: 'header' }, - { id: 'count' }, { id: 'list' }, { id: 'item' }, { id: 'itemHeader' }, - { id: 'files' }, { id: 'filesList' }, { id: 'time' }, - ], - states: [{ id: 'current', selector: { kind: 'self', suffix: '[data-bf-state~="current"]' } }], -}; diff --git a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.scss b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.scss deleted file mode 100644 index 8ab8cf6332..0000000000 --- a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.scss +++ /dev/null @@ -1,135 +0,0 @@ -@use '../../component-library/styles/_extended-mixins' as mixins; - -.turn-history-panel { - padding: 16px; - background: var(--bf-appearance-token-color-bg-primary); - border-radius: 8px; - box-shadow: var(--bf-appearance-token-shadow-sm); - - .turn-history-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; - padding-bottom: 12px; - border-bottom: 1px solid var(--bf-appearance-token-border-base); - - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; - color: var(--bf-appearance-token-color-text-primary); - } - - .turn-count { - font-size: var(--bf-appearance-token-tool-card-action-font-size); - color: var(--bf-appearance-token-color-text-secondary); - background: var(--bf-appearance-token-element-bg-soft); - padding: 4px 8px; - border-radius: 4px; - } - } - - .turn-history-list { - display: flex; - flex-direction: column; - gap: 12px; - max-height: 500px; - overflow-y: auto; - - } - - .turn-history-item { - padding: 12px; - border: 1px solid var(--bf-appearance-token-border-base); - border-radius: 6px; - transition: all 0.2s ease; - - &:hover { - border-color: var(--bf-appearance-token-color-accent-600); - box-shadow: 0 2px 4px color-mix(in srgb, var(--bf-appearance-token-color-accent-600) 10%, transparent); - } - - &.current { - background: var(--bf-appearance-token-color-accent-100); - border-color: var(--bf-appearance-token-color-accent-600); - } - - .turn-item-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; - - .turn-index { - font-size: 14px; - font-weight: 600; - color: var(--bf-appearance-token-color-text-primary); - } - } - - .turn-item-files { - margin: 8px 0; - padding: 8px; - background: var(--bf-appearance-token-element-bg-subtle); - border-radius: 4px; - - .files-label { - font-size: var(--bf-appearance-token-tool-card-action-font-size); - color: var(--bf-appearance-token-color-text-secondary); - font-weight: 500; - display: block; - margin-bottom: 4px; - } - - .files-list { - margin: 0; - padding-left: 16px; - - .file-item { - font-size: var(--bf-appearance-token-tool-card-action-font-size); - color: var(--bf-appearance-token-color-text-secondary); - margin: 2px 0; - font-family: var(--bf-appearance-token-font-family-mono); - } - - .file-item-more { - font-size: var(--bf-appearance-token-tool-card-action-font-size); - color: var(--bf-appearance-token-color-text-muted); - font-style: italic; - list-style: none; - margin-left: -16px; - } - } - } - - .turn-item-time { - font-size: var(--bf-appearance-token-flowchat-font-size-xs); - color: var(--bf-appearance-token-color-text-muted); - margin-top: 8px; - } - } -} - -.turn-history-panel-loading { - padding: 40px; - text-align: center; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 14px; -} - -.turn-history-panel-empty { - padding: 40px; - text-align: center; - - p { - margin: 8px 0; - color: var(--bf-appearance-token-color-text-secondary); - font-size: 14px; - - &.hint { - font-size: var(--bf-appearance-token-tool-card-action-font-size); - color: var(--bf-appearance-token-color-text-muted); - } - } -} diff --git a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx b/src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx deleted file mode 100644 index 0ccc75ad1e..0000000000 --- a/src/web-ui/src/flow_chat/components/TurnHistoryPanel.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { snapshotAPI } from '@/infrastructure/api'; -import { useI18n } from '@/infrastructure/i18n'; -import type { TurnSnapshot } from '@/infrastructure/api/service-api/SnapshotAPI'; -import { TurnRollbackButton } from './TurnRollbackButton'; -import { createLogger } from '@/shared/utils/logger'; -import './TurnHistoryPanel.scss'; - -const log = createLogger('TurnHistoryPanel'); - -interface TurnHistoryPanelProps { - sessionId: string; -} - -/** - * Turn history panel. - * Shows all turns in the current session and allows rollback. - */ -export const TurnHistoryPanel: React.FC = ({ sessionId }) => { - const { formatDate } = useI18n('flow-chat'); - const [turns, setTurns] = useState([]); - const [loading, setLoading] = useState(false); - const [currentTurnIndex, setCurrentTurnIndex] = useState(-1); - - const loadTurns = useCallback(async () => { - if (!sessionId) return; - - setLoading(true); - try { - const turnList = await snapshotAPI.getSessionTurnSnapshots(sessionId); - setTurns(turnList); - setCurrentTurnIndex(turnList.length > 0 ? turnList.length - 1 : -1); - } catch (error) { - log.error('Failed to load turn snapshots', { sessionId, error }); - } finally { - setLoading(false); - } - }, [sessionId]); - - useEffect(() => { - void loadTurns(); - }, [loadTurns]); - - const handleRollbackComplete = () => { - void loadTurns(); - }; - - if (loading) { - return
Loading...
; - } - - if (turns.length === 0) { - return ( -
-

No turn history available.

-

A snapshot is created after each AI response.

-
- ); - } - - return ( -
-
-

Session history

- {turns.length} turns -
- -
- {turns.map((turn, index) => ( -
-
- Turn {index + 1} - -
- - {turn.modifiedFiles.length > 0 && ( -
- Modified files: -
    - {turn.modifiedFiles.slice(0, 3).map((file: string, fileIndex: number) => ( -
  • {file}
  • - ))} - {turn.modifiedFiles.length > 3 && ( -
  • - {turn.modifiedFiles.length - 3} more files... -
  • - )} -
-
- )} - -
- {formatDate(new Date(turn.timestamp * 1000), { - dateStyle: 'medium', - timeStyle: 'short', - })} -
-
- ))} -
-
- ); -}; diff --git a/src/web-ui/src/flow_chat/components/TurnRollbackButton.appearance.ts b/src/web-ui/src/flow_chat/components/TurnRollbackButton.appearance.ts deleted file mode 100644 index 24c889ab06..0000000000 --- a/src/web-ui/src/flow_chat/components/TurnRollbackButton.appearance.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; - -export const turnRollbackButtonAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'turn-rollback-button', - parts: [{ id: 'root' }], - facets: [{ id: 'mode', attribute: 'data-bf-mode', values: ['current', 'action'] }], - states: [{ id: 'loading', selector: { kind: 'self', suffix: '[data-bf-state~="loading"]' } }], -}; diff --git a/src/web-ui/src/flow_chat/components/TurnRollbackButton.scss b/src/web-ui/src/flow_chat/components/TurnRollbackButton.scss deleted file mode 100644 index 0e68fba3f6..0000000000 --- a/src/web-ui/src/flow_chat/components/TurnRollbackButton.scss +++ /dev/null @@ -1,40 +0,0 @@ -.turn-rollback-button { - padding: 4px 12px; - font-size: var(--bf-appearance-token-tool-card-action-font-size); - font-weight: 500; - background: var(--bf-appearance-token-color-accent-600); - color: var(--bf-appearance-token-color-static-white); - border: none; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; - - &:hover { - background: var(--bf-appearance-token-color-accent-700); - transform: translateY(-1px); - box-shadow: 0 2px 4px color-mix(in srgb, var(--bf-appearance-token-color-accent-600) 30%, transparent); - } - - &:active { - transform: translateY(0); - } - - &:disabled { - opacity: var(--bf-appearance-token-opacity-disabled); - cursor: default; - transform: none; - box-shadow: none; - } -} - -.turn-rollback-button-current { - padding: 4px 12px; - font-size: var(--bf-appearance-token-tool-card-action-font-size); - font-weight: 500; - background: var(--bf-appearance-token-color-success); - color: var(--bf-appearance-token-color-static-white); - border-radius: 4px; - display: inline-block; - white-space: nowrap; -} diff --git a/src/web-ui/src/flow_chat/components/TurnRollbackButton.tsx b/src/web-ui/src/flow_chat/components/TurnRollbackButton.tsx deleted file mode 100644 index 2d34d814ce..0000000000 --- a/src/web-ui/src/flow_chat/components/TurnRollbackButton.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { snapshotAPI } from '@/infrastructure/api'; -import { notificationService } from '@/shared/notification-system'; -import { confirmDanger } from '@/component-library'; -import { createLogger } from '@/shared/utils/logger'; -import './TurnRollbackButton.scss'; - -const log = createLogger('TurnRollbackButton'); - -interface TurnRollbackButtonProps { - sessionId: string; - turnIndex: number; - isCurrent: boolean; - onRollbackComplete?: () => void; -} - -export const TurnRollbackButton: React.FC = ({ - sessionId, - turnIndex, - isCurrent, - onRollbackComplete, -}) => { - const { t } = useTranslation('flow-chat'); - const [loading, setLoading] = useState(false); - - const handleRollback = async () => { - if (isCurrent || loading) return; - - const index = turnIndex + 1; - const confirmed = await confirmDanger( - t('message.rollbackPanelDialogTitle', { index }), - ( - <> -

{t('message.rollbackPanelDialogIntro')}

-
    -
  • {t('message.rollbackPanelBulletRestore', { index })}
  • -
  • {t('message.rollbackPanelBulletUndo', { index })}
  • -
  • {t('message.rollbackPanelBulletHistory')}
  • -
- - ) - ); - - if (!confirmed) return; - - setLoading(true); - try { - const restoredFiles = await snapshotAPI.rollbackToTurn(sessionId, turnIndex); - - log.debug('Rollback completed', { sessionId, turnIndex, restoredFilesCount: restoredFiles.length }); - - // Notify related components to refresh. - const { globalEventBus } = await import('@/infrastructure/event-bus'); - - // Refresh file tree. - globalEventBus.emit('file-tree:refresh'); - - // Refresh open files in the editor. - restoredFiles.forEach(filePath => { - globalEventBus.emit('editor:file-changed', { filePath }); - }); - - // Refresh snapshot state. - globalEventBus.emit('snapshot:rollback-completed', { - sessionId, - turnIndex, - restoredFiles - }); - - // Notify parent. - if (onRollbackComplete) { - onRollbackComplete(); - } - - } catch (error) { - log.error('Rollback failed', { sessionId, turnIndex, error }); - notificationService.error( - `${t('message.rollbackFailed')}: ${error instanceof Error ? error.message : String(error)}` - ); - } finally { - setLoading(false); - } - }; - - if (isCurrent) { - return Current; - } - - return ( - - ); -}; - - diff --git a/src/web-ui/src/flow_chat/components/UserMessage.appearance.ts b/src/web-ui/src/flow_chat/components/UserMessage.appearance.ts deleted file mode 100644 index 62cec8eba6..0000000000 --- a/src/web-ui/src/flow_chat/components/UserMessage.appearance.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; - -export const userMessageAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'user-message', - parts: [ - { id: 'root' }, { id: 'content' }, { id: 'inlineContent' }, - { id: 'footer' }, { id: 'timestamp' }, { id: 'snapshotAction' }, - ], - states: [ - { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, - { id: 'collapsed', selector: { kind: 'self', suffix: '[data-bf-state~="collapsed"]' } }, - ], -}; diff --git a/src/web-ui/src/flow_chat/components/UserMessage.scss b/src/web-ui/src/flow_chat/components/UserMessage.scss deleted file mode 100644 index c05f5efa36..0000000000 --- a/src/web-ui/src/flow_chat/components/UserMessage.scss +++ /dev/null @@ -1,116 +0,0 @@ -/* User message component styles. */ - -@use '../../component-library/styles/tokens' as *; - -.user-message { - margin-bottom: 1rem; - padding: 1rem 1.25rem; - background: var(--bf-appearance-token-element-bg-medium); - border: 1px solid var(--bf-appearance-token-border-medium); - border-radius: 12px; - max-width: 100%; - width: 100%; - min-width: 200px; - max-height: 60vh; - display: block; - visibility: visible; - box-sizing: border-box; - position: relative; - animation: user-message-slide-in 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); - overflow-y: auto; - - &:focus-within { - border-color: var(--bf-appearance-token-border-prominent); - } - - // Collapsed state - &--collapsed { - .message-inline-content { - display: -webkit-box !important; - -webkit-line-clamp: 3; - line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - white-space: pre-wrap; - } - } - - // Expanded state - &--expanded { - .message-inline-content { - display: inline; - } - } -} - -.message-content { - font-weight: 500; - line-height: var(--bf-appearance-token-flowchat-text-line-height); - color: var(--bf-appearance-token-color-text-primary); - position: relative; - z-index: 1; - font-size: 1em; - user-select: text; -} - -.message-inline-content { - line-height: var(--bf-appearance-token-flowchat-text-line-height); - word-break: break-word; - white-space: pre-wrap; -} - -.message-footer { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 0.75rem; -} - -.message-timestamp { - font-size: 0.75em; - color: var(--bf-appearance-token-color-text-muted); - font-weight: 400; -} - -.message-snapshot-action { - display: flex; - align-items: center; -} - -// ==================== Inline context tags ==================== - -.inline-context-tag { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.125rem 0.5rem; - margin: 0 0.25rem; - border-radius: 0.375rem; - border: 1px solid; - font-size: 0.875em; - font-weight: 500; - vertical-align: middle; - white-space: nowrap; - transition: all 0.2s ease; - cursor: default; - - &:hover { - transform: translateY(-1px); - box-shadow: 0 2px 4px var(--bf-appearance-token-color-overlay-black-12); - } -} - -// ==================== Animations ==================== - -@keyframes user-message-slide-in { - from { - opacity: 0; - transform: translateX(-20px) scale(0.95); - } - to { - opacity: 1; - transform: translateX(0) scale(1); - } -} - diff --git a/src/web-ui/src/flow_chat/components/UserMessage.tsx b/src/web-ui/src/flow_chat/components/UserMessage.tsx deleted file mode 100644 index 54e2fd3f82..0000000000 --- a/src/web-ui/src/flow_chat/components/UserMessage.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/** - * User message component. - * Parses and renders inline context tags inside user text. - */ - -import React, { useMemo, useState, useRef, useEffect } from 'react'; -import { File, Folder, Code, Image, Terminal, GitBranch, Link, FileText, GitPullRequest } from 'lucide-react'; -import { Tag } from '@/component-library'; -import { useI18n } from '@/infrastructure/i18n'; -import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; -import { observeElementResize } from '@/shared/utils/sharedResizeObserver'; -import { SnapshotRollbackButton } from './SnapshotRollbackButton'; -import './UserMessage.scss'; - -export interface UserMessageProps { - message?: string; // New API - content?: string; // Legacy API - timestamp?: number; - showTimestamp?: boolean; - className?: string; - // Turn snapshot support - sessionId?: string; - turnIndex?: number; - turnId?: string; - showSnapshotButton?: boolean; - isCurrentTurn?: boolean; -} - -// Content segment type: text or tag. -type ContentPart = - | { type: 'text'; content: string } - | { type: 'tag'; tagType: string; label: string }; - -type InlineTagColor = 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'gray'; - -// Tag metadata -const TAG_CONFIG = { - file: { icon: File, tagColor: 'blue' as InlineTagColor, label: 'File' }, - dir: { icon: Folder, tagColor: 'purple' as InlineTagColor, label: 'Directory' }, - code: { icon: Code, tagColor: 'green' as InlineTagColor, label: 'Code' }, - img: { icon: Image, tagColor: 'yellow' as InlineTagColor, label: 'Image' }, - cmd: { icon: Terminal, tagColor: 'gray' as InlineTagColor, label: 'Command' }, - chart: { icon: FileText, tagColor: 'gray' as InlineTagColor, label: 'Chart' }, - git: { icon: GitBranch, tagColor: 'red' as InlineTagColor, label: 'Git' }, - link: { icon: Link, tagColor: 'blue' as InlineTagColor, label: 'Link' }, - pr: { icon: GitPullRequest, tagColor: 'purple' as InlineTagColor, label: 'Pull Request' } -}; - -/** - * Parse message content into inline segments. - * Supported format: #type:value - * - * Tag formats: - * - #file:filename - File reference - * - #dir:dirname - Directory reference - * - #code:file:10-20 - Code snippet - * - #img:image - Image reference - * - #cmd:command - Command reference - * - #chart:chart - Chart reference - * - #git:branch - Git reference - * - #link:URL - Link reference - */ -function parseMessageContent(content: string): ContentPart[] { - const parts: ContentPart[] = []; - - // Match #type:value until whitespace or line break. - const tagPattern = /#(file|dir|code|img|cmd|chart|git|link|pr):([^\s\n]+)/g; - - let lastIndex = 0; - let match; - - while ((match = tagPattern.exec(content)) !== null) { - if (match.index > lastIndex) { - const textBefore = content.slice(lastIndex, match.index); - if (textBefore) { - parts.push({ type: 'text', content: textBefore }); - } - } - - const tagType = match[1]; - const label = match[2]; - - parts.push({ - type: 'tag', - tagType, - label - }); - - lastIndex = match.index + match[0].length; - } - - if (lastIndex < content.length) { - const textAfter = content.slice(lastIndex); - if (textAfter) { - parts.push({ type: 'text', content: textAfter }); - } - } - - if (parts.length === 0) { - parts.push({ type: 'text', content }); - } - - return parts; -} - -/** - * Inline context tag component. - */ -const InlineContextTag: React.FC<{ tagType: string; label: string }> = ({ tagType, label }) => { - const config = TAG_CONFIG[tagType as keyof typeof TAG_CONFIG] || TAG_CONFIG.file; - const IconComponent = config.icon; - - return ( - - - {label} - - ); -}; - -export const UserMessage: React.FC = React.memo(({ - message, - content, - timestamp, - showTimestamp = false, - className = '', - sessionId, - turnIndex, - turnId, - showSnapshotButton = false, - isCurrentTurn = false -}) => { - const { formatDate } = useI18n('flow-chat'); - const messageContent = message || content || ''; - const parts = useMemo(() => parseMessageContent(messageContent), [messageContent]); - const [isExpanded, setIsExpanded] = useState(false); - const [hasOverflow, setHasOverflow] = useState(false); - const messageRef = useRef(null); - const contentRef = useRef(null); - - // Shared ResizeObserver instead of a per-message window resize listener: - // observer callbacks run after layout, avoiding forced reflow per message. - useEffect(() => { - const element = contentRef.current; - if (!element || isExpanded) { - setHasOverflow(false); - return; - } - - const checkOverflow = () => { - const isOverflowing = element.scrollHeight > element.clientHeight || - element.scrollWidth > element.clientWidth; - setHasOverflow(isOverflowing); - }; - - checkOverflow(); - - return observeElementResize(element, checkOverflow); - }, [messageContent, isExpanded]); - - const toggleExpand = (e: React.MouseEvent) => { - if (shouldIgnoreCardToggleClick(e, contentRef.current)) { - return; - } - - if (!hasOverflow && !isExpanded) { - return; - } - e.stopPropagation(); - setIsExpanded(prev => !prev); - }; - - useEffect(() => { - if (!isExpanded) { - return; - } - - const handleClickOutside = (event: MouseEvent) => { - if (messageRef.current && !messageRef.current.contains(event.target as Node)) { - setIsExpanded(false); - } - }; - - const timeoutId = setTimeout(() => { - document.addEventListener('click', handleClickOutside, true); - }, 100); - - return () => { - clearTimeout(timeoutId); - document.removeEventListener('click', handleClickOutside, true); - }; - }, [isExpanded]); - - const currentClassName = `user-message ${className} ${isExpanded ? 'user-message--expanded' : 'user-message--collapsed'}`; - - return ( -
-
-
- {parts.map((part, index) => { - if (part.type === 'text') { - return part.content.split('\n').map((line, lineIndex) => ( - - {lineIndex > 0 &&
} - {line} -
- )); - } else { - return ( - - ); - } - })} -
-
- -
- {showTimestamp && timestamp && ( -
- {formatDate(new Date(timestamp), { - hour: '2-digit', - minute: '2-digit', - })} -
- )} - - {showSnapshotButton && sessionId && turnId !== undefined && turnIndex !== undefined && ( -
- -
- )} -
-
- ); -}); - -UserMessage.displayName = 'UserMessage'; diff --git a/src/web-ui/src/flow_chat/components/UserSteeringBubble.appearance.ts b/src/web-ui/src/flow_chat/components/UserSteeringBubble.appearance.ts deleted file mode 100644 index 29db31ce20..0000000000 --- a/src/web-ui/src/flow_chat/components/UserSteeringBubble.appearance.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; - -export const userSteeringBubbleAppearanceDescriptor: AppearanceSurfaceDescriptor = { - id: 'user-steering-bubble', - parts: [ - { id: 'images' }, { id: 'image' }, - ], -}; diff --git a/src/web-ui/src/flow_chat/components/UserSteeringBubble.scss b/src/web-ui/src/flow_chat/components/UserSteeringBubble.scss deleted file mode 100644 index e9764b4aa7..0000000000 --- a/src/web-ui/src/flow_chat/components/UserSteeringBubble.scss +++ /dev/null @@ -1,26 +0,0 @@ -// Attachments on a steering message, matching the turn-level user message so -// an image reads the same whether it was sent at a turn boundary or injected -// into a running turn. -.bitfun-user-steering-bubble__images { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 6px; - margin-top: 6px; -} - -.bitfun-user-steering-bubble__image { - // Attachment-sized, not a hero image: the text stays the primary content. - width: 72px; - height: 72px; - border-radius: 5px; - overflow: hidden; - border: 1px solid var(--bf-appearance-token-border-base); - - img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; - } -} diff --git a/src/web-ui/src/flow_chat/components/UserSteeringBubble.tsx b/src/web-ui/src/flow_chat/components/UserSteeringBubble.tsx deleted file mode 100644 index 131739cd3c..0000000000 --- a/src/web-ui/src/flow_chat/components/UserSteeringBubble.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Renders a `user-steering` flow item as a normal user message in the - * conversation flow. The backend confirmation still updates this item by - * `steeringId`, but the user-facing surface is intentionally identical to a - * message sent from the composer — attachments included. - * - * The item is appended to the *current* model round's items, so it visually - * sits after whatever thinking / text / tool-call content has already - * streamed. When the backend finishes the current atomic step and starts a - * new model round, that next round renders below it — matching the user's - * mental model of "the agent reads my steering and responds in a new turn". - */ - -import { UserMessage } from './UserMessage'; -import type { FlowUserSteeringItem, SteeringImage } from '../types/flow-chat'; -import './UserSteeringBubble.scss'; - -interface UserSteeringBubbleProps { - item: FlowUserSteeringItem; -} - -function imageSource(image: SteeringImage): string | undefined { - if (image.dataUrl) return image.dataUrl; - if (image.imagePath) { - return `https://asset.localhost/${encodeURIComponent(image.imagePath)}`; - } - return undefined; -} - -export function UserSteeringBubble({ item }: UserSteeringBubbleProps): JSX.Element { - const images = item.images ?? []; - return ( - <> - - {images.length > 0 && ( -
- {images.map(image => { - const src = imageSource(image); - return src ? ( -
- {image.name -
- ) : null; - })} -
- )} - - ); -} - -export default UserSteeringBubble; diff --git a/src/web-ui/src/flow_chat/components/modern/AGENTS.md b/src/web-ui/src/flow_chat/components/modern/AGENTS.md index d96a429bbe..6799e08752 100644 --- a/src/web-ui/src/flow_chat/components/modern/AGENTS.md +++ b/src/web-ui/src/flow_chat/components/modern/AGENTS.md @@ -1,127 +1,205 @@ -# FlowChat Scroll Stability Instructions +# FlowChat Scroll Instructions This file applies to the modern FlowChat viewport implementation under this -directory. +directory. It is the rules. The reasoning, the measurements, and the failures +each rule was written against live in the documents below — read the one that +covers what you are about to change. + +Also follow the repository and Web UI instructions in the parent guides. ## Required Reading -Before changing FlowChat rendering, projection, virtualization, scrolling, -tool-card collapse behavior, footer reservations, runtime-status slots, or -typewriter/reveal behavior, read: - -- `FLOWCHAT_SCROLL_STABILITY.md` - -Also follow the repository and Web UI instructions in the parent `AGENTS.md` -files. - -The stability document describes the current contracts and the failure modes -that have caused visible flashes, header drops, stale tail whitespace, and -scroll ownership races. Treat it as part of the implementation contract, not -as background documentation. - -## Stability Model - -The central invariant is single ownership of viewport motion: - -- `FlowChatViewportCoordinator` owns semantic viewport modes and anchors: - `pinned-item`, `following-tail`, and `preserving-element`. -- `useFlowChatFollowOutput` owns the continuous streaming tail-follow RAF loop. - It is the only continuous writer that advances the viewport toward the - streaming tail and it must yield while an element anchor or collapse - transaction owns the viewport. -- `VirtualMessageList` keeps Virtuoso's `followOutput={false}`. Do not enable - Virtuoso's autonomous follow behavior or introduce another effect that writes - the outer FlowChat viewport tail position. Independent writers are what - produce the drop-then-restore flash. Local scroll surfaces inside a thinking, - explore, terminal, or subagent card have their own narrowly scoped behavior. -- Tool cards must not calculate `scrollTop`, `scrollBy`, compensation pixels, - or anchor offsets. They dispatch - `flowchat:tool-card-collapse-intent` before a known height reduction and use - `useToolCardHeightContract` for the state transition. -- Direct `scrollTop` / `scrollTo` writes are not categorically forbidden, but - outer-viewport writes are restricted to the coordinator, the follow - controller, and the narrowly scoped `VirtualMessageList` navigation, - physical-bottom recovery, and reservation transactions. Every new write - needs an explicit owner, a user-intent guard, and a reason why the existing - coordinator or follow controller cannot perform it. -- Footer `collapse` and `pin` reservations provide physical range while the - DOM or Virtuoso measurements settle. Apply footer compensation synchronously - before restoring an anchor; do not replace this with React-state-only footer - rendering. - -Stable virtual-item keys and projection identity are equally important. Do not -split one `ModelRound` into multiple `model-round` virtual items, add -mount-triggered animations, or use a timer to reclassify projection/grouping. -Card-local completion preview timers are allowed only when they leave the -virtual projection unchanged and use the existing height contract when they -eventually collapse a card. - -## Required Verification - -Choose focused tests for the code you changed, then run the normal Web UI -checks: - -```text -pnpm run type-check:web -pnpm --dir src/web-ui run lint -pnpm --dir src/web-ui run test:run -``` - -Relevant stability tests include: - -- `src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts` -- `src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx` -- `src/flow_chat/components/modern/VirtualMessageList.layout.test.ts` -- `src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx` -- `src/flow_chat/components/modern/flowChatCollapseMotion.test.ts` - -For tool-card or collapse-contract changes, also run the nearest focused card -tests, for example the `FileOperationToolCard`, `ExecProcessToolCardView`, -`TaskToolDisplay`, `useToolCardHeightContract`, or `SmoothHeightCollapse` tests. -Do not claim the stability behavior is verified from type-check/lint alone. - -## Manual Verification - -For changes that affect viewport ownership, tail follow, pinning, collapse -height, session handoff, or scroll event handling, **require the user to perform -the following manual verification before considering the change complete**. -These interactions are too stateful and timing-sensitive for an Agent to verify -reliably; an Agent may run static checks and automated tests, but must not claim -these manual results without explicit user confirmation. Use a real streaming -conversation and check all of the following: - -1. Start a new round and confirm the new user message is pinned to the intended - top position without a visible jump. -2. Do not touch the viewport while output streams. When the output reaches the - bottom, confirm it transitions naturally into follow-output mode instead of - stopping short or waiting for the round to finish. -3. While output is streaming and following, scroll upward and downward by hand. - Confirm the user's scroll intent immediately wins: auto-follow must not pull - the viewport back or re-pin it unexpectedly. -4. Switch to another conversation during an active round, then return to the - original conversation. Confirm the viewport resumes the correct follow mode - when appropriate, without a black/empty tail, excessive synthetic footer - space, or a delayed recovery that depends on another user scroll. -5. Exercise completed `Write`, `Edit`, `ExecCommand`, and terminal/tool-card - collapses both at the tail and in the middle of the transcript. Confirm the - header stays at the same viewport position while the body contracts. -6. Repeat the flow around thinking, explore groups, runtime-status/footer - visibility, and at least one multi-tool round. Confirm there is no visible - flash, drop-then-snap-back, permanent fall, accumulating tail whitespace, or - one-frame loss of the pinned header. - -When a manual check fails, enable the FlowChat diagnostics setting and inspect -the session `flowchat.log` before changing ownership rules. Keep diagnostic -payloads bounded and free of message content, tool arguments, and file data. - -## Change Discipline - -- Do not add a competing scroll writer, persistent scroll lock, or ad hoc - `scrollBy`/`scrollTo` call from a card or renderer. -- Do not bypass `flowchat:tool-card-collapse-intent` for a known shrink. -- Do not clear footer reservations or semantic anchors merely to make a test - pass; determine which viewport owner still needs them. -- Preserve user scroll intent, session/generation cancellation, and cleanup of - pending RAF/timer work. -- Update `FLOWCHAT_SCROLL_STABILITY.md` whenever the ownership model, - reservation contract, collapse lifecycle, or required verification changes. +| Changing | Read | +|---|---| +| the tail spacer, the follow target, pinning, holding, the snap back, resizing, the footer, the reveal | `FLOWCHAT_SCROLL_STABILITY.md` | +| history paging, the prepend, the viewport anchor, history presentation | `FLOWCHAT_HISTORY_PAGING.md` | +| anything that writes `scrollTop`, one-shot navigation, the diagnostic trail | `FLOWCHAT_VIEWPORT_REGISTER.md` | +| the virtualizer, item measurement, item keys, anything a row renders | `FLOWCHAT_VIRTUALIZATION.md` | + +`FLOWCHAT_SCROLL_STABILITY.md` also carries *Known Gaps* for all four — check it +before reporting a defect as new. + +## Reservation and Follow + +- FlowChat reserves a resident tail spacer of about one viewport, sized from + `scroller.clientHeight` and nothing else. +- Static reservation is allowed; reactive compensation is not. Do not derive any + reserved height from a measured content height, a collapse delta, an animation + duration, or a streaming rate. +- Do not add sticky Turn modes, pre-collapse compensation, or persistent + element-anchor guards. +- The follow target lives in `flowChatTailFollow.ts` as pure functions over + geometry. Keep it free of timers and mutation observers. +- `scheduleFollowToLatest` must not force the content end — the hold rule is + what keeps a collapse from moving the viewport. +- `useFlowChatFollowOutput` is the only continuous outer viewport writer. +- The follow's **write** may be eased; its **target** may not. Everything that + reads the follow — the settle budget, the at-tail band, the snap back — reads + the offset the rule owns, never how far behind the ease is riding. The ease + stands down while the transcript is opening, where the target is + authoritative. +- A follow the frame loop is still correcting counts as being at the tail. + Ownership cannot stand in for that: it outlives the loop, which is exactly the + state a viewport stranded in the reserved blank is in. +- The loop's stand-down for its own animated scroll is timed in milliseconds at + both ends — how long it waits for the animation to move, and how long it + waits for the animation at all. Neither may be a frame count: a frame count + is not a duration, and a smooth scroll eases in slowly enough that its first + frames do not move at all on a high-refresh display. +- Footer height represents only the current input-stack layout and real footer + content such as history state and `RuntimeStatusSlot`. The tail spacer is a + separate sibling and must not be folded into it. +- "At bottom" is measured against the end of real content, which sits above the + tail spacer, so no alignment to the last item can express it. + +## The Viewport Register + +- Every deliberate viewport write goes through `useFlowChatViewportOwner`, named + with the owner it belongs to, and holds that ownership for as long as it is + moving — an animation included. Never assign `scrollTop` or call `scrollTo` + on the FlowChat scroller directly. +- Adding an owner means adding it to `FLOWCHAT_VIEWPORT_OWNERS` in priority + order and to its test, not adding a condition to anyone else's predicate. +- The virtualizer's own writes are registered through its `scrollToFn` option + and attributed to whoever asked for the aim. Do not bypass it. +- A writer that declines to move the viewport says so through + `flowChatViewportDiagnostics.ts`. The register records the writes; a write + that never happened is invisible everywhere else, and "nothing happened" is + the more common report. Anything reachable every frame goes through + `traceViewportRepeating`, keyed by what distinguishes one run from another — + **including the magnitude a reader would feel and the subject it is about**. A + run collapses to its first sample and turns the rest into a sum, so a key too + coarse to separate a rounding correction from a 400px one hides exactly the + events worth having: two rounds of diagnosis in a row read `0.7px` against + 458.7px of suppressed travel spanning three different anchors. +- There are no viewport writes outside the register. Adding one means wrapping + it in `traceViewportPlacement` and having a reason it cannot be a register + write; both writers that used to be outside are gone. +- One user action gets one painted placement. A focus request that first + navigates to a Turn and then aims at an item inside it does both in the same + task — two placements a few frames apart are two movements the reader sees, + and the sampled drift lands on the first one, not the second. +- Centring a flow item is `VirtualMessageListRef.focusFlowItem`, not + `element.scrollIntoView`. The virtualizer aligns *items*, so it cannot express + "this tool call inside this Turn"; the computed offset is the contract's + carve-out for a target that is not an item, and it still goes through the + register. +- One-shot Turn/search/history navigation remains inside `VirtualMessageList`. +- A gesture ends an aim, it does not merely outrank it. The library's re-aim + runs for 5s and cannot see a refusal, while a gesture's hold lasts 200ms, so + `notifyUserScrollIntent` calls `cancelAim`. Anything else that hands the + viewport to a new owner mid-aim owes the same call. + +## History Paging and the Anchor + +- Deciding *that* a history boundary is worth asking about belongs to + `flowChatHistoryBoundary.ts` and reads only a visible item range and the + scroll distance to each end. Deciding whether the ask is honoured stays in the + container, which declines while follow-output owns the viewport and until the + visible range has left that boundary since the last page. +- A page asks for what lies past the *rendered* transcript, never past the + window the store cut. The continuous projection makes those differ, and the + window's end is then an ordinal already on screen. +- Every path out of a boundary intent records an outcome and leaves the boundary + status in a state it can be seen in. A silent return is a status the reader + keeps looking at, and `loading` is not a resting state. +- A boundary status is labelled by what it is. An `error` rendered with the + `loading` label is a permanent failure shown as permanent progress. +- The ask goes out a screenful before the boundary, so the junction lands off + screen. Do not express that lead in items: one item here is anything from a + 38px user message to a 5012px model round. +- The arming latch re-arms from `historyBoundariesReached`, never from the ask. + Sharing one predicate makes a boundary the reader can never be off, and the + direction stays disarmed for the rest of the session. +- A *visible* item range is `getVisibleItemRange`, never the rendered rows. The + rendered window carries overscan and reports both ends present for any + transcript short enough to render whole. +- Boundaries are evaluated on scroll **intent**, not only on `scroll` events. A + reader already at the top produces no scroll event, so the one signal that + they want more history is the gesture itself. Evaluate after ownership has + been released, so the ask is theirs rather than our placement's. +- Ownership is read through `isFollowingOutputNow()`, never a render-time mirror + of `isFollowingOutput`. A gesture releases it synchronously and asks in the + same handler; the mirror still reports the ownership that gesture just ended. +- `exhausted` is latched per *window*, not per session. It answers "nothing + before this start ordinal", so the latch clears whenever the window's ordinals + change — not only when a page is `applied`. +- A history prepend must be compensated for in `VirtualMessageList`, by the + height of the items that arrived above. Keying measurements on item identity + covers the measurements; it does not move the scroll offset. The compensation + cannot finish the job on its own — most of the movement is the arrived items + measuring over the frames after it, and only the anchor's relationship + survives that. +- That compensation and the viewport anchor are displacements, not positions: + `viewportOwner.shift`, never a write with an owner. A gesture must not be able + to refuse either — paging up happens only while the reader is scrolling up, so + anything a gesture can refuse here is refused every time. +- A scroll re-anchors the reader, except while the anchored Turn is missing from + the rendered window. A correction is owed there and cannot be measured yet, so + the anchor is carried through the scroll — credited with the reader's own + travel — never replaced by whatever else happens to be rendered. +- The anchor's settle window is refreshed by what a frame *observed*, never by a + counter left over from an earlier one. A frame that stood down for another + owner looked at nothing, so it refreshes nothing — otherwise the loop runs at + frame rate for as long as that owner rests on the viewport, which at the tail + is indefinitely. +- The viewport anchor lives in `flowChatViewportAnchor.ts` and + `useFlowChatViewportAnchor.ts` and must stay independent of the virtualizer: + it may read the scroller and the Turns rendered inside it, and nothing else. + Virtualizer-specific compensation stays in `VirtualMessageList`. +- "A new Turn" is `activeSession.dialogTurns.at(-1)`, never the end of the + projection. Do not qualify that identity by whether the Turn is on screen — + that belongs to the response, which defers until the Turn can be aligned. +- Detecting one means the ledger **grew**, not that the identity changed. A + rollback truncates `dialogTurns` and moves that identity backwards onto a Turn + that was always there; read as an arrival it pins the survivor to the top. +- An action that rewrites `dialogTurns` and wants the viewport moved announces + it — `FLOWCHAT_MESSAGE_SUBMITTED_EVENT` for giving up a navigated history + window, `FLOWCHAT_TURNS_ROLLED_BACK_EVENT` for settling on a new tail. The + ledger cannot tell a Turn the reader sent from one that arrived from + elsewhere, nor a rollback from a window re-cut, and there are two dozen + writers of that array. Do not infer either from a count. + +## Virtualization and Rendering + +- `useFlowChatVirtualizer.ts` is the only module that may import a virtualization + library. It speaks in scroller offsets and item positions; anything that would + make a caller aware of which library is underneath belongs inside it. +- Prefer `scrollItemIntoView` over computing an offset. The virtualizer re-aims + while items below the target measure, and an offset computed once cannot. + Compute one only when the target is not an item. +- Anything reading an item position in the commit that changed the items calls + `measureRenderedItems()` first. The library skips its inline measurement while + the reader is scrolling, which is exactly when history arrives, so the cache + holds reserved estimates until the ResizeObserver delivers a frame later. +- The virtualizer must not adjust the scroll for its own re-measurements. It + replays a delta against a scroll position it learns about a frame late, and + every continuous writer here assigns `scrollTop` directly. +- The virtualizer never follows output. +- No mount or enter animation inside `.virtual-item-wrapper`, no mount-triggered + motion that changes transcript geometry, and nothing keyed on a state change a + scroll can replay. A row mounts when it enters the rendered window, not when + its content arrives, so the animation runs again on every page up and every + scroll back. Cancel it at the wrapper rather than in the component — this has + been patched locally four times and recurred each time. +- Tool cards reflow naturally and dispatch only `tool-card-toggle` after an + expanded-state change so the virtualizer can remeasure. +- Stable virtual-item keys and projection identity must be preserved. Do not + split one `ModelRound` into multiple virtual items or reclassify projection + from a timer. + +## Verification + +`FLOWCHAT_VERIFICATION.md` is the single list — the automated checks, mapped to +the contract each one holds, and the manual ones grouped by scenario. Do not +keep a second copy here. + +Do not perform UI interaction verification. Report the manual checks as pending +unless the user confirms them. + +## Keeping These Documents True + +Update the document that owns the area you changed, and this file only if a rule +changed. A rule belongs here when a reviewer could catch its violation by +reading a diff; everything else — the reasoning, the numbers, the failure it was +written against — belongs in the document. diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index c75ea5e40d..cb1b094182 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -129,14 +129,10 @@ export const ExploreGroupRenderer: React.FC = React.m // One-shot auto-collapse: fires exactly once when the group transitions from // tail (wasCutByCritical=false) to cut (wasCutByCritical=true). // - // IMPORTANT: do NOT use `isExpanded` to guard this effect. When wasCutByCritical - // flips to true, the same render also recomputes isExpanded = false (because - // defaultExpanded = !wasCutByCritical). So `justGotCut && isExpanded` would - // always be false and the collapse-intent would never fire. - // - // No explicit state means the group was expanded by the live-tail default, - // so dispatch the height-contract event before compacting it. An explicit - // state is user intent and must not be overwritten by a later auto event. + // Do not use `isExpanded` to guard this effect. The render that flips + // `wasCutByCritical` also recomputes the default expanded state. No explicit + // state means the live-tail default may collapse naturally; an explicit + // state is user intent and must not be overwritten. useLayoutEffect(() => { const justGotCut = wasCutByCritical && !prevWasCutRef.current; prevWasCutRef.current = wasCutByCritical; @@ -147,8 +143,6 @@ export const ExploreGroupRenderer: React.FC = React.m applyExpandedState(true, false, () => { onCollapseGroup?.(groupId); - }, { - reason: 'auto', }); }, [ applyExpandedState, diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md new file mode 100644 index 0000000000..4a18afba17 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md @@ -0,0 +1,673 @@ +# FlowChat History Paging + +Older Turns are fetched when the reader approaches the head of the loaded +window, prepended above them, and paid for by moving the viewport down by +exactly what arrived. This document covers the whole of that: when the ask goes +out, what may refuse it, how the displacement is repaid, and how the reading +position survives the measurement that follows. + +Read alongside `FLOWCHAT_VIEWPORT_REGISTER.md` — the two repairs described here +are the only writers that do not take their turn in the priority order — and +`FLOWCHAT_VIRTUALIZATION.md`, which owns the measurement behaviour they depend +on. + +## A Page Is Asked For a Screenful Early + +A history page is not a quiet event, and no amount of care makes it one. The +items arrive above the reader and everything below them moves; the virtualizer +picks its window from a scroll offset it only learns from scroll events, so for +one commit it renders from a position the reader has already been moved off and +the transcript below the junction goes blank; the rows then measure and the +whole thing settles a second time. Watched frame by frame, at 1/8 speed: the +loading notice disappears, the tail of the previous Turn leaks out, everything +past the junction goes white, the viewport shifts up and clips the top of a user +message, and then it is fine. Four frames, twice, at the two junctions the +reader crossed. + +Every one of those steps is correct. The reader still called it a flicker and a +jolt, because they were looking at it. + +So the ask goes out while the boundary is still a screenful away — +`HISTORY_BOUNDARY_LEAD_SCREENS`, measured against live geometry rather than an +item count, since one item here is anything from a 38px user message to a +5012px model round. The page then lands above the viewport: the mis-aimed +window, the blank and the re-measurement all happen where nobody is looking, and +the reader scrolls up into content that is already there and already measured. +It costs one page of history fetched sooner than strictly needed, and nothing on +session open, where the boundary is a whole transcript away. + +The pixel rule is a union with the item rule, not a replacement. A window short +enough that its head is on screen has no screenful of lead to offer, and that +case is the one the item slack was written for. + +**The lead widens the ask and nothing else.** `historyBoundariesReached` is a +separate function for that reason: the arming latch below disarms a direction on +dispatch and re-arms it when the reader is no longer at the boundary, so serving +the latch from the wider answer leaves a boundary the reader can never be off. +Measured, with the two sharing one predicate: a 43-Turn session loaded one +window of five Turns, and every ask afterwards was refused as `not-rearmed` — +39 refusals over six minutes, the reader scrolling into a wall two Turns from +the top of what was loaded. The two questions look alike and are not: *has the +reader arrived* is about them, *is it worth asking* is about the fetch. + +A pass can therefore re-arm and ask in the same breath, which is the point +rather than an oversight — "off the boundary, and a screen from it" is exactly +the state the lead exists to serve. + +The lead is one screen rather than several because it only has to outlast the +fetch. At a brisk wheel scroll the reader covers a few hundred pixels in the +time a page takes to arrive from local storage; a longer lead just loads history +nobody reaches. + +## Two Refusals Stand Between an Ask and a Page + +`flowChatHistoryBoundary.ts` decides that a boundary is worth asking about. +Whether the ask is honoured is the container's, and it declines twice: + +**While follow-output owns the viewport.** The position the ask was derived from +is then our own placement, not the reader's — as true of a history window being +opened as of the live tail, so the test is ownership and not presentation mode. +Ownership ends the moment the reader scrolls, which is exactly when the ask +starts meaning something. + +**Until the visible range has left that boundary.** Prepend compensation puts +the viewport back on the reader's content, but the virtualizer places its rows +from a scroll offset it refreshes a frame later, so for one commit the visible +range is still read against the head. A direction is disarmed on dispatch and +armed again by the range leaving it — by `historyBoundariesReached`, never by +the wider ask; an ask that resolves to anything other than `applied` re-arms +immediately, because nothing was prepended and the range sitting at the boundary +is still the reader's own position. + +Both were free under react-virtuoso: `firstItemIndex` moved the reported range +with the prepend, so the local start index jumped by the number of items added +and the rule stopped applying by itself. + +**A gesture that moves nothing still asks.** The evaluation used to hang off +the `scroll` event, which is the one signal a reader at the top cannot produce: +the wheel changes no offset, so no event fires, so nothing asks. Combined with +the ownership refusal that is a closed loop, and it was measured as one. A tail +window of three Turns fitted inside the viewport, which put the entire scroll +range inside the reserved blank: + +- at the bottom of that range no row intersects the viewport at all, so + `getVisibleItemRange` returns nothing and there is no position to judge; +- the snap back returns the reader to offset 0 and hands the viewport to + follow-output, so the one evaluation that does land at the head is refused as + our own placement — four times in the log, each three milliseconds after a + `followOutput.enter`; +- and at offset 0 the reader's own gestures produced twenty `user-gesture` + claims over seven seconds with no scroll event, no anchor capture, and not + one evaluation. + +Scrolling up did nothing, permanently. So `notifyUserScrollIntent` evaluates +too, after it has cleared follow-output's ownership — which makes the ask the +reader's rather than our placement's, and gives the top of the range a signal +it can actually emit. + +The empty visible range is traced (`historyPaging.noVisibleRange`) rather than +returned from in silence. Reading that session, the absence of an anchor +capture was the only way to tell "nobody asked" from "the ask was refused". + +**The ask reads ownership as of now, not as of the last render.** A gesture +releases follow-output synchronously and then asks, so a render-time mirror of +`isFollowingOutput` reports the ownership the gesture has just ended. +`isFollowingOutputNow` is the hook's own ref, and the refusal reads it — in the +log, `followOutput.exit` and `historyPaging.refused: +follow-output-owns-the-viewport` sat at the same millisecond, three entries +apart, and the ask was refused for an ownership one line older than itself. + +**`exhausted` describes the window that asked, not the session.** "There is +nothing before this" is a fact about a start ordinal. Navigating to the first +Turn asks `before`, the store answers `reached-start` for `targetOrdinal: -1`, +and that is correct — but only until the window moves. Only `applied` used to +clear the latch, which is the single case where the window moves *because* of +the page; every other way it moves left the old answer standing. Measured: 3 +Turns of 43 loaded, `before` latched from a visit to Turn 1, and after jumping +back to the tail the reader could not page at all. +`warnHistoryPagingRefusedWithPendingTurns` fired +(`latched-exhausted-while-partial`) and nothing acted on it. The latch is now +cleared whenever the window's ordinals change. + +## Keeping the Viewport on the Reader's Content + +When history is prepended, the items arriving above the reader push their content +down by their own height while `scrollTop` stays the number it was. +`VirtualMessageList` adds that height back, in a layout effect, before anything +else observes the new transcript. The amount is read from the virtualizer's +placement of the item that *used to be first* — the height of exactly what +arrived, a delta and not a total. + +This is the half of `firstItemIndex` that keying measurements on item identity +does not supply, and it was left out of the TanStack migration. Everything +downstream assumes it holds, and three separate failures were that one +assumption breaking: + +- The virtualizer re-windows from its own scroll offset, which lags a frame, so + it renders the head. The paging rule reads that as the reader having arrived + at the head, and pages again — **five pages in 890ms on session open, and a + single junction paging a transcript back to its first Turn**. +- The anchored Turn falls outside that window, so the anchor cannot find its + element, drops the anchor, and corrects nothing. Measured: **655px of history + arrived and `scrollTop` held at 23**, leaving the reader at the top of a block + they never asked to see. +- With the reader left at the head, the paging boundary never re-arms and + history becomes unreachable. + +The arriving items are estimates until they measure, so this lands close rather +than exactly; the anchor — which can now find its Turn — takes it the rest of the +way. It writes as `layout-correction`, so the register refuses it while anything +above is moving the viewport on purpose: a navigation reaching a Turn, or the +follow loop re-asserting its target, both of which already say where the reader +belongs. + +## A Displacement Is Not a Movement + +Two things repair displacement rather than choose a position, and neither goes +through the priority order in `FLOWCHAT_VIEWPORT_REGISTER.md`: the prepend +compensation, and the viewport anchor. Both are `viewportOwner.shift`, which +asks a different question — content moving under the reader changes what their +offset *means*, and restoring that meaning is not competing with anyone over +where the viewport should be. So they are refused only by an owner that holds a +target and will re-assert it: follow-output, a navigation still reaching its +Turn, a snap back mid-animation. + +The two are not redundant. The compensation is a pixel delta applied in the +commit that prepends, and it is the only thing that can act when the reader's +Turn is not rendered. The anchor restores a relationship, so it is the only +thing that can act on a re-measurement landing *after* that commit — which is +the majority of the movement, since the arrived items measure over the frames +that follow. Measured: a compensation of exactly the DOM's own growth (949px) +still left the reader at the end of the transcript, because the transcript then +shrank 200px in the next 21ms. + +**The anchor cannot act in the prepend commit itself.** The virtualizer chooses +its rendered window from a scroll offset it only learns from scroll events +(`calculateRange` is memoised on `getScrollOffset()`, and `scrollOffset` is +assigned in the `observeElementOffset` callback and nowhere else), so the commit +that prepends is still windowing the position the reader has just been moved +off. Their Turn is in the DOM a frame later. This is why the anchor keeps an +anchor whose Turn is missing rather than dropping it on the first miss: dropping +threw the reading position away one frame before it could be used, at four +junctions in a row and not one correction between them. + +**A gesture is not one of them, and must not be.** The reader chose a position +in the transcript, not a number of pixels. Ranking these under `user-gesture` +was a total failure rather than an intermittent one, because history pages in +*only* while the reader scrolls up into the boundary — so the gesture was +holding the viewport every single time either of them had work to do. Measured: +2494px of history arrived and the compensation was refused, `scrollTop` held at +40, and the transcript jumped back thirteen Turns; in the same session the +anchor stood down 23 times and corrected nothing at all. + +Correcting during a gesture is safe for the reason the anchor already re-anchors +on every scroll of the reader's: within that window the anchor is tracking them, +so a correction is zero unless something else really moved. Outside it, nothing +changed — the claim had already lapsed. + +**The amount is the smallest of three bounds**, because each of them +over-states and they do so for different reasons: + +| Bound | Over-states when | +|---|---| +| `prependedPx` — height the arrived items measure, read back from the cache | the arrived items are not all rendered, so some of them are still estimates | +| `scrollRangeGrowthPx` — what the scroll range actually gained | the transcript also grew *below* the reader in the same commit | +| `contentEndPx - scrollTop` — what the range can absorb | never; content arriving above the reader cannot push them past the end | + +Overshooting is the expensive direction. It puts the reader below the content +end, where the snap back correctly reads them as parked in the reserved blank +and returns them to the tail — and since paging happens only while scrolling up, +it then does that on every attempt, which is what "scrolling up loops back to +the end" was. Undershooting leaves them looking at slightly earlier content, +which the anchor removes. + +**The cache is measured before it is read.** Left alone it is not close: +measured twice in one session, 2174px reserved against 670px of real growth, +then 2494px against 949px — and the whole nineteen-item transcript came to +1236px once measured. That gap is not a virtualizer being approximate, it is a +guard in the library. `measureElement` resizes an item inline only when the +reader is holding still: + +```js +if ((!this.isScrolling || this.scrollState) && ...) this.resizeItem(...) +``` + +History pages in precisely when they are not. So at a junction the rows are in +the DOM at their real heights, the cache holds the estimates it reserved for +them, and the ResizeObserver that would have reconciled the two does not deliver +until after the layout effects of the commit that added them — one frame later +than the compensation, which has to move the reader in the same paint as the +rows that displaced them. `virtualizer.measureRenderedItems()` does that +reconciliation itself, first thing: the same work, a frame earlier, and free for +any row whose height was already right (`resizeItem` returns on a zero delta). + +`prependCompensated` still records all three numbers, so the remaining gap +between `prependedPx` and `scrollRangeGrowthPx` is a reading rather than an +inference — and now it is a reading of how much of the arrived block was never +rendered, not of how far behind the cache is. + +## The Viewport Anchor Owns Scroll Compensation + +A virtualizer places items in the scroll range before it knows how tall they +are, so every late measurement rewrites the offset of everything below it. +Correcting for that is unavoidable. Doing it with `scrollTop` is not possible: +the same number means a different place after every measurement. The reading +position is therefore recorded as a **Turn and its offset from the viewport +top**, and restored as a relationship rather than replayed as a delta. That +makes the correction idempotent — when nothing moved it is zero. + +**The anchor is the only compensator.** The virtualizer's own adjustment is +turned off (see `FLOWCHAT_VIRTUALIZATION.md`) because it replays a delta against +a scroll position it learns about a frame late. Restoring a relationship has no +base to go stale, which is the whole reason this is the one that stays. + +This was not always true, and what happened when it was not is why the rule is +written down. react-virtuoso corrected by the change in *total* list height, +which assumes the change happened above the viewport — scrolling up into a +freshly paged block guarantees it did not, and one item measuring 38px -> 1003px +moved the viewport 965px, across a whole Turn. Worse, the correction was gated +on scroll direction, and its own prepend compensation set that direction to +`down`, disabling it for exactly the measurements that followed: `scrollHeight` +went 8393 -> 10073 with `scrollTop` held at 1133 and no correction at all, +sliding the transcript down by the full 1680px. Those corrections had to be +intercepted at `scrollBy` and answered by re-anchoring. **Do not reintroduce a +compensator whose amount is a total rather than a delta.** + +**Capture is qualified by intent, not by geometry.** A scroll event cannot say +whether the user moved or the transcript moved under them, so a *new* Turn is +taken as the reading position only within `USER_DRIVEN_SCROLL_WINDOW_MS` of a +wheel, touch, key, or scrollbar press — the same distinction follow-output +draws. Two rules were tried and measured first, and both failed in ways worth +recording: + +- Capturing at the intent event itself records the position *before* the scroll + it causes, which drags a scrolling viewport backwards. +- Gating on "the content height did not change" blocks almost every capture, + because lazy measurement changes it on nearly every frame: 1075 blocked + captures against 8 accepted ones, and a 1037px correction issued against the + user's own gesture. + +**A scroll may not replace an anchor that is owed a repair.** While the anchored +Turn is missing from the rendered window the displacement exists and cannot yet +be measured, and re-reading the anchor from the DOM takes whatever *is* rendered +at its already-displaced position — which files the displacement away as the +reader's own choice. Measured across five history junctions: the anchor was owed +104px, then 140px, then an amount never established, and at three of the five a +scroll 77ms later replaced it before the Turn came back. Two of the three were +never corrected at all. + +Refusing the scroll outright is not the answer either — paging up is something +the reader does *while scrolling*, so the anchor would fight them for the whole +settle. So the anchor is **carried** instead: their travel is a change to +`scrollTop`, and it is subtracted from where the Turn is expected to be, leaving +only the part the transcript moved outstanding. When the Turn renders, the +correction is that part and none of their scrolling — however far they got. + +**Falling outside the intent window is not grounds for ignoring a scroll.** The +window runs from the *input* event while the scrolling it authorises outlives +it: a wheel notch smooth-scrolls for longer than 200ms, and a main thread busy +with streaming delivers one coalesced event carrying the whole travel after the +window has closed. Ignoring it leaves the reader's own movement credited to +nobody, and the next settle undoes it in full. Measured over 181 seconds of +reading: fourteen gestures, **one** capture, and seven corrections between 308px +and 618px that each returned the viewport to exactly where its gesture had +started — the reader could not get anywhere. + +So a scroll has three answers, not two, and the third is the same **carry**: + +| | | +|---|---| +| **Captured** | A recent intent event. The Turn they arrived at is the new reading position. | +| **Carried** | Not provably theirs, and no registered writer owns the viewport. Nothing else changes `scrollTop`, so it is theirs. | +| **Left alone** | A registered writer owns the viewport. It chose that position; the stored relationship is not rewritten on its account. | + +Carrying is safe here for the same reason it is safe anywhere: a displacement +moves the transcript *under* a viewport whose `scrollTop` does not change, so +carrying is a no-op for exactly the case the anchor exists to repair. + +The third row changes nothing the reader can see — a restore carries the anchor +through whatever moved the viewport anyway, by the rule below — and it is kept +because the stored offset then goes on meaning "where the Turn was when the +reader last agreed to it", which is what the trail is read as. + +**A displacement moves the transcript under a `scrollTop` that stays put.** That +is what makes it a displacement, and it is the rule the correction is computed +from: whatever `scrollTop` has changed by since the anchor's offset was agreed +belongs to whoever changed it, and only what is left over is drift to repair. + +The alternative was in place for a long time and is what "scrolling down pulls +me back" turned out to be. The settle loop reads `scrollTop` from the DOM; a +commit opens a window on almost every frame; the scroll event carrying the +reader's travel is delivered after all of that. So a correction routinely runs +against a reading position agreed hundreds of pixels ago, with no capture in +between, and reads their own scrolling as drift. Measured over one reproduction, +ten corrections: **every one of them was the reader's travel**, the largest +putting a 508px scroll back where it started while the transcript had really +moved 7.8px. + +Note what this does *not* weaken. A displacement contributes nothing to +`scrollTop`, so taking the movement out never takes any of the repair with it — +when the reader has not moved, the correction is what it always was. + +**The offset and the viewport position it was agreed at are two halves of one +fact, and every writer moves both.** That is the invariant, and it is the whole +of why the movement is taken *into* the anchor at the top of a restore rather +than subtracted inside the correction. Subtracting it leaves the two halves free +to drift apart: a frame with nothing to correct advanced only the position, and +the reader's travel became a debt the next frame collected. Measured, with the +subtraction in place: a 32px scroll and an 81px scroll each reported back a +frame later as a correction of exactly itself, the anchored Turn provably not +having moved. Worse, the subtraction made that frame the *ordinary* outcome — +with the movement taken out, a frame in which only the reader moved corrects by +exactly zero. + +There is one movement of `scrollTop` that must not be taken into the offset, and +it is the repair itself: the shift puts the Turn back at the stored offset, so +the position advances by the correction and the offset stays. + +The baseline is therefore carried, not re-taken. It used to be re-set to the +current position every time a settle window opened, on the grounds that the +prepend compensation had written from the layout effect just before and that +write is not the reader — which was true of the compensation and false of +everything else the reset swallowed, the reader's own travel first among them. +The compensation now says so itself, through `absorbViewportShift`: it is the +one movement made on the anchor's behalf, so it is the one that must not count +as somebody moving the viewport. + +**The anchor must be a Turn the reader can see, at both edges.** A Turn's marker +is its user message, which is short, so a reader inside an answer taller than +the viewport has no marker on screen — and "the first marker below the top +edge" then answers with the *next* Turn, however far down it is. Measured: an +anchor held at an offset of 1695.5px in a scroller at most 1325.7px tall, at +least 370px past the bottom edge. + +The two directions are not symmetrical, which is why the bottom edge is a bound +and not a preference. Content above the viewport re-measuring moves everything +below it, the on-screen transcript included, so a marker above the fold is a +faithful proxy for what the reader sees. Content *below* the viewport +re-measuring moves nothing they can see — so a marker down there reports +movement that never reached the screen, and correcting to it **creates** a +displacement instead of repairing one. No anchor is the honest answer, and it is +what this already gave once every marker had gone off the top. + +**A navigation replaces the reading position; it does not displace it.** Standing +down for the register is not enough — that postpones the correction for the +length of the hold and no longer. A Turn navigation therefore drops the anchor +outright before it aims, and the settle window opened by the commit that renders +the placement takes the new one. Measured over four clicks on one Turn: the aim +placed the viewport at 287px each time, and each time the anchor put it back +1653px away on the first frame after `ONE_SHOT_NAVIGATION_HOLD_MS` lapsed, still +anchored to the Turn the reader had jumped away from. The re-capture cannot +happen in the aim's own task: the target is commonly outside the rendered window +when the aim is issued, so reading the DOM there anchors to whatever the reader +was moved off. + +**Restoring needs a window, not a callback.** A prepend settles over several +frames — a margin holds the position, the real heights land in padding, then the +margin is released — and *a margin change fires no ResizeObserver at all*, so no +single callback covers it. Every signal that the transcript moved therefore +opens `ANCHOR_SETTLE_FRAMES`, and a frame that had to correct refreshes it — as +does one still waiting for the anchored Turn to be rendered, since "not there +yet" is neither a repair nor a failure. Without that, the settle outlasts the +wait only for as long as `ANCHOR_SETTLE_FRAMES` and +`ANCHOR_MISSING_TURN_ATTEMPTS` happen to be the same number, which is a +coincidence and not a design. Measured before the window existed: four +consecutive painted frames displaced by 896px. + +The observer feeding this had to be repointed. `scrollerRef.firstElementChild` +is a viewport-sized box that stays at the scroller's height no matter how much +transcript there is — it never reported a content change at all, despite a +comment claiming it watched content. The item list is the element that grows, +and `border-box` is required because the virtualizer parks item space in +padding. + +**The keeper does not know there is a virtualizer.** It lives in +`flowChatViewportAnchor.ts` (geometry and the DOM contract for the anchor +element) and `useFlowChatViewportAnchor.ts` (capture, restore, and the settle +window), and it talks to a scroller element and the Turns rendered inside it and +to nothing else. That is what let the virtualizer underneath it be replaced +without the keeper changing at all. + +One consequence of the refresh rule is worth stating plainly: a frame that finds +the anchor already in place still counts as answered, so an open transcript that +no other writer owns holds one animation frame in flight indefinitely. The cost +is a `querySelectorAll` and two rect reads per frame. That is accepted; the +window winds down when there is no anchor to keep, and when another owner holds +the viewport. + +The anchor is skipped entirely while follow-output owns the viewport. Restoring +a pre-prepend position is only meaningful when the user owns it — and a frame +spent standing down is *not* evidence the settle is still running. It looked at +nothing. The loop used to refresh on the missing-Turn count instead, which is a +fact about the last frame that did look, and that count can only advance on a +frame that does not stand down: the same condition jammed the loop and put its +only exit out of reach. Measured at the tail after a jump-to-latest, where +follow-output does not release because resting there is what it is for: 27 +seconds of `anchor.stoodDown`, one per frame, zero travel, ending only when the +reader scrolled. The wait it reported for that — `waitedFrames: 6609`, +`waitedForMs: 28116`, against 20 attempts — was of a reading position that had +been correct the whole time. + +So the outcome of a restore is five-valued inside the loop, not a boolean. +`false` covers a stand-down, a Turn not rendered yet, and no anchor at all, and +the loop has to treat those differently; the public `restoreAnchor` still +answers the only question its other callers ask. + +**What the anchor cannot fix on its own** is a scroll range that was wrong to +begin with. Holding the reading position across a burst of measurement is worth +nothing if the burst blocks the main thread for 295ms. That is a property of how +unmeasured items are reserved, not of the anchor, and it is why the virtualizer +underneath it takes a per-item estimate. + +## The Ask Is Derived From the Transcript, Not From the Window + +**A page asks for what lies past the transcript on screen.** Those are the same +range only until the continuous projection splices a window starting at ordinal +0 with the live tail: the rendered transcript then runs to the newest Turn while +the store's window still ends where it was paged in. `resolveHistoryBoundaryTarget` +therefore takes the *rendered* range, and the store's window stays what the +extension below operates on. + +Deriving the ask from the store's window instead asks to load a Turn that is +already on screen. Measured, in a live session: a window paged in from the tail +ended at ordinal 6 while the session had grown to 10, so the reader was sitting +on the newest output with a `history-window` presentation behind it. Reaching +the bottom asked for ordinal 6 — which the *turn catalog* could not resolve +either, because it still held the six entries it was built with. 266 asks, every +one answered `not-found`, none of them recorded, and a boundary status the +reader was shown as history being prepared for a transcript that was already +complete. + +Three things had to be true for that to reach a reader, and each is fixed where +it belongs: + +- The ask used the wrong range. That is the bug; the rest is how it stayed + visible. +- **`reached-latest` is not `beyond-known-total`.** Asking past the newest Turn + is what the bottom edge of a live transcript answers every time the reader + arrives at it, and it must not raise the missing-history alarm. Asking past + the known total going *backwards* still does. +- **A load that fails records an outcome.** `not-ready` and the superseded + cancel both returned silently, which is why the trail held 266 asks and no + answers at all. The cancel also left the boundary status reading `loading` + forever; the status is ours to clear even when the load was not ours to + finish. + +The status the reader sees is separate again, and is in +`FLOWCHAT_SCROLL_STABILITY.md`'s footer contract only insofar as the sentinel +lives there: **a boundary in `error` must not be labelled as one in +`loading`.** Both ends now pick their label by state. One label for both is why +a permanent failure read as permanent progress, and why cancelling the Turn did +not clear it — nothing about the Turn was ever involved. + +## Reading History Is About the Transcript, Not the Intent + +`viewportMode: 'history-reading'` does two things — it suppresses streaming +follow, and it pins the jump-to-latest bar open and routes it through a +presentation reset. Both are asking one question: **does the transcript on +screen still reach the newest Turn?** + +A turn-navigation viewport intent used to answer that faithfully, because turn +navigation was the only thing that activated a history window. It is not any +more. A session whose loaded tail is shorter than the viewport pages older Turns +in the moment it opens, with nobody navigating, and the first paging step has to +set a turn intent — `isShowingHistoryPresentation` requires one, so without it +the paged-in Turns would not render at all. The viewport sitting on the newest +output was therefore reported as reading history: the bar was visible from the +moment the session opened, clicking it dropped the window and paged it straight +back in, and streaming output was not followed at all. + +`flowChatLiveTailWindow.ts` answers it from the window's own ordinals instead. +These are ledger numbers, not measurements — the rule against inferring intent +from geometry is about ambiguous quantities like `scrollTop`, and does not +apply. The answer also keeps up on its own: a Turn arriving past the end of the +window flips it back with no help, where a flag recorded at activation time +would go stale and leave no way to the live tail. + +`isReadingTurnViewport` keeps its old meaning for the auto-tail placement, which +asks a third question again — who owns the viewport. Merging those two is the +mistake this separates. + +**A tail-anchored window must grow with the session.** It stops at the newest +Turn that existed when it was cut, and nothing moves its end afterwards, so an +appended Turn is simply not rendered. That is worse than it sounds: `latestTurnId` +is read off the rendered items, so follow-output never learns the Turn exists — +no pin, no follow, and nothing to scroll to. `resolveTailWindowGrowth` is +level-triggered for that reason. An edge — "it reached the tail last render and +does not now" — is consumed whether or not the extension succeeded, stranding +the window permanently on one failure; the current state stays `'extend'` until +the window is actually repaired. A window the user navigated to has a different +end, so the session growing says nothing about it and it is left alone. When the +store cannot extend far enough, the fallback drops back to the canonical tail: +that costs a visible re-page of the history above, which is why it is the +fallback, but it is the only branch that always shows the message just sent. + +## A New Turn Is a Fact About the Session + +`latestTurnId` comes from `activeSession.dialogTurns`, never from +`virtualItems.at(-1)`. The projection answers where the presentation currently +*ends*, and a history window re-cut moves that to a Turn which has existed for +hours: measured, navigating to Turn 2 landed correctly and was then overwritten +twice, because each window loaded on the way ended somewhere new and each of +those read as a submission, pinning the window's last Turn to the top. + +**Whether the Turn can be acted on is a second question, and it does not belong +in the identity.** Qualifying `latestTurnId` by "and it is on screen" makes a +Turn that merely came into view look new — the same bug with the opposite sign, +and it is how navigating to Turn 29 ended on Turn 38. + +**An arrival is not a change.** Getting the identity right does not settle how +to *detect* one, and the detector asked whether `latestTurnId` differed from +last render. A rollback truncates `dialogTurns`, which moves that identity +backwards onto a Turn that has been there all along — so undoing a message +pinned the Turn *before* it to the viewport top. `dialogTurnCount` separates the +two: an arrival grows the ledger, and nothing else that rewrites `dialogTurns` +— a history page merging in above, a window re-cut, a hydration — moves the +last Turn at all, so requiring growth costs nothing and excludes every +truncation. + +**A rollback then says where to land, because the ledger cannot.** A shorter +`dialogTurns` is also what a window re-cut and a hydration merge look like, and +two dozen call sites write that array; inferring an action from its size is the +same mistake as inferring intent from `scrollTop`. So the rollback announces +itself through `FLOWCHAT_TURNS_ROLLED_BACK_EVENT`, exactly as a submission does, +and the transcript settles on the new tail — the Turn it was pinning is one of +the ones that stopped existing. + +It takes the viewport whether or not follow owned it, on the same asymmetry that +licenses the snap back. A rollback at Turn N removes N *and everything after +it*, and the reader had N on screen — they clicked its own button. So the new +tail is always within a Turn of where they already are, and there is no history +below them to be pulled out of. Gating it on ownership instead — the first +attempt — made it dead code in the case it was written for: reaching a Turn far +enough up to want it gone means scrolling, and scrolling is what hands the +viewport back to the reader. The viewport anchor then answered instead, and +answered a different question: it holds the reader's Turn at its offset from the +viewport top, so an 8-Turn session rolled back at Turn 7 came to rest showing +Turns 2..6, with the new last Turn's answer below the fold. Nothing was wrong +with the anchor. It was the only thing still running. + +The snap back cannot cover this either, and for two reasons worth keeping: it +runs only from a gesture coming to rest, and a truncation is not a gesture; and +`tailSnapBackScrollTop` returns nothing unless the viewport is *below* the +follow target, where a rollback leaves it above. + +The event fires a frame after the truncation, because the answer is a scroll to +the end of *real content* and that has to be read from a DOM the truncation has +already been committed to. Edit-and-rerun does not announce: its truncation is +followed by a rerun whose Turn really is new, and announcing would spend a +visible movement on the way to it. + +So the response carries it instead. A new Turn is answered by pinning it to the +viewport top; until it is in the transcript on screen there is nothing to align, +and the fallback — the end of real content — is not a stand-in, because it +would leave the Turn unpinned or pull a reader out of a history window. The +answer is therefore **deferred, not dropped**: held in `pendingNewTurnIdRef` and +retried when the transcript next changes, which is exactly when the presentation +is restored to the live tail. + +**Submitting is what gives up a navigated window.** `resolveTailWindowGrowth` +leaves such a window alone as the session grows, and that is right — a Turn +arriving from anywhere else must not take a reader out of the history they are +in. Nothing in the ledger separates that from a Turn the reader sent themselves, +so the composer says so directly: `useMessageSender` announces +`FLOWCHAT_MESSAGE_SUBMITTED_EVENT`, and the container gives up the window only +when the transcript does not already reach the latest Turn. Measured before it +existed: a message sent while parked on the first Turn left the transcript on a +24-item window it was never in, with follow-output holding an answer it had +nothing to align. + +## Diagnosing History Paging + +Older Turns are paged in when the viewport reaches the head of the loaded +window. Every way that handshake can fail is **silent and identical in the UI**: +the boundary status returns to `idle` and no indicator is shown, so "declined to +load" is indistinguishable from "there is no more history". The failure is also +intermittent, so it is traced permanently rather than reproduced on demand. + +`historySessionDiagnostics` keeps a per-session ring buffer shared with the +hydration timeline, and the two log channels carry different things: + +| | `flowchat.log` | `webview.log` | +|---|---|---| +| carries | the full paging step stream | the refusal alarm + its trail | +| enabled by | `app.logging.flow_chat_diagnostics` | always on | +| written via | `flowChatDiagnostics.trace` | `log.warn` | + +The in-memory trail is kept regardless of the flag, so +`warnHistoryPagingRefusedWithPendingTurns` is **self-sufficient** — the recent +events travel with the warning and no one has to reproduce the fault with +diagnostics turned on first. It warns once per session, so scrolling against a +dead boundary cannot flood the log. Turn the flag on only when the trail's +30-event cap is not enough. + +Two detectors raise it: + +- `exhausted` returned for `beyond-known-total`. That result **latches the + direction off for the rest of the session** and only `applied` clears it, so + reaching it on an unknown or contradictory total is how history goes + permanently missing rather than merely late. +- A `before` request blocked by that latch while the session is still + `isPartial`. This fires at the moment the user scrolls up and nothing happens. + +When the report is "scrolling up shows no history, but the Turn Rail can still +load those Turns", search the log for `declined to page older Turns`. Turn Rail +navigation goes through `loadSessionTurnWindow` directly and bypasses the +boundary latch entirely, which is why it keeps working. The accompanying +`FlowChat history paging trail` warning carries the preceding events, including +`anchor_capture_failed` — `captureHistoryPrependAnchor` returning `false` +cancels a window that was already fetched. + +The viewport side of the same junction is traced separately; see *Diagnosing the +Viewport* in `FLOWCHAT_VIEWPORT_REGISTER.md`, and in particular the pair of +numbers that separates "the compensation overshot" from "the transcript +re-measured". + +## Related Files + +- `flowChatHistoryBoundary.ts` +- `flowChatViewportAnchor.ts` +- `useFlowChatViewportAnchor.ts` +- `flowChatLiveTailWindow.ts` +- `VirtualMessageList.tsx` +- `ModernFlowChatContainer.tsx` diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index 4aefe19c57..052f725cea 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -1,788 +1,456 @@ # FlowChat Scroll Stability -This document explains the scroll-stability mechanism used by `VirtualMessageList.tsx`. - -## Rule Zero: Do Not Create Motion For This Mechanism To Chase - -Every rule below is compensation for content that changes size on its own. The -cheapest way to keep the pane stable is to not generate the movement in the -first place. Five invariants hold across the message list, and breaking any of -them reintroduces the "the chat keeps refreshing itself" report: - -1. **Keep a live action's top-level projection identity stable.** A rendered - `ModelRound` remains one `model-round` virtual item, and an explore-only - round remains one `explore-group` virtual item with a stable key. Within a - `ModelRound`, an active collapsible tool is intentionally kept as a critical - item; after it settles it may join the surrounding explore grouping. That - inner grouping transition must not split the round into multiple virtual - items or replace the item/round keys, which would unmount the card and look - like a flash. Likewise, never hide the old location with `display: none` as - a handoff mechanism. -2. **No mount-triggered animation on anything the list renders.** The list is - virtualized: an item that scrolls out of view unmounts and remounts, so a - `fadeIn` / `slideInUp` keyed off mount replays on every pass. Same for an - animation keyed off `--streaming` → `--complete`: it replays when the - typewriter drains. `getModelRoundItemClassName` deliberately has no `--enter` - modifier, and `.user-message-item` deliberately has no enter animation. -3. **Keep wall-clock state out of projection and grouping.** - `sessionToVirtualItems` and `buildModelRoundItemGroups` remain pure - functions of session data. A timer must not reclassify a round, change a - `VirtualItem` key, or create a recently-completed projection. The card layer - does have a bounded completion-preview timer (documented below), but it only - changes local expanded state after the card is already rendered; it does not - restructure the virtual list. -4. **Do not compact a live tail in the same completion commit.** The execution - and file-operation cards use a short completion-preview grace period while - they remain the expanded tail. A newer item still collapses them immediately; - if no newer item arrives, they compact after the grace period. Task, - question, thinking, and explore-group components retain their own - status/last-item policies and are not implicitly covered by this timer. When - an automatic collapse starts, it **may animate** for - `FLOWCHAT_COLLAPSE_DURATION_MS` (300ms) as long as - `flowchat:tool-card-collapse-intent` stays active for that full window plus - settle frames. Instant collapse is reserved for `prefers-reduced-motion` or - an explicit `disableAnimation` opt-out. Height, opacity, and transform must - share one duration (see `flowChatCollapseMotion.ts` / - `SmoothHeightCollapse`). Do not hard-swap `BaseToolCard` ↔ `CompactToolCard` - for expand/collapse — that remounts the body with no height transition. -5. **Keep the leading edge stable across collapse states.** A revealed body - must not add `margin-inline-start`, `padding-inline-start`, or an equivalent - left offset relative to its collapsed header. Expanded thinking, explore - rows, tool details, image previews, and subagent projections all begin on - their owning message/card edge. Vertical and trailing-edge spacing may - remain, but a leading inset reads as a horizontal jump during collapse. - -A sixth, related rule lives in `useTypewriter`: `replayOnMount` defaults to -false, so a still-streaming block that remounts continues from its current text -instead of resetting to an empty string and re-growing. - -Read this before changing any of the following: - -- footer height / footer rendering in `VirtualMessageList.tsx` -- scroll compensation state or refs -- semantic anchor lifetime and one-shot fallback restoration -- `ResizeObserver` / `MutationObserver` / transition listeners -- `flowchat:tool-card-collapse-intent` -- `tool-card-toggle` -- `overflow-anchor` styles in `VirtualMessageList.scss` - -## Problem - -FlowChat uses `react-virtuoso` for virtualization. When the user is already at or near the bottom, collapsing content near the end of the list can shrink total content height. - -Without compensation, the browser clamps `scrollTop` downward immediately because the previous bottom position no longer exists. That causes the visible header/content above to drop. - -If we compensate too late, the user sees a flash: - -1. browser clamps `scrollTop` -2. code restores `scrollTop` -3. header appears to drop and jump back - -If we restore without enough compensation, the final position is still wrong. - -The goal of this mechanism is: - -- keep the visible header/content vertically stable -- allow temporary invisible blank space at the bottom -- avoid the collapse flash - -## High-Level Strategy - -The fix is a two-stage approach: - -1. Pre-compensate before a known collapse starts. -2. Reconcile with the real measured height delta after layout updates. - -This prevents the "drop first, restore later" behavior while still using the actual measured shrink amount to settle on the correct final compensation. - -## Core Building Blocks - -## 1. Bottom Reservations - -The footer uses a unified bottom-reservation model. Each reservation contributes -temporary tail space, but keeps its own semantics: - -- `collapse`: shrink protection for height loss near the bottom -- `pin`: viewport positioning space for "pin turn to top" navigation - -The rendered footer height is the sum of all active reservations. - -A non-zero `collapse` reservation must have an explicit semantic owner: an -active or retained tool collapse, input-stack shrink, preserved-element range, -late-shrink clamp, or protected-range transfer from a pin. Ordinary Virtuoso -measurement convergence has no such owner. An idle measurement with no owner -must only rebase the measured height and must never create Footer space from a -negative `scrollHeight` delta alone. - -Reservation state is ref-owned first and mirrored into React state. A Virtuoso -Footer remount must synchronously read the ref-owned value; otherwise one stale -React commit can remove exactly the reserved scroll range for a frame. - -The Virtuoso Footer does not receive reservation pixels through React context. -Its stable DOM node is updated imperatively, and its ref callback restores the -current ref-owned height on mount. This keeps reservation updates from causing -an additional measurement-sensitive Virtuoso render. - -Important details: - -- the real footer height is `MESSAGE_LIST_FOOTER_HEIGHT + totalBottomReservationPx` -- reservation space is not real content height -- reservations may define a `floorPx` -- a floor prevents unrelated shrink reconciliation from dropping live scroll range -- collapse floors still drain from measured content growth or deliberate downward - user navigation; pin floors drain only through the sticky-pin settlement path -- all measurements that compare old vs new content height must use: - -```ts -effectiveScrollHeight = scroller.scrollHeight - getTotalBottomCompensationPx() +FlowChat reserves a resident tail spacer below the transcript, and pairs it with +a follow target that does not move backwards for free. Together these give a +newly submitted Turn a top-aligned position and keep a tool-card collapse from +dragging earlier content down. + +That is this document. Four siblings carry the rest. + +## Which Document + +| Changing | Read | +|---|---| +| the tail spacer, the follow target, pinning, holding, the snap back, resizing, the footer | this file | +| history paging, the prepend, the viewport anchor, history presentation | `FLOWCHAT_HISTORY_PAGING.md` | +| anything that writes `scrollTop`, one-shot navigation, the diagnostic trail | `FLOWCHAT_VIEWPORT_REGISTER.md` | +| the virtualizer, item measurement, item keys, anything a row renders | `FLOWCHAT_VIRTUALIZATION.md` | +| what to run before claiming it works | `FLOWCHAT_VERIFICATION.md` | + +*Known Gaps* below is the whole list for all five — accepted defects are easier +to keep in one place than to hunt for. + +## The Rule That Matters + +**Static reservation is allowed. Reactive compensation is not.** + +The tail spacer's height is a function of the viewport and the input-stack +inset. It must never be derived from a measured content height, a collapse +delta, an animation duration, or a streaming rate. The moment its height reacts +to content, it stops being a reservation and becomes the compensation engine +that was removed in "remove synthetic tail-space scrolling" — do not rebuild +that under a new name. + +## How Much To Reserve + +The spacer keeps two offsets inside the scroll range, and is the larger of what +they need. Both are bounds, not estimates: reserving more than the larger one is +pure blank at the end of the scroll range, and reserving less than either is a +clamp. + +- **A pinned Turn.** Worst case its user message is the newest item with nothing + answering it yet, so the message, the input inset and the spacer are all that + lie below the message top. `clientHeight - bottomInsetPx - + PINNED_TURN_MIN_ITEM_HEIGHT_PX` is exactly enough to put it on the top edge. +- **A held collapse gap.** `hold-tail` parks up to `tailHoldMaxGapPx` past the + content end, and an offset the browser clamps is one the hold rule does not + actually get to hold. + +`PINNED_TURN_MIN_ITEM_HEIGHT_PX` must stay an **under**estimate of a +user-message item. Too low costs a few spare pixels of blank; too high puts the +pinned offset past the end of the scroll range, and the Turn is clamped back +down from the viewport top while the follow loop rewrites the clamped offset +every frame. + +While the pin reserve is the binding bound, the spacer and the footer sum to a +constant: growing the composer moves the content end without moving the end of +the scroll range. Under the hold-gap floor the spacer stops tracking the inset +and the range grows with the composer, exactly as it did when the spacer was a +flat viewport. + +## Why Both Halves Are Required + +The spacer alone fixes nothing. It only removes the browser's forced `scrollTop` +clamp when content shrinks, which is *permission* to hold position. A follow +target that re-aligns the content end to the viewport bottom every frame will +still drag earlier content down by the collapse delta, spacer or not. + +`flowChatTailFollow.ts` supplies the second half: + +- `pin-turn-top` holds a freshly submitted Turn's user message at the viewport + top while its answer is shorter than one viewport, then hands off at the + crossover. The blank below a pinned Turn is the mode, not a defect. +- `hold-tail` keeps its previous offset when content shrinks, and gives ground + only once the blank below the live output exceeds `tailHoldMaxGapPx` + (a share of the viewport, not a measured delta). + +Both are pure functions over geometry. They hold no timers and observe no +mutation. + +`useFlowChatFollowOutput` is the only continuous outer viewport writer. Three +things about how it runs are load-bearing: + +- **`scheduleFollowToLatest` re-asserts ownership after a layout change but does + not force the content end.** A collapse resizes content too, and the hold rule + is what keeps that from moving the viewport. +- **The pinned Turn's offset is re-resolved from live layout every frame.** + Items above it are estimates until they are measured, so a cached absolute + offset would drift. +- **When streaming stops, `hold-tail` settles any remaining blank with one + smooth scroll.** A pinned Turn does not settle. + +`tailHoldMaxGapPx` is a **streaming allowance**. Blank below the live output is +tolerable only because more output is about to fill it. Do not reuse it to +absorb anything else — applied to a foreign forward move it parks the content +end mid-viewport permanently, since nothing pulls the target back down. + +## Opening a Session + +A session mounts against an unsettled transcript: item heights are still +estimates, and an `isPartial` session pages older Turns in for hundreds of +milliseconds. The end of content can travel thousands of pixels after the first +alignment, so opening is its own phase with its own rules. + +**While opening, the transcript is hidden and the follow target is +authoritative.** It tracks the content end exactly — no remembered offset, no +gap tolerance, and no accommodation of a foreign `scrollTop` write. The +virtualizer writes during this window too, as items measure and it corrects for +the ones above the viewport; fighting it is invisible because nothing is +painted, and accommodating it would be permanent once paging stops. + +Nothing places the opening viewport by aligning to an item. The end of *real +content* is above the resident tail spacer, and no item knows where that is, so +the follow target writes the offset and the reveal waits for it. + +Session open enters follow-output as `session-open`, even with nothing +streaming. The frame loop then runs on a `SETTLE_FRAMES` budget that refreshes +whenever the target actually moves, so it tracks measurement and paging and then +goes quiet. Without it nothing owns the viewport after the one-shot alignment, +and the transcript strands wherever that early shot landed. `scrollToTurnEnd` +deliberately does **not** exit follow-output for the same reason: it is the +session-open placement and wants the same position the settle is converging on, +and releasing ownership there hands the viewport back to nobody. + +**After the reveal, the follow target is cooperative.** The gap tolerance +applies again, because from then on a shrinking content end means a card +collapsed, not that measurement is still catching up. + +The reveal waits for a *semantic* signal — the last virtual item rendered with +its end inside the viewport, plus the viewport in position — not for geometry to +stop changing. Before the virtualizer renders anything, `scrollHeight` and the +end sit unchanged at their unmeasured values, which is indistinguishable from +having finished; a stability test reveals on frame 3 and shows the whole settle. + +## Snapping Back Out of the Reserved Blank + +The spacer is a full viewport the user can scroll into, and under slow streaming +it can take a long time for output to push it away. So a gesture that comes to +rest **below the follow target** returns to that target and hands the viewport +to follow, whether or not follow owned it before. + +Three properties carry the whole design: + +**The target is the follow target, never the content end.** A short new Turn is +pinned above the content end, so snapping to the content end would scroll *up* +and shove the message the user just sent into the middle of the viewport. A +held collapse gap is likewise a legitimate offset up to `tailHoldMaxGapPx` past +the content end; judged against the content end it would read as an overshoot +and fight the hold rule on every collapse. `memorylessFollowState` computes the +target from live geometry with no remembered offset, because the offset the hold +rule was protecting stopped being meaningful the moment the user took over. + +**It acts on rest, never during the gesture.** `scrollend` where available, a +quiet period after the last scroll event where it is not. Correcting inside a +`scroll` handler fights momentum and the virtualizer's own writes; correcting +after the gesture ends fights nothing. + +**Re-entering follow here does not violate "no intent from geometry".** The +region below the follow target is reserved blank — it carries no content, so a +gesture ending there can only mean "take me to the end". Scrolling up to read +history can never satisfy the condition. That asymmetry is the licence; do not +extend it to any position that has content in it. + +The pin's *identity* therefore outlives a user takeover; only its *activity* +stops. Three things retire a pin: the crossover to `hold-tail`, a newer Turn, +and a session change. The crossover has to be one-way — a collapse can pull +content back under one viewport, and re-pinning there would jump the viewport +backwards. Since nothing re-pins a Turn whose identity was dropped, that is +automatic. + +The snap completes on a second settle, and only when the viewport actually +arrived: a gesture that overrode the animation mid-flight belongs to the user +and keeps the viewport. + +**The snap asks whether follow is *correcting* the viewport, not whether it owns +it.** Ownership outlives the frame loop deliberately — streaming has to be able +to resume follow after the settle budget runs out — so the two questions differ. +A live loop gets the viewport to itself, since it reaches its target in one +frame and a snap back would only race it. An asleep one does not: a viewport +left in the reserved blank under a sleeping loop is stranded, and nothing else +was watching for it. This is the half of the scrollbar problem that is fixed +everywhere, including where the drag itself cannot be recognised. + +**Where a jump to latest lands.** Every entry into follow-output resumes at the +end of real content, with one exception: a jump to latest while the **newest** +Turn is still pinned returns to the pin. That mode only holds while the Turn's +answer is shorter than one viewport, so everything it has produced is already on +screen, and aiming at the content end would scroll *up* and shove the message +the user just sent into the middle. It is also the landing place the snap back +picks for the same viewport state — having the two disagree would be worse than +either choice. The exemption therefore outlives the Turn: a short Turn stays +pinned until a newer one replaces it. + +## The Follow Eases Its Write, Never Its Target + +The follow target moves when the transcript reflows, and Markdown reflows a +line at a time. A loop that assigns the target outright therefore spends 24px +on one frame out of seven and nothing on the other six, which is what a reader +reports as the output jumping rather than scrolling. `flowChatTailEase.ts` +spends the same distance over all seven. + +It buys latency, not speed. Under steady growth the eased offset settles where +its per-frame catch-up equals the growth, so the visible step converges on *the +content's growth per frame* whatever the fraction is; smoothing spreads a lumpy +motion evenly across frames it already had. What `TAIL_EASE_ALPHA` actually +sets is how far behind the tail the offset rides, and that lag is what has to be +given back when the stream stops. + +**Only the write is eased.** `followStateRef` still holds the offset the rule +owns, so the settle budget, the at-tail band and the snap back all keep reading +a target rather than a position in transit. An ease that leaked into the target +would make every one of them chase the lag. + +Four boundaries, and none of them is a matter of taste: + +- **Past `TAIL_EASE_SNAP_ABOVE_PX` it jumps.** The first frame of an ease covers + a quarter of the distance, so beyond four lines the ease's *opening step* is + already bigger than the jump it set out to replace. +- **A target above the current offset is never eased.** That is content getting + shorter — a card collapsing, a table reflowing — and easing down through it + reads as the transcript being clawed backwards. +- **Not while the transcript is opening.** There the target is authoritative and + nothing is painted, so an ease is travel nobody can see, holding open the one + phase whose whole point is to end. The reveal is watching for the viewport to + reach the content end. +- **An ease in flight keeps the loop alive.** The settle budget is refreshed by + the *target* travelling, so without this a correction arriving on the last + budgeted frame would be abandoned partway. It terminates on its own: the ease + halves what is left every frame, and a write the register refuses moves + nothing and so books no further frame. + +A step of the list's own scroll offset changes no layout, and the worry that it +would still be charged for — the virtualizer re-windows from the scroll events +it produces, and each one is also an anchor carry and a visible-Turn pass — did +not show up in the measurement. Over 1580 steps and 8435px of following, list +commits held between 15/s and 28/s while the step rate varied from 29/s to +119/s, so the cost per step *falls* as the follow gets busier. The two highest +commit rates in the session, 46/s and 51/s, were windows where the list follow +took no step at all and a thinking card was growing instead. Commits track +content changing height, which is also what moves the follow target, and the ++0.80 correlation between the two is that shared cause rather than a price. + +What the ease actually did, over the same run: no step over a line that was not +a deliberate snap, 99% of steps under 12px, 67% under 4px, and every window's +largest step exactly `TAIL_EASE_ALPHA` of its largest lag — 0.242 to 0.261 +against a nominal 0.25, which is also the evidence that nothing else was +writing the viewport in between. + +"At bottom" is a band, not a point: from the end of real content down to +whatever the follow rule owns. A pinned Turn and a held collapse gap are both +inside it, so neither raises the jump-to-latest affordance; the reserved blank +is outside it, so parking there does. No virtualizer-reported "at bottom" can +express this: the end of the scroll range is the bottom of the reserved blank, +not the end of content. + +The band is recomputed on scroll, on resize, **and when follow ownership +changes** — its lower edge is the follow target, which can move while the +viewport is perfectly still. A snap back completes at rest by construction, +and a jump to latest that lands on a pin the viewport already sits on writes +nothing at all. Driving the band from scroll events alone left the affordance +visible over a viewport that was at the tail, and clicking it then had nothing +to do — an inert button is worse than a missing one. + +**A follow the frame loop is still correcting is inside the band by +definition.** The eased write rides behind the offset it owns, so a burst of +two or three lines would otherwise drop the viewport out of the band for a few +frames and flash the affordance over a transcript that is following the newest +output. Ownership cannot express this: it outlives the loop deliberately, and a +viewport stranded in the reserved blank under a sleeping loop is the case the +snap back exists for. A gesture stops the loop before it can hide anything — +that is what makes reading the loop safe here and reading ownership not. + +## Resizing Anchors the Viewport Bottom + +A plain scroller preserves `scrollTop` across a resize, which anchors the **top** +edge — the bottom is where content gets revealed or swallowed. For a transcript +that is backwards, because the interesting end is the bottom. +`handleViewportResize` anchors there instead. Follow output already behaves this +way for a viewport it owns; this is the same rule for one it does not, so the +same drag stops producing two different results depending on whether the user +had scrolled. + +The two halves are not equally capable, and the difference is the useful part: + +- **A height change moves no content.** Preserving `scrollTop + clientHeight` is + exact and needs no judgement about what the user was doing, so it is applied + unconditionally. It also preserves the distance to the content end, which + makes "was at the end, stays at the end" fall out for free rather than being a + case. Growing the viewport is additionally a *restoration*: the browser used + to clamp a bottom-anchored viewport at `scrollHeight - clientHeight`, and the + resident spacer removed that clamp. +- **A width change reflows the transcript.** Where the line that was on the + bottom edge went is a DOM question, and by the time the resize is observed the + reflow has already happened, so it cannot be answered after the fact. + Answering it would mean sampling an element anchor on the scroll path, which + is a `getBoundingClientRect` per scroll event. Instead only the one position + that can be recomputed from geometry is restored — the end of the transcript — + which needs `wasAtTail`, the band check from *before* the resize. + `VirtualMessageList` mirrors `isAtBottom` into a ref for that, and calls the + handler ahead of recomputing it. + +**One correction is not enough.** A width change reflows every item and a height +change makes the virtualizer render a different number of them; either way it +re-measures over the following passes, so the content end keeps moving after the +first callback. The correction therefore repeats over +`TAIL_REALIGN_RESIZE_CALLBACKS`, a window opened only by a change to the +scroller's own box. Streaming content growth arrives through the same observer +and must never inherit that window — it moves the content end away from a +resting viewport and can never strand it, so reacting to it would be all risk +and no benefit. + +Two properties are shared with the gesture path, and one is not: + +- **Instant, never animated.** A height change moves the viewport by exactly the + height that was added or removed, so nothing appears to move at all; the rest + is a correction the user is already watching happen under the cursor. An + animation would add a scroll nobody asked for. +- **No transfer of ownership** — unlike the gesture path. A gesture ending in + the blank says "take me to the end"; a layout change says nothing. The + browser's clamp never changed who owned the viewport either. + +Native scroll anchoring cannot help here: `overflow-anchor: none` is set +throughout the transcript, because it fights the virtualizer. + +## The Frame Loop Yields to Its Own Animated Scrolls + +`applyFollowTarget` assigns `scrollTop` outright, which cancels an in-flight +smooth scroll on the very next frame. Both `'smooth'` requests in +`useFlowChatFollowOutput` — the jump to latest and the post-streaming settle — +were therefore jumps in practice, so the loop stands down while one travels. + +**What ends the stand-down is the viewport having sat still for +`SMOOTH_SCROLL_STALL_MS`.** That says the animation is over, or was cancelled, +or never started; either way there is nothing left to yield to. Arriving on +target ends it as well, and sooner. + +**Neither half of this may be counted in frames, and both were.** A frame count +is not a duration, and the two failures are the same mistake at opposite ends +of the animation: + +- The budget was 45 frames — 0.75s at 60Hz, 0.52s on a busy 200Hz display — so + what a caller bought depended on the machine. The browser scales a smooth + scroll's duration with its distance, and a jump to latest from the top of a + transcript is the longest thing this issues; measured, one aimed at 8717px + animated 5480 of them and was finished by the loop in a single 3290px write, + 38% short. +- The stall check was then two frames, which is 10ms at 200Hz. A programmatic + smooth scroll *eases in* — measured, 2px in its first 50ms against 9734px to + travel — and with scroll offsets quantised to 0.8px the early frames + genuinely do not move. So the stand-down ended 21ms after it began, having + animated nothing, and the jump to latest lost its animation entirely. + +`SMOOTH_SCROLL_STALL_MS` is therefore derived from the curve's start rather +than from the platform's startup latency, which is the shorter of the two: +visible increments arrive up to ~40ms apart early on, and the constant is that +doubled. `SMOOTH_SCROLL_YIELD_MS` remains as a backstop for an animation that +never ends at all — also a wall-clock fact, and now written as one. + +`followOutput.animatedScrollEnded` says which of the three ended it and how far +the animation actually got. Both bugs above were a stand-down ending early, and +both were invisible in the trail: the loop simply started writing, exactly as +it does when an animation finishes properly. + +The stand-down ends *by falling through to the write*, not by returning. An +animation aims at the offset it was issued for, and content arrives while it +travels, so the frame that reclaims the viewport is also the frame that covers +whatever grew — one catch-up step rather than one wasted frame and then a +bigger one. + +The whole mechanism is *intra-owner* and deliberately outside the register: +this is follow-output yielding to its own animation, and the register +arbitrates between writers rather than inside one. See +`FLOWCHAT_VIEWPORT_REGISTER.md`. + +## Footer Contract + +The footer below the items holds two independent pieces, and they must stay +separate: + +```text +message-list-footer = current input-stack height + bottom inset + clearance +message-list-tail-spacer = tailSpacerPxForViewport(clientHeight, footer) ``` -If you forget to subtract reservation space, future shrink/growth calculations become wrong. - -`pin` reservations use this extra metadata: - -- `targetTurnId`: which user turn the viewport should align to -- `mode: 'transient' | 'sticky-latest'` -- `floorPx`: the minimum tail space needed to keep the pinned target stable - -`sticky-latest` is used for the "latest turn should stay pinned to top" behavior. -Its floor grows when live DOM measurements require more range and drains only -from measured positive content growth. -The pinned item may hand off to tail-follow only after both the complete pin -reservation (`px`, not only `floorPx`) and collapse reservation reach zero. - -## 2. Synchronous Footer DOM Apply - -React state alone is not enough here. - -`applyFooterCompensationNow()` writes footer height directly to the DOM and forces layout reads: - -- `footer.style.height` -- `footer.style.minHeight` -- `footer.offsetHeight` -- `scroller.scrollHeight` - -This is intentional. It ensures the browser uses the new footer height in the same turn, before we restore the anchor. - -If you move compensation back to "React render only", the flash can return because the DOM may still be one frame behind when `scrollTop` is restored. - -## 3. Semantic Anchor Coordinator - -`FlowChatViewportCoordinator` owns the semantic viewport anchor. It tracks one -primary mode at a time: pinned item, following tail, or preserving an element. -Tool cards supply an anchor element but never calculate scroll offsets or -heights. The coordinator records the element's viewport-relative position and -restores it after the list remeasures. While an element anchor is active, the -coordinator also owns virtualizer compensation corrections, so independent -scroll writers cannot fight the pinned header. - -The logical `isFollowingOutput` flag follows the same ownership rule: it is -only true while the coordinator owns `following-tail`. A `sticky-latest` pin -clears the flag and arms its turn for handoff. Once collapse protection and -unsettled pin growth have drained, the handoff re-enters tail follow when -either the pin reservation is empty or the natural content tail (excluding -Footer reservations) reaches the viewport bottom. The latter condition avoids -making real content grow through stale synthetic pin space before follow can -start. This prevents a stale React render from allowing follow effects to -overwrite a pinned header. -The armed turn identity is owned only by `useFlowChatFollowOutput`; the list -must not mirror it in a second ref because session resume and pin preparation -can otherwise update the two identities in different commits. - -Collapse anchors have three phases: active while CSS layout is changing, -retained-provisional while delayed virtualizer measurements may still arrive, -and settled-grace after the provisional estimate has been reconciled to current -DOM geometry. A short negative-layout quiet window ends the provisional phase. -The Footer is then reduced atomically to the minimum physical range needed by -the captured `scrollTop`; an anchor at `scrollTop === 0` needs no overflow range. -The semantic anchor remains retained through one final grace window, so a late -Virtuoso shrink can extend the range and restart settlement without exposing a -clamped frame. User navigation, a new pin, session reset, DOM disconnection, or -a quiet grace with no further correction releases it. Scroll -events enqueue semantic-element restores into the coordinator's single pending -animation frame. A transaction-owned non-user clamp may additionally extend its -physical range and restore the captured raw position synchronously before paint. -Active preservation blocks automatic tail takeover, while retained preservation -allows the tail controller to take ownership when its normal distance and intent -rules say that following should resume. - -There is no persistent raw `scrollTop` lock or scroll-listener lock. An -unsignaled shrink may adjust an already owned protected range, but it may not -create a collapse reservation from idle geometry alone. Without a semantic -owner, the list accepts the new Virtuoso measurement and rebases its height and -scroll baselines. Subsequent layout changes are handled by the semantic anchor -(when present), the owned reservation transaction, or follow mode. - -An element anchor also owns the minimum physical scroll range needed to restore -its offset. After writing `scrollTop`, the coordinator remeasures the actual DOM -offset. If a positive correction remains because the browser clamped at the -bottom, the range host synchronously extends the matching reservation, flushes -layout, and retries in the same frame. The post-write DOM measurement is the -source of truth because integer `scrollHeight` can overstate the browser's -subpixel scroll limit. - -Physical-bottom synchronization must yield whenever the coordinator owns an -element anchor. It also yields while streaming `following-tail` owns the -viewport, because the single tail loop is the writer for content-growth motion. -Outside tail follow, physical-bottom synchronization is limited to a real -viewport `clientHeight` change. A message, round footer, or other content growth -changes `scrollHeight` only and must remain below the existing viewport instead -of moving the transcript upward to reach the new physical bottom. -A sticky pin intentionally sits at the physical bottom created by its -reservation; treating that geometry as tail-follow causes every content growth -measurement to push the pinned header upward before the coordinator can restore -it. Pinned, anchored, and non-streaming paths keep the normal physical-bottom -synchronization behavior. - -Sticky pin floors are not reduced from a transient target rect. Positive -effective content growth first enters a short settlement ledger (currently -300 ms) instead of immediately removing physical bottom range. An unsignaled -negative height correction cancels matching unsettled growth; a known collapse -does not. Growth that reaches the complete remaining pin floor settles -immediately because the sticky viewport has reached its tail-follow handoff -boundary; if a collapse transaction is still active, that settlement resumes -as soon as the transaction finishes. Sub-threshold growth still waits for the -quiet window. Stable growth consumes the pin floor in one synchronous Footer -update. Live pin reconciliation may increase a floor immediately, but cannot -shrink it while Virtuoso item measurements are still moving. Stream end performs -one final pin measurement when the target is available, transfers all remaining -pin range into protected collapse space, and releases `pinned-item` ownership in -the same transaction. A temporarily virtualized target must not block this -release: the existing physical range is retained until a later explicit drain. -Pending pin retries and growth settlement are canceled at that boundary. - -When a sticky target is temporarily virtualized, its provisional range must be -computed from `scrollHeight - currentPinPx`. Reusing physical `scrollHeight` -directly feeds the synthetic footer back into the next retry and grows the range -on every frame. Provisional pins remain at `floorPx: 0`; if the request expires -without capturing an element anchor, that range is removed atomically. - -The pin-owned portion of the footer is capped at one viewport. A rendered -target can never require more than `clientHeight` of extra range to align its -top inside the viewport, and one viewport is also sufficient to materialize a -virtualized target. This cap applies to provisional and established pin ranges. -It does not apply to collapse compensation or the total footer: a large card or -several cumulative collapses can legitimately require more than one viewport to -preserve the current semantic anchor. - -Pending pin retries carry a synchronous generation plus the owning session and -turn. Canceling or replacing a request increments the generation before React -state is updated, so already-queued animation frames cannot restore a canceled -reservation. User navigation drops a provisional sticky range instead of -transferring it into protected collapse. Established pins keep the existing -protected-range handoff. - -Arbitrary-turn navigation is a materialize-then-align transaction. Starting a -new request exits tail follow, but it does not remove the previous established -pin reservation before the target DOM exists. That reservation remains only as -physical scroll range; the active request prevents the old sticky target from -reconciling it. Once the requested user message is rendered, the shared pin -resolver applies the request's alignment policy. Exact requests replace the old -reservation, align the message to the 57px viewport offset, and start bounded -transient stabilization. Turn-rail requests use best-effort alignment: they -still align exactly when the natural range is sufficient, but when the target -cannot reach the 57px offset without synthetic tail space, they remove the -transient pin reservation, clamp to the natural maximum, and release -`pinned-item` ownership immediately. The natural boundary is an expected -content limit, not a pending transaction, so it must not retry until TTL expiry. -`sticky-latest` always uses the exact policy because streaming follow-output -depends on its protected pin range. An expired request releases semantic -ownership while preserving the current physical range, so failure cannot -silently clamp the pane to the bottom. - -`rangeChanged` is a target-materialization signal, not a source of turn -identity. It retries the active generation against real DOM geometry. RAF -retries remain as a bounded fallback for browsers that coalesce range updates. -Transient navigation remains pending until the requested turn stays aligned for -two consecutive geometry samples. During that bounded transaction, Virtuoso's -materialization range expands to two viewport heights in both directions so -height-estimate reconciliation cannot immediately evict the target. If the -pinned DOM element still disconnects, the coordinator drops the stale element -anchor but retains logical `pinned-item` ownership while the active generation -rematerializes it. User intent, replacement, expiry, and explicit handoff still -release that ownership. -Virtuoso mounts on the first initial-history commit. A target prepared before -its ref is available becomes `initialTopMostItemIndex`; targets selected after -mount enter the normal immediate materialize-then-align transaction. The -left-side -`FlowChatTurnRail` is mounted outside the scroller and delegates navigation to -the same container-owned turn-pin request, so it does not need to rebind across -renderer handoffs or write the FlowChat viewport directly. - -Mounting an already-streaming session is not a new-turn event. Session entry -resumes tail follow directly, while sticky pinning remains reserved for a new -turn that appears in the currently mounted session. - -## 4. Collapse Intent - -Some collapses are predictable before layout actually shrinks. - -`flowchat:tool-card-collapse-intent` is emitted before a known collapsible UI -shrinks. `VirtualMessageList` uses that event to: - -- capture the card root as the semantic header anchor -- capture the pre-collapse anchor `scrollTop` -- capture the bottom distance before collapse -- estimate required compensation from current card height -- apply provisional compensation immediately - -This pre-compensation is what avoids the flash. - -Runtime status is transient session UI state, not a `FlowItem`. The always-mounted -`RuntimeStatusSlot` occupies the first 24px of the existing Footer spacer and -switches only `visibility`; showing, hiding, and clearing it never change list -height or enter collapse reconciliation. Subagent projections use the same -fixed-height slot inside their local scroll surface. - -If the list waits until `ResizeObserver` sees the shrink, the browser may already have clamped `scrollTop`. - -### Completion-preview grace period - -`useToolCardCompletionGracePeriod.ts` provides the bounded tail-preview window -used by `ExecProcessToolCardView`, `TerminalToolCard`, and -`FileOperationToolCard`. Its default is -`TOOL_CARD_COMPLETION_PREVIEW_GRACE_MS = 800`. - -The timer starts only when a card that was expanded during execution is still -the last rendered item and has not been manually toggled. A newer item, user -interaction, unmount, or loss of tail ownership cancels the pending preview. -For ExecProcess/Terminal cards this covers terminal completion, cancellation, -errors, and rejections. For successful Write/Edit cards, the timer starts after -the typewriter reveal finishes so the completed content is not truncated. The -timer does not change `isLastItem`; an empty next round can still leave the -previous card as the rendered tail, but the grace period bounds that wait. The -timer expiry calls the existing height-contract collapse path, so footer -pre-compensation and semantic-anchor handling remain the same as for a -successor-driven collapse. - -This is deliberately separate from the VirtualMessageList collapse-intent TTL -and settlement timers: the former controls when a card may compact, while the -latter protects the viewport while its height changes. - -## Runtime Flow - -## A. Known Tool Card Collapse - -When a helper-backed card or region is about to collapse: - -1. it dispatches `flowchat:tool-card-collapse-intent` with its anchor element before the collapse state is applied -2. `VirtualMessageList` estimates the upcoming shrink using `cardHeight` -3. `VirtualMessageList` adds provisional footer compensation immediately -4. `VirtualMessageList` applies the provisional footer synchronously and records - the semantic anchor's viewport offset -5. actual layout shrink happens -6. `ResizeObserver` / `MutationObserver` / transition listeners trigger `measureHeightChange()` -7. measured shrink reconciles the compensation to the real final value -8. the coordinator restores the anchor element's exact viewport-relative position - -Common examples: - -- `FileOperationToolCard` -- `ModelThinkingDisplay` -- `TerminalToolCard` -- `ExploreGroupRenderer` - -## B. Unknown or Unsignaled Shrink - -If a shrink happens without a collapse intent: - -1. `measureHeightChange()` detects the negative height delta -2. compensation falls back to `shrinkAmount - distanceFromBottom` -3. `restoreScrollPositionOnce()` makes one clamped fallback restore using the - previously known scroll position - -This path is safer than doing nothing, but it is more likely to show visible movement than the pre-compensation path. - -## C. Initial-History Snapshot Handoff - -Virtuoso is the only initial-history scroller and mounts on the first commit. -For sessions that still need the initial history render budget, a bounded recent -projection is rendered above it as a non-interactive snapshot. The snapshot: - -- has no scroll container, spacers, pagination handlers, or viewport writer -- uses `pointer-events: none` and cannot consume wheel, touch, keyboard, or - scrollbar intent -- keeps the previous pixels visible while Virtuoso measures its initial range -- releases immediately when the user starts scrolling so the real Virtuoso - motion is never hidden behind a frozen frame -- retargets its release condition when Turn navigation begins during handoff -- disappears only after the requested Turn has visible text, the session - changes, or the bounded handoff timeout expires - -All Turn navigation, search materialization, boundary pagination, bottom state, -and follow-output transitions run through the mounted Virtuoso instance even -while the snapshot is visible. A catalog-backed partial session still keeps -only its restored tail as the default data presentation; this rendering change -does not imply full-history hydration. - -## D. Arbitrary Turn Navigation Through Virtuoso - -The left-side turn rail delegates to the container-owned top-aligned pin -transaction: - -1. record generation, session, target turn, behavior, and pin mode -2. exit tail follow without removing established physical range -3. if the target is absent, issue an immediate `scrollToIndex(..., align: - 'start')` -4. retry from `rangeChanged` and bounded RAF work until the target user message - exists -5. replace the prior pin reservation with the target's measured reservation -6. align the target to the shared 57px header offset and stabilize delayed - Virtuoso measurements -7. cancel stale work on a newer request, user intent, session switch, jump to - latest, or timeout - -Every turn-rail marker, including the canonical latest Turn, uses this same -immediate transient top-pin transaction. Selecting the latest marker means -"show this Turn header"; it does not restore the tail presentation or resume -follow-output. Only the explicit jump-to-latest action restores the canonical -tail presentation and re-enters live-tail following. This separation keeps -turn navigation consistent and treats every rail selection as user reading -intent, including while the latest Turn is streaming. - -Do not clear the previous pin/footer range in step 2. The target may be outside -the current Virtuoso range, and removing the footer first lets the browser clamp -the old position to the physical bottom before materialization succeeds. - -The turn rail is an independent overlay surface. Its height is bounded to 60% -of the FlowChat content area; overflow scrolls only the rail, and keeping the -current marker visible may update only the rail list's `scrollTop`. Rail wheel, -keyboard, hover, and tooltip behavior must never become another writer for the -outer FlowChat viewport. - -The existing visible-turn DOM measurement also collects every distinct turn -whose rendered items intersect the readable viewport. The first intersecting -turn remains the semantic current turn, while every intersecting turn marker -uses the same rail emphasis. Publish a new ordered `visibleTurnIds` snapshot -only when membership or order changes so ordinary scroll frames do not cause -redundant rail renders. - -### Catalog-backed history loading - -Catalog, loaded Turn cache, and active presentation are separate layers. Keep -these ownership rules intact: - -- `Session.dialogTurns` remains the live restored tail unless an explicit - full-history consumer calls `ensureSessionFullHistory`. -- Data residency, viewport intent, and follow-output ownership are independent. - A cached history presentation may remain resident after the viewport returns - to the live tail, but it must not keep the UI in history-reading mode, - suppress live-tail anchoring, or imply that follow-output is active. -- For a small session whose cached presentation is contiguous from ordinal zero - through the current total (`[0, totalTurnCount)`) and stays within the - continuous projection budgets (24 Turns and 200 virtual items), explicit - jump-to-latest changes only the viewport intent and follow-output ownership. - The rendered projection and its stable virtual-item keys remain unchanged; - `historyWindow` is disabled so boundary loading cannot start while following - the tail. Canonical overlapping Turns are still overlaid by stable id, and a - newly appended canonical Turn extends the projection at the end. -- Incomplete, discontinuous, or over-budget presentations retain the fallback - behavior: explicit jump-to-latest clears the Store's `activeRange`, restores - the canonical tail data source, and keeps the most recent component - presentation only as a reactivation hint. The Store LRU remains authoritative: - reactivation must find the complete range in `loadedRanges`, touch it as MRU, - and otherwise fall back to the ordinary window-load transaction. -- Turn-rail navigation and sequential boundary loading use - `load_session_turn_window`; neither path writes the FlowChat scroller. -- Upward user intent at the restored-tail boundary loads the adjacent ordinal - window without holding viewport ownership. Presentation activation then waits - for a bounded 320 ms quiet window after the latest wheel, touch, keyboard, or - scrollbar intent. New input resets that wait; session changes and newer - presentation-owner generations cancel it. Only after the quiet window is - acquired does the list capture the current element anchor and change to one - contiguous history-window presentation. This keeps a multi-thousand-pixel - prepend commit out of an active wheel gesture while still allowing the data - request itself to prefetch in parallel. Never expose a later cached range - across an unloaded gap. -- Derive the restored-tail boundary from the canonical `Session.dialogTurns` - ordinal interval, never from the start of a merged `loadedRanges` entry. - Cache residency may extend to the first Turn while the canonical tail still - renders only recent Turns. Reaching ordinal zero is an exhausted boundary, - not a not-ready or failed load. -- Appending below the current presentation does not require compensation. - Prepending or trimming above it must retain the existing element-anchor - transaction until the same user message returns to its captured viewport - offset. -- A rejected or failed adjacent-window request must release only the element - anchor lease created during its commit preparation, if any. A stale - completion must never release a newer navigation or layout-preservation - transaction. -- The non-tail loaded Turn cache uses a 48-Turn soft budget and a 64-Turn hard - budget. Crossing the hard budget evicts least-recently-used ordinals back - toward the soft budget. The live tail, active presentation, pending target, - and in-flight request intervals are protected; merged cached ranges may be - sliced, but the active presentation is never trimmed by cache eviction. -- Passive live-tail updates outside the presented history range remain hidden - while the user reads history. When the history range overlaps canonical live - Turns, stable Turn ids select the canonical objects instead of cached - snapshots so streaming or recently completed content stays current without - changing the viewport intent. An explicit `send-message` Turn-pin request - first restores the tail presentation, then lets the existing sticky-latest - pin materialize the newly submitted Turn. -- Cross-feature focus requests identify a Turn by stable `turnId` whenever one - is available, with `turnIndex` reserved for the absolute one-based visible - ordinal. They delegate to the same catalog/window materialization transaction - as the Turn rail. Never pass that absolute ordinal to `scrollToTurn` on a - partial tail or bounded history presentation; that method only understands - the currently rendered local list. -- Search, edit, rollback, and compatibility fallback are explicit full-history - consumers. Their shared ensure operation deduplicates an existing request and - applies the completed projection only after the caller asks for it. -- A Host without `turnCatalog`, or without `load_session_turn_window`, retains - the legacy full-restore fallback. This compatibility path must not cause a - catalog-capable Host to resume unconditional background hydration. - -## Why Transition Tracking Exists - -User-initiated expand/collapse still uses animated layout properties such as: - -- `grid-template-rows` -- `height` -- `max-height` - -Automatic and manual collapses both animate through the shared motion contract -unless animation is explicitly disabled. - -During those transitions, the DOM may report intermediate sizes for multiple frames. - -The collapse intent carries a hard TTL (`expiresAtMs`, currently 1000 ms), but -that TTL only bounds collapse measurement and reservation settlement; it does -not expire the semantic element anchor. Automatic collapses are -finalized after `FLOWCHAT_COLLAPSE_DURATION_MS` plus a short settle-frame window; -manual or otherwise unsignaled intents use the TTL timer. The scroll handler keeps only a throttled-background -timer fallback for browsers that delay timers. While the intent is alive, the -grow branch of `measureHeightChange` protects the collapse reservation, but it -may still consume measured content growth from the sticky pin reservation. -Intent settlement follows the current semantic viewport owner. A sticky pinned -turn always reconciles provisional collapse space back into a freshly measured -pin reservation, even when the active transaction established a non-zero -collapse floor. That floor protects the pin only while layout is moving; it -must not cause the full-card estimate to survive into the next collapse. If the -pinned target is temporarily unavailable, settlement retries without dropping -the current range. A following tail instead enters retained-provisional quiet -settlement, while a collapsing header that owns `preserving-element` reduces -the footer atomically to the minimum range needed by its captured `scrollTop`. -A detached protected viewport uses the same geometric settlement against its -current `scrollTop`, without retaining provisional pixels above that range. -These owner-specific transactions prevent both clear-and-reacquire frames and -cumulative provisional whitespace. Any deferred follow is then replayed. - -## E. Follow-Output Mode (continuous tail) - -When the viewport is in follow-output mode and the latest turn is still -streaming, the user's intent is "keep the tail visible". After the viewport -coordinator has entered `following-tail`, one RAF loop eases `scrollTop` toward -the effective bottom. Follow events only wake this loop; they do not launch -additional scroll writers. The target subtracts the current Footer -reservation, and large gaps snap directly to the target instead of leaving the -user visibly behind the output. - -The loop is dormant while `pinned-item`, `preserving-element`, or a collapse -transaction owns the viewport. It never clears reservations or calls -`followTail()` from inside the animation frame. This keeps the semantic -handoff and Virtuoso compensation paths authoritative while allowing small -line-height growth to move over several frames. Explicit "jump to latest" -navigation keeps its native smooth scroll; the RAF loop waits for that motion -to settle before writing. - -Collapses interact with follow mode in three mutually exclusive ways: - -1. **Known collapse while follow + streaming is active:** the intent applies - synchronous Footer pre-compensation before the card shrinks. The active - intent allows shrink reconciliation even though tail follow is running. - When the CSS window ends, the transaction becomes a retained-provisional - collapse anchor instead of shrinking the Footer from a signed net-height - estimate. Virtuoso - can publish the matching item measurement after the CSS transition and after - stream end; reducing synthetic range before that measurement clamps the - viewport by exactly the removed pixels. - The retained transaction records the latest safe follow position. After a - negative-layout quiet window, it replaces both the provisional `px` and stale - `floorPx` with the minimum geometrically required Footer range. The final - release uses one timer plus a geometry generation: any effective height - change invalidates that timer's snapshot, and the timer performs one more - quiet check instead of every token allocating new timer work. If a later - measurement clamps below that position, the scroll handler synchronously - extends the range and restores it before paint. Real content growth and - downward follow movement consume the range one-for-one. User intent, a new - pin, session reset, or a final quiet grace releases the retained anchor. - Stream end restarts the same settlement path; - it does not preserve the provisional full-card estimate indefinitely. -2. **Unsignaled shrink while follow + streaming is active:** a strict physical - clamp signature (the previous and current geometries are both at their - physical bottoms, the range shrink matches the negative `scrollTop` delta, - viewport height is stable, and there is no user intent) starts a - `late-shrink` viewport transaction. The scroll handler extends the Footer and - restores the pre-clamp position synchronously, covering virtualizer size - commits that arrive after the originating collapse transaction was released. - Other unsignaled shrinks remain owned by the tail loop; it follows only - downward toward the new effective bottom on the next frame. - A negative `scrollBy` issued by Virtuoso after a virtualized height - reduction is also suppressed when the previous geometry was already at the - physical bottom. That compensation would move the viewport away from the - tail; the next follow frame owns the single tail correction instead. -3. **Not following (user reading older content):** the intent + - pre-compensation + semantic-anchor path applies as described above, and - `shouldSuspendAutoFollow` keeps event-driven follow scheduling - deferred until the intent's TTL lapses. - -The loop is cancelled as soon as follow exits (user upward scroll, -session change, or streaming end). Explicit "jump to latest" navigation pauses -the writer while its native smooth scroll completes, then resumes the same -single tail loop. - -## Why `overflow-anchor: none` Must Stay - -`VirtualMessageList.scss` disables native browser scroll anchoring on: - -- `[data-virtuoso-scroller]` -- `.message-list-footer` - -This is required because the browser's built-in anchoring fights the manual compensation logic. - -If you remove `overflow-anchor: none`, the browser may apply its own anchor correction on top of our compensation and produce unstable or inconsistent results. - -## Required Event Contract - -`tool-card-toggle` - -- dispatch after a generic expand/collapse action that changes height -- purpose: schedule a follow-up measurement - -`flowchat:tool-card-collapse-intent` - -- dispatch before a collapse that can reduce list height near the bottom -- include the card root as `anchorElement`; its top edge represents the stable header position -- include `cardHeight` when possible -- purpose: pre-compensate before the browser clamps scroll position - -Current producer: - -- `useToolCardHeightContract.ts` (used by most tool cards, including - `ExecProcessToolCardView`, `FileOperationToolCard`, and `TerminalToolCard`) -- `ModelThinkingDisplay.tsx` -- `ExploreGroupRenderer.tsx` - -Most tool cards now emit these events through `useToolCardHeightContract`. -The helper measures the visible `cardRootRef` and retains recent visible -measurements so state-driven collapses still report the pre-collapse height. -Never substitute an inner scroll container's `scrollHeight`; hidden overflow is -not layout height removed from the FlowChat list. - -If a future collapsible component shows the same "header drops" or "flash on collapse" symptom, it should likely emit `flowchat:tool-card-collapse-intent` before collapsing. - -## Invariants To Preserve - -- Footer compensation must remain additive temporary space, not real content. -- Effective height comparisons must subtract current compensation. -- Footer DOM compensation must be applied synchronously before anchor restore. -- Anchor restore must clamp against current `maxScrollTop`. -- A stalled positive anchor correction must extend physical bottom range and - retry before paint. -- Resize and height observers must not synchronize to the physical bottom while - a semantic element anchor owns the viewport. -- Sticky pin floors must shrink from measured content growth, not a transient - target-element position. -- A user gesture that exits pinned mode must release the semantic anchor and - atomically transfer the pin reservation to a protected collapse range in the - same operation; an idle coordinator must never retain a live pin reservation. -- Stream end or cancellation must perform that same protected-range transfer - before releasing `pinned-item`, even when the pinned DOM target is unavailable. -- Scroll-handler anchor corrections must be coalesced through the coordinator's - animation-frame restore queue; they must not write `scrollTop` synchronously. -- A retained collapse transaction may synchronously restore only a non-user - downward clamp. It is transaction-scoped, advances with downward tail follow, - and must release on user intent; it is not a general `scrollTop` lock. -- Following-tail collapse finalization must never reduce Footer range directly - from a signed net-height estimate. It may reduce the retained estimate only - after the negative-layout quiet window, using current DOM geometry while the - anchor remains protected through the final grace. -- Unsignaled shrink reconciliation must not reduce a protected collapse floor; - only measured growth, downward navigation, bottom arrival, or an explicit - reservation reset may consume it. -- Unsignaled shrink reconciliation must not create a collapse reservation when - no collapse transaction owns the range. Session-open and history-projection - handoffs rebase their measurements instead of treating estimate convergence - as content collapse. -- Pre-collapse intent must capture the anchor before the component shrinks. -- Compensation must not be consumed too early during active layout transitions. -- Session changes and empty-list resets must clear compensation and anchor state. - -## Common Ways To Break This - -- Adding a mount-triggered CSS animation to a virtualized list item, or animating - an automatic collapse without keeping collapse-intent protection alive for the - full `FLOWCHAT_COLLAPSE_DURATION_MS` window (see Rule Zero). -- Feeding `Date.now()` back into `sessionToVirtualItems` / - `buildModelRoundItemGroups`, or splitting one `ModelRound` into several - `model-round` virtual items — both swap stable Virtuoso keys for new ones and - remount visible content. -- Replacing `applyFooterCompensationNow()` with state-only rendering. -- Measuring raw `scrollHeight` deltas without subtracting existing compensation. -- Removing `flowchat:tool-card-collapse-intent` from a helper-backed collapsible component. -- Finalizing an active collapse intent when a new one arrives mid-burst instead of - coalescing TTL / provisional shrink (drops footer protection for a frame). -- Dispatching collapse intent after `setState` instead of before it. -- Removing `overflow-anchor: none`. -- Removing the intent TTL, settle-frame finalizer, or the throttled scroll - fallback that covers delayed background timers. -- Reintroducing a persistent scroll-listener lock or allowing multiple competing - scroll writers. Semantic anchors and the bounded fallback must remain separate. -- Passing reservation pixels through Virtuoso context or React-owned Footer - styles. The stable Footer DOM and ref-owned reservation are the hot path. -- Restoring the blanket follow-mode early return in - `handleToolCardCollapseIntent` or applying it to an active known intent in - `measureHeightChange`. Known streaming collapses require synchronous range - reservation; only unsignaled shrinks are delegated entirely to the RAF loop. -- Removing the `shouldSuspendAutoFollow` gate from event-driven follow - scheduling. Outside follow mode it keeps deferred follows from firing while a - collapse intent is still protecting the anchor. -- Removing the continuous RAF follow loop. Event-driven follow alone cannot - keep up with dense token streams without visible jitter outside collapse - windows. - -## If You Need To Change This Logic - -### Opt-in viewport diagnostics - -Enable `app.logging.flow_chat_diagnostics` from the logging settings only while -reproducing a viewport stability issue. The frontend records bounded JSONL -batches to `flowchat.log` in the current session log directory. When disabled, -probe payloads are not evaluated and no timer, IPC request, or file is created. - -The diagnostic schema groups events by hypothesis: - -- `A`: user scroll intent, pin release, reservation transfer, and tail handoff -- `B`: semantic anchor capture, correction, release, or unexpected reacquisition -- `C`: content measurement, Footer compensation, and physical range changes -- `D`: Virtuoso scroll compensation and tail-follow ownership -- `E`: streaming tool-card collapse intent and anchor preservation - -Do not add message content, tool arguments, file contents, or other sensitive -payloads to this channel. Keep all data producers lazy and guard hot-path probes -with `flowChatDiagnostics.isEnabled()` before allocating probe objects. - -Use this checklist: - -1. Verify a just-completed ExecCommand/Write tail keeps its preview during the - short grace period, then compacts if no follow-on item arrives. -2. Verify manual collapse of a completed `Write` / `Edit` tool card. -3. Verify a newer item still causes immediate automatic compaction before the - grace period expires. -4. Verify repeated expand/collapse near the bottom. -5. Verify thinking / explore / other collapsible sections still schedule measurements correctly. -6. Verify there is no visible "drop then snap back" flash. -7. Verify the final header position remains stable after collapse. +The footer must not retain an earlier input height or include an estimated card +shrink. The spacer reads the footer to size itself, but the two must not be +folded into one number: the footer is content the transcript clears, the spacer +is range past the end of content, and only the footer is inside the content end. + +Footer height represents only the current input-stack layout and real footer +content such as history state and `RuntimeStatusSlot`. + +## Known Gaps + +- The eased follow raises the scroll-event rate from one a line to one a frame + while output streams — 1247 of them in one 120-second session, each an anchor + stand-down and a visible-Turn pass. It does not show in list commits, and + nothing here counts the DOM reads themselves, so what is actually known is + that the rendering cost did not move. Re-measure with the `tailFollow` probe + before changing `TAIL_EASE_ALPHA`. +- Easing is bounded by the frames the display gives it. The step it converges + on is the content's growth *per frame*, so the same stream smooths less at + 60Hz than on the ~200Hz display these numbers come from, and a fast enough + stream is a line a frame anywhere — at which point the ease is spending its + lag and buying nothing. +- `SMOOTH_SCROLL_YIELD_MS` is only a backstop now, but it is still a guess: an + animation that stalls mid-flight without ever resuming holds the follow off + for its whole duration. Nothing observed has done that — the stall check ends + every real animation long before it — and the cost if one did is the follow + resuming late, not the viewport landing wrong. +- A scrollbar drag is recognised from the gutter the bar occupies, so it is + invisible where the platform draws overlay scrollbars that take no layout + width — WebKit-backed builds, where `scrollbar-gutter: stable` reserves + nothing either. There the drag still fights the frame loop while output + streams; it no longer strands the viewport, because the snap back now asks + whether follow is correcting rather than whether it owns. Closing the rest + means either a signal that does not depend on the bar having a box, or a + scrollbar of our own — which would also stop the thumb from reaching the + reserved blank at all, and take the empty-range gap below with it. +- A collapse larger than `tailHoldMaxGapPx` still moves the viewport, by the + excess only. +- An animated scroll aims at the target it was issued for. Jumping to latest + while output is arriving therefore ends with one catch-up step covering + whatever content grew during the animation — under the ease's snap threshold + at ordinary streaming rates, and a visible jump above them. +- A width change anchors the viewport bottom only for a viewport that was at the + end of the transcript. Everywhere else the reflow moves content out from under + the bottom edge and nothing puts it back, because the anchor would have to be + captured before the reflow. Closing this means sampling an element anchor on + the scroll path. +- On a very short transcript the scrollbar exposes a viewport of empty range. + The snap back makes this more visible, not less: the range is draggable and + bounces back. +- The opening reveal has a hard frame cap. A session that pages for longer than + the cap is revealed mid-settle; raising the cap trades that against a longer + blank on open. +- **Estimates are still estimates.** A page of history is now reserved per item + rather than at one scalar, so the range it takes up is close instead of wrong + by an order of magnitude — but `estimateVirtualMessageItemHeight` cannot know + how a model round wraps. Corrections shrink; they do not reach zero. And the + cost of rendering a heavy item is a separate axis: less measurement is forced + at once, but the work each one costs is unchanged. See + `FLOWCHAT_VIRTUALIZATION.md`. +- A junction still costs one frame at the first page of a session, measured at + 93px: the commit paints before the settle frame that would correct it. Slower + frames swallow both and show nothing. Closing it means a tighter estimator, + not a further correction — the correction already equals the change in the + scroll range every time it runs. ## Related Files -- `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx` -- `src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts` -- `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss` -- `src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts` -- `src/web-ui/src/flow_chat/tool-cards/useToolCardCompletionGracePeriod.ts` -- `src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx` -- `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` -- `src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx` -- `src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx` -- `src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx` +- `flowChatTailFollow.ts` +- `useFlowChatFollowOutput.ts` +- `../../utils/flowChatScrollLayout.ts` +- `../../tool-cards/useToolCardHeightContract.ts` +- `VirtualMessageList.tsx` +- `ModernFlowChatContainer.tsx` diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md new file mode 100644 index 0000000000..68d63fc03b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -0,0 +1,212 @@ +# FlowChat Viewport Verification + +The automated checks, and the manual ones agents may not run. + +## Automated + +```text +pnpm run type-check:web +pnpm --dir src/web-ui run lint +pnpm --dir src/web-ui run test:run +``` + +Pick by what you changed rather than running the whole column — but this is the +whole column, and it is the only list. Two divergent copies of it used to exist, +each missing what the other had. + +| Test | Contract it holds | +|---|---| +| `flowChatTailFollow.test.ts` | the follow target, `pin-turn-top`, `hold-tail` | +| `flowChatCollapseMotion.test.ts` | collapse does not move earlier content | +| `useFlowChatFollowOutput.test.tsx` | the frame loop, snap back, resize realign | +| `../../tool-cards/useToolCardHeightContract.test.tsx` | tool cards reflow rather than compensate | +| `flowChatHistoryBoundary.test.ts` | the screenful lead, and the latch's own predicate | +| `flowChatLiveTailWindow.test.ts` | "does the transcript still reach the newest Turn" | +| `flowChatViewportAnchor.test.ts` | anchor geometry and the DOM contract | +| `useFlowChatViewportAnchor.test.tsx` | capture, restore, carry, the settle window | +| `VirtualMessageList.session-boundary.test.tsx` | prepend compensation and the ask | +| `ModernFlowChatContainer.history-state.test.tsx` | history presentation and the submission event | +| `flowChatViewportOwnership.test.ts` | the priority order, preemption, expiry | +| `../../../infrastructure/diagnostics/flowChatViewportDiagnostics.test.ts` | coalescing, placement sampling, the switch | +| `useFlowChatVirtualizer.test.ts` | the offsets-and-positions boundary | +| `useFlowChatVirtualizer.measurement.test.tsx` | `measureRenderedItems` against a real virtualizer | +| `useFlowChatVirtualizer.aim.test.tsx` | the re-aim, and giving it up on takeover | +| `VirtualMessageList.layout.test.ts` | the item-height estimate and the spacer | + +## Manual + +**Agents must not perform UI interaction verification.** Report these as pending +unless a human confirms them. They are grouped so that adding a check to one +group does not renumber the others. + +### Opening a session + +1. Session open lands at the end of the transcript, not inside the spacer. +2. Open a long `isPartial` session and leave it alone. Nothing may page in + behind the reveal: the transcript opens on its loaded tail and stays there. + Five pages arriving over 890ms is what this checks for, and the reveal only + hides the first frame of it. +3. Open a long session and check the scrollbar thumb: its size should be close + to right on the first painted frame, and it should not jump as items + measure. This is the per-item estimate doing its job, and it is the single + most visible symptom if the estimate ever regresses. +4. Session switching and history paging do not restore stale footer height. + +### Submitting and pinning + +1. A newly submitted Turn opens at the viewport top with room below it. +2. Send a one-line message and let it pin. It must come to rest at the top with + the same small gap above it as the very first Turn of the session, and the + pin must hold steady rather than creeping down — a pinned offset past the + end of the scroll range is clamped, and the follow loop will rewrite it + every frame. +3. Scroll down into the blank and let go right after submitting a short Turn: + it returns that Turn to the viewport top, not to the content end. +4. Send a message from the live tail, and again while parked deep in history. + Both must end with the new Turn at the viewport top; the second also has to + leave the history window to get there. +5. Send a message, let it pin, then roll it back from its own message actions. + The transcript must come to rest with the surviving last Turn at the + *bottom* — not with it pinned to the top, which is what reading the + truncation as an arrival used to do. +6. Roll a Turn back from further up a transcript, having scrolled to reach it. + The surviving last Turn must end at the bottom here too — scrolling to reach + the button hands the viewport to the reader, and the answer has to run + anyway. Leaving it to the anchor is what showed Turns 2..6 of an 8-Turn + session with the new last Turn's answer below the fold. +7. Edit a message and rerun it. There must be one movement, not two — the + truncation is silent and the rerun's Turn pins as usual. + +### Streaming and follow + +1. Streaming follows the tail until the user scrolls, and the pinned Turn hands + off once its answer overflows the viewport. +2. With output streaming, scroll up and hold still. Follow must not write while + the gesture is recent, and must resume once it goes quiet. +3. Jump to latest is animated rather than an instant jump — including from the + top of a long transcript, which is the longest animation anything here + issues and the one a frame-counted stand-down used to cut short. It must + glide the whole way, with at most a small catch-up for content that arrived + while it travelled. +4. While reading a history window, let output arrive from somewhere else. The + viewport must not move — this is the case the submission event exists to + stay out of. +5. Watch a Markdown answer stream past the bottom of the viewport. It must + scroll rather than step: no move of a whole line, and none of the ease's + lag left behind once the stream stops. +6. Stream a burst — a code fence or a table arriving at once — and confirm it + goes the whole way in one move rather than gliding through content nobody + has seen, and that the jump-to-latest bar does not flash while it does. +7. Turn on `prefers-reduced-motion` and stream again. The follow must step + straight to its target, as it did before the ease. + +### Collapse + +1. An auto-collapsing TodoWrite or ExecCommand card leaves earlier content + visually still. +2. Expand and collapse a tall tool card near the top of the viewport, and one + below it, and confirm earlier content stays put in both cases. + +### The reserved blank and the snap back + +1. Scrolling down into the reserved blank and letting go returns to the end of + real content, and streaming resumes following from there. +2. Pressing End scrolls to the bottom of the scroll range and then comes back — + that key is the cheapest way to land deep in the spacer. +3. Scroll to the very bottom and confirm the transcript ends where content + ends, with the reserved blank below it reachable but not where the session + opens. +4. Wheel down into the reserved blank and stop. The transcript must return to + the content end in one smooth movement, not shudder in place. +5. With a short Turn pinned, scroll up, jump to latest, then scroll down into + the blank and let go. After the snap back the jump-to-latest affordance must + be gone — this is the one path where the viewport arrives at the tail + without a scroll event to notice it. + +### The scrollbar + +1. Drag the scrollbar to the very bottom. The screen must not be entirely + blank: the last Turn and the input clearance stay visible above the + reservation. Repeat with the composer expanded, which is where the reserve + falls back to the hold-gap floor. +2. Drag the scrollbar, without touching the wheel first, down into the reserved + blank and let go: it must snap back. Then drag it while output streams: the + transcript must follow the thumb without the frame loop fighting it. A press + on the thumb that moves nothing must leave the viewport alone. + +### Resizing + +1. With the viewport resting at the end but *not* following — scroll away and + back, and check the jump-to-latest affordance is hidden — resizing the + window keeps content against the bottom in every direction: taller reveals + more history above, shorter does not cut the last lines off, and narrower + does not push them off screen as the text rewraps. Repeat while reading + history: nothing should move. + +### History paging + +1. Open a session long enough to be `isPartial` — the loaded tail is shorter + than the viewport, so it pages older Turns in on its own. No jump-to-latest + bar should appear, and streaming output should be followed. Then send a + message: it must appear immediately and pin to the viewport top, with the + history above neither moving nor reloading. +2. Scroll up to a junction. **One** page loads, the Turn under the cursor stays + where it is, and paging stops until the head is reached again. Then keep + going: every junction must behave the same way all the way to the first + Turn, with no run of pages and no point where scrolling up stops doing + anything. +3. Open a long `isPartial` session and scroll up slowly through several paging + junctions. The Turn under the cursor must not move — not backwards, not + forwards, and not for a single frame. A stall while a page is measured is a + known gap and reads differently from a jump: the picture freezes and + resumes in place, rather than showing different content and snapping back. + Then scroll up fast through the same junctions, which is where the anchor + and the user's gesture are most likely to disagree. +4. During that scroll, check that a paging junction does not leave the viewport + stuck: keep scrolling past it, then wheel back down, and confirm the + transcript still tracks the gesture in both directions. Nothing on screen may + fade or slide as rows come back — a row entering the rendered window is not + its content arriving. +5. Navigate to the first Turn of a long session, then jump to latest. The tail + window it lands on can be short enough to fit inside the viewport, which + puts the whole scroll range inside the reserved blank. Scroll up from there: + history must load. This is the case where the reader is at the top, so the + wheel emits no scroll event and the gesture is the only thing to go on. +6. In an `isPartial` session that paged on open and is now streaming, scroll + down into the reserved blank and let the snap back return you. No history + status may appear at either end of the transcript — the transcript already + reaches the newest Turn, so there is nothing past its bottom to load. This + is the case that showed "preparing the conversation history" under a + complete transcript, permanently, and survived cancelling the Turn. + +### Turn navigation + +1. Turn Rail and Usage Report navigation can top-align the final Turns. +2. Click the last Turn on the Turn Rail while it is short. It must land in one + movement at the end of the transcript — no top-align followed by a slide + back down. Do it from near the tail *and* from the top of a long session: + those are the rendered and unrendered branches, and they take different + paths. Then click a final Turn whose answer is longer than the viewport: it + must still top-align. +3. Navigate to a Turn from the Turn Rail — a near one, a far one, and one close + enough to the end that the window loaded for it reaches the newest Turn. All + three must come to rest on that Turn at the viewport top and stay there. +4. Navigate to a far Turn and start scrolling with the wheel before it comes to + rest. The gesture wins immediately and nothing pulls the viewport back to + the Turn afterwards, including several seconds later. +5. From the Session Usage report, click a tool call and a slow span. Each must + come to rest with that item centred, in **one** movement — no landing on the + Turn followed by a slide onto the item a few frames later. +6. **While a Turn is streaming**, click a Turn well up the history. It must land + on that Turn and stay there — not drift back to where you were reading half a + second later, which is when the navigation's hold lapses. Then click the same + Turn again from where you land: the second and later clicks used to fail + where the first appeared to work. +7. While a Turn is streaming, scroll up with the wheel and let it stop. The + viewport stays where the gesture left it. Repeat several times in a row and + keep going after streaming ends: the failure was a scroll that came to rest + and was then returned, in full, to where that gesture had started. +8. Scroll **down** through a long answer in several flicks, in a session with + enough history to keep re-measuring. Each flick keeps its distance — the + failure was arriving and then sliding back part of the way, every time, so + that a given point in the transcript could not be passed at all. diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md new file mode 100644 index 0000000000..dd4cc8b261 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md @@ -0,0 +1,323 @@ +# FlowChat Viewport Register + +Every deliberate write to the FlowChat scroller goes through one register, which +decides whether the writer may act. This document covers the register, what it +replaced, the two writers that stand outside it, and the trail it leaves. + +## Who May Move the Viewport at All + +Continuous movement belongs to `useFlowChatFollowOutput`; one-shot navigation +belongs to `VirtualMessageList`. The virtualizer never follows output. Card +renderers and tool cards must not write the outer FlowChat `scrollTop` — local +scroll surfaces inside a thinking, explore, terminal, or subagent card may +manage their own scroll position, but they must not dispatch an outer viewport +compensation request. + +## The Register and Its Order + +Every deliberate write goes through `useFlowChatViewportOwner`, which asks +`flowChatViewportOwnership.ts` whether the writer may act. The order is the +design: + +| | Owner | Held while | +|---|---|---| +| 1 | `user-gesture` | 200ms from the last wheel, touch, key, or scrollbar press | +| 2 | `one-shot-navigation` | a Turn, search hit, or focus request is being reached | +| 3 | `snap-back` | the return from the reserved blank is animating | +| 4 | `follow-output` | the continuous writer owns the viewport | +| 5 | `layout-correction` | the scroller's box changed and a resting viewport is re-aligned | + +The register decides **whether**, never **where** — targets stay with the writer +that owns them, and the anchor's correction stays idempotent. Ordering answers +what idempotency cannot: whether a movement was ours on purpose or something to +be undone. The anchor is idempotent and still destroyed the snap back, because +it restored a relationship we had deliberately changed. + +**Repairing a displacement is not on this list**, and putting it there was a +mistake worth keeping recorded. The prepend compensation and the viewport anchor +both use `viewportOwner.shift` — see *A Displacement Is Not a Movement* in +`FLOWCHAT_HISTORY_PAGING.md`. + +**Taking ownership and writing are one call.** Adding a writer without declaring +it means not using the helper, which is visible in review — where the previous +scheme needed the new writer added by hand to every other writer's private +predicate, and a single missed pair was a bug. The failure that made this +explicit: a snap back was missing from the hand-written predicate that preceded +the register, so it belonged to nobody while it animated. The snap travelled +0.7px, the anchor wrote it back, the write cancelled the animation, the +cancellation read as a gesture coming to rest, and the snap was issued again — +**958 times over 20 seconds, arriving nowhere**. + +**Only an owner that releases may hold the viewport indefinitely**, and +follow-output is the only one — a claim with no expiry and no release is a +viewport nothing below it can ever write again. Every other writer states its +own window: a gesture goes quiet, an animation may never report completion, a +re-aim runs while measurements settle. Saying nothing means the write is +instantaneous and owns only itself, which is what a correction wants; the claim +still resolves against whoever holds the viewport, because that is the question +deciding whether the write happens at all. + +The first thing this caught was its own: the virtualizer places the scroller +against its own state on mount, before any aim of ours exists. That 0px write +was attributed to a navigation and took an unbounded claim, so follow-output was +refused for the whole opening reveal and a long session opened at the head of +its loaded window instead of on the newest Turn. A library write nobody asked +for is now attributed to `layout-correction`, which is what it is. + +**The opening reveal is not an owner.** It is a phase, and the thing moving the +viewport during it is follow-output, so a claim standing in for the reveal would +outrank follow-output and refuse it. One slot cannot hold a phase and a writer +at once, so the reveal remains an explicit condition beside the register. + +Not everything collapsed into it, and the difference is worth keeping straight. +`smoothScrollFramesRef` is follow-output yielding to *its own* animation, which +is intra-owner; `settleFramesRef` is a frame budget; `pendingSnapBackTargetRef` +still answers "did our snap land here"; `boundaryArmedRef` is paging policy. +Only the parts that were really answering "is someone else moving the viewport" +are gone. + +## What Counts as a Gesture + +Ordinary `scroll` events do not transfer viewport ownership; only explicit +wheel, touch, or keyboard navigation exits follow-output. A gesture that comes +to rest inside the reserved blank hands it back — see *Snapping Back Out of the +Reserved Blank* in `FLOWCHAT_SCROLL_STABILITY.md`. + +A scrollbar drag is the one exception, and the press is what makes it one: a +pointer held past the content box's trailing edge is on the bar, so the +scrolling it causes *is* intent. The press only arms it — `scrollbar-gutter: +stable` keeps the gutter reserved whether or not a bar is drawn there, so a +press that scrolls nothing changes nothing. Unqualified, a drag never released +the viewport: measured on WebView2, follow-output rewrote its target against the +thumb every frame for a 100px oscillation, and a drag that came to rest in the +reserved blank was skipped by the snap back because follow still nominally owned +it — with the frame loop long since asleep, so nothing corrected it. + +## Reaching a Turn Is One Shot + +One-shot Turn, search and history navigation lives in `VirtualMessageList`, and +holds `one-shot-navigation` for as long as it is still arriving. + +**Alignment is asked for, not computed, wherever it fits.** A navigation +correcting its own scroll must re-issue through the virtualizer, never by +writing the scroller. The virtualizer keeps re-aiming at its last target for as +long as the measurements under it move, and only another scroll issued through +it replaces that. Writing the scroller directly is what left the tail Turn +top-aligning, being pulled to the content end, and being re-aimed at the top +again. + +**The clamp branches on what is knowable, not on where the Turn is.** A rendered +Turn resolves its own offset, so the decision is made before anything moves and +the requested `behavior` survives. An unrendered one is known only to the +virtualizer, so it is placed with `behavior: 'auto'` and the landing read back; +an animated placement would not have arrived yet, so there would be nothing to +read. Both writes land in the same task, so the correction costs a second scroll +but not a second visible movement. + +**Turn navigation never scrolls into the reserved blank to top-align a Turn.** A +Turn whose top lies past the content end is stopped at the content end instead, +which is where the tail rests. The blank belongs to follow-output — `pin-turn-top` +holds it for output that is arriving, and nothing arrives under a Turn the user +navigated to. There is no "is this the last Turn" test and no measurement of +what lies below it: a Turn with a viewport of content under it has its top above +the content end already, so the clamp does not bind and the final Turns of a +long transcript still top-align. Before the resident spacer the browser did this +for free by clamping at the end of the scroll range. + +**Top-aligning a Turn aims at `FLOWCHAT_TURN_TOP_GAP_PX` above its user +message**, not at the message itself. The first Turn already sits below that gap +for free, because `.message-list-header` occupies it at the head of the scroll +content; every other Turn used to land flat on the top edge, and the two read as +different alignments. The header renders at the same constant so they cannot +drift. Both the one-shot scroll and the offset the follow loop re-asserts every +frame carry it — if only one did, they would fight. + +**A gesture preempts a navigation still in flight, and ends it.** The library's +re-aim keeps computing for up to 5s after the aim that started it, recomputing +the target offset from measurements that are still landing and writing again +whenever it moves. + +Refusing those writes is not enough, and this is the one place the register's +guarantee stops short on its own. The refusal is invisible to the library — it +has no return value to read — so it keeps its schedule either way, and the +gesture's hold is `USER_DRIVEN_SCROLL_WINDOW_MS`, 200ms after the last wheel +notch. A measurement landing after the reader has stopped, and inside the +remaining five seconds, is granted. Measured on a rail click into a long +history window: placed at 5358, the reader took over 6ms later, and 12ms after +that the re-aim asked for 7784 and was refused — with nothing having ended it. + +So `notifyUserScrollIntent` gives the aim up outright, through `cancelAim`. It +aims at the offset the scroller already holds: an offset aim carries no index, +the re-aim recomputes its target *from* the index, and writing again is the only +thing it does when that target changes. The library's `scrollState` is private, +and a cast into it is the kind of thing a version bump breaks silently. + +The cost is accepted, and it is what the reader asked for: a distant navigation +the wheel brushes stops where it is and is not corrected further. In the +measured case that is 2460px short of the Turn that was clicked — the placement +is deliberately approximate, and the re-aim is what would have finished it. + +**A hold postpones a corrector; it does not cancel one.** Standing down for the +register keeps a correction from landing while the hold is live, and hands it +over intact the moment the hold lapses. Where the movement being held off was a +*change of intent* rather than a displacement, that is not what was wanted, and +the register cannot know the difference — it ranks writers, it does not carry +meaning. Whoever changes the intent has to say so: a Turn navigation drops the +viewport anchor before it aims, because otherwise the anchor spends the hold +waiting and then undoes the jump. Measured over four clicks on one Turn: 1653px +back, four times out of four, each on the first frame after +`ONE_SHOT_NAVIGATION_HOLD_MS` lapsed. + +This is also why a placement's outcome is sampled *after* the hold rather than +inside it. `turnNavigation.placed.outcome` used to read back at 400ms against a +600ms hold and reported `driftPx: 0` on a placement that was dragged 1653px away +11ms later — the probe could not see the one thing the hold was postponing. + +## Why There Was No Coordinator Before, and What Changed + +There was one — `FlowChatViewportCoordinator.ts`, removed alongside the +compensation engine, and it was a compensation engine itself: reservations, +pin and collapse compensation, element-anchor leases, a synthetic bottom range. +Nothing here does any of that. + +The argument recorded against replacing it was that single-writer semantics were +unreachable, because the virtualizer writes `scrollTop` from inside the library — +its own re-aim, and its adjustment for a re-measured item — so a coordinator +could only serialise *our* writes while the conflicts in practice were with that +third writer. Two things retired that premise: + +- The library's adjustment for a re-measured item is **off** + (`shouldAdjustScrollPositionOnItemSizeChange`), because it replayed a delta + against a scroll offset it learns about a frame late. +- **`scrollToFn` is a first-class virtualizer option.** Every write the library + makes — `scrollToIndex`, `scrollToOffset`, and the re-aim that follows them — + goes through a function we supply, so it is registered like any other. + +What remains outside the register is the reader and the browser, and browser +scroll anchoring is off. A library write is attributed to whoever asked for the +aim, which is also what lets a gesture preempt a navigation still chasing its +Turn. + +## Diagnosing the Viewport + +Viewport faults are intermittent, leave nothing in the DOM once they are over, +and read identically to two or three other causes: a Turn that lands and is +dragged away and a Turn that never landed are the same complaint. So the trail +is permanent, in `flowchat.log`, behind `app.logging.flow_chat_diagnostics` — +the same switch history paging uses — and tagged `viewport`. + +`flowChatViewportDiagnostics.ts` records two different kinds of thing: + +| | Where | What | +|---|---|---| +| **Writes** | the register, `viewportOwner.write` / `.claim` / `.release` | who moved the viewport, from where to where, and **who was refused** | +| **Decisions** | each writer | why it wanted to move, and why it did not | + +The second half is the one that pays. A write that never happened leaves nothing +at the register to find, and "nothing happened" has been the report more often +than a wrong move has: a deferred new Turn, an anchor whose Turn left the +rendered window, a snap back declined because the settle belonged to the opening +reveal, a boundary that never re-armed. Each of those is now one line saying +which. + +**A placement is recorded with what became of it.** `traceViewportPlacement` +samples the offset on the next frame and again once things have settled, and +reports the drift from the target. Read against the register's writes in the +same window, the drift says *who* took it away. Every deliberate write now goes +through the register; the two that did not are both gone, and what the sampling +found before they went is worth keeping, because in both cases it was not what +the probe was written to catch. + +**The focus request was overriding, not overridden.** A usage-report click +lands a Turn navigation through the register and then centres the flow item +that was actually clicked — a tool call inside the Turn. The comment on the +second write predicted the anchor undoing it, since a focus request carries no +gesture. It never did: measured over four clicks the anchor stood down 93 times +and the register refused nothing, and the two item aims drifted 0px. The drift +was on the *first* write. The Turn navigation settled 178px and 334.7px from +where it had put itself, because the item aim arrived 41ms — three frames — +later, and `nextFramePx` equalled the Turn placement both times, so the reader +watched the transcript land and then move. The aim now goes through +`focusFlowItem` on the list, which is a register write, and is attempted in the +same task as the Turn navigation so that only the final position is painted. +When the item is not rendered yet the retry loop still runs, and the Turn +placement is what is on screen until it lands — that part is unavoidable. + +**The sticky Task indicator had never run at all.** Its selector wanted +`.flowchat-flow-item[data-flow-item-id][data-tool-name]` on one element, and +`data-tool-name` has only ever been on the tool card *inside* that wrapper — +added two months later, by a commit adding e2e locators, for an unrelated +reason. So its probe could not have fired either, which is why there was no +evidence rather than no problem. Deleted; if the affordance is wanted again it +needs registering as well as fixing. + +`traceViewportPlacement` stays for the next writer that has to sit outside the +register, and for what it proved here: read against the register's writes, it +answers *which of two placements won* as readily as *who undid one*. + +Everything here can fire on every frame, so repeated identical events collapse +into one entry per 500ms carrying the count and travel it stands for. The key +includes whatever makes one run a different run — the owner, the outcome, the +direction — so a *transition* always emits immediately. A thousand copies of a +steady state would only bury the transitions, which are the point. + +**A duration is reported once, when it ends.** Coalescing is what makes the +per-frame traces readable, and it is also why they cannot answer "how long did +that last": only the first of a run is emitted, and the rest are a count. +`anchor.turnReturned` and `anchor.waitAbandoned` therefore report the whole of a +wait for the anchored Turn — milliseconds, settle frames, restore attempts, and +the reader travel carried through it — at the one moment the whole of it exists. +That wait is what decides whether a junction is seen, so it is the number to +read first. These are the only traces here that go through `traceViewport` +rather than the coalescer, because they fire once per wait and their payload is +the point rather than a sample of it. + +**Read the state out before the state changes.** A `data` callback is evaluated +by `flowChatDiagnostics.trace`, synchronously, so a thunk over refs is normally +exactly right. It is not right when the caller resets those refs on the next +line, and the wait report does: it takes its numbers eagerly and closes over +them. + +**Two numbers say whether a correction was a mistake.** `scrollRangePx` is +recorded on `prependCompensated` and on every `anchor.correct`, because a +correction and the reason for one read the same otherwise. Measured over five +junctions, the correction equalled the change in the scroll range every time +(−94/−93.4, +76/+76.5, +26/+30.7, +8/+7.6): the compensation had not over-shot, +the transcript above the reader had re-measured, and the anchor was following +it. A correction with the range *unchanged* would be the other diagnosis. + +Nothing evaluates a payload while the switch is off. + +### Reading the log + +```text +pnpm run flowchat:log:analyze -- [--around ] +``` + +`scripts/diagnostics/analyze-flowchat-log.mjs` reports, in this order: + +1. **Episodes** of viewport activity, worst *churn* first — travel per pixel of + progress. A clean move is 1. The snap back that never arrived would be + hundreds, and that number is the difference between "it moved wrongly" and + "it fought". +2. **Placements that did not stick**, ranked by drift. A placement with no + outcome sampled is listed separately rather than counted as clean. +3. **Refusals**, as owner × who outranked them. +4. **Declines**, as writer × reason. + +Two things it is careful about, because both would otherwise flatter the +result: a coalesced entry is weighed by the run it stands for, not as one event; +and dropped entries are reported at the top, since every count below one is a +lower bound. + +The paging side of the same fault is traced separately; see *Diagnosing History +Paging* in `FLOWCHAT_HISTORY_PAGING.md`. + +## Related Files + +- `flowChatViewportOwnership.ts` +- `useFlowChatViewportOwner.ts` +- `@/infrastructure/diagnostics/flowChatViewportDiagnostics.ts` +- `VirtualMessageList.tsx` +- `useFlowChatNavigation.ts` +- `scripts/diagnostics/analyze-flowchat-log.mjs` diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md new file mode 100644 index 0000000000..e9ce863a89 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -0,0 +1,138 @@ +# FlowChat Virtualization + +What the virtualization library is allowed to decide, what stays ours, and the +one rule about rendering that only makes sense once a row's lifetime is shorter +than its content's. + +## What Belongs to the Virtualizer + +FlowChat virtualizes with **TanStack Virtual**, behind `useFlowChatVirtualizer.ts`. +Nothing else imports it. The rest of FlowChat asks for offsets in scroller +coordinates and gets them back; there is no index space of the virtualizer's own +to convert at the edges, because measurements are cached against **item keys**, +so a history prepend leaves every measured item exactly where it was. + +That is only half of what react-virtuoso's `firstItemIndex` did, and the other +half has to be supplied — see *Keeping the Viewport on the Reader's Content* in +`FLOWCHAT_HISTORY_PAGING.md`. + +The reason it is TanStack and not react-virtuoso is one line of its measurement +pass: `size = measured ?? estimateSize(i)`. A per-item estimate for everything +unmeasured. react-virtuoso reserves a single scalar (`lastSize`) for all of +them, and this transcript alternates 38px user messages with model rounds up to +5012px, so the scroll range was wrong by an order of magnitude until an item was +actually measured. `estimateVirtualMessageItemHeight` now feeds it directly. + +**Items stay in normal flow inside a padded window**, not absolutely positioned. +Everything outside the window stands in as `padding-top` and `padding-bottom` +(`virtualWindowPaddingPx`). This matters for more than tidiness: when an item +inside the window changes height, the browser reflows the ones below it in the +same layout pass, so there is no frame where the scroll has been corrected but +the items have not moved yet. + +**The virtualizer does not compensate for its own late measurements.** +`shouldAdjustScrollPositionOnItemSizeChange` is set to refuse, always. Its rule +is the right shape — this item's delta, only for an item above the viewport — +but it applies that delta to `scrollOffset`, the library's own copy of the +scroll position, refreshed only from scroll events. Every continuous writer here +assigns `scrollTop` directly and the matching scroll event lands a frame later, +so a measurement arriving in between is compensated from a position the viewport +has already left. Measured on session open: **nine corrections across two frames +walked the viewport from 7440 back to 3556**, and the follow loop wrote 7440 +again on the next frame. The interception this replaces was written for +react-virtuoso and removed on the assumption that TanStack asked the right +question. It does — from a stale base. + +**Measurement is forced before any position is read in the commit that changed +the items.** The library skips its inline resize while the reader is scrolling, +which is exactly when history arrives, so the cache holds reserved estimates +until the ResizeObserver delivers a frame later. +`virtualizer.measureRenderedItems()` does that reconciliation itself — the same +work, a frame earlier, free for any row whose height was already right. The +evidence and the numbers are in *A Displacement Is Not a Movement* in +`FLOWCHAT_HISTORY_PAGING.md`. + +**Alignment is asked for, not computed, wherever it fits.** `scrollItemIntoView` +goes through the virtualizer so that its re-aim keeps chasing the item while the +measurements under it move; an offset computed once is already stale by then. +The gap above a top-aligned Turn is the virtualizer's `scrollPaddingStart`, for +the same reason. Only two places compute an offset by hand, and both do it +because the target is not an item: the end of *real content*, which is above the +resident tail spacer, and the end of a Turn. + +Two things that look like they belong here do not: + +- **Positions in `virtualItems`.** That array is FlowChat's own projection, so + an index into it means the same thing under any virtualizer. `scrollToIndex`, + `scrollToSearchMatch`, and `data-virtual-index` all carry one and are left + alone. +- **When to page.** `historyBoundariesForVisibleRange` decides that a boundary + is worth asking about, from where the reader stands and nothing else. Its + thresholds are the ones that decide *where* a junction happens, which is why + they are named and tested rather than inline. + +**Visible is not rendered.** `getVisibleItemRange` intersects the rows with the +scroller box; the rendered window carries overscan, and a transcript short +enough to render whole reports the first *and* last item present wherever the +viewport stands. Feeding the rendered window to a rule that means "has the +reader arrived here" asks whether the item exists instead. Measured: a 21-item +transcript rendered rows 0..20 from index 0 no matter where the reader was, so +the head boundary read as reached forever. It has to be a callback rather than a +value, because a scroll moves the viewport across the window without changing +it. + +react-virtuoso remains a dependency: the file tree (`VirtualFileTree.tsx`) still +uses it. Nothing under `flow_chat/` does. + +## The Projection Is the Stable Thing + +Stable virtual-item keys and projection identity are required. Do not split one +`ModelRound` into multiple virtual items, and do not reclassify projection from +a timer. + +`getVirtualItemStableKey` keys on type, Turn and content id — never on an index. +That is what lets a prepend renumber every row without React unmounting any of +them, and it is what the measurement cache is keyed on underneath. + +Tool cards reflow naturally and dispatch only `tool-card-toggle` after an +expanded-state change, so the virtualizer can remeasure. There is no +pre-collapse intent event and no per-card compensation. + +## A Row's Mount Is Not an Arrival + +**No mount or enter animation may live inside `.virtual-item-wrapper`**, no +mount-triggered motion may change transcript geometry, and nothing may be keyed +on a state change a scroll can replay. + +Outside a virtualized list an element's insertion means its content arrived, and +a fade or a slide says so honestly. Here insertion means the row entered the +rendered window. Paging up mounts the Turns the page brought, the rows the +junction's own correction scrolls past, and every row the reader scrolls back +over afterwards — each one replaying whatever its stylesheet attached to mount. +`--streaming` to `--complete` is the same mistake in a different key: it fires +when the typewriter finishes, which is not when the reader is looking. + +The one that shipped was `.markdown-renderer`, from the shared component +library: `animation: fadeIn var(--bf-appearance-token-motion-base) ease-out`, +350ms from `opacity: 0`. Once the junction displacement was down to tens of +pixels that fade was the entire remaining complaint — most of the screen +dimming and coming back on every page up. `VirtualItemRenderer.scss` cancels it +for anything inside a row and leaves the library alone, where a markdown block +really is mounted once. + +The rule is stated here because four correct local fixes could not reach it. +`ModelRoundItem.scss` and `UserMessageItem.scss` each refuse an enter animation +of their own, in comments that name this reason. `FlowTextBlock`'s typewriter +refuses to replay on mount, because a streaming block that scrolled out and +back would restart from an empty string and re-grow. `FlowTextBlock.scss` +cancels this very fade — but only under `.streaming`, so the one block still +being written was exempt and the whole of history was not. Each author saw the +defect, guarded their own file, and had no way to guard a component in another +package. + +## Related Files + +- `useFlowChatVirtualizer.ts` +- `virtualMessageListLayout.ts` +- `VirtualItemRenderer.tsx` + `.scss` +- `VirtualMessageList.tsx` diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts index b40a5c4ff1..32cb3ce771 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts @@ -114,13 +114,3 @@ describe('FlowChat collapse spacing', () => { ); }); }); - -describe('FlowChat initial projection alignment', () => { - it('reserves the Virtuoso scrollbar gutter in the handoff overlay', () => { - const stylesheet = readSource('./VirtualMessageList.scss'); - - expect(stylesheet).toMatch( - /&__projection-handoff-overlay\s*\{[\s\S]*?scrollbar-gutter:\s*stable;/, - ); - }); -}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index 6befe113e7..32baff1de8 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -8,44 +8,18 @@ justify-content: space-between; gap: $size-gap-1; padding: 0 $size-gap-3; - border-bottom: 1px dashed var(--bf-appearance-token-border-base); + border-bottom: 1px solid var(--bf-appearance-token-border-base); + + // Opaque bar stacked above the message list, so message geometry stays + // independent of the header. + background: var(--bf-appearance-token-color-bg-scene); - // Frosted glass effect. - background: color-mix(in srgb, var(--bf-appearance-token-color-bg-elevated) 45%, transparent); - backdrop-filter: blur(20px) saturate(1.3); - -webkit-backdrop-filter: blur(20px) saturate(1.3); - height: 36px; min-height: 36px; flex-shrink: 0; transition: all $motion-base $easing-standard; position: relative; z-index: 10; - - // ==================== Bottom gradient blur ==================== - &::after { - content: ''; - position: absolute; - left: 0; - right: 0; - top: 100%; - height: 16px; - pointer-events: none; - - background: linear-gradient( - to bottom, - color-mix(in srgb, var(--bf-appearance-token-color-bg-elevated) 35%, transparent) 0%, - color-mix(in srgb, var(--bf-appearance-token-color-bg-elevated) 20%, transparent) 35%, - color-mix(in srgb, var(--bf-appearance-token-color-bg-elevated) 8%, transparent) 65%, - transparent 100% - ); - - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - - mask-image: linear-gradient(to bottom, black 0%, transparent 100%); - -webkit-mask-image: linear-gradient(to bottom, black 0%, transparent 100%); - } &__btw-back { flex: 0 0 auto; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 6553cf9563..3cd97ede3c 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -464,9 +464,13 @@ export const FlowChatHeader: React.FC = ({ count: backgroundCommandCount, }); - if (!visible || totalTurns === 0) { + // The header occupies a row above the message list, so its mount state must + // not depend on turn measurement: unmounting on a transient `totalTurns === 0` + // would resize the list mid-session. Only the centre message is withheld. + if (!visible) { return null; } + const hasTurnInfo = totalTurns > 0; return (
= ({
- -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onJumpToCurrentTurn?.(); - } - }} - aria-label={t('flowChatHeader.jumpToCurrentTurn', { - turn: currentTurn - })} - > - +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onJumpToCurrentTurn?.(); + } + }} + aria-label={t('flowChatHeader.jumpToCurrentTurn', { + turn: currentTurn + })} > - {turnBadgeLabel} - - - {truncatedMessage} - -
- + + {turnBadgeLabel} + + + {truncatedMessage} + +
+
+ ) : null}
({ - bottom: top + 40, - height: 40, - left: 0, - right: 300, - top, - width: 300, - x: 0, - y: top, - toJSON: () => ({}), - }), - }); -} - -function setScrollerGeometry(scroller: HTMLElement, scrollTop: number): void { - Object.defineProperties(scroller, { - clientHeight: { configurable: true, value: 500 }, - scrollHeight: { configurable: true, value: 2000 }, - scrollTop: { configurable: true, writable: true, value: scrollTop }, - }); -} - -afterEach(() => { - document.body.replaceChildren(); - vi.restoreAllMocks(); -}); - -describe('FlowChatViewportCoordinator', () => { - it('hands off after the pin drains or natural content reaches the viewport tail', () => { - expect(canHandoffPinnedItemToTail({ - pinReservationPx: 1029, - collapseReservationPx: 0, - pendingStickyPinGrowthPx: 0, - hasPendingCollapseIntent: false, - viewport: { - scrollTop: 900, - clientHeight: 1000, - naturalContentHeight: 1899, - }, - })).toBe(false); - expect(canHandoffPinnedItemToTail({ - pinReservationPx: 1029, - collapseReservationPx: 0, - pendingStickyPinGrowthPx: 0, - hasPendingCollapseIntent: false, - viewport: { - scrollTop: 900, - clientHeight: 1000, - naturalContentHeight: 1900, - }, - })).toBe(true); - expect(canHandoffPinnedItemToTail({ - pinReservationPx: 1029, - collapseReservationPx: 200, - pendingStickyPinGrowthPx: 0, - hasPendingCollapseIntent: false, - viewport: { - scrollTop: 900, - clientHeight: 1000, - naturalContentHeight: 2000, - }, - })).toBe(false); - expect(canHandoffPinnedItemToTail({ - pinReservationPx: 1029, - collapseReservationPx: 0, - pendingStickyPinGrowthPx: 20, - hasPendingCollapseIntent: false, - viewport: { - scrollTop: 900, - clientHeight: 1000, - naturalContentHeight: 2000, - }, - })).toBe(false); - expect(canHandoffPinnedItemToTail({ - pinReservationPx: 1029, - collapseReservationPx: 0, - pendingStickyPinGrowthPx: 0, - hasPendingCollapseIntent: true, - viewport: { - scrollTop: 900, - clientHeight: 1000, - naturalContentHeight: 2000, - }, - })).toBe(false); - expect(canHandoffPinnedItemToTail({ - pinReservationPx: 0, - collapseReservationPx: 0, - pendingStickyPinGrowthPx: 0, - hasPendingCollapseIntent: false, - viewport: null, - })).toBe(true); - }); - - it('restores a collapsing card header to its captured viewport offset', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const card = document.createElement('div'); - scroller.append(card); - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - setRect(scroller, 0); - setRect(card, 120); - - const coordinator = new FlowChatViewportCoordinator(); - expect(coordinator.preserveElement(card)).toBe(true); - - setRect(card, 80); - expect(coordinator.restoreElementAnchor(scroller)).toBe(true); - expect(scroller.scrollTop).toBe(860); - }); - - it('does not let automatic tail follow replace a preserved card anchor', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const card = document.createElement('div'); - scroller.append(card); - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - setRect(scroller, 0); - setRect(card, 120); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.preserveElement(card); - - expect(coordinator.followTail()).toBe(false); - expect(coordinator.getMode()).toBe('preserving-element'); - expect(coordinator.followTail({ force: true })).toBe(true); - expect(coordinator.getMode()).toBe('following-tail'); - }); - - it('retains a settled card anchor without a wall-clock expiry', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const card = document.createElement('div'); - scroller.append(card); - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - setRect(scroller, 0); - setRect(card, 120); - - const now = vi.spyOn(performance, 'now').mockReturnValue(1_000); - const coordinator = new FlowChatViewportCoordinator(); - expect(coordinator.preserveElement(card)).toBe(true); - expect(coordinator.settleElementPreservation('test-settled')).toBe(true); - - now.mockReturnValue(60_000); - expect(coordinator.ownsElementAnchor()).toBe(true); - expect(coordinator.getMode()).toBe('preserving-element'); - - setRect(card, 80); - expect(coordinator.restoreElementAnchor(scroller, 'test-delayed-layout')).toBe(true); - expect(scroller.scrollTop).toBe(860); - }); - - it('allows automatic tail follow to take ownership from a retained anchor', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const card = document.createElement('div'); - scroller.append(card); - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - setRect(scroller, 0); - setRect(card, 120); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.preserveElement(card); - coordinator.settleElementPreservation('test-settled'); - - expect(coordinator.followTail()).toBe(true); - expect(coordinator.getMode()).toBe('following-tail'); - expect(coordinator.ownsElementAnchor()).toBe(false); - }); - - it('releases a retained anchor when its DOM element disconnects', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const card = document.createElement('div'); - scroller.append(card); - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - setRect(scroller, 0); - setRect(card, 120); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.preserveElement(card); - coordinator.settleElementPreservation('test-settled'); - card.remove(); - - expect(coordinator.ownsElementAnchor()).toBe(false); - expect(coordinator.getMode()).toBe('idle'); - }); - - it('does not let a stale element-anchor lease release a newer preservation transaction', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const firstCard = document.createElement('div'); - const secondCard = document.createElement('div'); - scroller.append(firstCard, secondCard); - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - setRect(scroller, 0); - setRect(firstCard, 120); - setRect(secondCard, 180); - - const coordinator = new FlowChatViewportCoordinator(); - const firstLease = coordinator.preserveElementWithLease(firstCard); - const secondLease = coordinator.preserveElementWithLease(secondCard); - - expect(firstLease).not.toBeNull(); - expect(secondLease).not.toBeNull(); - expect(coordinator.releaseElementPreservationLease( - firstLease!, - 'stale-request-finished', - )).toBe(false); - expect(coordinator.ownsElementAnchor()).toBe(true); - expect(coordinator.releaseElementPreservationLease( - secondLease!, - 'current-request-finished', - )).toBe(true); - expect(coordinator.getMode()).toBe('idle'); - }); - - it('keeps a pinned item anchored until follow mode takes ownership', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const item = document.createElement('div'); - scroller.append(item); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(item, 57); - - const coordinator = new FlowChatViewportCoordinator(); - expect(coordinator.pinElement(item)).toBe(true); - - setRect(item, 87); - expect(coordinator.restoreElementAnchor(scroller)).toBe(true); - expect(scroller.scrollTop).toBe(730); - expect(coordinator.getMode()).toBe('pinned-item'); - - coordinator.followTail({ force: true }); - setRect(item, 117); - expect(coordinator.restoreElementAnchor(scroller)).toBe(false); - }); - - it('retains logical pin ownership while Virtuoso rematerializes a disconnected item', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const item = document.createElement('div'); - scroller.append(item); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(item, 57); - - const coordinator = new FlowChatViewportCoordinator(); - expect(coordinator.pinElement(item)).toBe(true); - - item.remove(); - expect(coordinator.ownsElementAnchor()).toBe(false); - expect(coordinator.getMode()).toBe('pinned-item'); - - coordinator.release('test-cleanup'); - expect(coordinator.getMode()).toBe('idle'); - }); - - it('does not let a tool-card collapse replace an active pinned-item anchor', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const pinnedItem = document.createElement('div'); - const toolCard = document.createElement('div'); - scroller.append(pinnedItem, toolCard); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(pinnedItem, 57); - setRect(toolCard, 300); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.pinElement(pinnedItem); - - expect(coordinator.preserveElement(toolCard)).toBe(false); - expect(coordinator.getMode()).toBe('pinned-item'); - }); - - it('owns virtualizer scroll compensation while an element anchor is active', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const pinnedItem = document.createElement('div'); - scroller.append(pinnedItem); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(pinnedItem, 57); - - const coordinator = new FlowChatViewportCoordinator(); - expect(coordinator.ownsElementAnchor()).toBe(false); - coordinator.pinElement(pinnedItem); - expect(coordinator.ownsElementAnchor()).toBe(true); - coordinator.followTail({ force: true }); - expect(coordinator.ownsElementAnchor()).toBe(false); - }); - - it('restores an idle viewport position once without creating a persistent lock', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - - const coordinator = new FlowChatViewportCoordinator(); - expect(coordinator.restoreScrollPositionOnce(scroller, 1200, 'test-idle')).toBe(true); - expect(scroller.scrollTop).toBe(1200); - - scroller.scrollTop = 900; - expect(coordinator.getMode()).toBe('idle'); - expect(scroller.scrollTop).toBe(900); - }); - - it('clamps the idle fallback restore to the current physical scroll range', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - - const coordinator = new FlowChatViewportCoordinator(); - expect(coordinator.restoreScrollPositionOnce(scroller, 5000, 'test-clamp')).toBe(true); - expect(scroller.scrollTop).toBe(1500); - }); - - it('delegates one-shot restoration to the semantic anchor when it owns the viewport', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const pinnedItem = document.createElement('div'); - scroller.append(pinnedItem); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(pinnedItem, 57); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.pinElement(pinnedItem); - setRect(pinnedItem, 87); - - expect(coordinator.restoreScrollPositionOnce(scroller, 0, 'test-semantic')).toBe(true); - expect(scroller.scrollTop).toBe(730); - }); - - it('extends the physical bottom range before retrying a stalled semantic restore', () => { - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const pinnedItem = document.createElement('div'); - scroller.append(pinnedItem); - document.body.append(scroller); - - let scrollHeight = 1200; - const clientHeight = 500; - let scrollTop = 700; - let itemTop = 57; - Object.defineProperties(scroller, { - clientHeight: { configurable: true, get: () => clientHeight }, - scrollHeight: { configurable: true, get: () => scrollHeight }, - scrollTop: { - configurable: true, - get: () => scrollTop, - set: (requested: number) => { - const maxScrollTop = Math.max(0, scrollHeight - clientHeight); - const applied = Math.min(maxScrollTop, Math.max(0, requested)); - itemTop -= applied - scrollTop; - scrollTop = applied; - }, - }, - }); - setRect(scroller, 0); - vi.spyOn(pinnedItem, 'getBoundingClientRect').mockImplementation(() => ({ - bottom: itemTop + 40, - height: 40, - left: 0, - right: 300, - top: itemTop, - width: 300, - x: 0, - y: itemTop, - toJSON: () => ({}), - })); - - const ensureBottomRange = vi.fn(({ additionalPx }: { additionalPx: number }) => { - scrollHeight += additionalPx; - return true; - }); - const coordinator = new FlowChatViewportCoordinator(); - coordinator.setRangeHost({ ensureBottomRange }); - coordinator.pinElement(pinnedItem); - - scrollHeight = 1150; - scroller.scrollTop = 700; - expect(scrollTop).toBe(650); - expect(itemTop).toBe(107); - - expect(coordinator.restoreElementAnchor(scroller, 'test-range-recovery')).toBe(true); - expect(ensureBottomRange).toHaveBeenCalledWith(expect.objectContaining({ - additionalPx: 51, - mode: 'pinned-item', - source: 'test-range-recovery', - })); - expect(scrollTop).toBe(700); - expect(itemTop).toBe(57); - coordinator.release('test-cleanup'); - }); - - it('reconciles a pinned anchor from the internal animation-frame guard', () => { - let scheduledFrame: FrameRequestCallback | null = null; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - scheduledFrame = callback; - return 1; - }); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); - - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const pinnedItem = document.createElement('div'); - scroller.append(pinnedItem); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(pinnedItem, 57); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.pinElement(pinnedItem); - setRect(pinnedItem, 157); - - expect(scheduledFrame).not.toBeNull(); - (scheduledFrame as FrameRequestCallback)(0); - expect(scroller.scrollTop).toBe(800); - coordinator.release('test-cleanup'); - }); - - it('coalesces scheduled anchor restores into the existing frame using the latest request', () => { - let scheduledFrame: FrameRequestCallback | null = null; - const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - scheduledFrame = callback; - return 1; - }); - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); - - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const pinnedItem = document.createElement('div'); - scroller.append(pinnedItem); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(pinnedItem, 57); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.pinElement(pinnedItem); - const restoreAnchor = vi.spyOn(coordinator, 'restoreElementAnchor'); - setRect(pinnedItem, 87); - - expect(coordinator.scheduleElementAnchorRestore(scroller, 'scroll-handler:first')).toBe(true); - expect(coordinator.scheduleElementAnchorRestore(scroller, 'scroll-handler:latest')).toBe(true); - expect(requestFrame).toHaveBeenCalledTimes(1); - - expect(scheduledFrame).not.toBeNull(); - (scheduledFrame as FrameRequestCallback)(0); - expect(restoreAnchor).toHaveBeenCalledTimes(1); - expect(restoreAnchor).toHaveBeenCalledWith(scroller, 'scroll-handler:latest'); - expect(scroller.scrollTop).toBe(730); - coordinator.release('test-cleanup'); - }); - - it('cancels a scheduled anchor restore when semantic ownership is released', () => { - let scheduledFrame: FrameRequestCallback | null = null; - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - scheduledFrame = callback; - return 17; - }); - const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); - - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const pinnedItem = document.createElement('div'); - scroller.append(pinnedItem); - document.body.append(scroller); - setScrollerGeometry(scroller, 700); - setRect(scroller, 0); - setRect(pinnedItem, 57); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.pinElement(pinnedItem); - const restoreAnchor = vi.spyOn(coordinator, 'restoreElementAnchor'); - coordinator.scheduleElementAnchorRestore(scroller, 'scroll-handler'); - coordinator.release('stream-end-pinned-item'); - - expect(cancelFrame).toHaveBeenCalledWith(17); - expect(scheduledFrame).not.toBeNull(); - (scheduledFrame as FrameRequestCallback)(0); - expect(restoreAnchor).not.toHaveBeenCalled(); - expect(coordinator.getMode()).toBe('idle'); - }); - - it('stops the animation-frame guard after element preservation settles', () => { - vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 17); - const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); - - const scroller = document.createElement('div'); - scroller.dataset.virtuosoScroller = 'true'; - const card = document.createElement('div'); - scroller.append(card); - document.body.append(scroller); - setScrollerGeometry(scroller, 900); - setRect(scroller, 0); - setRect(card, 120); - - const coordinator = new FlowChatViewportCoordinator(); - coordinator.preserveElement(card); - coordinator.settleElementPreservation('test-settled'); - - expect(cancelFrame).toHaveBeenCalledWith(17); - expect(coordinator.ownsElementAnchor()).toBe(true); - }); -}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts deleted file mode 100644 index 32e4589cd8..0000000000 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { flowChatDiagnostics } from '@/infrastructure/diagnostics/flowChatDiagnostics'; - -export type FlowChatViewportAnchorMode = - | 'idle' - | 'pinned-item' - | 'following-tail' - | 'preserving-element'; - -export interface FlowChatViewportRangeHost { - ensureBottomRange(options: { - additionalPx: number; - mode: Extract; - source: string; - }): boolean; -} - -export type FlowChatElementAnchorLease = number; - -type ElementAnchor = { - element: HTMLElement; - scroller: HTMLElement; - offsetFromScrollerTop: number; - preservationPhase: 'active' | 'retained' | null; - lease: FlowChatElementAnchorLease; -}; - -type PendingElementAnchorRestore = { - scroller: HTMLElement; - source: string; -}; - -const ELEMENT_ANCHOR_EPSILON_PX = 0.5; -const ELEMENT_ANCHOR_RANGE_GUARD_PX = 1; - -export function canHandoffPinnedItemToTail(options: { - pinReservationPx: number; - collapseReservationPx: number; - pendingStickyPinGrowthPx: number; - hasPendingCollapseIntent: boolean; - viewport: { - scrollTop: number; - clientHeight: number; - naturalContentHeight: number; - } | null; -}): boolean { - if ( - options.collapseReservationPx > ELEMENT_ANCHOR_EPSILON_PX || - options.pendingStickyPinGrowthPx > ELEMENT_ANCHOR_EPSILON_PX || - options.hasPendingCollapseIntent - ) { - return false; - } - - if (options.pinReservationPx <= ELEMENT_ANCHOR_EPSILON_PX) { - return true; - } - - const viewport = options.viewport; - if ( - !viewport || - !Number.isFinite(viewport.scrollTop) || - !Number.isFinite(viewport.clientHeight) || - !Number.isFinite(viewport.naturalContentHeight) || - viewport.clientHeight <= ELEMENT_ANCHOR_EPSILON_PX - ) { - return false; - } - - return ( - viewport.naturalContentHeight + ELEMENT_ANCHOR_EPSILON_PX >= - viewport.scrollTop + viewport.clientHeight - ); -} - -/** Owns anchor priority independently from the virtualizer implementation. */ -export class FlowChatViewportCoordinator { - private mode: FlowChatViewportAnchorMode = 'idle'; - private elementAnchor: ElementAnchor | null = null; - private anchorGuardFrame: number | null = null; - private pendingElementAnchorRestore: PendingElementAnchorRestore | null = null; - private rangeHost: FlowChatViewportRangeHost | null = null; - private nextElementAnchorLease = 0; - - setRangeHost(host: FlowChatViewportRangeHost | null): void { - this.rangeHost = host; - } - - getMode(): FlowChatViewportAnchorMode { - this.validateElementAnchor('get-mode'); - return this.mode; - } - - ownsElementAnchor(): boolean { - this.validateElementAnchor('owns-element-anchor'); - return Boolean( - this.elementAnchor && - (this.mode === 'pinned-item' || this.mode === 'preserving-element'), - ); - } - - pinItem(reason = 'unspecified'): void { - const previousMode = this.mode; - this.cancelElementAnchorRestoreWork(); - this.elementAnchor = null; - this.mode = 'pinned-item'; - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'B', - location: 'FlowChatViewportCoordinator.pinItem', - message: 'Viewport coordinator entered pinned item mode', - data: () => ({ previousMode, reason }), - }); - } - } - - pinElement(element: HTMLElement | null | undefined): boolean { - return this.captureElement(element, 'pinned-item') !== null; - } - - followTail(options?: { force?: boolean }): boolean { - this.validateElementAnchor('follow-tail'); - const hasActiveElementPreservation = ( - this.mode === 'preserving-element' && - this.elementAnchor?.preservationPhase === 'active' - ); - if (hasActiveElementPreservation && !options?.force) { - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'B', - location: 'FlowChatViewportCoordinator.followTail', - message: 'Tail follow rejected during active element preservation', - data: () => ({ - mode: this.mode, - preservationPhase: this.elementAnchor?.preservationPhase ?? null, - force: options?.force === true, - }), - }); - } - return false; - } - - const previousMode = this.mode; - this.cancelElementAnchorRestoreWork(); - this.elementAnchor = null; - this.mode = 'following-tail'; - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'B', - location: 'FlowChatViewportCoordinator.followTail', - message: 'Viewport coordinator entered tail follow mode', - data: () => ({ previousMode, force: options?.force === true }), - }); - } - return true; - } - - preserveElement(element: HTMLElement | null | undefined): boolean { - return this.preserveElementWithLease(element) !== null; - } - - preserveElementWithLease( - element: HTMLElement | null | undefined, - ): FlowChatElementAnchorLease | null { - this.validateElementAnchor('preserve-element'); - if (!element || this.mode === 'following-tail' || this.mode === 'pinned-item') { - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'E', - location: 'FlowChatViewportCoordinator.preserveElement', - message: 'Element preservation request rejected', - data: () => ({ hasElement: Boolean(element), mode: this.mode }), - }); - } - return null; - } - - return this.captureElement( - element, - 'preserving-element', - ); - } - - releaseElementPreservationLease( - lease: FlowChatElementAnchorLease, - reason = 'unspecified', - ): boolean { - this.validateElementAnchor(`release-element-preservation-lease:${reason}`); - if ( - this.mode !== 'preserving-element' - || this.elementAnchor?.lease !== lease - ) { - return false; - } - - this.release(reason); - return true; - } - - settleElementPreservation(source = 'unspecified'): boolean { - this.validateElementAnchor('settle-element-preservation'); - const anchor = this.elementAnchor; - if (!anchor || this.mode !== 'preserving-element') { - return false; - } - - const previousPhase = anchor.preservationPhase; - anchor.preservationPhase = 'retained'; - this.stopAnchorGuard(); - this.startAnchorGuard(); - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'E', - location: 'FlowChatViewportCoordinator.settleElementPreservation', - message: 'Element preservation retained after layout settlement', - data: () => ({ previousPhase, source }), - }); - } - return true; - } - - private captureElement( - element: HTMLElement | null | undefined, - mode: 'pinned-item' | 'preserving-element', - ): FlowChatElementAnchorLease | null { - if (!element) { - return null; - } - - const scroller = element.closest('[data-virtuoso-scroller="true"]'); - if (!scroller) { - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'B', - location: 'FlowChatViewportCoordinator.captureElement', - message: 'Element anchor capture failed without a scroller', - data: () => ({ mode }), - }); - } - return null; - } - - const elementRect = element.getBoundingClientRect(); - const scrollerRect = scroller.getBoundingClientRect(); - const lease = ++this.nextElementAnchorLease; - this.cancelElementAnchorRestoreWork(); - this.elementAnchor = { - element, - scroller, - offsetFromScrollerTop: elementRect.top - scrollerRect.top, - preservationPhase: mode === 'preserving-element' ? 'active' : null, - lease, - }; - this.mode = mode; - this.startAnchorGuard(); - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: mode === 'preserving-element' ? 'E' : 'B', - location: 'FlowChatViewportCoordinator.captureElement', - message: 'Semantic element anchor captured', - data: () => ({ - mode, - preservationPhase: this.elementAnchor?.preservationPhase ?? null, - lease, - elementConnected: element.isConnected, - offsetFromScrollerTop: this.elementAnchor?.offsetFromScrollerTop ?? null, - scrollTop: scroller.scrollTop, - scrollHeight: scroller.scrollHeight, - clientHeight: scroller.clientHeight, - }), - }); - } - return lease; - } - - restoreElementAnchor(scroller: HTMLElement, source = 'external'): boolean { - this.validateElementAnchor(`restore:${source}`); - const anchor = this.elementAnchor; - if (!anchor || (this.mode !== 'preserving-element' && this.mode !== 'pinned-item')) { - return false; - } - - const readCorrection = () => { - const elementRect = anchor.element.getBoundingClientRect(); - const scrollerRect = scroller.getBoundingClientRect(); - return elementRect.top - scrollerRect.top - anchor.offsetFromScrollerTop; - }; - const applyCorrection = (correction: number) => { - const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - const desiredScrollTop = scroller.scrollTop + correction; - const requestedScrollTop = Math.min(maxScrollTop, Math.max(0, desiredScrollTop)); - scroller.scrollTop = requestedScrollTop; - }; - - const initialCorrection = readCorrection(); - if (Math.abs(initialCorrection) <= ELEMENT_ANCHOR_EPSILON_PX) { - return false; - } - - const diagnosticsEnabled = flowChatDiagnostics.isEnabled(); - const scrollTopBefore = diagnosticsEnabled ? scroller.scrollTop : null; - applyCorrection(initialCorrection); - let remainingCorrection = readCorrection(); - if (diagnosticsEnabled) { - flowChatDiagnostics.trace({ - hypothesis: 'B', - location: 'FlowChatViewportCoordinator.restoreElementAnchor', - message: 'Semantic anchor correction applied', - data: () => ({ - mode: this.mode, - source, - initialCorrection, - remainingCorrection, - scrollTopBefore, - scrollTopAfter: scroller.scrollTop, - maxScrollTop: Math.max(0, scroller.scrollHeight - scroller.clientHeight), - }), - }); - } - - if ( - remainingCorrection > ELEMENT_ANCHOR_EPSILON_PX && - this.rangeHost && - (this.mode === 'pinned-item' || this.mode === 'preserving-element') - ) { - const rangeExtended = this.rangeHost.ensureBottomRange({ - additionalPx: remainingCorrection + ELEMENT_ANCHOR_RANGE_GUARD_PX, - mode: this.mode, - source, - }); - if (rangeExtended) { - void scroller.scrollHeight; - remainingCorrection = readCorrection(); - if (Math.abs(remainingCorrection) > ELEMENT_ANCHOR_EPSILON_PX) { - applyCorrection(remainingCorrection); - } - } - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'C', - location: 'FlowChatViewportCoordinator.restoreElementAnchor', - message: 'Semantic anchor requested additional bottom range', - data: () => ({ - mode: this.mode, - source, - rangeExtended, - remainingCorrection, - scrollTop: scroller.scrollTop, - scrollHeight: scroller.scrollHeight, - clientHeight: scroller.clientHeight, - }), - }); - } - } - return true; - } - - scheduleElementAnchorRestore(scroller: HTMLElement, source = 'external'): boolean { - this.validateElementAnchor(`schedule-restore:${source}`); - if ( - !this.elementAnchor || - (this.mode !== 'preserving-element' && this.mode !== 'pinned-item') - ) { - return false; - } - - this.pendingElementAnchorRestore = { scroller, source }; - if (typeof requestAnimationFrame === 'undefined') { - this.pendingElementAnchorRestore = null; - return this.restoreElementAnchor(scroller, source); - } - - this.startAnchorGuard(); - return true; - } - - restoreScrollPositionOnce( - scroller: HTMLElement, - targetScrollTop: number, - source = 'unspecified', - ): boolean { - if (this.ownsElementAnchor()) { - this.restoreElementAnchor(scroller, source); - return true; - } - - const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - const previousScrollTop = scroller.scrollTop; - const nextScrollTop = Math.min(maxScrollTop, Math.max(0, targetScrollTop)); - if (Math.abs(nextScrollTop - previousScrollTop) <= ELEMENT_ANCHOR_EPSILON_PX) { - return false; - } - - scroller.scrollTop = nextScrollTop; - return true; - } - - release(reason = 'unspecified'): void { - const previousMode = this.mode; - const hadElementAnchor = Boolean(this.elementAnchor); - const previousPreservationPhase = this.elementAnchor?.preservationPhase ?? null; - this.cancelElementAnchorRestoreWork(); - this.elementAnchor = null; - this.mode = 'idle'; - if (flowChatDiagnostics.isEnabled()) { - flowChatDiagnostics.trace({ - hypothesis: 'B', - location: 'FlowChatViewportCoordinator.release', - message: 'Viewport coordinator released semantic ownership', - data: () => ({ previousMode, previousPreservationPhase, hadElementAnchor, reason }), - }); - } - } - - private validateElementAnchor(source: string): void { - const anchor = this.elementAnchor; - if (anchor && (!anchor.element.isConnected || !anchor.scroller.isConnected)) { - if (this.mode === 'pinned-item' && anchor.scroller.isConnected) { - this.cancelElementAnchorRestoreWork(); - this.elementAnchor = null; - return; - } - this.release(`element-anchor-disconnected:${source}`); - } - } - - private startAnchorGuard(): void { - const shouldContinuouslyGuard = ( - this.mode === 'pinned-item' || - ( - this.mode === 'preserving-element' && - this.elementAnchor?.preservationPhase === 'active' - ) - ); - if ( - this.anchorGuardFrame !== null || - typeof requestAnimationFrame === 'undefined' || - (!shouldContinuouslyGuard && !this.pendingElementAnchorRestore) - ) { - return; - } - this.anchorGuardFrame = requestAnimationFrame(this.runAnchorGuardFrame); - } - - private stopAnchorGuard(): void { - if (this.anchorGuardFrame === null || typeof cancelAnimationFrame === 'undefined') { - this.anchorGuardFrame = null; - return; - } - cancelAnimationFrame(this.anchorGuardFrame); - this.anchorGuardFrame = null; - } - - private cancelElementAnchorRestoreWork(): void { - this.pendingElementAnchorRestore = null; - this.stopAnchorGuard(); - } - - private runAnchorGuardFrame = (): void => { - this.anchorGuardFrame = null; - this.validateElementAnchor('anchor-guard'); - const anchor = this.elementAnchor; - if ( - !anchor || - (this.mode !== 'pinned-item' && this.mode !== 'preserving-element') || - ( - this.mode === 'preserving-element' && - anchor.preservationPhase === 'retained' && - !this.pendingElementAnchorRestore - ) - ) { - return; - } - - const pendingRestore = this.pendingElementAnchorRestore; - this.pendingElementAnchorRestore = null; - this.restoreElementAnchor( - pendingRestore?.scroller ?? anchor.scroller, - pendingRestore?.source ?? 'anchor-guard', - ); - this.startAnchorGuard(); - }; -} diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index 0f2e7e2613..c2a93cd4a6 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -1,2454 +1,21 @@ -// @vitest-environment jsdom +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; -import React, { act } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRoot, type Root } from 'react-dom/client'; -import { ModernFlowChatContainer } from './ModernFlowChatContainer'; -import type { HistoryWindowBoundaryIntentResult } from './VirtualMessageList'; -import type { Session } from '../../types/flow-chat'; -import { flowChatStore } from '../../store/FlowChatStore'; -import { - clearHistorySessionOpenTransition, - dispatchHistorySessionOpenIntent, - HISTORY_SESSION_OPEN_INTENT_EVENT, -} from '../../services/sessionOpenIntent'; -import { FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX } from './flowChatTurnRailWindow'; +const componentSource = fs.readFileSync( + path.resolve(__dirname, 'ModernFlowChatContainer.tsx'), + 'utf8', +); -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -const stateMocks = vi.hoisted(() => ({ - activeSession: null as Session | null, - virtualItems: [] as unknown[], - visibleTurnInfo: null as unknown, -})); - -const switchChatSessionMock = vi.hoisted(() => vi.fn()); -const virtualListMock = vi.hoisted(() => ({ - scrollToTurn: vi.fn(), - scrollToIndex: vi.fn(), - scrollToSearchMatch: vi.fn(), - clearSearchMatch: vi.fn(), - scrollToPhysicalBottomAndClearPin: vi.fn(), - scrollToTurnEndAndClearPin: vi.fn(() => true), - scrollToLatestEndPosition: vi.fn(), - isTurnRenderedInViewport: vi.fn(() => false), - isTurnTextRenderedInViewport: vi.fn(() => false), - pinTurnToTop: vi.fn(() => true), - pinTurnToTopWithStatus: vi.fn(() => 'settled' as const), - prepareTurnPinToTop: vi.fn(() => 'pending' as const), -})); -const virtualListActionClickMock = vi.hoisted(() => vi.fn()); -const startupTraceMock = vi.hoisted(() => ({ - markPhase: vi.fn(), -})); -const historySessionDiagnosticsMock = vi.hoisted(() => ({ - beginHistorySessionDiagnostics: vi.fn(() => 'diag-1'), - recordHistorySessionDiagnosticEvent: vi.fn(), - warnHistorySessionLoadingLayerStalled: vi.fn(), -})); -const searchStateMock = vi.hoisted(() => ({ - searchQuery: '', - onSearchChange: vi.fn(), - matches: [] as unknown[], - matchIndices: [] as number[], - currentMatchIndex: -1, - currentMatchVirtualIndex: -1, - goToNext: vi.fn(), - goToPrev: vi.fn(), - clearSearch: vi.fn(), -})); -const headerPropsMock = vi.hoisted(() => ({ - latest: null as Record | null, -})); -const virtualListPropsMock = vi.hoisted(() => ({ - latest: null as Record | null, -})); -const navigationOptionsMock = vi.hoisted(() => ({ - latest: null as Record | null, -})); -const agentApiMock = vi.hoisted(() => ({ - listBackgroundCommandActivities: vi.fn(() => Promise.resolve({ activities: [] })), - onPermissionRequestEvent: vi.fn(() => vi.fn()), - subscribePermissionRequests: vi.fn(() => Promise.resolve()), - listPendingPermissionRequests: vi.fn(() => Promise.resolve([])), -})); - -vi.mock('react-i18next', () => ({ - initReactI18next: { - type: '3rdParty', - init: () => undefined, - }, - useTranslation: () => ({ - t: (key: string) => { - const labels: Record = { - 'historyState.loadingTitle': 'Loading saved session', - 'historyState.loadingDescription': 'Preparing the conversation history.', - 'historyState.failedTitle': 'Session history did not load', - 'historyState.failedDescription': 'Retry loading the saved conversation.', - 'historyState.retry': 'Retry', - }; - return labels[key] ?? key; - }, - }), -})); - -vi.mock('@/infrastructure/hooks/useShortcut', () => ({ - useShortcut: vi.fn(), -})); - -vi.mock('@/flow_chat/services/FlowChatManager', () => ({ - FlowChatManager: { - getInstance: () => ({ - cancelCurrentTask: vi.fn(), - createChatSession: vi.fn(), - switchChatSession: switchChatSessionMock, - }), - }, -})); - -vi.mock('@/app/stores/sessionModeStore', () => ({ - useSessionModeStore: { - getState: () => ({ - setMode: vi.fn(), - }), - }, -})); - -vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ - useWorkspaceContext: () => ({ - workspacePath: 'D:/workspace/BitFun', - }), -})); - -vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ - agentAPI: agentApiMock, -})); - -vi.mock('../../utils/acpSession', () => ({ - isAcpFlowSession: () => false, -})); - -vi.mock('../../store/modernFlowChatStore', () => ({ - sessionToVirtualItems: (session: Session | null) => (session?.dialogTurns ?? []).map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })), - useVirtualItems: () => stateMocks.virtualItems, - useActiveSession: () => stateMocks.activeSession, - useVisibleTurnInfo: () => stateMocks.visibleTurnInfo, -})); - -vi.mock('./VirtualMessageList', () => ({ - VirtualMessageList: React.forwardRef((props: Record, ref) => { - virtualListPropsMock.latest = props; - React.useImperativeHandle(ref, () => virtualListMock); - return ( -
- -
- ); - }), -})); - -vi.mock('@/shared/utils/startupTrace', () => ({ - isRemoteTraceContext: () => false, - startupTrace: startupTraceMock, -})); - -vi.mock('../../services/historySessionDiagnostics', () => historySessionDiagnosticsMock); - -vi.mock('./FlowChatHeader', () => ({ - FlowChatHeader: (props: Record) => { - headerPropsMock.latest = props; - return
; - }, -})); - -vi.mock('../WelcomePanel', () => ({ - WelcomePanel: () =>
Welcome panel
, -})); - -vi.mock('./useExploreGroupState', () => ({ - useExploreGroupState: () => ({ - exploreGroupStates: {}, - onExploreGroupToggle: vi.fn(), - onExpandGroup: vi.fn(), - onExpandAllInTurn: vi.fn(), - onCollapseGroup: vi.fn(), - }), -})); - -vi.mock('./useFlowChatFileActions', () => ({ - useFlowChatFileActions: () => ({ - handleFileViewRequest: vi.fn(), - }), -})); - -vi.mock('./useFlowChatNavigation', () => ({ - useFlowChatNavigation: (options: Record) => { - navigationOptionsMock.latest = options; - }, -})); - -vi.mock('./useFlowChatCopyDialog', () => ({ - useFlowChatCopyDialog: vi.fn(), -})); - -vi.mock('./useFlowChatSync', () => ({ - useFlowChatSync: vi.fn(), -})); - -vi.mock('./useFlowChatToolActions', () => ({ - useFlowChatToolActions: () => ({ - handleToolConfirm: vi.fn(), - handleToolReject: vi.fn(), - }), -})); - -vi.mock('./useFlowChatSearch', () => ({ - useFlowChatSearch: () => searchStateMock, -})); - -function createSession(overrides: Partial = {}): Session { - return { - sessionId: 'session-1', - title: 'Saved session', - dialogTurns: [], - status: 'idle', - config: { agentType: 'agentic' }, - createdAt: 1, - lastActiveAt: 1, - error: null, - isHistorical: true, - todos: [], - mode: 'agentic', - workspacePath: 'D:/workspace/BitFun', - sessionKind: 'normal', - ...overrides, - }; -} - -function createTurn(id: string, content: string, status: 'completed' | 'processing' = 'completed') { - return { - id, - turnId: id, - sessionId: 'session-1', - timestamp: 1, - userMessage: { id: `user-${id}`, content, timestamp: 1 }, - modelRounds: [], - startTime: 1, - status, - }; -} - -let rafCallbacks: FrameRequestCallback[] = []; - -function flushAnimationFrame() { - const callbacks = rafCallbacks; - rafCallbacks = []; - act(() => { - callbacks.forEach(callback => callback(performance.now())); - }); -} - -function clickTurnRailItem(container: HTMLElement, turnId: string) { - const item = container.querySelector(`[data-turn-id="${turnId}"]`); - expect(item).not.toBeNull(); - act(() => { - item?.click(); - }); -} - -function scrollTurnRailToOrdinal( - container: HTMLElement, - ordinal: number, - clientHeight = FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX * 5, -) { - const list = container.querySelector('.flowchat-turn-rail__list'); - expect(list).not.toBeNull(); - if (!list) return; - - Object.defineProperty(list, 'clientHeight', { configurable: true, value: clientHeight }); - list.scrollTop = ordinal * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX; - act(() => { - list.dispatchEvent(new Event('scroll', { bubbles: true })); - }); -} - -describe('ModernFlowChatContainer historical empty state', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - vi.restoreAllMocks(); - rafCallbacks = []; - vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { - rafCallbacks.push(callback); - return rafCallbacks.length; - })); - vi.stubGlobal('cancelAnimationFrame', vi.fn()); - // jsdom in vitest 4.x may expose window.localStorage without a callable - // getItem; provide a minimal storage so shouldShowMockBackgroundActivities - // does not crash during render. - vi.stubGlobal('localStorage', { - getItem: vi.fn(() => null), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(() => null), - length: 0, - }); - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - stateMocks.virtualItems = []; - stateMocks.visibleTurnInfo = null; - switchChatSessionMock.mockReset(); - virtualListMock.scrollToTurn.mockReset(); - virtualListMock.scrollToIndex.mockReset(); - virtualListMock.scrollToSearchMatch.mockReset(); - virtualListMock.clearSearchMatch.mockReset(); - virtualListMock.scrollToPhysicalBottomAndClearPin.mockReset(); - virtualListMock.scrollToTurnEndAndClearPin.mockReset(); - virtualListMock.scrollToTurnEndAndClearPin.mockReturnValue(true); - virtualListMock.scrollToLatestEndPosition.mockReset(); - virtualListMock.isTurnRenderedInViewport.mockReset(); - virtualListMock.isTurnRenderedInViewport.mockReturnValue(false); - virtualListMock.isTurnTextRenderedInViewport.mockReset(); - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - virtualListMock.pinTurnToTop.mockReset(); - virtualListMock.pinTurnToTop.mockReturnValue(true); - virtualListMock.pinTurnToTopWithStatus.mockReset(); - virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); - virtualListMock.prepareTurnPinToTop.mockReset(); - virtualListMock.prepareTurnPinToTop.mockReturnValue('pending'); - virtualListActionClickMock.mockReset(); - startupTraceMock.markPhase.mockReset(); - historySessionDiagnosticsMock.beginHistorySessionDiagnostics.mockReset(); - historySessionDiagnosticsMock.beginHistorySessionDiagnostics.mockReturnValue('diag-1'); - historySessionDiagnosticsMock.recordHistorySessionDiagnosticEvent.mockReset(); - historySessionDiagnosticsMock.warnHistorySessionLoadingLayerStalled.mockReset(); - agentApiMock.listBackgroundCommandActivities.mockClear(); - agentApiMock.listBackgroundCommandActivities.mockResolvedValue({ activities: [] }); - searchStateMock.searchQuery = ''; - searchStateMock.onSearchChange.mockReset(); - searchStateMock.matches = []; - searchStateMock.matchIndices = []; - searchStateMock.currentMatchIndex = -1; - searchStateMock.currentMatchVirtualIndex = -1; - searchStateMock.goToNext.mockReset(); - searchStateMock.goToPrev.mockReset(); - searchStateMock.clearSearch.mockReset(); - headerPropsMock.latest = null; - virtualListPropsMock.latest = null; - navigationOptionsMock.latest = null; - clearHistorySessionOpenTransition(); - }); - - afterEach(() => { - if (root) { - act(() => { - root.unmount(); - }); - } - container?.remove(); - stateMocks.activeSession = null; - clearHistorySessionOpenTransition(); - vi.useRealTimers(); - vi.unstubAllGlobals(); - }); - - it('shows a history loading shell for metadata-only sessions instead of the new-session welcome', () => { - stateMocks.activeSession = createSession({ historyState: 'metadata-only' } as Partial); - - act(() => { - root.render(); - }); - - expect(container.textContent).toContain('Loading saved session'); - expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); - }); - - it('defers viewport anchoring while the host scene is inactive', () => { - const turn = createTurn('turn-1', 'One'); - stateMocks.activeSession = createSession({ - dialogTurns: [turn], - historyState: 'ready', - contextRestoreState: 'ready', - }); - stateMocks.virtualItems = [{ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - }]; - - act(() => { - root.render(); - }); - - expect(virtualListPropsMock.latest).toMatchObject({ isViewportActive: false }); - expect(virtualListMock.scrollToTurnEndAndClearPin).not.toHaveBeenCalled(); - - act(() => { - root.render(); - }); - - expect(virtualListPropsMock.latest).toMatchObject({ isViewportActive: true }); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledWith(turn.id); - }); - - it('keeps the loading shell while historical sessions are hydrating', () => { - stateMocks.activeSession = createSession({ historyState: 'hydrating' } as Partial); - - act(() => { - root.render(); - }); - - expect(container.textContent).toContain('Loading saved session'); - expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); - }); - - it('renders a host-provided empty state instead of the generic welcome panel', () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'new', - dialogTurns: [], - } as Partial); - - act(() => { - root.render( - MiniApp welcome
} - /> - ); - }); - - expect(container.querySelector('[data-testid="miniapp-welcome"]')).not.toBeNull(); - expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); - }); - - it('reports a stalled history loading layer after the diagnostic threshold', async () => { - vi.useFakeTimers(); - stateMocks.activeSession = createSession({ - sessionId: 'history-session', - historyState: 'metadata-only', - dialogTurns: [], - } as Partial); - - await act(async () => { - root.render(); - }); - - expect(container.textContent).toContain('Loading saved session'); - expect(historySessionDiagnosticsMock.warnHistorySessionLoadingLayerStalled).not.toHaveBeenCalled(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(799); - }); - - expect(historySessionDiagnosticsMock.warnHistorySessionLoadingLayerStalled).not.toHaveBeenCalled(); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1); - }); - - expect(historySessionDiagnosticsMock.warnHistorySessionLoadingLayerStalled).toHaveBeenCalledWith( - 'history-session', - expect.objectContaining({ - durationMs: 800, - historyState: 'metadata-only', - isHistorical: true, - isRemote: false, - activeSessionIdMatches: true, - hasRenderableContent: false, - dialogTurnCount: 0, - }), - ); - }); - - it('does not show the new-session welcome while a restored session is waiting for virtual items', () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [{ - id: 'turn-1', - turnId: 'turn-1', - sessionId: 'session-1', - timestamp: 1, - userMessage: { id: 'user-1', content: 'Saved prompt', timestamp: 1 }, - modelRounds: [], - startTime: 1, - status: 'completed', - }], - } as Partial); - - act(() => { - root.render(); - }); - - expect(container.textContent).toContain('Loading saved session'); - expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); - }); - - it('covers the current message list after a historical session open intent', async () => { - stateMocks.activeSession = createSession({ - sessionId: 'current-session', - isHistorical: false, - historyState: 'ready', - dialogTurns: [createTurn('turn-1', 'Current visible prompt')], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Current visible prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - - act(() => { - window.dispatchEvent(new CustomEvent(HISTORY_SESSION_OPEN_INTENT_EVENT, { - detail: { sessionId: 'history-session', sessionTitle: 'Saved investigation' }, - })); - }); - - expect(container.textContent).not.toContain('Loading saved session'); - expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - expect(container.querySelector('.modern-flowchat-container__history-open-intent-shield')).not.toBeNull(); - expect(container.querySelector('.modern-flowchat-container__history-open-intent-spinner')).not.toBeNull(); - expect(container.textContent).toContain('Hidden action'); - expect(container.textContent).not.toContain('Saved investigation'); - expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-open-intent-overlay')) - .toBe('true'); - (container.querySelector('[data-testid="virtual-list-action"]') as HTMLButtonElement | null)?.click(); - expect(virtualListActionClickMock).not.toHaveBeenCalled(); - - stateMocks.activeSession = createSession({ - sessionId: 'history-session', - historyState: 'metadata-only', - } as Partial); - stateMocks.virtualItems = []; - - await act(async () => { - root.render(); - }); - - expect(container.textContent).not.toContain('Loading saved session'); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); - expect(container.querySelector('.modern-flowchat-container__history-open-intent-shield')).not.toBeNull(); - expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-loading-layer')) - .toBe('false'); - expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-open-intent-overlay')) - .toBe('true'); - - stateMocks.activeSession = createSession({ - sessionId: 'history-session', - isHistorical: false, - historyState: 'ready', - dialogTurns: [createTurn('turn-2', 'Restored latest prompt')], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Restored latest prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(container.querySelector('.modern-flowchat-container__history-open-intent-shield')).toBeNull(); - expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-open-intent-overlay')) - .toBe('false'); +describe('ModernFlowChatContainer natural navigation contract', () => { + it('restores historical tails through natural end alignment', () => { + expect(componentSource).toContain('scrollToTurnEnd(latestTurnId)'); + expect(componentSource).toContain('prepareTurnNavigation'); }); - it('removes the loading layer when a hydrating session receives its initial tail turns', async () => { - stateMocks.activeSession = createSession({ - sessionId: 'history-session', - historyState: 'hydrating', - dialogTurns: [], - } as Partial); - stateMocks.virtualItems = []; - - await act(async () => { - root.render(); - }); - - const initialOverlay = container.querySelector('.modern-flowchat-container__history-overlay'); - expect(initialOverlay).not.toBeNull(); - expect(container.querySelector('[data-testid="virtual-list"]')).toBeNull(); - - stateMocks.activeSession = createSession({ - sessionId: 'history-session', - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).not.toBe(initialOverlay); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - expect(container.textContent).not.toContain('Loading saved session'); - expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-transition-overlay')) - .toBe('true'); - }); - - it('keeps restored content visible while restored latest text is not ready', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(container.textContent).not.toContain('Loading saved session'); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-transition-overlay')) - .toBe('true'); - - flushAnimationFrame(); - expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(container.textContent).not.toContain('Loading saved session'); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); - flushAnimationFrame(); - - expect(container.textContent).not.toContain('Loading saved session'); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - }); - - it('does not show the initial history progress again when full hydration adds older turns', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); - flushAnimationFrame(); - - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-0', 'Restored older prompt'), - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-0', data: { id: 'user-turn-0', content: 'Restored older prompt' } }, - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - }); - - it('blocks pointer activation until restored latest text is visible', async () => { - const releaseSpy = vi - .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') - .mockReturnValue(true); - - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - const hiddenAction = container.querySelector('[data-testid="virtual-list-action"]') as HTMLButtonElement; - expect(hiddenAction).not.toBeNull(); - expect(container.textContent).not.toContain('Loading saved session'); - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - - act(() => { - hiddenAction.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - }); - - expect(virtualListActionClickMock).not.toHaveBeenCalled(); - - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); - flushAnimationFrame(); - flushAnimationFrame(); - flushAnimationFrame(); - - expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); - expect(releaseSpy).toHaveBeenCalledWith('session-1'); - expect(startupTraceMock.markPhase).toHaveBeenCalledWith( - 'historical_session_initial_content_painted', - expect.objectContaining({ - sessionId: 'session-1', - latestTurnId: 'turn-2', - released: true, - }), - ); - - act(() => { - hiddenAction.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - }); - - expect(virtualListActionClickMock).toHaveBeenCalledTimes(1); - releaseSpy.mockRestore(); - }); - - it('defers background command snapshot until restored latest text is visible and painted', async () => { - const releaseSpy = vi - .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') - .mockReturnValue(true); - - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - expect(agentApiMock.listBackgroundCommandActivities).not.toHaveBeenCalled(); - - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); - flushAnimationFrame(); - expect(releaseSpy).not.toHaveBeenCalled(); - expect(agentApiMock.listBackgroundCommandActivities).not.toHaveBeenCalled(); - - flushAnimationFrame(); - expect(releaseSpy).not.toHaveBeenCalled(); - expect(agentApiMock.listBackgroundCommandActivities).not.toHaveBeenCalled(); - - flushAnimationFrame(); - expect(releaseSpy).toHaveBeenCalledWith('session-1'); - expect(agentApiMock.listBackgroundCommandActivities).not.toHaveBeenCalled(); - - flushAnimationFrame(); - expect(agentApiMock.listBackgroundCommandActivities).not.toHaveBeenCalled(); - - flushAnimationFrame(); - expect(agentApiMock.listBackgroundCommandActivities).toHaveBeenCalledTimes(1); - expect(agentApiMock.listBackgroundCommandActivities).toHaveBeenCalledWith({ - agentSessionId: 'session-1', - }); - - releaseSpy.mockRestore(); - }); - - it('skips stale background command snapshot when another history session open starts first', async () => { - const releaseSpy = vi - .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') - .mockReturnValue(true); - - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); - flushAnimationFrame(); - flushAnimationFrame(); - - act(() => { - dispatchHistorySessionOpenIntent('session-2', 'Next saved session'); - }); - flushAnimationFrame(); - - expect(releaseSpy).toHaveBeenCalledWith('session-1'); - flushAnimationFrame(); - flushAnimationFrame(); - expect(agentApiMock.listBackgroundCommandActivities).not.toHaveBeenCalled(); - - releaseSpy.mockRestore(); - }); - - it('keeps full history projection deferred when latest text visibility signal is missed', async () => { - const releaseSpy = vi - .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') - .mockReturnValue(true); - - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'pending', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - for (let index = 0; index < 30; index += 1) { - flushAnimationFrame(); - } - - expect(container.textContent).not.toContain('Loading saved session'); - expect(releaseSpy).not.toHaveBeenCalled(); - expect(startupTraceMock.markPhase).toHaveBeenCalledWith( - 'historical_session_initial_content_paint_signal_missed', - expect.objectContaining({ attempts: 30 }), - ); - - releaseSpy.mockRestore(); - }); - - it('requests full history when search starts from a partial session', async () => { - const ensureSpy = vi - .spyOn(flowChatStore, 'ensureSessionFullHistory') - .mockResolvedValue(true); - - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - contextRestoreState: 'ready', - isPartial: true, - dialogTurns: [ - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); - - await act(async () => { - root.render(); - }); - - await act(async () => { - (headerPropsMock.latest?.onSearchChange as ((query: string) => void) | undefined)?.( - 'older prompt', - ); - await Promise.resolve(); - }); - - expect(searchStateMock.onSearchChange).toHaveBeenCalledWith('older prompt'); - expect(ensureSpy).toHaveBeenCalledWith('session-1', 'flowchat-search'); - }); - - it('repositions an unchanged virtual match when the search query changes', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [createTurn('turn-1', 'Searchable prompt')], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Searchable prompt' } }, - ]; - searchStateMock.searchQuery = 'search'; - searchStateMock.matches = [{ - virtualItemIndex: 0, - turnId: 'turn-1', - type: 'user-message', - occurrenceIndex: 0, - }]; - searchStateMock.currentMatchIndex = 0; - searchStateMock.currentMatchVirtualIndex = 0; - - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - - expect(virtualListMock.scrollToSearchMatch).toHaveBeenLastCalledWith({ - virtualItemIndex: 0, - query: 'search', - flowItemId: undefined, - occurrenceIndex: 0, - expandableIds: undefined, - }); - - searchStateMock.searchQuery = 'searchable'; - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - - expect(virtualListMock.scrollToSearchMatch).toHaveBeenCalledTimes(2); - expect(virtualListMock.scrollToSearchMatch).toHaveBeenLastCalledWith({ - virtualItemIndex: 0, - query: 'searchable', - flowItemId: undefined, - occurrenceIndex: 0, - expandableIds: undefined, - }); - }); - - it('keeps the new-session welcome for genuinely new empty sessions', () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'new', - } as Partial); - - act(() => { - root.render(); - }); - - expect(container.querySelector('[data-testid="welcome-panel"]')).not.toBeNull(); - }); - - it('shows retry for failed history loads', () => { - stateMocks.activeSession = createSession({ historyState: 'failed' } as Partial); - - act(() => { - root.render(); - }); - - const retryButton = Array.from(container.querySelectorAll('button')) - .find(button => button.textContent?.includes('Retry')); - expect(container.textContent).toContain('Session history did not load'); - expect(retryButton).toBeTruthy(); - - act(() => { - retryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - }); - - expect(switchChatSessionMock).toHaveBeenCalledWith('session-1'); - }); - - it('shows global turn numbers for partial tail history while navigation stays within loaded turns', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - isPartial: true, - loadedTurnCount: 2, - totalTurnCount: 100, - dialogTurns: [ - createTurn('turn-99', 'Recent restored prompt'), - createTurn('turn-100', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-99', data: { id: 'user-turn-99', content: 'Recent restored prompt' } }, - { type: 'user-message', turnId: 'turn-100', data: { id: 'user-turn-100', content: 'Latest restored prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-100', - turnIndex: 2, - totalTurns: 2, - userMessage: 'Latest restored prompt', - visibleTurnIds: ['turn-99', 'turn-100'], - }; - - await act(async () => { - root.render(); - }); - - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 100, - totalTurns: 100, - }); - const previousTurnRailItem = container.querySelector('[data-turn-id="turn-99"]'); - const currentTurnRailItem = container.querySelector('[data-turn-id="turn-100"]'); - expect(previousTurnRailItem?.dataset.turnIndex).toBe('99'); - expect(currentTurnRailItem?.dataset.turnIndex).toBe('100'); - expect(currentTurnRailItem?.getAttribute('aria-current')).toBe('step'); - expect(previousTurnRailItem?.className).toContain('flowchat-turn-rail__item--visible'); - expect(currentTurnRailItem?.className).toContain('flowchat-turn-rail__item--visible'); - - clickTurnRailItem(container, 'turn-99'); - - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-99', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - - }); - - it('treats the latest streaming Turn marker as transient immediate navigation', async () => { - const streamingTurn = { - ...createTurn('turn-2', 'Streaming prompt', 'processing'), - modelRounds: [{ - id: 'round-2', - index: 0, - items: [{ - id: 'text-2', - type: 'text' as const, - content: 'Streaming output', - isStreaming: true, - timestamp: 1, - status: 'streaming' as const, - }], - isStreaming: true, - isComplete: false, - status: 'streaming' as const, - startTime: 1, - }], - } as Session['dialogTurns'][number]; - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older prompt'), - streamingTurn, - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Streaming prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-1', - turnIndex: 1, - totalTurns: 2, - userMessage: 'Older prompt', - }; - - await act(async () => { - root.render(); - }); - - const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); - const latestEndCallCount = virtualListMock.scrollToLatestEndPosition.mock.calls.length; - clickTurnRailItem(container, 'turn-2'); - - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-2', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - expect(restoreTailSpy).not.toHaveBeenCalled(); - expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCount); - - restoreTailSpy.mockRestore(); - }); - - it('keeps an active history presentation when its latest Turn marker is selected', async () => { - const presentationTurns = Array.from( - { length: 8 }, - (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`), - ); - const catalog = { - schemaVersion: 1, - sessionId: 'session-1', - revision: 'catalog-v1', - totalTurnCount: 10, - complete: true, - entries: Array.from({ length: 10 }, (_, ordinal) => ({ - ordinal, - storageTurnIndex: ordinal, - turnId: `turn-${ordinal + 1}`, - preview: `Prompt ${ordinal + 1}`, - previewTruncated: false, - })), - }; - const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ - status: 'ready', - sessionId: 'session-1', - targetOrdinal: 4, - targetTurnId: 'turn-5', - navigationGeneration: 7, - isCurrent: true, - cacheHit: true, - catalog, - range: { - startOrdinal: 2, - endOrdinalExclusive: 10, - turns: presentationTurns, - lastAccessedAt: 1, - source: 'target', - }, - }); - const activateSpy = vi.spyOn(flowChatStore, 'activateSessionHistoryWindow').mockReturnValue({ - range: { - startOrdinal: 2, - endOrdinalExclusive: 10, - targetTurnId: 'turn-5', - mode: 'history-window', - }, - turns: presentationTurns, - }); - stateMocks.activeSession = createSession({ - historyState: 'ready', - isPartial: true, - totalTurnCount: 10, - turnCatalog: catalog, - dialogTurns: [ - createTurn('turn-9', 'Recent prompt'), - createTurn('turn-10', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - - await act(async () => { - root.render(); - }); - await act(async () => { - container.querySelector('[data-turn-id="turn-5"]')?.click(); - await Promise.resolve(); - }); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); - - const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); - const latestEndCallCount = virtualListMock.scrollToLatestEndPosition.mock.calls.length; - scrollTurnRailToOrdinal(container, 9); - clickTurnRailItem(container, 'turn-10'); - - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-10', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - expect(restoreTailSpy).not.toHaveBeenCalled(); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); - expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCount); - - stateMocks.activeSession = { - ...stateMocks.activeSession, - dialogTurns: [ - createTurn('turn-9', 'Recent prompt'), - createTurn('turn-10', 'Latest live update', 'processing'), - ], - }; - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - await act(async () => { - root.render(); - }); - const liveLatestItem = (virtualListPropsMock.latest?.items as Array<{ - type: string; - turnId?: string; - data?: { content?: string }; - }>).find(item => item.type === 'user-message' && item.turnId === 'turn-10'); - expect(liveLatestItem?.data?.content).toBe('Latest live update'); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); - - await act(async () => { - (virtualListPropsMock.latest?.onRequestJumpToLatest as (() => void) | undefined)?.(); - }); - flushAnimationFrame(); - expect(restoreTailSpy).toHaveBeenCalledOnce(); - expect(restoreTailSpy).toHaveBeenLastCalledWith('session-1'); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); - expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCount + 1); - - const tailAnchorCallCount = virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length; - stateMocks.activeSession = { - ...stateMocks.activeSession, - totalTurnCount: 11, - dialogTurns: [ - ...stateMocks.activeSession.dialogTurns, - createTurn('turn-11', 'New completed prompt'), - ], - }; - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); - expect(virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length).toBe(tailAnchorCallCount + 1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-11'); - - const reactivateSpy = vi.spyOn(flowChatStore, 'reactivateSessionHistoryWindow').mockReturnValue({ - range: { - startOrdinal: 2, - endOrdinalExclusive: 10, - targetTurnId: 'turn-5', - mode: 'history-window', - }, - turns: presentationTurns, - }); - scrollTurnRailToOrdinal(container, 4); - clickTurnRailItem(container, 'turn-5'); - expect(reactivateSpy).toHaveBeenCalledWith('session-1', { - startOrdinal: 2, - endOrdinalExclusive: 10, - targetTurnId: 'turn-5', - mode: 'history-window', - }); - expect(loadSpy).toHaveBeenCalledTimes(1); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); - - reactivateSpy.mockRestore(); - restoreTailSpy.mockRestore(); - loadSpy.mockRestore(); - activateSpy.mockRestore(); - }); - - it('retains a complete small history projection when jumping to the latest Turn', async () => { - const presentationTurns = Array.from( - { length: 10 }, - (_, index) => createTurn(`turn-${index + 1}`, `Prompt ${index + 1}`), - ); - const catalog = { - schemaVersion: 1, - sessionId: 'session-1', - revision: 'catalog-complete-v1', - totalTurnCount: 10, - complete: true, - entries: Array.from({ length: 10 }, (_, ordinal) => ({ - ordinal, - storageTurnIndex: ordinal, - turnId: `turn-${ordinal + 1}`, - preview: `Prompt ${ordinal + 1}`, - previewTruncated: false, - })), - }; - const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ - status: 'ready', - sessionId: 'session-1', - targetOrdinal: 4, - targetTurnId: 'turn-5', - navigationGeneration: 8, - isCurrent: true, - cacheHit: true, - catalog, - range: { - startOrdinal: 0, - endOrdinalExclusive: 10, - turns: presentationTurns, - lastAccessedAt: 1, - source: 'target', - }, - }); - const activateSpy = vi.spyOn(flowChatStore, 'activateSessionHistoryWindow').mockReturnValue({ - range: { - startOrdinal: 0, - endOrdinalExclusive: 10, - targetTurnId: 'turn-5', - mode: 'history-window', - }, - turns: presentationTurns, - }); - stateMocks.activeSession = createSession({ - historyState: 'ready', - isPartial: true, - totalTurnCount: 10, - turnCatalog: catalog, - dialogTurns: [ - createTurn('turn-9', 'Recent prompt'), - createTurn('turn-10', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - - await act(async () => { - root.render(); - }); - await act(async () => { - container.querySelector('[data-turn-id="turn-5"]')?.click(); - await Promise.resolve(); - }); - - expect(loadSpy).toHaveBeenCalledWith('session-1', 4, { source: 'target' }); - expect(virtualListPropsMock.latest).toMatchObject({ - presentationMode: 'history-window', - viewportMode: 'history-reading', - }); - expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)) - .toEqual(presentationTurns.map(turn => turn.id)); - - const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); - const initialItems = virtualListPropsMock.latest?.items; - await act(async () => { - (virtualListPropsMock.latest?.onRequestJumpToLatest as (() => void) | undefined)?.(); - }); - - expect(restoreTailSpy).not.toHaveBeenCalled(); - expect(virtualListPropsMock.latest).toMatchObject({ - presentationMode: 'history-window', - viewportMode: 'live-tail', - historyWindow: null, - }); - expect(virtualListPropsMock.latest?.items).toBe(initialItems); - - stateMocks.activeSession = { - ...stateMocks.activeSession, - totalTurnCount: 11, - dialogTurns: [ - createTurn('turn-10', 'Latest live update', 'processing'), - createTurn('turn-11', 'New live prompt', 'processing'), - ], - }; - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - await act(async () => { - root.render(); - }); - - expect(virtualListPropsMock.latest).toMatchObject({ - presentationMode: 'history-window', - viewportMode: 'live-tail', - }); - expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)) - .toEqual([...presentationTurns.map(turn => turn.id), 'turn-11']); - const liveLatestItem = (virtualListPropsMock.latest?.items as Array<{ - turnId: string; - data?: { content?: string }; - }>).find(item => item.turnId === 'turn-10'); - expect(liveLatestItem?.data?.content).toBe('Latest live update'); - - restoreTailSpy.mockRestore(); - loadSpy.mockRestore(); - activateSpy.mockRestore(); - }); - - it('retries turn-rail selection without advancing visible-turn state until the virtual list accepts it', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older prompt'), - createTurn('turn-2', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-2', - turnIndex: 2, - totalTurns: 2, - userMessage: 'Latest prompt', - }; - virtualListMock.pinTurnToTopWithStatus.mockReturnValue('rejected'); - - await act(async () => { - root.render(); - }); - - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 2, - totalTurns: 2, - }); - - clickTurnRailItem(container, 'turn-1'); - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 2, - totalTurns: 2, - }); - - virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); - stateMocks.virtualItems = [ - ...stateMocks.virtualItems, - ]; - - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 2, - totalTurns: 2, - }); - - stateMocks.visibleTurnInfo = { - turnId: 'turn-1', - turnIndex: 1, - totalTurns: 2, - userMessage: 'Older prompt', - }; - - await act(async () => { - root.render(); - }); - - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 1, - totalTurns: 2, - }); - }); - - it('delegates accepted turn-rail selections to the list without container-level retry', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older prompt'), - createTurn('turn-2', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-2', - turnIndex: 2, - totalTurns: 2, - userMessage: 'Latest prompt', - }; - virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); - - await act(async () => { - root.render(); - }); - - clickTurnRailItem(container, 'turn-1'); - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 2, - totalTurns: 2, - }); - - const acceptedCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - flushAnimationFrame(); - expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(acceptedCallCount); - - stateMocks.visibleTurnInfo = { - turnId: 'turn-1', - turnIndex: 1, - totalTurns: 2, - userMessage: 'Older prompt', - }; - - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 1, - totalTurns: 2, - }); - const settledCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - flushAnimationFrame(); - expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(settledCallCount); - }); - - it('accepts list-owned pending turn pins without retrying from the container', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older prompt'), - createTurn('turn-2', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-2', - turnIndex: 2, - totalTurns: 2, - userMessage: 'Latest prompt', - }; - virtualListMock.pinTurnToTopWithStatus.mockReturnValue('pending'); - - await act(async () => { - root.render(); - }); - - clickTurnRailItem(container, 'turn-1'); - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - const pendingCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - flushAnimationFrame(); - flushAnimationFrame(); - expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(pendingCallCount); - }); - - it('does not render stale turn-rail targets', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older prompt'), - createTurn('turn-2', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-2', - turnIndex: 2, - totalTurns: 2, - userMessage: 'Latest prompt', - }; - - await act(async () => { - root.render(); - }); - - const beforeSelectionCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - expect(container.querySelector('[data-turn-id="turn-missing"]')).toBeNull(); - expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount); - }); - - it('keeps long-session turn-rail selections single-shot after the list accepts the pin', async () => { - const turns = Array.from({ length: 25 }, (_, index) => { - const turnNumber = index + 1; - return createTurn(`turn-${turnNumber}`, `Prompt ${turnNumber}`); - }); - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: turns, - } as Partial); - stateMocks.virtualItems = turns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: { id: `user-${turn.id}`, content: turn.userMessage.content }, - })); - stateMocks.visibleTurnInfo = { - turnId: 'turn-25', - turnIndex: 25, - totalTurns: 25, - userMessage: 'Prompt 25', - }; - virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); - - await act(async () => { - root.render(); - }); - - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 25, - totalTurns: 25, - }); - expect(container.querySelector('[data-testid="flowchat-turn-rail"]')?.getAttribute( - 'data-total-turn-count', - )).toBe('25'); - expect(container.querySelectorAll('.flowchat-turn-rail__item').length).toBeLessThan(25); - - scrollTurnRailToOrdinal(container, 6); - - const beforeSelectionCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - clickTurnRailItem(container, 'turn-7'); - expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount + 1); - expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-7', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - - flushAnimationFrame(); - flushAnimationFrame(); - flushAnimationFrame(); - expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount + 1); - }); - - it('cancels a not-yet-accepted turn-navigation retry when the user scrolls manually', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older prompt'), - createTurn('turn-2', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-2', - turnIndex: 2, - totalTurns: 2, - userMessage: 'Latest prompt', - }; - virtualListMock.pinTurnToTopWithStatus.mockReturnValue('rejected'); - - await act(async () => { - root.render(); - }); - - clickTurnRailItem(container, 'turn-1'); - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 2, - totalTurns: 2, - }); - - flushAnimationFrame(); - const retryCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - expect(retryCallCount).toBeGreaterThan(1); - - await act(async () => { - (virtualListPropsMock.latest?.onUserScrollIntent as (() => void) | undefined)?.(); - }); - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 2, - totalTurns: 2, - }); - - flushAnimationFrame(); - expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(retryCallCount); - }); - - it('renders ordinal navigation placeholders for old hosts without a turn catalog', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - isPartial: true, - loadedTurnCount: 2, - totalTurnCount: 100, - dialogTurns: [ - createTurn('turn-99', 'Recent restored prompt'), - createTurn('turn-100', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-99', data: { id: 'user-turn-99', content: 'Recent restored prompt' } }, - { type: 'user-message', turnId: 'turn-100', data: { id: 'user-turn-100', content: 'Latest restored prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-99', - turnIndex: 1, - totalTurns: 2, - userMessage: 'Recent restored prompt', - }; - - await act(async () => { - root.render(); - }); - - expect(headerPropsMock.latest).toMatchObject({ - currentTurn: 99, - totalTurns: 100, - }); - expect(container.querySelector('[data-testid="flowchat-turn-rail"]')?.getAttribute( - 'data-total-turn-count', - )).toBe('100'); - expect(container.querySelectorAll('.flowchat-turn-rail__item').length).toBeLessThan(100); - expect(container.querySelector('[data-turn-key="storage:0"]')).toBeNull(); - expect(container.querySelector('[data-turn-id="turn-98"]')).toBeNull(); - expect(container.querySelector('[data-turn-id="turn-99"]')?.getAttribute('aria-disabled')).toBeNull(); - expect(container.querySelector('[data-turn-id="turn-100"]')?.getAttribute('aria-disabled')).toBeNull(); - - scrollTurnRailToOrdinal(container, 0); - - expect(container.querySelector('[data-turn-key="storage:0"]')?.getAttribute('aria-disabled')).toBeNull(); - expect(container.querySelector('[data-turn-id="turn-99"]')).toBeNull(); - expect(virtualListMock.pinTurnToTopWithStatus).not.toHaveBeenCalled(); - }); - - it('windows catalog markers while resolving loaded tail identities', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - isPartial: true, - loadedTurnCount: 2, - totalTurnCount: 100, - turnCatalog: { - schemaVersion: 1, - sessionId: 'session-1', - revision: 'catalog-1', - totalTurnCount: 100, - complete: false, - entries: Array.from({ length: 100 }, (_, ordinal) => ({ - ordinal, - storageTurnIndex: ordinal, - ...(ordinal === 98 - ? { turnId: 'turn-99', preview: 'Stale catalog preview' } - : ordinal === 99 - ? { turnId: 'turn-100', preview: 'Latest catalog preview' } - : {}), - previewTruncated: false, - })), - }, - dialogTurns: [ - createTurn('turn-99', 'Recent restored prompt'), - createTurn('turn-100', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-99', data: { id: 'user-turn-99', content: 'Recent restored prompt' } }, - { type: 'user-message', turnId: 'turn-100', data: { id: 'user-turn-100', content: 'Latest restored prompt' } }, - ]; - stateMocks.visibleTurnInfo = { - turnId: 'turn-100', - turnIndex: 2, - totalTurns: 2, - userMessage: 'Latest restored prompt', - visibleTurnIds: ['turn-99', 'turn-100'], - }; - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('[data-testid="flowchat-turn-rail"]')?.getAttribute( - 'data-total-turn-count', - )).toBe('100'); - expect(container.querySelectorAll('.flowchat-turn-rail__item').length).toBeLessThan(100); - expect(container.querySelector('[data-turn-key="storage:0"]')).toBeNull(); - expect(container.querySelector('[data-turn-id="turn-99"]')?.getAttribute('aria-disabled')).toBeNull(); - expect(container.querySelector('[data-turn-id="turn-100"]')?.getAttribute('aria-disabled')).toBeNull(); - - scrollTurnRailToOrdinal(container, 0); - - expect(container.querySelector('[data-turn-key="storage:0"]')?.getAttribute('aria-disabled')).toBeNull(); - expect(container.querySelector('[data-turn-id="turn-100"]')).toBeNull(); - }); - - it('requests the unified full-history fallback for an unloaded catalog target', async () => { - const ensureSpy = vi - .spyOn(flowChatStore, 'ensureSessionFullHistory') - .mockResolvedValue(true); - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - isPartial: true, - loadedTurnCount: 2, - totalTurnCount: 100, - turnCatalog: { - schemaVersion: 1, - sessionId: 'session-1', - revision: 'complete-catalog', - totalTurnCount: 100, - complete: true, - entries: Array.from({ length: 100 }, (_, ordinal) => ({ - ordinal, - storageTurnIndex: ordinal, - turnId: `turn-${ordinal + 1}`, - preview: `Prompt ${ordinal + 1}`, - previewTruncated: false, - })), - }, - dialogTurns: [ - createTurn('turn-99', 'Recent restored prompt'), - createTurn('turn-100', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-99', data: { id: 'user-turn-99', content: 'Recent restored prompt' } }, - { type: 'user-message', turnId: 'turn-100', data: { id: 'user-turn-100', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - await act(async () => { - container.querySelector('[data-turn-id="turn-1"]')?.click(); - await Promise.resolve(); - }); - - expect(ensureSpy).toHaveBeenCalledWith('session-1', 'turn-rail-navigation'); - expect(virtualListMock.pinTurnToTopWithStatus).not.toHaveBeenCalledWith( - 'turn-1', - expect.anything(), - ); - }); - - it('materializes a loaded Turn window for cross-feature focus before reusing the shared pin transaction', async () => { - const targetTurn = createTurn('turn-5', 'Target prompt'); - const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ - status: 'ready', - sessionId: 'session-1', - targetOrdinal: 4, - targetTurnId: 'turn-5', - navigationGeneration: 7, - isCurrent: true, - cacheHit: false, - range: { - startOrdinal: 2, - endOrdinalExclusive: 7, - turns: Array.from({ length: 5 }, (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`)), - lastAccessedAt: 1, - source: 'target', - }, - }); - const activateSpy = vi.spyOn(flowChatStore, 'activateSessionHistoryWindow').mockReturnValue({ - range: { - startOrdinal: 2, - endOrdinalExclusive: 7, - targetTurnId: targetTurn.id, - mode: 'history-window', - }, - turns: Array.from({ length: 5 }, (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`)), - }); - stateMocks.activeSession = createSession({ - historyState: 'ready', - isPartial: true, - totalTurnCount: 10, - turnCatalog: { - schemaVersion: 1, - sessionId: 'session-1', - revision: 'catalog-v1', - totalTurnCount: 10, - complete: true, - entries: Array.from({ length: 10 }, (_, ordinal) => ({ - ordinal, - storageTurnIndex: ordinal, - turnId: `turn-${ordinal + 1}`, - preview: `Prompt ${ordinal + 1}`, - previewTruncated: false, - })), - }, - dialogTurns: [ - createTurn('turn-9', 'Recent prompt'), - createTurn('turn-10', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - - await act(async () => { - root.render(); - }); - const target = container.querySelector('[data-turn-id="turn-5"]'); - expect(target).not.toBeNull(); - await act(async () => { - const onNavigateToFocusTurn = navigationOptionsMock.latest?.onNavigateToFocusTurn as ( - request: { - sessionId: string; - turnIndex: number; - source: 'usage-report'; - }, - ) => Promise; - await expect(onNavigateToFocusTurn({ - sessionId: 'session-1', - turnIndex: 5, - source: 'usage-report', - })).resolves.toBe(true); - }); - - expect(loadSpy).toHaveBeenCalledWith('session-1', 4, { source: 'target' }); - expect(virtualListMock.prepareTurnPinToTop).toHaveBeenCalledWith('turn-5', { - behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', - }); - expect(activateSpy).toHaveBeenCalledWith('session-1', 4, 7); - expect(virtualListPropsMock.latest).toMatchObject({ - presentationMode: 'history-window', - presentationRevision: 1, - }); - expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)).toEqual([ - 'turn-3', - 'turn-4', - 'turn-5', - 'turn-6', - 'turn-7', - ]); - expect(stateMocks.activeSession.dialogTurns.map(turn => turn.id)).toEqual(['turn-9', 'turn-10']); - expect(virtualListMock.prepareTurnPinToTop.mock.invocationCallOrder[0]).toBeLessThan( - activateSpy.mock.invocationCallOrder[0], - ); - - const latestTailPinCallCount = virtualListMock.pinTurnToTop.mock.calls.length; - const latestTailEndCallCount = virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length; - stateMocks.activeSession = { - ...stateMocks.activeSession, - totalTurnCount: 11, - dialogTurns: [ - ...stateMocks.activeSession.dialogTurns, - createTurn('turn-11', 'Streaming prompt', 'processing'), - ], - }; - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - await act(async () => { - root.render(); - }); - flushAnimationFrame(); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); - expect(virtualListMock.pinTurnToTop.mock.calls.length).toBe(latestTailPinCallCount); - expect(virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length).toBe(latestTailEndCallCount); - - const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); - const latestEndCallCountBeforeSend = virtualListMock.scrollToLatestEndPosition.mock.calls.length; - await act(async () => { - const onBeforeTurnPinRequest = navigationOptionsMock.latest?.onBeforeTurnPinRequest as ( - request: { - sessionId: string; - turnId: string; - source: 'send-message'; - behavior: 'auto'; - pinMode: 'sticky-latest'; - }, - ) => void; - onBeforeTurnPinRequest({ - sessionId: 'session-1', - turnId: 'turn-11', - source: 'send-message', - behavior: 'auto', - pinMode: 'sticky-latest', - }); - }); - expect(restoreTailSpy).toHaveBeenCalledTimes(1); - expect(restoreTailSpy).toHaveBeenLastCalledWith('session-1'); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); - expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)).toEqual([ - 'turn-9', - 'turn-10', - 'turn-11', - ]); - expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCountBeforeSend); - - await act(async () => { - container.querySelector('[data-turn-id="turn-5"]')?.click(); - await Promise.resolve(); - }); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); - - await act(async () => { - (virtualListPropsMock.latest?.onRequestJumpToLatest as (() => void) | undefined)?.(); - }); - flushAnimationFrame(); - expect(restoreTailSpy).toHaveBeenCalledTimes(2); - expect(restoreTailSpy).toHaveBeenLastCalledWith('session-1'); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); - expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCountBeforeSend + 1); - - restoreTailSpy.mockRestore(); - loadSpy.mockRestore(); - activateSpy.mockRestore(); - }); - - it('materializes an adjacent catalog window when tail history requests older turns', async () => { - const catalog = { - schemaVersion: 1, - sessionId: 'session-1', - revision: 'catalog-1', - totalTurnCount: 10, - complete: true, - entries: Array.from({ length: 10 }, (_, ordinal) => ({ - ordinal, - storageTurnIndex: ordinal, - turnId: `turn-${ordinal + 1}`, - preview: `Prompt ${ordinal + 1}`, - previewTruncated: false, - })), - }; - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - isPartial: true, - loadedTurnCount: 2, - totalTurnCount: 10, - turnCatalog: catalog, - dialogTurns: [ - createTurn('turn-9', 'Recent prompt'), - createTurn('turn-10', 'Latest prompt'), - ], - } as Partial); - stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ - type: 'user-message', - turnId: turn.id, - data: turn.userMessage, - })); - const presentationTurns = Array.from( - { length: 8 }, - (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`), - ); - const cachedTurns = Array.from( - { length: 10 }, - (_, index) => createTurn(`turn-${index + 1}`, `Prompt ${index + 1}`), - ); - vi.spyOn(flowChatStore, 'getState').mockReturnValue({ - sessions: new Map([['session-1', stateMocks.activeSession]]), - activeSessionId: 'session-1', - }); - vi.spyOn(flowChatStore, 'getSessionHistoryViewState').mockReturnValue({ - catalog, - loadedRanges: [{ - startOrdinal: 0, - endOrdinalExclusive: 10, - turns: cachedTurns, - lastAccessedAt: 1, - source: 'prefetch', - }], - activeRange: null, - pendingTargetOrdinal: null, - navigationGeneration: 0, - }); - vi.spyOn(flowChatStore, 'getSessionCanonicalTailRange').mockReturnValue({ - startOrdinal: 8, - endOrdinalExclusive: 10, - }); - const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ - status: 'ready', - sessionId: 'session-1', - targetOrdinal: 7, - targetTurnId: 'turn-8', - navigationGeneration: 0, - isCurrent: true, - cacheHit: true, - catalog, - }); - const activateSpy = vi.spyOn( - flowChatStore, - 'activateSessionHistoryWindowFromTail', - ).mockReturnValue({ - range: { - startOrdinal: 2, - endOrdinalExclusive: 10, - targetTurnId: null, - mode: 'history-window', - }, - turns: presentationTurns, - }); - let resolveViewportPreparation: ((ready: boolean) => void) | undefined; - const viewportPreparation = new Promise(resolve => { - resolveViewportPreparation = resolve; - }); - const prepareViewportForPresentationCommit = vi.fn(() => viewportPreparation); - const cancelViewportPresentationCommit = vi.fn(); - - await act(async () => { - root.render(); - }); - let boundaryIntent: Promise | undefined; - await act(async () => { - boundaryIntent = ( - virtualListPropsMock.latest?.onHistoryWindowBoundaryIntent as - | (( - direction: 'before' | 'after', - options?: { - prepareViewportForPresentationCommit?: () => ( - boolean | void | Promise - ); - cancelViewportPresentationCommit?: () => void; - }, - ) => Promise) - | undefined - )?.('before', { - prepareViewportForPresentationCommit, - cancelViewportPresentationCommit, - }); - await Promise.resolve(); - }); - - expect(prepareViewportForPresentationCommit).toHaveBeenCalledOnce(); - expect(cancelViewportPresentationCommit).not.toHaveBeenCalled(); - expect(activateSpy).not.toHaveBeenCalled(); - expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); - - await act(async () => { - resolveViewportPreparation?.(true); - expect(await boundaryIntent).toBe('applied'); - }); - - expect(loadSpy).toHaveBeenCalledWith('session-1', 7, { - source: 'prefetch', - before: 12, - after: 1, - }); - expect(activateSpy).toHaveBeenCalledWith('session-1', 7); - expect(virtualListPropsMock.latest).toMatchObject({ - presentationMode: 'history-window', - presentationRevision: 1, - }); - }); - - it('lets streaming restored sessions use follow-output instead of container sticky anchoring', async () => { - stateMocks.activeSession = createSession({ - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt', 'processing'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); - expect(startupTraceMock.markPhase).toHaveBeenCalledWith( - 'historical_session_latest_anchor_skipped', - expect.objectContaining({ reason: 'streaming_follow_output', mode: 'follow-output' }), - ); - }); - - it('scrolls completed restored history to the tail after hydration clears isHistorical', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - flushAnimationFrame(); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledWith('turn-2'); - expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); - expect(startupTraceMock.markPhase).toHaveBeenCalledWith( - 'historical_session_latest_anchor_attempt', - expect.objectContaining({ accepted: true, attempt: 1, mode: 'bottom' }), - ); - }); - - it('retries completed history tail anchoring when the virtual list is not ready on the first frame', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-2', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, - ]; - virtualListMock.scrollToTurnEndAndClearPin - .mockReturnValueOnce(false) - .mockReturnValueOnce(true); - - await act(async () => { - root.render(); - }); - - flushAnimationFrame(); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-2'); - - flushAnimationFrame(); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-2'); - expect(startupTraceMock.markPhase).toHaveBeenCalledWith( - 'historical_session_latest_anchor_attempt', - expect.objectContaining({ accepted: false, attempt: 1, mode: 'bottom' }), - ); - expect(startupTraceMock.markPhase).toHaveBeenCalledWith( - 'historical_session_latest_anchor_attempt', - expect.objectContaining({ accepted: true, attempt: 2, mode: 'bottom' }), - ); - }); - - it('does not re-anchor local restored history after full hydration expands the same latest turn', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-80', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - flushAnimationFrame(); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-80', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - flushAnimationFrame(); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); - expect(startupTraceMock.markPhase).toHaveBeenCalledWith( - 'historical_session_latest_anchor_skipped', - expect.objectContaining({ reason: 'local_full_history_projection' }), - ); - }); - - it('does not re-anchor local full hydration when the latest restored turn is already visible', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-80', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - flushAnimationFrame(); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - - stateMocks.visibleTurnInfo = { - turnId: 'turn-80', - turnIndex: 1, - totalTurns: 1, - userMessage: 'Latest restored prompt', - }; - virtualListMock.isTurnRenderedInViewport.mockReturnValue(true); - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-80', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - - flushAnimationFrame(); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); - }); - - it('does not repeat immediate latest anchoring after visible turn info catches up', async () => { - stateMocks.activeSession = createSession({ - isHistorical: true, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-80', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - - stateMocks.visibleTurnInfo = { - turnId: 'turn-80', - turnIndex: 1, - totalTurns: 1, - userMessage: 'Latest restored prompt', - }; - - await act(async () => { - root.render(); - }); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); - }); - - it('does not re-anchor local full hydration when visible turn info is stale after prepending older turns', async () => { - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-80', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - flushAnimationFrame(); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - - stateMocks.visibleTurnInfo = { - turnId: 'turn-80', - turnIndex: 1, - totalTurns: 1, - userMessage: 'Latest restored prompt', - }; - virtualListMock.isTurnRenderedInViewport.mockReturnValue(false); - stateMocks.activeSession = createSession({ - isHistorical: false, - historyState: 'ready', - dialogTurns: [ - createTurn('turn-1', 'Older restored prompt'), - createTurn('turn-44', 'Middle restored prompt'), - createTurn('turn-80', 'Latest restored prompt'), - ], - } as Partial); - stateMocks.virtualItems = [ - { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, - { type: 'user-message', turnId: 'turn-44', data: { id: 'user-turn-44', content: 'Middle restored prompt' } }, - { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, - ]; - - await act(async () => { - root.render(); - }); - - flushAnimationFrame(); - - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); - expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); + it('does not recreate sticky latest or pin reservation modes', () => { + expect(componentSource).not.toContain('sticky-latest'); + expect(componentSource).not.toContain('pinTurnToTop'); + expect(componentSource).not.toContain('prepareTurnPinToTop'); }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss index 9944df23b3..4687cdf51c 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -16,24 +16,13 @@ display: flex; flex-direction: column; background: var(--bf-appearance-token-color-bg-scene); - position: relative; // Positioning context for floating header. - - // Header overlays message list for glass effect. - > .flowchat-header { - position: absolute; - top: 0; - left: 0; - right: 0; - z-index: 20; // Ensure it stays above the message list. - } - + position: relative; // Positioning context for the permission panel and overlays. + &__messages { - // Messages fill the container, including header area. - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + // Header and messages stack vertically; messages take the remaining space. + position: relative; + flex: 1 1 auto; + min-height: 0; overflow: hidden; background: transparent; } @@ -143,7 +132,7 @@ display: flex; align-items: flex-start; justify-content: center; - padding-top: 76px; + padding-top: 40px; pointer-events: auto; background: var(--bf-appearance-token-color-bg-scene); contain: paint; @@ -152,10 +141,10 @@ .modern-flowchat-container__history-open-intent-shield::before { content: ""; position: absolute; - top: 116px; + top: 80px; left: 50%; width: min(720px, calc(100% - 48px)); - height: min(220px, calc(100% - 168px)); + height: min(220px, calc(100% - 132px)); min-height: 120px; transform: translateX(-50%); border-radius: 8px; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 58df5f33e4..be34c18719 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -10,7 +10,7 @@ import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; import { useSessionModeStore } from '@/app/stores/sessionModeStore'; import { VirtualMessageList, - type FlowChatTurnPinRequestStatus, + type FlowChatTurnNavigationStatus, type HistoryWindowBoundaryIntentResult, type HistoryWindowBoundaryIntentOptions, type VirtualMessageListRef, @@ -51,9 +51,10 @@ import type { SessionHistoryPresentation, } from '../../types/flow-chat'; import type { SessionHistoryWindowDirection } from '../../store/FlowChatStore'; -import type { - FlowChatFocusItemRequest, - FlowChatPinTurnToTopRequest, +import { + FLOWCHAT_MESSAGE_SUBMITTED_EVENT, + type FlowChatFocusItemRequest, + type FlowChatMessageSubmittedRequest, } from '../../events/flowchatNavigation'; import { useBackgroundCommandActivityStore, @@ -77,7 +78,6 @@ import { resolveThreadGoalHeaderTitle } from '../../utils/threadGoalDisplay'; import { hasActiveSessionLineageDescendants } from '../../utils/sessionLineage'; import { findDialogTurn, - shouldUseStickyLatestPin, shouldUseLatestTurnFollowOutput, } from '../../utils/flowChatTurnScrollPolicy'; import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace'; @@ -92,9 +92,17 @@ import { type HistorySessionOpenIntentDetail, } from '../../services/sessionOpenIntent'; import { + recordHistoryPagingEvent, recordHistorySessionDiagnosticEvent, + warnHistoryPagingRefusedWithPendingTurns, warnHistorySessionLoadingLayerStalled, } from '../../services/historySessionDiagnostics'; +import { + resolveHistoryBoundaryTarget, + resolveTailWindowGrowth, + transcriptReachesLatestTurn, + type RenderedTranscriptRange, +} from './flowChatLiveTailWindow'; import './ModernFlowChatContainer.scss'; import { PermissionRequestPanel } from './PermissionRequestPanel'; import { pendingPermissionToolCallIdsForSession } from './permissionRequestRouting'; @@ -111,7 +119,6 @@ import { const log = createLogger('ModernFlowChatContainer'); - interface ModernFlowChatContainerProps { className?: string; config?: Partial; @@ -387,6 +394,44 @@ export const ModernFlowChatContainer: React.FC = ( ? canonicalizedHistoryPresentation : null; const isRenderingHistoryProjection = Boolean(renderedHistoryPresentation); + /** + * The range the reader is actually looking at, for the paging ask. + * + * Deliberately the *rendered* presentation and not `historyPresentationRef`, + * which holds the window the store cut. The continuous projection makes those + * two differ — see `resolveHistoryBoundaryTarget`. + */ + const renderedHistoryPresentationRef = useRef(renderedHistoryPresentation); + renderedHistoryPresentationRef.current = renderedHistoryPresentation; + /* + * Whether the transcript on screen still reaches the newest Turn. + * + * Both consumers of `history-reading` — suppressing streaming follow, and the + * jump-to-latest affordance — are asking this, not "did the user navigate". + * A turn intent used to answer it faithfully because only navigation ever + * activated a history window. Automatic tail paging activates one with nobody + * navigating: a session whose loaded tail is shorter than the viewport pages + * on open, and the viewport sitting on the newest output was then reported as + * reading history, which pinned the jump-to-latest bar open and routed it + * through a presentation reset that dropped the window and paged it back in. + * + * The window's own ordinal bookkeeping answers it exactly — these are ledger + * numbers, not measurements — and keeps answering it as the session grows: a + * Turn arriving past the end of the window flips this back on its own, where + * a provenance flag recorded at activation time would stay stale and leave no + * way back to the live tail. + * + * `isReadingTurnViewport` deliberately keeps its old meaning for the auto-tail + * placement below, which asks a different question again: who owns the + * viewport. Merging those two is the mistake this fixes. + */ + const renderedTranscriptReachesLatestTurn = transcriptReachesLatestTurn({ + windowEndOrdinalExclusive: renderedHistoryPresentation?.range.endOrdinalExclusive ?? null, + knownTurnCount: activeSessionKnownTurnCount, + }); + const isViewportDetachedFromLiveTail = ( + isReadingTurnViewport && !renderedTranscriptReachesLatestTurn + ); const virtualItems = useMemo(() => { if (!activeSession || !renderedHistoryPresentation) { return canonicalVirtualItems; @@ -433,12 +478,12 @@ export const ModernFlowChatContainer: React.FC = ( const [stoppingBackgroundCommandIds, setStoppingBackgroundCommandIds] = useState>(() => new Set()); const [backgroundCommandInputTarget, setBackgroundCommandInputTarget] = useState(null); const [isSendingBackgroundCommandInput, setIsSendingBackgroundCommandInput] = useState(false); - const autoPinnedTurnKeyRef = useRef(null); + const autoTailTurnKeyRef = useRef(null); const releasedHistoryCompletionKeyRef = useRef(null); const visibleTurnInfoRef = useRef(visibleTurnInfo); const turnSummariesRef = useRef([]); const turnRailTurnIdsRef = useRef>(new Set()); - const requestTurnNavigationPinRef = useRef<((turnId: string) => FlowChatTurnPinRequestStatus) | null>(null); + const requestTurnNavigationRef = useRef<((turnId: string) => FlowChatTurnNavigationStatus) | null>(null); const searchFullHistorySessionIdRef = useRef(null); const virtualListRef = useRef(null); const chatScopeRef = useRef(null); @@ -534,18 +579,6 @@ export const ModernFlowChatContainer: React.FC = ( setQueuedTurnNavigation(null); }, [activeSession?.sessionId, continuousHistoryProjectionEligible, updateViewportIntent]); - const handleBeforeTurnPinRequest = useCallback((request: FlowChatPinTurnToTopRequest) => { - const currentViewportIntent = viewportIntentRef.current; - if ( - request.source === 'send-message' - && currentViewportIntent?.sessionId === request.sessionId - && currentViewportIntent.kind === 'turn' - && currentViewportIntent.source === 'history-range' - ) { - switchToLiveTailForSession(request.sessionId); - } - }, [switchToLiveTailForSession]); - useEffect(() => { historyPresentationRef.current = historyPresentation; }, [historyPresentation]); @@ -1027,7 +1060,6 @@ export const ModernFlowChatContainer: React.FC = ( [activeSession?.dialogTurns, latestTurnId], ); const latestTurnUsesFollowOutput = shouldUseLatestTurnFollowOutput(latestTurn); - const latestTurnUsesStickyPin = shouldUseStickyLatestPin(latestTurn); const navigationVisibleTurnInfo = useMemo(() => { if (!visibleTurnInfo) { @@ -1084,19 +1116,17 @@ export const ModernFlowChatContainer: React.FC = ( return effectiveVisibleTurnInfo?.userMessage ?? ''; }, [effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, renderedTurns, resolveLocalCommandHeaderTitle]); - const requestTurnNavigationPin = useCallback((turnId: string): FlowChatTurnPinRequestStatus => { + const requestTurnNavigation = useCallback((turnId: string): FlowChatTurnNavigationStatus => { if (!isViewportActive) { return 'rejected'; } - return virtualListRef.current?.pinTurnToTopWithStatus(turnId, { + return virtualListRef.current?.navigateToTurnWithStatus(turnId, { behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', }) ?? 'rejected'; }, [isViewportActive]); useEffect(() => { - requestTurnNavigationPinRef.current = requestTurnNavigationPin; - }, [requestTurnNavigationPin]); + requestTurnNavigationRef.current = requestTurnNavigation; + }, [requestTurnNavigation]); const handleVirtualListUserScrollIntent = useCallback(() => { setQueuedTurnNavigation(null); }, []); @@ -1140,8 +1170,8 @@ export const ModernFlowChatContainer: React.FC = ( return; } - const pinStatus = requestTurnNavigationPinRef.current?.(queuedTurnId) ?? 'rejected'; - if (pinStatus === 'settled' || pinStatus === 'pending') { + const navigationStatus = requestTurnNavigationRef.current?.(queuedTurnId) ?? 'rejected'; + if (navigationStatus === 'settled' || navigationStatus === 'pending') { setQueuedTurnNavigation(null); return; } @@ -1170,7 +1200,7 @@ export const ModernFlowChatContainer: React.FC = ( ]); useLayoutEffect(() => { - autoPinnedTurnKeyRef.current = null; + autoTailTurnKeyRef.current = null; releasedHistoryCompletionKeyRef.current = null; searchFullHistorySessionIdRef.current = null; }, [activeSession?.sessionId]); @@ -1187,163 +1217,47 @@ export const ModernFlowChatContainer: React.FC = ( ? `${sessionId}:${latestTurnId}:${turnSummaries.length}` : null; if ( - !isViewportActive - || - !sessionId - || isReadingTurnViewport - || !latestTurnId - || autoPinnedTurnKeyRef.current === latestTurnKey + !isViewportActive || + !sessionId || + isReadingTurnViewport || + !latestTurnId || + !latestTurnKey || + autoTailTurnKeyRef.current === latestTurnKey ) { return; } - const resolvedLatestTurnId = latestTurnId; - const resolvedLatestTurnKey = latestTurnKey; - const pinMode = latestTurnUsesStickyPin - ? 'sticky-latest' - : null; if (latestTurnUsesFollowOutput) { - autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; - startupTrace.markPhase('historical_session_latest_anchor_skipped', { - sessionId, - latestTurnId, - reason: 'streaming_follow_output', - mode: pinMode ?? 'follow-output', - turnCount: turnSummaries.length, - }); + autoTailTurnKeyRef.current = latestTurnKey; return; } - const previousAnchoredLatestTurnKeyPrefix = `${sessionId}:${latestTurnId}:`; - const hasPreviouslyAnchoredSameLatestTurn = - autoPinnedTurnKeyRef.current?.startsWith(previousAnchoredLatestTurnKeyPrefix) === true; - const latestTurnRenderedInViewport = virtualListRef.current?.isTurnRenderedInViewport(latestTurnId) === true; - const sameLatestTurnCountChanged = - hasPreviouslyAnchoredSameLatestTurn && - autoPinnedTurnKeyRef.current !== resolvedLatestTurnKey; - const shouldSkipLocalFullHistoryReanchor = - sameLatestTurnCountChanged && - !isRemoteTraceContext(activeSession.remoteConnectionId, activeSession.remoteSshHost); - const shouldForceLatestAnchorAfterTurnCountChange = - sameLatestTurnCountChanged && - !shouldSkipLocalFullHistoryReanchor; - if (shouldSkipLocalFullHistoryReanchor) { - autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; - startupTrace.markPhase('historical_session_latest_anchor_skipped', { - sessionId, - latestTurnId, - reason: 'local_full_history_projection', - mode: pinMode ?? 'bottom', - turnCount: turnSummaries.length, - }); - return; - } - if ( - !shouldForceLatestAnchorAfterTurnCountChange && - hasPreviouslyAnchoredSameLatestTurn && - visibleTurnInfo?.turnId === latestTurnId && - latestTurnRenderedInViewport - ) { - autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; - startupTrace.markPhase('historical_session_latest_anchor_skipped', { - sessionId, - latestTurnId, - reason: 'latest_turn_already_visible', - mode: pinMode ?? 'bottom', - }); - return; - } - if ( - hasPreviouslyAnchoredSameLatestTurn && - visibleTurnInfo?.turnId === latestTurnId && - !latestTurnRenderedInViewport - ) { - startupTrace.markPhase('historical_session_latest_anchor_stale_visible_info', { - sessionId, - latestTurnId, - mode: pinMode ?? 'bottom', - }); - } - let frameId: number | null = null; let cancelled = false; + let frameId: number | null = null; let attempts = 0; - - const attemptLatestViewportAnchor = () => { - if (cancelled) { - return; - } - + const scrollLatestTurnToNaturalEnd = () => { + if (cancelled) return; attempts += 1; - let accepted = false; - const list = virtualListRef.current; - - if (pinMode) { - accepted = list?.pinTurnToTop(resolvedLatestTurnId, { - behavior: 'auto', - pinMode, - }) ?? false; - } else if (list) { - accepted = list.scrollToTurnEndAndClearPin(resolvedLatestTurnId); - } - - startupTrace.markPhase('historical_session_latest_anchor_attempt', { - sessionId, - latestTurnId: resolvedLatestTurnId, - accepted, - attempt: attempts, - mode: pinMode ?? 'bottom', - }); - - if (accepted) { - autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; + if (virtualListRef.current?.scrollToTurnEnd(latestTurnId)) { + autoTailTurnKeyRef.current = latestTurnKey; return; } - - if (attempts >= LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS) { - startupTrace.markPhase('historical_session_latest_anchor_failed', { - sessionId, - latestTurnId: resolvedLatestTurnId, - attempts, - mode: pinMode ?? 'bottom', - }); - return; + if (attempts < LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS) { + frameId = requestAnimationFrame(scrollLatestTurnToNaturalEnd); } - - frameId = requestAnimationFrame(attemptLatestViewportAnchor); }; - - const shouldAttemptLatestAnchorImmediately = - shouldForceLatestAnchorAfterTurnCountChange || - activeSession?.isHistorical === true || - activeSession?.contextRestoreState === 'pending' || - hasPendingHistoryCompletion; - - if (shouldAttemptLatestAnchorImmediately) { - attemptLatestViewportAnchor(); - } else { - frameId = requestAnimationFrame(attemptLatestViewportAnchor); - } - + frameId = requestAnimationFrame(scrollLatestTurnToNaturalEnd); return () => { cancelled = true; - if (frameId !== null) { - cancelAnimationFrame(frameId); - } + if (frameId !== null) cancelAnimationFrame(frameId); }; }, [ activeSession?.sessionId, - activeSession?.isHistorical, - activeSession?.contextRestoreState, - activeSession?.remoteConnectionId, - activeSession?.remoteSshHost, - hasPendingHistoryCompletion, - isViewportActive, isReadingTurnViewport, + isViewportActive, latestTurnId, latestTurnUsesFollowOutput, - latestTurnUsesStickyPin, turnSummaries.length, - visibleTurnInfo?.turnId, ]); useEffect(() => { @@ -1527,10 +1441,108 @@ export const ModernFlowChatContainer: React.FC = ( return true; }, [activeSession?.sessionId, switchToLiveTailForSession]); + /* + * Keep a tail-anchored history window anchored as the session grows. + * + * A window paged in from the tail stops at the newest Turn that existed when + * it was cut. The session then appends a Turn and nothing moves the window's + * end, so the transcript on screen silently stops at the previous Turn: the + * message the user just sent is not rendered at all, and because + * `latestTurnId` is read off the rendered items, follow-output never even + * learns a new Turn exists — no pin, no follow, and no way to scroll to it. + * + * `resolveTailWindowGrowth` carries the reasoning and the reason it is not + * edge-triggered; this effect is only the plumbing. + */ + const tailAnchoredWindowEndRef = useRef(null); + useEffect(() => { + const sessionId = activeSession?.sessionId; + const windowEndOrdinalExclusive = sessionId + ? renderedHistoryPresentation?.range.endOrdinalExclusive ?? null + : null; + const growth = resolveTailWindowGrowth({ + windowEndOrdinalExclusive, + knownTurnCount: activeSessionKnownTurnCount, + tailAnchoredWindowEnd: tailAnchoredWindowEndRef.current, + }); + + if (growth === 'release') { + tailAnchoredWindowEndRef.current = null; + return; + } + if (growth === 'anchor') { + tailAnchoredWindowEndRef.current = windowEndOrdinalExclusive; + return; + } + if (growth === 'none' || !sessionId || windowEndOrdinalExclusive === null) { + return; + } + + const extended = flowChatStore.extendSessionHistoryWindow(sessionId, 'after'); + // Requiring real growth keeps a store that declines to extend from being + // re-applied under an ever-rising revision forever. + if (extended && extended.range.endOrdinalExclusive > windowEndOrdinalExclusive) { + tailAnchoredWindowEndRef.current = extended.range.endOrdinalExclusive; + applyHistoryPresentation(sessionId, extended, { completedBoundary: 'after' }); + return; + } + + // The newest Turn is not inside the loaded range this window was cut from. + // Dropping back to the canonical tail costs a visible re-page of the + // history above, which is why it is the fallback and not the rule — but it + // is the only branch that always shows the message the user just sent. + restoreTailPresentation(); + }, [ + activeSession?.sessionId, + activeSessionKnownTurnCount, + applyHistoryPresentation, + renderedHistoryPresentation, + restoreTailPresentation, + ]); + const jumpToLiveTail = useCallback(() => { return restoreTailPresentation({ followLatest: true }); }, [restoreTailPresentation]); + /* + * A message sent from the composer gives up whatever history window is on + * screen. + * + * `resolveTailWindowGrowth` deliberately leaves a navigated window alone as + * the session grows, because a Turn arriving from elsewhere is no reason to + * take a reader out of the history they are in. A Turn they submitted + * themselves is, and nothing in the ledger tells the two apart — measured, a + * message sent while parked on the first Turn left the transcript on a + * 24-item window it was never in, with follow-output holding an answer it + * had nothing to align. + * + * Deliberately not `followLatest`. Restoring the tail is enough: the Turn + * comes into the transcript, and follow-output pins it to the viewport top + * the way it pins any newly submitted Turn. + */ + useEffect(() => { + const handleMessageSubmitted = (event: Event) => { + const { sessionId } = (event as CustomEvent).detail ?? {}; + const reaches = transcriptReachesLatestTurn({ + windowEndOrdinalExclusive: renderedHistoryPresentation?.range.endOrdinalExclusive ?? null, + knownTurnCount: activeSessionKnownTurnCount, + }); + if (!sessionId || sessionId !== activeSessionIdRef.current) return; + if (reaches) { + return; + } + restoreTailPresentation(); + }; + window.addEventListener(FLOWCHAT_MESSAGE_SUBMITTED_EVENT, handleMessageSubmitted); + return () => { + window.removeEventListener(FLOWCHAT_MESSAGE_SUBMITTED_EVENT, handleMessageSubmitted); + }; + }, [ + activeSessionKnownTurnCount, + renderedHistoryPresentation, + restoreTailPresentation, + ]); + const handleSearchChange = useCallback((query: string) => { setSearchQuery(query); const sessionId = activeSession?.sessionId; @@ -1573,8 +1585,8 @@ export const ModernFlowChatContainer: React.FC = ( turnId: renderedTargetId, source: isRenderingHistoryProjection ? 'history-range' : 'canonical-tail', }); - const pinStatus = requestTurnNavigationPin(renderedTargetId); - if (pinStatus === 'settled' || pinStatus === 'pending') { + const navigationStatus = requestTurnNavigation(renderedTargetId); + if (navigationStatus === 'settled' || navigationStatus === 'pending') { setQueuedTurnNavigation(null); return true; } @@ -1606,12 +1618,10 @@ export const ModernFlowChatContainer: React.FC = ( recentHistoryPresentation.range, ); if (reactivatedPresentation) { - const preparedPin = virtualListRef.current?.prepareTurnPinToTop(recentHistoryTurn.id, { + const preparedNavigation = virtualListRef.current?.prepareTurnNavigation(recentHistoryTurn.id, { behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', }) ?? 'rejected'; - if (preparedPin !== 'rejected') { + if (preparedNavigation !== 'rejected') { applyHistoryPresentation(sessionId, reactivatedPresentation, { viewportTarget: { ordinal: targetItem.ordinal, @@ -1647,12 +1657,10 @@ export const ModernFlowChatContainer: React.FC = ( return false; } - const preparedPin = virtualListRef.current?.prepareTurnPinToTop(targetTurnId, { + const preparedNavigation = virtualListRef.current?.prepareTurnNavigation(targetTurnId, { behavior: 'auto', - pinMode: 'transient', - alignmentPolicy: 'best-effort', }) ?? 'rejected'; - if (preparedPin === 'rejected') { + if (preparedNavigation === 'rejected') { return false; } const presentation = flowChatStore.activateSessionHistoryWindow( @@ -1702,7 +1710,7 @@ export const ModernFlowChatContainer: React.FC = ( applyHistoryPresentation, isRenderingHistoryProjection, renderedTurnSummaries, - requestTurnNavigationPin, + requestTurnNavigation, restoreTailPresentation, turnRailItems, updateViewportIntent, @@ -1774,7 +1782,6 @@ export const ModernFlowChatContainer: React.FC = ( virtualItems, virtualListRef, onExpandExploreGroup: handleExpandGroup, - onBeforeTurnPinRequest: handleBeforeTurnPinRequest, onNavigateToFocusTurn: handleNavigateToFocusTurn, }); @@ -1815,28 +1822,105 @@ export const ModernFlowChatContainer: React.FC = ( historyView?.catalog?.totalTurnCount ?? 0, session?.totalTurnCount ?? 0, ); - let targetOrdinal: number; - if (presentation) { - targetOrdinal = direction === 'before' - ? presentation.range.startOrdinal - 1 - : presentation.range.endOrdinalExclusive; - } else { + const loadedTurnCount = session?.dialogTurns.length ?? 0; + recordHistoryPagingEvent(sessionId, 'requested', { + direction, + hasPresentation: presentation !== null, + isPartial: session?.isPartial, + historyState: session?.historyState, + turnCatalogMatches: session?.turnCatalog?.sessionId === sessionId, + catalogTotalTurnCount: historyView?.catalog?.totalTurnCount ?? null, + sessionTotalTurnCount: session?.totalTurnCount ?? null, + resolvedTotalTurnCount: totalTurnCount, + loadedTurnCount, + loadedRangeCount: historyView?.loadedRanges.length ?? null, + }); + + /* + * The range the ask is derived from is the one on screen, which is the + * continuous projection when that is what is rendered. `presentation` + * stays the store's window, because the extension below operates on it. + */ + let renderedRange: RenderedTranscriptRange | null = + renderedHistoryPresentationRef.current?.sessionId === sessionId + ? renderedHistoryPresentationRef.current.range + : null; + if (!presentation) { if ( direction !== 'before' || session?.isPartial !== true || session.turnCatalog?.sessionId !== sessionId ) { + recordHistoryPagingEvent(sessionId, 'outcome_cancelled', { + direction, + reason: 'precondition', + isPartial: session?.isPartial, + turnCatalogMatches: session?.turnCatalog?.sessionId === sessionId, + }); return 'cancelled'; } const canonicalTailRange = flowChatStore.getSessionCanonicalTailRange(sessionId); if (!canonicalTailRange) { + recordHistoryPagingEvent(sessionId, 'outcome_not_ready', { + direction, + reason: 'no-canonical-tail-range', + }); return 'not-ready'; } - targetOrdinal = canonicalTailRange.startOrdinal - 1; + // No window, so the transcript on screen is the canonical tail: it + // starts where that range does and runs to the newest Turn. + renderedRange = { + startOrdinal: canonicalTailRange.startOrdinal, + endOrdinalExclusive: totalTurnCount, + }; + recordHistoryPagingEvent(sessionId, 'target_resolved', { + direction, + canonicalTailStartOrdinal: canonicalTailRange.startOrdinal, + targetOrdinal: canonicalTailRange.startOrdinal - 1, + }); + } + if (!renderedRange) { + recordHistoryPagingEvent(sessionId, 'outcome_not_ready', { + direction, + reason: 'no-rendered-range', + }); + return 'not-ready'; } - if (targetOrdinal < 0 || targetOrdinal >= totalTurnCount) { + const target = resolveHistoryBoundaryTarget({ + direction, + renderedRange, + knownTurnCount: totalTurnCount, + }); + if (target.status === 'exhausted') { + recordHistoryPagingEvent(sessionId, 'outcome_exhausted', { + direction, + reason: target.reason, + renderedStartOrdinal: renderedRange.startOrdinal, + renderedEndOrdinalExclusive: renderedRange.endOrdinalExclusive, + windowEndOrdinalExclusive: presentation?.range.endOrdinalExclusive ?? null, + totalTurnCount, + }); + /* + * `exhausted` latches the direction off until the window moves, so + * reaching it on an unknown or contradictory total is how history goes + * silently missing rather than merely late. + * + * `reached-latest` is not that. It is what the bottom edge of a live + * transcript answers every time the reader arrives at it. + */ + if (target.reason === 'beyond-known-total') { + warnHistoryPagingRefusedWithPendingTurns(sessionId, { + direction, + reason: totalTurnCount <= 0 ? 'exhausted-on-unknown-total' : 'exhausted-beyond-total', + isPartial: session?.isPartial, + loadedTurnCount, + totalTurnCount, + targetOrdinal: renderedRange.startOrdinal - 1, + }); + } return 'exhausted'; } + const targetOrdinal = target.targetOrdinal; setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'loading' })); let viewportPreparationStarted = false; @@ -1847,6 +1931,22 @@ export const ModernFlowChatContainer: React.FC = ( after: direction === 'after' ? 12 : 1, }); if (!result.isCurrent || activeSessionIdRef.current !== sessionId) { + recordHistoryPagingEvent(sessionId, 'outcome_cancelled', { + direction, + reason: 'superseded', + targetOrdinal, + resultIsCurrent: result.isCurrent, + activeSessionIsCurrent: activeSessionIdRef.current === sessionId, + }); + /* + * The status is ours to clear even though the load was not ours to + * finish. Left as it was, this returns silently with the boundary + * still reading `loading`, and the reader is shown history being + * prepared by nobody for the rest of the session. + */ + if (activeSessionIdRef.current === sessionId) { + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'idle' })); + } return 'cancelled'; } if (result.status !== 'ready') { @@ -1856,10 +1956,27 @@ export const ModernFlowChatContainer: React.FC = ( 'sequential-history-navigation', ); if (historyReady && activeSessionIdRef.current === sessionId) { + recordHistoryPagingEvent(sessionId, 'outcome_applied', { + direction, + targetOrdinal, + reason: 'full-history-hydrated', + }); setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'idle' })); return 'applied'; } } + /* + * A refusal the reader is shown. It was silent here, and a status the + * boundary keeps forever deserves a line saying which load produced + * it: 266 asks in one session all landed on `not-found` and left the + * status standing, with nothing in the trail between the ask and the + * complaint. + */ + recordHistoryPagingEvent(sessionId, 'outcome_not_ready', { + direction, + reason: `load-${result.status}`, + targetOrdinal, + }); setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); return 'not-ready'; } @@ -1875,6 +1992,26 @@ export const ModernFlowChatContainer: React.FC = ( || !activeSessionIsCurrent || !presentationOwnerIsCurrent ) { + recordHistoryPagingEvent(sessionId, 'outcome_cancelled', { + direction, + reason: 'viewport-preparation', + preparationResult: preparationResult ?? null, + activeSessionIsCurrent, + presentationOwnerIsCurrent, + }); + if (preparationResult === false && activeSessionIsCurrent) { + // The window was fetched and then thrown away: the Turns exist but + // never reach the transcript, and the boundary status goes back to + // idle exactly as if there were none. + warnHistoryPagingRefusedWithPendingTurns(sessionId, { + direction, + reason: 'viewport-preparation-declined', + isPartial: session?.isPartial, + loadedTurnCount, + totalTurnCount, + targetOrdinal, + }); + } if (viewportPreparationStarted) { options?.cancelViewportPresentationCommit?.(); } @@ -1891,6 +2028,11 @@ export const ModernFlowChatContainer: React.FC = ( if (viewportPreparationStarted) { options?.cancelViewportPresentationCommit?.(); } + recordHistoryPagingEvent(sessionId, 'outcome_not_ready', { + direction, + reason: 'no-next-presentation', + targetOrdinal, + }); setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); return 'not-ready'; } @@ -1905,6 +2047,12 @@ export const ModernFlowChatContainer: React.FC = ( }, } : {}), }); + recordHistoryPagingEvent(sessionId, 'outcome_applied', { + direction, + targetOrdinal, + startOrdinal: nextPresentation.range.startOrdinal, + endOrdinalExclusive: nextPresentation.range.endOrdinalExclusive, + }); return 'applied'; } catch (error) { if (viewportPreparationStarted) { @@ -2376,7 +2524,7 @@ export const ModernFlowChatContainer: React.FC = ( items={virtualItems} isViewportActive={isViewportActive} presentationMode={isRenderingHistoryProjection ? 'history-window' : 'tail'} - viewportMode={isReadingTurnViewport ? 'history-reading' : 'live-tail'} + viewportMode={isViewportDetachedFromLiveTail ? 'history-reading' : 'live-tail'} historyWindow={isShowingHistoryPresentation ? activeHistoryPresentation?.range ?? null : null} presentationRevision={isShowingHistoryPresentation ? activeHistoryPresentation?.revision ?? 0 : 0} historyBoundaryState={historyBoundaryState} diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index d8b1a0a21c..e585de21d7 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -12,6 +12,10 @@ import { flowChatManager } from '../../services/FlowChatManager'; import { useFlowChatContext } from './FlowChatContext'; import { useActiveSession } from '../../store/modernFlowChatStore'; import { flowChatStore } from '../../store/FlowChatStore'; +import { + FLOWCHAT_TURNS_ROLLED_BACK_EVENT, + type FlowChatTurnsRolledBackRequest, +} from '../../events/flowchatNavigation'; import { useMessageEditStore } from '../../store/messageEditStore'; import { snapshotAPI } from '@/infrastructure/api'; import { useI18n } from '@/infrastructure/i18n'; @@ -298,6 +302,24 @@ export const UserMessageItem = React.memo( // 1) Truncate local dialog turns from this index. flowChatStore.truncateDialogTurnsFrom(resolvedSessionId, hydratedTurnIndex); + /* + * 1b) Tell the transcript the ledger got shorter, so it can settle on + * the new tail rather than leave the viewport on a Turn that no + * longer exists. Nothing in `dialogTurns` distinguishes this from a + * window re-cut, which is why it is announced — the same reason a + * submission announces itself. + * + * On the next frame, because the answer is a scroll to the end of + * real content and that has to be read from a DOM the truncation + * has already been committed to. + */ + requestAnimationFrame(() => { + window.dispatchEvent(new CustomEvent( + FLOWCHAT_TURNS_ROLLED_BACK_EVENT, + { detail: { sessionId: resolvedSessionId, fromTurnIndex: hydratedTurnIndex } }, + )); + }); + // 2) Refresh file tree and open editors. const { globalEventBus } = await import('@/infrastructure/event-bus'); globalEventBus.emit('file-tree:refresh'); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss index daa9aa97f6..560680ee97 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss @@ -20,6 +20,25 @@ max-width: 100%; } + /* + * No mount animation survives inside a virtualized row. + * + * `.markdown-renderer` carries `animation: fadeIn 0.35s` from the shared + * component library, where a block is mounted once and a fade reads as the + * content arriving. Here a row mounts every time it enters the rendered + * window: the four Turns a history page brings, the rows the junction's own + * correction scrolls into view, and every row the reader scrolls back over + * afterwards. Each one fades from transparent, so a page up reads as the + * transcript dimming and coming back — the same reason `ModelRoundItem.scss` + * and the typewriter's `replayOnMount` refuse a mount animation of their own. + * + * Scoped to the wrapper rather than removed from the library: outside the + * list — docs, previews, panels — a markdown block really is mounted once. + */ + .markdown-renderer { + animation: none; + } + &--search-match { outline: 1px solid color-mix(in srgb, var(--bf-appearance-token-color-accent-500) 35%, transparent); outline-offset: -1px; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index 00497c479c..e5c9e1dbc3 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -18,10 +18,17 @@ import './VirtualItemRenderer.scss'; interface VirtualItemRendererProps { item: VirtualItem; index: number; + /** + * Ref callback the virtualizer measures this item through. + * + * It reads `data-virtual-index` back off the element, so the attribute below + * and this callback have to land on the same node. + */ + measureRef?: (element: HTMLElement | null) => void; } export const VirtualItemRenderer = React.memo( - ({ item, index }) => { + ({ item, index, measureRef }) => { const { searchMatchIndices, searchCurrentMatchVirtualIndex } = useFlowChatVolatileContext(); const isSearchMatch = searchMatchIndices != null && searchMatchIndices.size > 0 ? searchMatchIndices.has(index) @@ -109,6 +116,7 @@ export const VirtualItemRenderer = React.memo( return (
( }, (prev, next) => ( prev.item === next.item && - prev.index === next.index + prev.index === next.index && + prev.measureRef === next.measureRef ) ); VirtualItemRenderer.displayName = 'VirtualItemRenderer'; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss index 373b2be22f..3f9c6395d8 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -29,26 +29,42 @@ } - // Ensure the Virtuoso container has correct sizing. - > div:first-child { + /* + * The scroller. It spans the full panel width even though message content + * inside it is max-width constrained, and `overflow-anchor: none` keeps the + * browser from adding a second opinion about where the viewport belongs — + * the viewport anchor is the only one. + */ + &__scroller { width: 100%; height: 100%; position: relative; z-index: 1; // Keep scroll content below overlays. - overflow-x: hidden; // Disable horizontal scrolling. + overflow-y: auto; + overflow-x: hidden; + overflow-anchor: none; + scrollbar-gutter: stable; } - // Ensure the real scroller always spans the full panel width, - // even when inner message content is max-width constrained. - [data-virtuoso-scroller='true'], - [data-virtuoso-scroller] { - width: 100% !important; - max-width: none !important; - margin: 0 !important; + /* + * The rendered window. Everything outside it stands in as padding, so this + * element's height is the whole transcript's and it is what the resize + * observer watches for a content change. + */ + &__items { + width: 100%; overflow-anchor: none; - scrollbar-gutter: stable; } + /* + * The opening viewport settles over several frames as items measure and + * history pages in. Keep it laid out and measurable but unpainted until then; + * see the opening-reveal effect in VirtualMessageList.tsx. + */ + &[data-open-viewport-settled='false'] { + visibility: hidden; + } + &--empty { display: flex; align-items: center; @@ -62,11 +78,16 @@ padding: 2rem; } - // Reserve space for the floating FlowChatHeader. - // Header height (36px) + border (1px) + blur area (16px) + extra gap (4px) = 57px. + // FlowChatHeader sits above the list in normal flow, so only a small + // breathing gap is needed before the first item. Its height is supplied + // inline from `FLOWCHAT_TURN_TOP_GAP_PX`, which every top-aligned Turn also + // scrolls to — the gap must be one number, not two that happen to match. + .message-list-header-block { + flex-shrink: 0; + overflow-anchor: none; + } + .message-list-header { - height: 57px; - min-height: 57px; flex-shrink: 0; } @@ -112,42 +133,20 @@ } .message-list-footer { - /* Inline height from VirtualMessageList (measured drop-zone + bottom inset + tail clearance). */ - height: 120px; - min-height: 72px; + /* Current input-stack inset is supplied inline by VirtualMessageList. */ overflow-anchor: none; } - &__projection-handoff-items { + /* + * Resident tail reservation (~1 viewport, supplied inline). It exists so the + * browser cannot clamp scrollTop when content shrinks; see + * `flowChatTailFollow.ts`. Height must stay a function of the viewport only. + */ + .message-list-tail-spacer { width: 100%; - } - - &__projection-handoff-overlay { - position: absolute; - inset: 0; - z-index: 2; - overflow: hidden; - scrollbar-gutter: stable; + flex-shrink: 0; pointer-events: none; - background: var(--bf-appearance-token-color-bg-scene); - contain: paint; - } - - &__projection-handoff-content { - width: 100%; - will-change: transform; - - &--bottom { - position: absolute; - left: 0; - right: 0; - bottom: 0; - min-height: 100%; - display: flex; - flex-direction: column; - justify-content: flex-end; - transform: none; - } + overflow-anchor: none; } } diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 8c292f2968..d1c73a2054 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -1,444 +1,247 @@ // @vitest-environment jsdom -import React from 'react'; -import { act } from 'react'; +import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - VirtualMessageList, - type VirtualMessageListRef, -} from './VirtualMessageList'; -import { getLeadingVirtualItemIndexDelta } from './virtualMessageListLayout'; -import { FlowChatViewportCoordinator } from './FlowChatViewportCoordinator'; -import { - clampPinReservationPxToViewport, - consumeBottomReservationForContentGrowth, - ensureCollapseReservationForScrollTop, - getCanceledUnsettledStickyPinGrowthPx, - isTurnPinRequestIdentityCurrent, - protectCurrentCollapseReservation, - reconcileUnsignaledShrinkReservation, - releasePinReservationForUserNavigation, - resolveAutoCollapseAnchorScrollTop, - resolveCollapseIntentSettlementStrategy, - resolveFollowingTailShrinkClampRecovery, - resolveProvisionalStickyPinReservationPx, - resolveStickyPinGrowthSettlementStrategy, - settleRetainedCollapseReservationForAnchor, - settleCollapseReservationForViewport, - shouldBypassShrinkCompensationInTailFollow, - shouldClearExpiredProvisionalStickyPin, - shouldSyncPhysicalBottom, - shouldSuppressFollowingTailNegativeScrollBy, - transferCollapseReservationToPin, - transferPinReservationToProtectedCollapse, -} from './flowChatScrollStability'; -import { activeSessionHistoryProjectionHandoff } from './historyProjectionHandoff'; -import type { Session } from '../../types/flow-chat'; -import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { tailSpacerPxForViewport } from './flowChatTailFollow'; +import { ONE_SHOT_NAVIGATION_HOLD_MS } from './flowChatViewportOwnership'; +import { VirtualMessageList, type VirtualMessageListRef } from './VirtualMessageList'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; -const stateMocks = vi.hoisted(() => ({ - activeSession: null as Session | null, - virtualItems: [] as VirtualItem[], - visibleTurnInfo: null as unknown, +const mocks = vi.hoisted(() => ({ + items: [] as Array>, + activeSession: null as Record | null, + scrollItemIntoView: vi.fn(), + scrollToOffset: vi.fn(), + cancelAim: vi.fn(), setVisibleTurnInfo: vi.fn(), -})); -const virtuosoMocks = vi.hoisted(() => ({ - renderedRange: null as { start: number; end: number } | null, - scrollerScrollTo: vi.fn(), - scrollToIndex: vi.fn(), - initialTopMostItemIndex: null as unknown, - initialTopMostItemIndexHistory: [] as unknown[], - increaseViewportBy: null as unknown, - rangeChanged: null as (() => void) | null, -})); -const flowStoreMocks = vi.hoisted(() => ({ - hasPendingSessionHistoryCompletion: vi.fn(() => false), - hasDeferredSessionHistoryProjection: vi.fn(() => false), - revealPreviousSessionHistoryWindow: vi.fn(() => false), - releaseSessionHistoryCompletionAfterInitialPaint: vi.fn(() => false), -})); -const inputStateMocks = vi.hoisted(() => ({ - isActive: false, - isExpanded: false, - inputHeight: 0, -})); -const activeSessionStateMocks = vi.hoisted(() => ({ - isProcessing: false, -})); -const flowDiagnosticsMocks = vi.hoisted(() => ({ - enabled: false, - trace: vi.fn(), -})); -const resizeObserverMocks = vi.hoisted(() => ({ - callbacks: [] as Array<() => void>, -})); - -vi.mock('@/infrastructure/diagnostics/flowChatDiagnostics', () => ({ - flowChatDiagnostics: { - isEnabled: () => flowDiagnosticsMocks.enabled, - trace: flowDiagnosticsMocks.trace, - }, + enterFollowOutput: vi.fn(), + exitFollowOutput: vi.fn(), + handleUserScrollIntent: vi.fn(), + /** + * The two answers to "does follow own the viewport", which the real hook + * gives at two different moments: `isFollowingOutput` is a render value, and + * `followsNow` is the ref a gesture clears synchronously before it asks. + * Keeping both here is what lets a test put them out of step, which is the + * state the paging refusal used to read from the wrong side of. + */ + isFollowingOutput: false, + followsNow: false, + /** False stands in for a Turn the virtualizer can place but the DOM cannot. */ + renderItemMetadata: true, })); -vi.mock('react-i18next', () => ({ - initReactI18next: { - type: '3rdParty', - init: vi.fn(), - }, - useTranslation: () => ({ - t: (key: string) => { - const translations: Record = { - 'historyState.preparingOlderHistory': 'Preparing older history...', - 'historyState.olderHistoryNotReady': 'Older history is not ready yet.', - }; - return translations[key] ?? key; - }, - }), -})); +/** Input-stack footer the chat-input mock produces: 140 + 4 + 24. */ +const BOTTOM_INSET = 168; -vi.mock('react-virtuoso', () => ({ - Virtuoso: React.forwardRef((props: any, ref) => { - const scrollerRef = React.useRef(null); - const [, rerender] = React.useReducer((value: number) => value + 1, 0); - virtuosoMocks.initialTopMostItemIndex = props.initialTopMostItemIndex; - virtuosoMocks.initialTopMostItemIndexHistory.push(props.initialTopMostItemIndex); - virtuosoMocks.increaseViewportBy = props.increaseViewportBy; - virtuosoMocks.rangeChanged = props.rangeChanged ?? null; - React.useImperativeHandle(ref, () => ({ - scrollTo: vi.fn(), - scrollToIndex: vi.fn((options: { index: number }) => { - virtuosoMocks.scrollToIndex(options); - const localIndex = Math.max(0, options.index); - virtuosoMocks.renderedRange = { - start: localIndex, - end: Math.min(props.data?.length ?? 0, localIndex + 4), - }; - rerender(); - }), - })); - - React.useLayoutEffect(() => { - if (!scrollerRef.current) { - return; - } +/** + * jsdom has no layout engine, so both halves of the navigation clamp have to be + * supplied: the scroller's own box, and where a user message sits inside it. + */ +function fakeLayout(options: { + clientHeight: number; + /** A function where the range has to grow, as it does when history arrives. */ + scrollHeight: number | (() => number); + turnTopFromScrollerTop: number; +}) { + const readScrollHeight = typeof options.scrollHeight === 'function' + ? options.scrollHeight + : () => options.scrollHeight as number; + const originals = (['clientHeight', 'scrollHeight'] as const).map(name => { + const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, name); + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + get: () => (name === 'clientHeight' ? options.clientHeight : readScrollHeight()), + }); + return [name, descriptor] as const; + }); + const originalRect = HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = function getRect(this: HTMLElement) { + const top = this.classList.contains('virtual-item-wrapper') + ? options.turnTopFromScrollerTop + : 0; + return { ...new DOMRect(0, top, 0, 40), top, bottom: top + 40 } as DOMRect; + }; - if (typeof scrollerRef.current.scrollTo !== 'function') { - Object.defineProperty(scrollerRef.current, 'scrollTo', { - configurable: true, - writable: true, - value: (options?: ScrollToOptions) => { - virtuosoMocks.scrollerScrollTo(options); - if (typeof options?.top === 'number') { - scrollerRef.current!.scrollTop = options.top; - } - }, - }); + return () => { + HTMLElement.prototype.getBoundingClientRect = originalRect; + originals.forEach(([name, descriptor]) => { + if (descriptor) { + Object.defineProperty(HTMLElement.prototype, name, descriptor); + } else { + delete (HTMLElement.prototype as unknown as Record)[name]; } + }); + }; +} - props.scrollerRef?.(scrollerRef.current); - return () => { - props.scrollerRef?.(null); +/* + * The virtualizer is mocked at FlowChat's own seam rather than at the library. + * These tests are about which scroll the list decides to ask for, and the seam + * is where that decision is expressed; the library's own behaviour belongs to + * the library. Every item is rendered, which is what a list shorter than the + * viewport would do anyway. + */ +vi.mock('./useFlowChatVirtualizer', async () => { + // The visible range is real geometry, and the paging rule reads it. Faking it + // would leave the rule tested against an answer no viewport can produce. + const actual = await vi.importActual( + './useFlowChatVirtualizer', + ); + return { + ...actual, + useFlowChatVirtualizer: (options: { + items: Array>; + getItemKey: (item: Record) => string; + scrollerRef: { current: HTMLElement | null }; + }) => { + const rows = options.items.map((item, index) => ({ + index, + key: options.getItemKey(item), + startPx: index * 40, + endPx: index * 40 + 40, + })); + return { + rows, + paddingTopPx: 0, + paddingBottomPx: 0, + measureRowElement: () => {}, + getItemBounds: (index: number) => ( + index >= 0 && index < rows.length + ? { startPx: index * 40, endPx: index * 40 + 40 } + : null + ), + // The real one flushes the DOM's heights into the library's cache; + // these rows are placed by arithmetic, so there is nothing to flush. + measureRenderedItems: () => {}, + getVisibleItemRange: () => { + const scroller = options.scrollerRef.current; + return scroller + ? actual.visibleRowRange(rows, scroller.scrollTop, scroller.clientHeight) + : null; + }, + scrollItemIntoView: mocks.scrollItemIntoView, + scrollToOffset: mocks.scrollToOffset, + cancelAim: mocks.cancelAim, }; - }, [props]); - - React.useEffect(() => { - if (props.data?.[0]?.turnId === 'turn-a') { - props.atBottomStateChange?.(false); - } - }, [props]); - - return ( -
- {props.components?.Header ? : null} - {props.data - ?.map((item: VirtualItem, index: number) => ({ item, index })) - .filter(({ index }: { index: number }) => { - const range = virtuosoMocks.renderedRange; - return !range || (index >= range.start && index < range.end); - }) - .map(({ item, index }: { item: VirtualItem; index: number }) => ( -
- {item.type === 'user-message' ? item.data.content : item.turnId} -
- ))} - {props.components?.Footer ? : null} -
- ); - }), -})); + }, + }; +}); vi.mock('../../store/modernFlowChatStore', () => { - const useModernFlowChatStore = (selector: (state: any) => unknown) => selector({ - visibleTurnInfo: stateMocks.visibleTurnInfo, - }); - useModernFlowChatStore.getState = () => ({ - visibleTurnInfo: stateMocks.visibleTurnInfo, - setVisibleTurnInfo: stateMocks.setVisibleTurnInfo, - }); - + const useModernFlowChatStore = Object.assign( + (selector: (state: Record) => unknown) => selector({ visibleTurnInfo: null }), + { getState: () => ({ setVisibleTurnInfo: mocks.setVisibleTurnInfo }) }, + ); return { - useActiveSession: () => stateMocks.activeSession, - useVirtualItems: () => stateMocks.virtualItems, + useVirtualItems: () => mocks.items, + useActiveSession: () => mocks.activeSession, useModernFlowChatStore, }; }); vi.mock('../../hooks/useActiveSessionState', () => ({ - useActiveSessionState: () => ({ - isProcessing: activeSessionStateMocks.isProcessing, - processingPhase: null, - }), + useActiveSessionState: () => ({ isProcessing: false }), })); vi.mock('../../store/chatInputStateStore', () => ({ - useChatInputState: (selector: (state: any) => unknown) => selector(inputStateMocks), -})); - -vi.mock('../../store/FlowChatStore', () => ({ - flowChatStore: { - getState: () => ({ - sessions: new Map(stateMocks.activeSession ? [[stateMocks.activeSession.sessionId, stateMocks.activeSession]] : []), - }), - hasPendingSessionHistoryCompletion: flowStoreMocks.hasPendingSessionHistoryCompletion, - hasDeferredSessionHistoryProjection: flowStoreMocks.hasDeferredSessionHistoryProjection, - revealPreviousSessionHistoryWindow: flowStoreMocks.revealPreviousSessionHistoryWindow, - releaseSessionHistoryCompletionAfterInitialPaint: flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint, - }, + useChatInputState: (selector: (state: Record) => unknown) => selector({ + isActive: false, + isExpanded: false, + inputHeight: 140, + }), })); -vi.mock('@/shared/utils/startupTrace', () => ({ - startupTrace: { markPhase: vi.fn() }, +vi.mock('./useFlowChatFollowOutput', () => ({ + useFlowChatFollowOutput: () => ({ + isFollowingOutput: mocks.isFollowingOutput, + enterFollowOutput: mocks.enterFollowOutput, + exitFollowOutput: mocks.exitFollowOutput, + scheduleFollowToLatest: vi.fn(), + isFollowingOutputNow: () => mocks.followsNow, + // Nothing streams here, so the frame loop is never correcting: the band is + // judged on the viewport itself, exactly as it is for a resting transcript. + isFollowCorrectingViewport: () => false, + handleUserScrollIntent: () => { + // What the real one does first: release, synchronously. + mocks.followsNow = false; + mocks.handleUserScrollIntent(); + }, + handleTurnsRolledBack: vi.fn(), + handleScroll: vi.fn(), + handleScrollSettled: vi.fn(), + handleViewportResize: vi.fn(), + // Follow owns nothing here, which is what the real hook returns when + // `isFollowingOutput` is false. + getFollowTargetScrollTop: () => null, + }), })); vi.mock('./VirtualItemRenderer', () => ({ - VirtualItemRenderer: ({ item, index }: { item: VirtualItem; index: number }) => ( -
- {item.turnId} + VirtualItemRenderer: ({ item, index, measureRef }: { + item: any; + index: number; + measureRef?: (element: HTMLElement | null) => void; + }) => ( +
+ {item.data?.content ?? item.turnId}
), })); -vi.mock('../ScrollToLatestBar', () => ({ - ScrollToLatestBar: ({ visible, onClick }: { visible: boolean; onClick?: () => void }) => ( -