diff --git a/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts b/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts index e9bca17278..3757bed336 100644 --- a/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-marker-cascade-contract.test.ts @@ -40,18 +40,18 @@ describe('chat Marker shell migration contract (#332 PR2)', () => { } }); - it('keeps the turn container + deferred reasoning chrome (out of scope)', async () => { + it('keeps the turn container (out of scope)', async () => { const css = await readAllRendererCss(); for (const selector of [ // The `.maka-turn` flex/measure container is NOT a marker — it stays. '.maka-turn {', - '.maka-turn-tools', '.maka-turn-streaming', '.maka-turn[data-search-highlight="true"]', - // `.maka-turn-thinking` is explicitly deferred (pseudo-element chevron + - // @starting-style fade don't reduce to leaf utilities); it stays authored. - '.maka-turn-thinking', - '.maka-turn-thinking [data-slot="collapsible-trigger"]', + // NOTE: `.maka-turn-thinking` and `.maka-turn-tools` were retired by the + // streaming UI rework — reasoning now renders through the `DeepThinking` + // disclosure (Tailwind-literal chrome + the `maka-text-shimmer` primitive) + // and tools through the flat `ToolTrow`, so the hand-authored committed- + // turn thinking `
` chrome and the tools-section wrapper are gone. ]) { assert.ok(css.includes(selector), `out-of-scope turn rule "${selector}" must be preserved`); } diff --git a/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts b/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts index abc1e7b419..01c037761a 100644 --- a/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts +++ b/apps/desktop/src/main/__tests__/chat-status-cluster-layout-contract.test.ts @@ -21,18 +21,66 @@ describe('chat status cluster layout contract', () => { it('keeps alert/status badges in flow between the header and first chat content', async () => { const src = await readRepo('packages/ui/src/chat-view.tsx'); + // The cluster div is ALWAYS mounted (streaming-settle polish): it + // collapses via the CSS `:empty` height transition instead of conditional + // mount/unmount, which used to snap the conversation column up by the + // badge-row height the frame a run completed. The badges inside stay + // conditional. assert.match( src, - /
[\s\S]*?<\/header>\s*\{\(props\.sessionStatusBadge \|\| props\.connectionAlert \|\| props\.eventStreamAlert\) && \(/, + /<\/header>\s*\{\/\*[\s\S]*?\*\/\}\s*
/, 'status badges should render after the header, not inside the header toolbar row', ); assert.match( src, - /
[\s\S]*?<\/div>\s*\)\}\s*\{isLocalSimulationBackend && \(/, + /
\s*\{props\.sessionStatusBadge && /, + 'the cluster div must be unconditionally mounted with badges conditional INSIDE (the :empty transition depends on it)', + ); + assert.match( + src, + /
[\s\S]*?<\/div>\s*\{isLocalSimulationBackend && \(/, 'status badges should stay before the fake-backend banner so normal flow reserves vertical space before first content', ); }); + it('collapses via :empty with tokenized height/opacity transition instead of unmount', async () => { + const css = await readRendererContractCss(); + const body = ruleBody(css, '.maka-chat-status-cluster'); + assert.match(body, /interpolate-size:\s*allow-keywords/, 'height auto↔0 transition needs interpolate-size'); + assert.match(body, /transition:[\s\S]*?height var\(--duration-large\)/, 'height must transition on a duration token'); + const emptyBody = ruleBody(css, '.maka-chat-status-cluster:empty'); + assert.match(emptyBody, /height:\s*0/); + assert.match(emptyBody, /opacity:\s*0/); + }); + + it('reserves the footer placeholder for every live turn, not only text answers', async () => { + // Three-way review (ChatGPT P2): a settled turn ALWAYS mounts a footer + // (deriveTurnFooterActions yields regenerate/branch from TurnStatus alone; + // materialize emits a timeline item for a step's thinking even with empty + // text), so a thinking-only turn settles WITH a footer. The live footer + // placeholder must live inside the `streamingText || thinkingText` section + // and render unconditionally there — never re-narrowed to streamingText — + // so it reserves the footer box for every live turn. + // + // Groundwork only: this locks the reserved box. It makes the swap + // height-neutral where the live section is held to settle (text turns, via + // the draining handshake). The textless / thinking-only completion path is + // still non-atomic (clears live before the committed footer mounts); that + // is tracked in the single-render-path convergence (#642), not asserted + // here. + const src = await readRepo('packages/ui/src/chat-view.tsx'); + assert.match( + src, + /Unconditional \(not gated on streamingText\)[\s\S]*?\*\/\}\s*
- {(props.sessionStatusBadge || props.connectionAlert || props.eventStreamAlert) && ( - /* In normal flow below the header (see .maka-chat-status-cluster) - so wrapped multi-badge rows reserve space before banners and - messages. */ -
- {props.sessionStatusBadge && } - {props.connectionAlert && } - {props.eventStreamAlert && } -
- )} + {/* In normal flow below the header (see .maka-chat-status-cluster) + so wrapped multi-badge rows reserve space before banners and + messages. ALWAYS mounted (even with zero badges): the cluster + collapses/expands via the CSS `:empty` height transition instead of + conditional mount/unmount — unmounting it when a run completes used + to snap the whole conversation column up by the badge-row height in + a single frame (the settle "jump"). */} +
+ {props.sessionStatusBadge && } + {props.connectionAlert && } + {props.eventStreamAlert && } +
{isLocalSimulationBackend && (