Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions scripts/diagnostics/analyze-flowchat-log.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

function printUsage() {
console.log(`Usage:
node scripts/diagnostics/analyze-flowchat-log.mjs <flowchat.log> [options]

Options:
--top <count> Maximum rows per summary table (default: 20)
--min-delta <px> Minimum positive reservation jump to show (default: 100)
--around <sequence> Show a compact event window around a sequence
--radius <count> Sequence radius for --around (default: 8)
--help Show this help`);
}

function parseNumberOption(args, index, optionName) {
const rawValue = args[index + 1];
const value = Number(rawValue);
if (!rawValue || !Number.isFinite(value)) {
throw new Error(`${optionName} requires a finite number`);
}
return value;
}

function parseArgs(argv) {
if (argv.includes('--help')) {
printUsage();
process.exit(0);
}

const logPath = argv[0];
if (!logPath || logPath.startsWith('--')) {
printUsage();
throw new Error('A FlowChat JSONL log path is required');
}

const options = {
logPath,
top: 20,
minDelta: 100,
around: null,
radius: 8,
};

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));
index += 1;
} else if (arg === '--around') {
options.around = Math.floor(parseNumberOption(argv, index, arg));
index += 1;
} else if (arg === '--radius') {
options.radius = Math.max(0, Math.floor(parseNumberOption(argv, index, arg)));
index += 1;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}

return options;
}

function finiteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}

function round(value) {
return Math.round(finiteNumber(value) * 100) / 100;
}

function reservationTotal(reservation) {
return finiteNumber(reservation?.collapse?.px) + finiteNumber(reservation?.pin?.px);
}

function compactData(data) {
if (!data) return '';
const serialized = JSON.stringify(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 });

for await (const line of lines) {
lineCount += 1;
if (!line.trim()) continue;

let event;
try {
event = JSON.parse(line);
} catch {
parseErrorCount += 1;
continue;
}
eventCount += 1;

const countKey = `${event.location ?? ''}\u0000${event.message ?? ''}`;
const existingCount = eventCounts.get(countKey);
if (existingCount) {
existingCount.count += 1;
} else {
eventCounts.set(countKey, {
count: 1,
location: event.location ?? '',
message: event.message ?? '',
});
}

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,
});
}
}

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 ?? '',
});
}

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),
});
}
}

console.log(`FlowChat log: ${options.logPath}`);
console.log(`Lines: ${lineCount}, events: ${eventCount}, parse errors: ${parseErrorCount}`);

console.log('\nMost frequent events');
console.table(
[...eventCounts.values()]
.sort((left, right) => right.count - left.count)
.slice(0, options.top),
);

console.log(`\nLargest reservation increases (>= ${options.minDelta}px)`);
console.table(
reservationJumps
.sort((left, right) => right.deltaPx - left.deltaPx)
.slice(0, options.top),
);

console.log('\nLargest collapse-intent estimates');
console.table(
collapseIntents
.sort((left, right) => right.addedPx - left.addedPx)
.slice(0, options.top),
);

if (options.around !== null) {
console.log(`\nEvents around sequence ${options.around} (+/- ${options.radius})`);
console.table(sequenceWindow);
}
}

try {
const options = parseArgs(process.argv.slice(2));
await analyze(options);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,6 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = React.m
} = useToolCardHeightContract({
toolId: groupId,
toolName: 'explore-group',
getCardHeight: () => (
containerRef.current?.scrollHeight
?? containerRef.current?.getBoundingClientRect().height
?? null
),
});

const hasExplicitState = exploreGroupStates?.has(groupId) ?? false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,18 @@ 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.

There is no persistent scroll-position lock or scroll-listener lock. For an unsignaled
Collapse anchors have an active phase while layout is changing and a retained
phase after the collapse intent settles. Retained anchors do not expire on a
wall-clock timer: Virtuoso can publish a delayed size compensation after the
collapse animation and intent have finished. They keep owning virtualizer
compensation until user navigation, tail/pin ownership transfer, session reset,
or DOM disconnection. The retained phase stops the continuous animation-frame
guard; observer and scroll paths still restore the anchor on demand. 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. For an unsignaled
shrink with no semantic element anchor, `restoreScrollPositionOnce()` performs
one clamped `scrollTop` fallback using the pre-change position. It is a bounded
last resort, not a second controller: subsequent layout changes are handled by
Expand Down Expand Up @@ -281,7 +292,8 @@ 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
its settlement is autonomous rather than scroll-driven. Automatic collapses are
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
Expand Down Expand Up @@ -362,8 +374,10 @@ Current producer:
- `ExploreGroupRenderer.tsx`

Most tool cards now emit these events through `useToolCardHeightContract`.
Components that need more accurate collapse estimation can pass a custom
`getCardHeight` function to the helper.
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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,68 @@ describe('FlowChatViewportCoordinator', () => {
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('keeps a pinned item anchored until follow mode takes ownership', () => {
const scroller = document.createElement('div');
scroller.dataset.virtuosoScroller = 'true';
Expand Down Expand Up @@ -289,4 +351,25 @@ describe('FlowChatViewportCoordinator', () => {
expect(scroller.scrollTop).toBe(800);
coordinator.release('test-cleanup');
});

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);
});
});
Loading