diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index 6674f157bf..e9fc11b49c 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -58,8 +58,9 @@ .explore-region__content { position: relative; - // Align nested tool cards with the summary text, not the group chevron. - padding: 0 0 0 20px; + // Keep expanded rows on the same leading edge as the collapsed group. + // A nested inset makes auto-collapse read as a horizontal jump. + padding: 0; box-sizing: border-box; // Hide scrollbar track by default; only show when content actually overflows. overflow: hidden; 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 dbe603d217..a4596a2bbd 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 @@ -6,7 +6,7 @@ This document explains the scroll-stability mechanism used by `VirtualMessageLis 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. Four invariants hold across the message list, and breaking any of +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 projection identity stable.** A collapsible tool @@ -37,8 +37,14 @@ them reintroduces the "the chat keeps refreshing itself" report: share one duration (see `flowChatCollapseMotion.ts` / `SmoothHeightCollapse`). Do not hard-swap `BaseToolCard` ↔ `CompactToolCard` for expand/collapse — that remounts the body with no height transition. - -A fifth, related rule lives in `useTypewriter`: `replayOnMount` defaults to +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. 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 new file mode 100644 index 0000000000..ebd816ba16 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts @@ -0,0 +1,108 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readSource(relativePath: string): string { + return readFileSync( + fileURLToPath(new URL(relativePath, import.meta.url)), + 'utf8', + ).replace(/\r\n?/g, '\n'); +} + +function extractBlock(stylesheet: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = stylesheet.match( + new RegExp(`${escapedSelector}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`), + ); + return match?.groups?.body ?? ''; +} + +describe('FlowChat collapse leading-edge alignment', () => { + it('guards every shared collapse body against leading indentation', () => { + const stylesheet = readSource('./VirtualMessageList.scss'); + const projectionRoots = [ + '.explore-region__content', + '.thinking-content', + '.base-tool-card-expanded', + '.base-tool-card-error', + '.compact-tool-card-expanded', + '.view-image-tool-card__content', + '.subagent-items-container', + '.subagent-projection-container--expanded', + ]; + + for (const selector of projectionRoots) { + expect(stylesheet).toContain(selector); + } + expect(stylesheet).toContain('margin-inline-start: 0;'); + expect(stylesheet).toContain('padding-inline-start: 0;'); + }); + + it('keeps custom explore and thinking collapse bodies on their header edge', () => { + const exploreStyles = readSource('./ExploreRegion.scss'); + const thinkingStyles = readSource('../../tool-cards/ModelThinkingDisplay.scss'); + const exploreContent = extractBlock(exploreStyles, '.explore-region__content'); + const thinkingContent = extractBlock(thinkingStyles, '.thinking-content'); + + expect(exploreContent).toContain('padding: 0;'); + expect(thinkingContent).toMatch( + /padding:\s*var\(--flowchat-card-expanded-pad-y\)\s*var\(--flowchat-card-expanded-pad-x\)\s*var\(--flowchat-card-expanded-pad-y\)\s*0;/, + ); + }); + + it('keeps tool, image, and subagent detail roots free of leading offsets', () => { + const baseToolStyles = readSource('../../tool-cards/BaseToolCard.scss'); + const compactToolStyles = readSource('../../tool-cards/CompactToolCard.scss'); + const imageStyles = readSource('../../tool-cards/ViewImageToolCard.scss'); + const subagentStyles = readSource('./SubagentItems.scss'); + const taskStyles = readSource('../../tool-cards/TaskToolDisplay.scss'); + + expect(extractBlock(baseToolStyles, '.base-tool-card-expanded')).toMatch( + /padding:\s*var\(--flowchat-card-expanded-pad-y\)\s*var\(--tool-card-expanded-pad-x\)\s*var\(--flowchat-card-expanded-pad-y\)\s*0;/, + ); + expect(extractBlock(baseToolStyles, '.base-tool-card-error')).toContain( + 'margin-left: 0;', + ); + expect(extractBlock(compactToolStyles, '.compact-tool-card-expanded')).toContain( + 'margin-left: 0;', + ); + expect(extractBlock(compactToolStyles, '.flow-tool-card-note')).toContain( + 'margin-left: 0;', + ); + expect(extractBlock(imageStyles, '.view-image-tool-card__content')).toContain( + 'margin: 8px 0 0;', + ); + expect(extractBlock(subagentStyles, '.subagent-items-container')).toContain( + 'padding: 10px 10px 10px 0;', + ); + expect( + extractBlock(taskStyles, '.task-expanded-content .task-prompt-content'), + ).toContain('padding: 0 var(--flowchat-card-pad-x) 0 0;'); + expect(taskStyles).toContain( + '.subagent-projection-container--expanded {\n' + + ' padding:\n' + + ' 8px\n' + + ' calc(var(--flowchat-card-expanded-pad-x) + var(--flowchat-card-pad-x))\n' + + ' 10px\n' + + ' 0;', + ); + }); + + it('does not pull expanded footer or list surfaces past the leading edge', () => { + const terminalStyles = readSource('../../tool-cards/TerminalToolCard.scss'); + const gitStyles = readSource('../../tool-cards/GitToolDisplay.scss'); + const miniAppStyles = readSource('../../tool-cards/MiniAppToolDisplay.scss'); + const todoStyles = readSource('../../tool-cards/TodoWriteDisplay.scss'); + + expect(terminalStyles).toContain( + '.base-tool-card-wrapper.terminal-tool-card .terminal-result-footer {\n margin-left: 0;', + ); + expect(gitStyles).not.toMatch(/git-result-footer[\s\S]{0,120}margin-left:\s*-\d/); + expect(miniAppStyles).toContain( + '.base-tool-card-wrapper.miniapp-tool-display .miniapp-result-footer {\n margin-left: 0;', + ); + expect(extractBlock(todoStyles, '.todo-expanded-body')).toMatch( + /margin:\s*calc\(var\(--flowchat-card-expanded-pad-y\) \* -1\)\s*calc\(var\(--tool-card-expanded-pad-x\) \* -1\)\s*calc\(var\(--flowchat-card-expanded-pad-y\) \* -1\)\s*0;/, + ); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index 85411fc2b8..533242e19a 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -27,9 +27,9 @@ // Subagent container for items under the same parent task. .subagent-items-container { - // Match header right padding (10px). Left padding aligns subagent content - // with the header content column (past the icon rail). - padding: 10px 10px 10px var(--tool-card-header-icon-slot); + // Do not offset projected content past the task icon rail: expansion and + // collapse must keep the same leading edge. + padding: 10px 10px 10px 0; // Continue the task card border on left/right/bottom; top border is hidden. background: var(--color-bg-scene); 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 dfeb443981..08e0003b12 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -11,6 +11,25 @@ background: transparent; // Transparent background for glass effect. overflow: hidden; /* Prevent overflow. */ overflow-x: hidden; /* Explicitly disable horizontal scrolling. */ + + /* + * FlowChat collapse contract: revealing a body may change height, never its + * leading edge. Owner styles also keep their offsets at zero; this selector + * is the surface-level guard for FlowChat's collapsible projection roots. + * It stays explicit so ordinary card-internal padding (for example question + * options) remains untouched. + */ + .explore-region__content, + .thinking-content, + .base-tool-card-expanded, + .base-tool-card-error, + .compact-tool-card-expanded, + .view-image-tool-card__content, + .subagent-items-container, + .subagent-projection-container--expanded { + margin-inline-start: 0; + padding-inline-start: 0; + } // Ensure the Virtuoso container has correct sizing. diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss index b22e3ed979..835f8d7a0f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss @@ -219,7 +219,11 @@ /* ========== Expanded content area ========== */ .base-tool-card-expanded { /* Full-bleed dividers in children use these (header pad-x is 0; do not conflate). */ - padding: var(--flowchat-card-expanded-pad-y) var(--tool-card-expanded-pad-x); + padding: + var(--flowchat-card-expanded-pad-y) + var(--tool-card-expanded-pad-x) + var(--flowchat-card-expanded-pad-y) + 0; background: transparent; position: relative; opacity: 1; @@ -234,8 +238,8 @@ var(--flowchat-card-expanded-pad-y) var(--flowchat-card-expanded-pad-x) var(--flowchat-card-expanded-pad-y) - calc(var(--flowchat-card-expanded-pad-x) + var(--flowchat-card-expanded-pad-x)); - margin-left: var(--flowchat-inline-gap); + 0; + margin-left: 0; background: none !important; border: none !important; box-shadow: none !important; diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss index f5d53f5817..ed13a4fc36 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss @@ -77,7 +77,7 @@ } .flow-tool-card-note { - margin-left: 1.5rem; + margin-left: 0; color: var(--color-text-secondary); font-size: 0.82em; line-height: var(--flowchat-support-line-height); @@ -298,8 +298,8 @@ .compact-tool-card-expanded { position: relative; margin-top: 8px; - margin-left: 1rem; - padding: 12px; + margin-left: 0; + padding: 12px 12px 12px 0; background: var(--color-bg-primary); border: 1px solid var(--border-base); border-radius: 6px; diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss index b217956997..8849fa7324 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss @@ -51,7 +51,7 @@ /* Footer bar: full width + background to bottom inner edge of expanded content (matches .compact-tool-card-expanded padding). */ .base-tool-card-wrapper.git-tool-display .git-result-footer, .compact-tool-card-wrapper.git-tool-display .git-result-footer { - margin-left: -8px; + margin-left: 0; margin-right: -8px; margin-bottom: -8px; padding: 0.375rem 0.5rem; @@ -65,7 +65,7 @@ /* Expanded + same shell as Terminal: match `terminal-result-footer` inset from `TerminalToolCard.scss`. */ .base-tool-card-wrapper.terminal-tool-card.git-tool-display .git-result-footer { - margin-left: -12px; + margin-left: 0; margin-right: -12px; margin-bottom: -12px; padding: 0.375rem var(--flowchat-card-pad-x); diff --git a/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss index da78d04dee..697768f7e9 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/MiniAppToolDisplay.scss @@ -13,7 +13,7 @@ } .base-tool-card-wrapper.miniapp-tool-display .miniapp-result-footer { - margin-left: calc(-1 * var(--flowchat-card-expanded-pad-x)); + margin-left: 0; margin-right: calc(-1 * var(--flowchat-card-expanded-pad-x)); margin-bottom: calc(-1 * var(--flowchat-card-expanded-pad-y)); padding: var(--flowchat-card-expanded-pad-y) var(--flowchat-card-expanded-pad-x); diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss index bece2b61f3..978ca12c38 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss @@ -104,7 +104,12 @@ font-family: var(--font-family-sans); word-break: break-word; color: var(--color-text-muted); - padding: var(--flowchat-card-expanded-pad-y) var(--flowchat-card-expanded-pad-x); + // Preserve vertical breathing room without shifting expanded reasoning right. + padding: + var(--flowchat-card-expanded-pad-y) + var(--flowchat-card-expanded-pad-x) + var(--flowchat-card-expanded-pad-y) + 0; background: transparent; border: none; border-radius: 6px; diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index 9c9ff7f8b2..24b7fab344 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -340,7 +340,7 @@ The generic thinking content has vertical padding; with a one-line max-height that can leave only padding visible and push the prompt text out of view. */ .task-expanded-content .task-prompt-content { - padding: 0 var(--flowchat-card-pad-x); + padding: 0 var(--flowchat-card-pad-x) 0 0; max-height: 3.2em; overflow-y: auto; } @@ -370,7 +370,7 @@ .task-interruption-divider { height: 1px; background: var(--border-base); - margin: -2px calc(-1 * var(--tool-card-expanded-pad-x)) 0; + margin: -2px calc(-1 * var(--tool-card-expanded-pad-x)) 0 0; } .task-expanded-content .task-prompt-markdown { @@ -610,16 +610,13 @@ border-top: 1px solid var(--border-base); } - // Match `.task-expanded-content .task-prompt-content` horizontal inset. + // Keep projected subagent output on the task card's leading edge. .subagent-projection-container--expanded { - --task-prompt-inline-pad: calc( - var(--flowchat-card-expanded-pad-x) + var(--flowchat-card-pad-x) - ); padding: 8px - var(--task-prompt-inline-pad) + calc(var(--flowchat-card-expanded-pad-x) + var(--flowchat-card-pad-x)) 10px - var(--task-prompt-inline-pad); + 0; } // Hide the header top accent line — the wrapper border already frames diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss index 963c540e26..8c6f803994 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss @@ -69,7 +69,7 @@ /* Footer bar: full width + background to bottom inner edge of expanded content (matches .base-tool-card-expanded padding). */ .base-tool-card-wrapper.terminal-tool-card .terminal-result-footer { - margin-left: calc(-1 * var(--flowchat-card-expanded-pad-x)); + margin-left: 0; margin-right: calc(-1 * var(--flowchat-card-expanded-pad-x)); margin-bottom: calc(-1 * var(--flowchat-card-expanded-pad-y)); padding: var(--flowchat-inline-gap) var(--flowchat-card-expanded-pad-x); diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss index b1386bd144..32f733bf3c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.scss @@ -154,7 +154,11 @@ /* Expanded body — align list with card edges */ .todo-expanded-body { - margin: calc(var(--flowchat-card-expanded-pad-y) * -1) calc(var(--tool-card-expanded-pad-x) * -1); + margin: + calc(var(--flowchat-card-expanded-pad-y) * -1) + calc(var(--tool-card-expanded-pad-x) * -1) + calc(var(--flowchat-card-expanded-pad-y) * -1) + 0; } .todo-full-list { background: transparent; diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss index 9e57bac26e..9134f6574d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss @@ -2,7 +2,7 @@ display: flex; justify-content: flex-start; min-width: 0; - margin: 8px 0 0 28px; + margin: 8px 0 0; } .view-image-tool-card__preview-button { @@ -58,10 +58,6 @@ } @media (max-width: 640px) { - .view-image-tool-card__content { - margin-left: 24px; - } - .view-image-tool-card__preview-button { width: 144px; }