Skip to content
13 changes: 11 additions & 2 deletions packages/flow-chat-presentation/src/thinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@ export interface ThinkingDisclosureInput {
isLastItem: boolean;
forceExpanded?: boolean;
displayContext?: 'default' | 'subagent-projection';
/** Embedded subagent transcripts stay compact until the reader opens them. */
compactByDefault?: boolean;
}

export function defaultThinkingExpanded({ isSummary, isActive, isRevealing = false, isLastItem, forceExpanded = false }: ThinkingDisclosureInput): boolean {
export function defaultThinkingExpanded({
isSummary,
isActive,
isRevealing = false,
isLastItem,
forceExpanded = false,
compactByDefault = false,
}: ThinkingDisclosureInput): boolean {
// A successor can arrive before the reasoning stream/typewriter has drained.
return forceExpanded || (!isSummary && (isActive || isRevealing || isLastItem));
return forceExpanded || (!compactByDefault && !isSummary && (isActive || isRevealing || isLastItem));
}

/** Stream/reveal and the inner/outer scroll owners stay outside this hook. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
} from './sessionNavExpand';
import { useSessionRowRemovalTransition } from './sessionRowShift';
import { SessionStatusIndicator } from './SessionStatusIndicator';
import { createSessionsNavSelector } from './sessionsNavSelector';
import './SessionsSection.scss';

const log = createLogger('SessionsSection');
Expand Down Expand Up @@ -339,29 +340,10 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
const bufferPrefetchSignatureRef = useRef<string | null>(null);

useEffect(() => {
const selector = (s: FlowChatState): string => {
const parts: string[] = [s.activeSessionId ?? ''];
for (const session of s.sessions.values()) {
const latestTurn = session.dialogTurns[session.dialogTurns.length - 1];
const dispatchTarget = session.config.dispatchTarget;
const dispatchTargetSnapshot = dispatchTarget?.kind === 'ssh'
? `ssh:${dispatchTarget.connectionId}:${dispatchTarget.workspacePath}:${dispatchTarget.displayName}`
: dispatchTarget?.kind === 'device'
? `device:${dispatchTarget.deviceId}:${dispatchTarget.workspacePath}:${dispatchTarget.displayName}`
: 'local';
parts.push(
`${session.sessionId}|${session.isTransient ? '1':'0'}|${session.sessionKind}|` +
`${session.parentSessionId ?? ''}|${session.parentToolCallId ?? ''}|${session.subagentType ?? ''}|` +
`${session.workspacePath ?? ''}|${session.mode ?? ''}|${session.needsUserAttention ?? ''}|` +
`${session.hasUnreadCompletion ?? ''}|${latestTurn?.status ?? ''}|` +
`${session.title ?? ''}|${dispatchTargetSnapshot}|${session.config.dispatchJobState ?? ''}`
);
}
return parts.join(';');
};
const selector = createSessionsNavSelector();
const unsub = flowChatStore.subscribeSelector(selector, (() => {
setFlowChatState(flowChatStore.getState());
}), { isEqual: (a, b) => a === b });
}));
return () => unsub();
}, []);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat';
import { createSessionsNavSelector } from './sessionsNavSelector';

function session(overrides: Partial<Session> = {}): Session {
return {
sessionId: 'session-1',
dialogTurns: [],
status: 'idle',
config: {},
sessionKind: 'normal',
...overrides,
} as Session;
}

function state(sessions: Session[], activeSessionId: string | null = null): FlowChatState {
return { sessions: new Map(sessions.map(item => [item.sessionId, item])), activeSessionId } as FlowChatState;
}

describe('createSessionsNavSelector', () => {
it('reuses the revision when only non-navigation session data changes', () => {
const select = createSessionsNavSelector();
const first = session();

expect(select(state([first]))).toBe(1);
expect(select(state([{ ...first, dialogTurns: [{ id: 'turn-1' }] as Session['dialogTurns'] }]))).toBe(1);
});

it('increments for navigation fields and session ordering changes', () => {
const select = createSessionsNavSelector();
const first = session();
const second = session({ sessionId: 'session-2' });

expect(select(state([first, second]))).toBe(1);
expect(select(state([{ ...first, title: 'Renamed' }, second]))).toBe(2);
expect(select(state([{ ...first, title: 'Renamed', needsUserAttention: 'ask_user' }, second]))).toBe(3);
expect(select(state([second, { ...first, title: 'Renamed', needsUserAttention: 'ask_user' }]))).toBe(4);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat';

type DispatchTargetKind = 'local' | 'ssh' | 'device';

interface SessionNavSignature {
sessionId: string;
isTransient: boolean | undefined;
sessionKind: Session['sessionKind'];
parentSessionId: string | undefined;
parentToolCallId: string | undefined;
subagentType: string | undefined;
workspacePath: string | undefined;
mode: string | undefined;
needsUserAttention: Session['needsUserAttention'];
hasUnreadCompletion: Session['hasUnreadCompletion'];
latestTurnStatus: string | undefined;
title: string | undefined;
dispatchTargetKind: DispatchTargetKind;
dispatchConnectionId: string | undefined;
dispatchDeviceId: string | undefined;
dispatchWorkspacePath: string | undefined;
dispatchDisplayName: string | undefined;
dispatchJobState: Session['config']['dispatchJobState'];
}

function readSessionNavSignature(session: Session): SessionNavSignature {
const latestTurn = session.dialogTurns[session.dialogTurns.length - 1];
const dispatchTarget = session.config.dispatchTarget;

return {
sessionId: session.sessionId,
isTransient: session.isTransient,
sessionKind: session.sessionKind,
parentSessionId: session.parentSessionId,
parentToolCallId: session.parentToolCallId,
subagentType: session.subagentType,
workspacePath: session.workspacePath,
mode: session.mode,
needsUserAttention: session.needsUserAttention,
hasUnreadCompletion: session.hasUnreadCompletion,
latestTurnStatus: latestTurn?.status,
title: session.title,
dispatchTargetKind: dispatchTarget?.kind ?? 'local',
dispatchConnectionId: dispatchTarget?.kind === 'ssh' ? dispatchTarget.connectionId : undefined,
dispatchDeviceId: dispatchTarget?.kind === 'device' ? dispatchTarget.deviceId : undefined,
dispatchWorkspacePath: dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.workspacePath,
dispatchDisplayName: dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.displayName,
dispatchJobState: session.config.dispatchJobState,
};
}

function sameSessionNavSignature(previous: SessionNavSignature, session: Session): boolean {
const dispatchTarget = session.config.dispatchTarget;
const dispatchTargetKind = dispatchTarget?.kind ?? 'local';
const latestTurn = session.dialogTurns[session.dialogTurns.length - 1];

return previous.sessionId === session.sessionId
&& previous.isTransient === session.isTransient
&& previous.sessionKind === session.sessionKind
&& previous.parentSessionId === session.parentSessionId
&& previous.parentToolCallId === session.parentToolCallId
&& previous.subagentType === session.subagentType
&& previous.workspacePath === session.workspacePath
&& previous.mode === session.mode
&& previous.needsUserAttention === session.needsUserAttention
&& previous.hasUnreadCompletion === session.hasUnreadCompletion
&& previous.latestTurnStatus === latestTurn?.status
&& previous.title === session.title
&& previous.dispatchTargetKind === dispatchTargetKind
&& previous.dispatchConnectionId === (dispatchTarget?.kind === 'ssh' ? dispatchTarget.connectionId : undefined)
&& previous.dispatchDeviceId === (dispatchTarget?.kind === 'device' ? dispatchTarget.deviceId : undefined)
&& previous.dispatchWorkspacePath === (dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.workspacePath)
&& previous.dispatchDisplayName === (dispatchTarget?.kind === 'local' ? undefined : dispatchTarget?.displayName)
&& previous.dispatchJobState === session.config.dispatchJobState;
}

/**
* Selects only the fields rendered by the sessions navigation.
*
* The selector is called for every FlowChatStore notification. It keeps a
* primitive revision as its result, so stream updates do not rebuild a large
* joined string merely to discover that the navigation did not change.
*/
export function createSessionsNavSelector(): (state: FlowChatState) => number {
let activeSessionId: string | null | undefined;
let orderedSessionIds: string[] = [];
const signatures = new Map<string, SessionNavSignature>();
let revision = 0;

return (state: FlowChatState): number => {
let changed = activeSessionId !== state.activeSessionId
|| orderedSessionIds.length !== state.sessions.size;
let orderChanged = orderedSessionIds.length !== state.sessions.size;
let index = 0;

for (const session of state.sessions.values()) {
if (orderedSessionIds[index] !== session.sessionId) {
changed = true;
orderChanged = true;
}

const previous = signatures.get(session.sessionId);
if (!previous || !sameSessionNavSignature(previous, session)) {
signatures.set(session.sessionId, readSessionNavSignature(session));
changed = true;
}
index += 1;
}

if (index !== orderedSessionIds.length) {
changed = true;
orderChanged = true;
}

if (changed) {
revision += 1;
activeSessionId = state.activeSessionId;
if (orderChanged) {
orderedSessionIds = Array.from(state.sessions.keys());
for (const sessionId of signatures.keys()) {
if (!state.sessions.has(sessionId)) {
signatures.delete(sessionId);
}
}
}
}

return revision;
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@

.virtual-item-wrapper {
width: 100%;
display: block;
// Preserve the shared flow-root: virtual padding must include child margins.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ function extractBlock(stylesheet: string, selector: string): string {
}

describe('BtwSessionPanel layout styles', () => {
it('preserves the shared virtual row margin containment', () => {
const stylesheet = readBtwSessionPanelStylesheet();
const shared = readFileSync(
fileURLToPath(new URL('../modern/VirtualItemRenderer.scss', import.meta.url)), 'utf8',
);
expect(extractBlock(shared, '.virtual-item-wrapper')).toContain('display: flow-root;');
// A more specific sidebar rule must not undo the measured row boundary.
expect(extractBlock(stylesheet, '.virtual-item-wrapper')).not.toMatch(/display\s*:/);
});

it('keeps the review action bar wrapper bounded inside the panel viewport', () => {
const stylesheet = readBtwSessionPanelStylesheet();
const wrapper = extractBlock(stylesheet, '&__action-bar-wrapper');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ describe('BtwVirtualSessionList', () => {
type: 'image-analyzing', turnId: `turn-${index}`,
} as VirtualItem));

function render() {
function render(renderedItems = items) {
act(() => root.render(
<BtwVirtualSessionList
items={items}
items={renderedItems}
scrollerRef={{ current: scroller }}
headerRef={{ current: header }}
followRef={followRef}
Expand Down Expand Up @@ -150,4 +150,74 @@ describe('BtwVirtualSessionList', () => {
owner: 'follow-output', topPx: 120000, holdForMs: 0,
});
});

it.each([false, true])('settles tail rows when trailing margins are measured: %s', contained => {
// Supplied CSS geometry: a 100px body and an 8px trailing child margin.
// With flow-root both occupy the measured border box; with block the
// margin escapes, still occupies layout space, and vanishes on unmount.
// jsdom does not perform margin collapsing; this tests its consequences
// against the real virtualizer and the panel's real follow scheduler.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function () {
return this.hasAttribute('data-virtual-index') ? (contained ? 108 : 100) : 0;
});
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, get: () => {
const window = host.firstElementChild as HTMLElement | null;
if (!window) return 500;
return Math.max(500, 15 + Number.parseFloat(window.style.paddingTop || '0')
+ Number.parseFloat(window.style.paddingBottom || '0') + window.children.length * 108);
} });
let offset = 0;
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => Math.max(0, Math.min(offset, scroller.scrollHeight - scroller.clientHeight)),
set: (value: number) => { offset = Math.max(0, Math.min(value, scroller.scrollHeight - scroller.clientHeight)); },
});
vi.mocked(owner.write).mockImplementation(({ topPx }) => {
scroller.scrollTop = topPx;
return true;
});
vi.mocked(owner.shift).mockImplementation(delta => {
scroller.scrollTop += delta;
return true;
});
followRef.current = true;
render(items.slice(0, 20));
const oldRowNodes: Array<Element | null> = [];
const deliveredSizes = new WeakMap<object, Map<Element, number>>();
for (let frame = 0; frame < 40; frame++) {
act(() => {
// New and resized rows are measured after layout, including rows
// whose inline measurement was skipped during native scrolling.
for (const observer of [...resizeObservers]) {
const sizes = deliveredSizes.get(observer) ?? new Map<Element, number>();
deliveredSizes.set(observer, sizes);
const entries: ResizeObserverEntry[] = [];
for (const target of observer.targets) {
const blockSize = target === host.firstElementChild
? scroller.scrollHeight - 15 : (target as HTMLElement).offsetHeight;
if (sizes.get(target) === blockSize) continue;
sizes.set(target, blockSize);
entries.push({ target, borderBoxSize: [{ blockSize, inlineSize: 500 }] } as unknown as ResizeObserverEntry);
}
if (entries.length) observer.callback(entries, {} as ResizeObserver);
}
});
act(() => {
// Browser scroll delivery precedes the next animation-frame write.
scroller.dispatchEvent(new Event('scroll'));
const pending = [...frames.values()];
frames.clear();
pending.forEach(callback => callback(frame * 16));
});
if (frame >= 10) oldRowNodes.push(host.querySelector('[data-virtual-index="9"]'));
}
const identities = new Set(oldRowNodes.filter(node => node !== null));
if (contained) {
expect(oldRowNodes).not.toContain(null);
expect(identities.size).toBe(1);
} else {
expect(oldRowNodes).toContain(null);
expect(identities.size).toBeGreaterThan(10);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ Do not substitute clipping overflow: user-message decorations and controls can
extend outside the reading column. The rhythm test protects this stylesheet
contract; it does not prove browser layout or end-to-end scroll stability.

Embedded panels must preserve that shared formatting context. The Btw sidebar
previously overrode the wrapper with `display: block`, allowing trailing child
margins to escape measurement. Removing a boundary row then removed space not
represented by virtual padding; tail follow could bring the row back, restoring
the space and removing it again. `BtwSessionPanelLayout.test.ts` protects the
cascade contract. `BtwVirtualSessionList.test.tsx` reproduces the feedback with
supplied contained/escaping margin geometry, the real virtualizer, and delayed
resize/scroll delivery. This isolates a window-churn mechanism; native WebView2
memory savings and the fraction of observed runtime churn it explains still
require a retest.

## Interrupted turn continuity

Cancelled rounds remain in the ordinary transcript. The display projection removes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ describe('visible FlowChat item composition', () => {
it.each<Host>(['round', 'explore', 'subagent'])('%s uses the last visible item for reasoning disclosure', host => {
act(() => root.render(renderHost(host, [thinking, blank, hidden])));
const panel = container.querySelector('[data-testid="chat-thinking-panel"]');
expect(panel?.getAttribute('data-expanded')).toBe(host === 'explore' ? 'false' : 'true');
expect(panel?.getAttribute('data-expanded')).toBe(host === 'round' ? 'true' : 'false');
expect(panel?.hasAttribute('data-thinking-attachment')).toBe(false);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,9 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({
onFileViewRequest,
onTabOpen,
sessionId,
activeSessionOverride,
} = useFlowChatContext();
const isSubagentSurface = activeSessionOverride?.sessionKind === 'subagent';

switch (item.type) {
case 'text':
Expand All @@ -879,7 +881,9 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({
<ModelThinkingDisplay
thinkingItem={item as FlowThinkingItem}
isLastItem={isLastItem}
forceExpanded={expandedThinkingItemIds.includes(item.id)}
// The embedded panel owns the compact default, including persisted
// expansion state from the primary session.
forceExpanded={!isSubagentSurface && expandedThinkingItemIds.includes(item.id)}
/>
);

Expand Down
Loading
Loading