diff --git a/design-system/packages/ui/src/components/Icon/assets/thinking.svg b/design-system/packages/ui/src/components/Icon/assets/thinking.svg index 9bcdfd2587..922515b082 100644 --- a/design-system/packages/ui/src/components/Icon/assets/thinking.svg +++ b/design-system/packages/ui/src/components/Icon/assets/thinking.svg @@ -1,6 +1,21 @@ - - - - + + + + + + + + + + + + + + + + + + + diff --git a/design-system/packages/ui/tests/fixtures/icon-assets.json b/design-system/packages/ui/tests/fixtures/icon-assets.json index 3b975c035d..ceb770750e 100644 --- a/design-system/packages/ui/tests/fixtures/icon-assets.json +++ b/design-system/packages/ui/tests/fixtures/icon-assets.json @@ -27,7 +27,7 @@ "name": "thinking", "node": "user:thinking-icon-2026-09-17", "asset": "thinking.svg", - "sha256": "e09f657a1c22ed3cba5508ed4542e8733d01449c144e62e9552b14037c7c326e" + "sha256": "edd429f85a8df3a939f6dd2e20438d58a0bef4d31a406fcdc230d71e5bdc1679" }, { "name": "ultimate", diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 7a91775c60..5ec24f7f9c 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -663,14 +663,23 @@ $_section-header-height: 22px; &__miniapp-item { display: flex; + // Anchors the trailing app bubbles, which trail as an overlay. + position: relative; width: 100%; min-height: var(--openbitfun-layout-navigation-panel-item-height); padding: 0; background: transparent; } + // Every nav row paints its hover/selected pill on the ActionItem trigger. This + // row's root also lays the trailing actions region out in flow, so the root gap + // plus that region's own inset pushed the trigger 12px off the row's right edge + // and the pill stopped short with it. Trailing the region as an overlay keeps + // the bubbles on their existing gutter and lets the pill span the full row. &__miniapp-item > [data-openbitfun-part='actions'] { - margin-inline-end: var(--openbitfun-space-2); + position: absolute; + inset-block: 0; + inset-inline-end: var(--openbitfun-space-2); } &__miniapp-entry { @@ -936,9 +945,15 @@ $_section-header-height: 22px; } &__section-action { - flex: 0 0 var(--_nav-icon-slot-size); - inline-size: var(--_nav-icon-slot-size); - block-size: var(--_nav-icon-slot-size); + // The header's three actions sit directly above the workspace row's three hover + // actions and read as one set of icon columns, so the cluster uses the row's + // compact 20px box instead of the 22px `--_nav-icon-slot-size` slot. The row + // pitch is 20px + the 4px `__section-actions` gap below, and only that pair + // puts every icon centre on the row's column centres. The `size="sm"` glyph + // stays 14px and remains centred inside the smaller box. + flex: 0 0 20px; + inline-size: 20px; + block-size: 20px; color: var(--_nav-icon-color); &:hover { @@ -955,8 +970,8 @@ $_section-header-height: 22px; position: relative; display: inline-flex; align-items: center; - inline-size: var(--_nav-icon-slot-size); - block-size: var(--_nav-icon-slot-size); + inline-size: 20px; + block-size: 20px; } /* Positioning-only overrides; the design-system Menu owns the surface look. */ @@ -2572,6 +2587,13 @@ $_section-header-height: 22px; height: 22px; margin: 0 6px; padding: 0 var(--openbitfun-space-2); + // Trailing edge of the shared action column, not the header's own padding + // gutter. The sessions list below starts at the 6px list inset and every row + // ends its last action 4px inside its own 1px row border, so the header has to + // reach one border-width further in than its 8px gutter to land on that + // column. Without it the header icons sit 3px inside the workspace row + // actions and read as a second, accidental axis. + padding-right: calc(var(--openbitfun-space-1) + var(--openbitfun-border-width-default)); border-radius: var(--openbitfun-radius-sm); opacity: 1; transition: diff --git a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts index 4d15e08d6b..2a91eebb54 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts @@ -18,6 +18,14 @@ function readNavPanelTypographyStylesheet(): string { return stylesheet.replace(/\r\n/g, '\n'); } +function readWorkspaceListStylesheet(): string { + const stylesheet = readFileSync( + fileURLToPath(new URL('./sections/workspaces/WorkspaceListSection.scss', import.meta.url)), + 'utf8', + ); + return stylesheet.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*\\}`)); @@ -57,6 +65,52 @@ describe('NavPanel layout styles', () => { expect(sectionHeaderBlock).toContain('margin: 0 var(--openbitfun-space-1);'); }); + it('ends the section header actions on the workspace row action column', () => { + const navStylesheet = readNavPanelStylesheet(); + const workspaceListStylesheet = readWorkspaceListStylesheet(); + + // The sticky section header and the rows below it live in different + // stylesheets, so their trailing edges only agree if both resolve to the same + // column: the 6px list inset plus the row's own transparent 1px border plus + // the 4px row-action offset. The header override has to give up exactly that + // border width, or its icons sit 3px inside the workspace row actions. + const trailingHeaderOverrides = [...navStylesheet.matchAll(/&__section-header\s*\{(?[^}]*)\}/g)] + .map(match => match.groups?.body ?? '') + .filter(body => body.includes('padding-right:')); + const itemsOverrides = [...navStylesheet.matchAll(/&__items\s*\{(?[^}]*)\}/g)] + .map(match => match.groups?.body ?? '') + .filter(body => body.includes('padding:')); + const sectionActionsBlock = extractBlock(navStylesheet, '&__section-actions'); + const workspaceItemBlock = extractBlock(workspaceListStylesheet, '&__workspace-item'); + const workspaceActionsBlock = extractBlock(workspaceListStylesheet, '&__workspace-item-actions'); + const workspaceActionMenuBlock = extractBlock(workspaceListStylesheet, '&__workspace-item-menu'); + const workspaceActionTriggerBlock = + extractBlock(workspaceListStylesheet, '&__workspace-item-menu-trigger'); + + // A single trailing-edge owner keeps the column from being silently undone by + // a later block, so assert the count as part of the contract. + expect(trailingHeaderOverrides).toHaveLength(1); + const headerTrailing = trailingHeaderOverrides[0]!; + const itemsTrailing = itemsOverrides[itemsOverrides.length - 1]!; + + expect(itemsTrailing).toContain('padding: 2px 6px;'); + expect(workspaceItemBlock).toContain('border: 1px solid transparent;'); + expect(workspaceActionsBlock).toContain('right: 4px;'); + expect(headerTrailing).toContain('margin: 0 6px;'); + expect(headerTrailing).toContain( + 'padding-right: calc(var(--openbitfun-space-1) + var(--openbitfun-border-width-default));', + ); + expect(headerTrailing).not.toContain('padding-right: var(--openbitfun-space-2);'); + + // Matching trailing edges are not enough: the pitch has to match too, or the + // first and second icons drift 2px and 4px off the row columns. Both clusters + // are therefore a 20px box on a 4px gap. + expect(workspaceActionTriggerBlock).toContain('width: 20px;'); + expect(workspaceActionTriggerBlock).toContain('height: 20px;'); + expect(workspaceActionMenuBlock).toContain('gap: 4px;'); + expect(sectionActionsBlock).toContain('gap: 4px;'); + }); + it('keeps the sessions section header static and visually flat', () => { const stylesheet = readNavPanelStylesheet(); const sectionHeaderBlock = extractBlock(stylesheet, '&__section-header'); @@ -67,13 +121,19 @@ describe('NavPanel layout styles', () => { expect(stylesheet).not.toContain('&__collapsible'); }); - it('keeps section actions on the shared compact icon-button token', () => { + it('keeps section actions on the compact row action box', () => { const stylesheet = readNavPanelStylesheet(); const sectionActionBlock = extractBlock(stylesheet, '&__section-action'); + const actionWrapBlock = extractBlock(stylesheet, '&__workspace-action-wrap'); const itemActionBlock = extractBlock(stylesheet, '&__item-action'); - expect(sectionActionBlock).toContain('inline-size: var(--_nav-icon-slot-size);'); - expect(sectionActionBlock).toContain('block-size: var(--_nav-icon-slot-size);'); + // The header cluster shares the workspace row's geometry so the two groups + // stack on one set of icon columns; the wrap has to follow the button, or it + // reserves a box the button does not fill and the columns drift again. + expect(sectionActionBlock).toContain('inline-size: 20px;'); + expect(sectionActionBlock).toContain('block-size: 20px;'); + expect(actionWrapBlock).toContain('inline-size: 20px;'); + expect(actionWrapBlock).toContain('block-size: 20px;'); expect(itemActionBlock).toContain('width: 20px;'); expect(itemActionBlock).toContain('height: 20px;'); }); @@ -163,4 +223,22 @@ describe('NavPanel layout styles', () => { '> span:not([data-overflow-content]):not(.openbitfun-nav-panel__top-action-icon-circle)', ); }); + + it('keeps the miniapp row pill on the full row width', () => { + const stylesheet = readNavPanelStylesheet(); + const itemBlock = extractBlock(stylesheet, '&__miniapp-item'); + const trailingBlock = extractBlock( + stylesheet, + "&__miniapp-item > [data-openbitfun-part='actions']", + ); + + // The row paints its hover/selected pill on the ActionItem trigger, so the + // trailing actions region must stay out of the trigger's flex line: as an + // in-flow sibling it took the root gap plus its own margin off the row and + // left the pill short of the row's right edge. + expect(itemBlock).toContain('position: relative;'); + expect(trailingBlock).toContain('position: absolute;'); + expect(trailingBlock).toContain('inset-inline-end: var(--openbitfun-space-2);'); + expect(trailingBlock).not.toContain('margin-inline-end'); + }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 7efb452eaf..6cf4e8aa1f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -561,6 +561,17 @@ } } + // The toggle is a lighter sibling of `__inline-item`, so its state icon needs + // the row's 20px trailing cell as well: the cell ends on the row inset and the + // glyph centres inside it. Flushing the bare 12px icon to the padding edge put + // its centre half a cell off the column the dots above and below it share. + &__inline-toggle-trailing { + display: grid; + flex: 0 0 var(--openbitfun-space-5); + place-items: center; + inline-size: var(--openbitfun-space-5); + } + &__inline-toggle-spinner { flex: 0 0 auto; inline-size: var(--openbitfun-control-icon-size-xs); @@ -843,7 +854,9 @@ &__inline-item { height: 30px; gap: var(--openbitfun-space-2); - padding-right: var(--openbitfun-space-2); + // Trailing edge of the row, not the reading gutter: it has to match the + // workspace rows and the group toggle so all trailing actions end on one column. + padding-right: var(--openbitfun-space-1); border-radius: var(--openbitfun-radius-base); @include nav-font.nav-panel-text-row; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index ab2de66d8f..18d2bc1012 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -2057,7 +2057,9 @@ const SessionsSection: React.FC = ({ +{topLevelSessions.length - sessionDisplayLimit} - + + + )} @@ -2082,13 +2084,15 @@ const SessionsSection: React.FC = ({ +{expandToggleLabels.remainingCount} )} - {metadataPageState.isLoading ? ( - - ) : expandToggleLabels.remainingCount === null ? ( - - ) : ( - - )} + + {metadataPageState.isLoading ? ( + + ) : expandToggleLabels.remainingCount === null ? ( + + ) : ( + + )} + )} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts index 6ee1ab4e74..92efd9be7a 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts @@ -204,4 +204,30 @@ describe('SessionsSection layout styles', () => { expect(stylesheet).not.toContain('--openbitfun-subagent-bot-optical-y'); expect(stylesheet).not.toContain('translateY(var(--openbitfun-subagent-bot-optical-y))'); }); + + it('ends session rows and the group toggle on the workspace trailing column', () => { + const stylesheet = readSessionsSectionStylesheet(); + const source = readSessionsSectionSource(); + + // Workspace card rows end their trailing actions 4px inside the row box + // (`__workspace-item` inline padding plus the actions' own right offset). + // Session rows must end on that same column instead of the 8px reading + // gutter, or their three-dot buttons sit left of the workspace ones. + const rowBlock = stylesheet.slice(stylesheet.lastIndexOf('&__inline-item {')); + expect(rowBlock).toContain('padding-right: var(--openbitfun-space-1);'); + expect(rowBlock).not.toContain('padding-right: var(--openbitfun-space-2);'); + + // The toggle's state icon needs the row's trailing cell: flushed to the + // padding edge its centre sat half a cell right of the dots' column. + const toggleTrailingBlock = extractBlock(stylesheet, '&__inline-toggle-trailing'); + expect(toggleTrailingBlock).toContain('display: grid;'); + expect(toggleTrailingBlock).toContain('flex: 0 0 var(--openbitfun-space-5);'); + expect(toggleTrailingBlock).toContain('place-items: center;'); + expect(extractInlineItemBlock(stylesheet, 'trailing')).toContain( + 'flex: 0 0 var(--openbitfun-space-5);', + ); + expect(source).toMatch( + /className="openbitfun-nav-panel__inline-toggle-trailing"[\s\S]{0,400}?inline-toggle-chevron/, + ); + }); }); diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx index fc8fd3b093..04871fc500 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx @@ -286,6 +286,7 @@ export const TabBar: React.FC = ({ data-openbitfun-product-part="action" className="canvas-tab__action-btn" data-motion="none" + shape="circle" size="xs" aria-label={`${t(tab.state === 'pinned' ? 'tabs.unpin' : 'tabs.close')} ${tab.title}`} title={t(tab.state === 'pinned' ? 'tabs.unpin' : 'tabs.close')} diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss index 953a5ab779..283eff7224 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss @@ -131,7 +131,11 @@ &__detail-back { min-height: 28px; gap: 3px; - margin-left: -5px; + /* + * No optical negative offset here: the modal host gives the results + * viewport no inline-start padding above 980px, so a leading negative + * margin crosses the clip line and chops the pill's leading radius. + */ padding: 3px 6px 3px 4px; border-radius: 7px; font-size: var(--openbitfun-type-label-sm-font-size); @@ -529,11 +533,30 @@ font-size: var(--openbitfun-type-body-md-font-size); } - .global-search__group, + .global-search__group { + min-width: 0; + } + + /* + * Groups only stack in flow while the overview grid is inactive -- a query or + * a drilldown. Those states still need the shared flow gap: the old blanket + * `margin-top: 0` left the next group's title sitting on the previous group. + * Above 700px the overview grid keeps owning its own separation through its + * row gap, which uses the same token. + */ .global-search__group + .global-search__group, .global-search__group--assistants { - min-width: 0; - margin-top: 0 !important; + margin-top: var(--openbitfun-space-4) !important; + } + + @media (min-width: 701px) { + .global-search__results[data-search-state='default'][data-search-view='overview'] { + .global-search__group, + .global-search__group + .global-search__group, + .global-search__group--assistants { + margin-top: 0 !important; + } + } } .global-search__group--actions, diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss index 6d92f1ebfe..ae7765ddcd 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss @@ -117,7 +117,7 @@ flex: 1 1 240px; align-items: center; min-inline-size: 0; - gap: var(--openbitfun-space-4); + gap: var(--openbitfun-space-3); > div { flex: 1; @@ -125,12 +125,15 @@ } } + /* + * The logo box hugs its product icon. A fixed 56px box left 9px of dead space + * on each side of a 38px icon, which added itself to the identity gap and put + * the disc 25px away from the product name. + */ &__product-logo { display: inline-grid; place-items: center; flex: none; - inline-size: 56px; - block-size: 56px; } &__product-title-row { diff --git a/src/web-ui/src/flow_chat/_transcript-layout.scss b/src/web-ui/src/flow_chat/_transcript-layout.scss index 7e2105932e..7234208f50 100644 --- a/src/web-ui/src/flow_chat/_transcript-layout.scss +++ b/src/web-ui/src/flow_chat/_transcript-layout.scss @@ -14,14 +14,20 @@ $turn-rail-width: 20px; max-width: 900px; margin-inline: auto; - // Reserve only the rail side plus a small opposite inset. Wide columns stay - // centered; neither the composer nor the welcome surface needs this lane. + // The column shares the panel axis with the composer, the header, and the + // welcome surface, so both lateral insets stay equal: the rail lane is split + // evenly instead of riding on the rail side alone. + // + // Reserve only the rail side plus a small opposite inset and the column stays + // centred only while the centring slack covers the lane. Below that width the + // lane won and moved the whole column — cards, text, tool metadata — up to + // 10px to the trailing side of the axis the composer was still centred on. + // + // The rail still clears the column content: rail offset + rail width is 24px, + // and the content starts 18px of inset plus 12px of content padding, so 30px, + // from the panel edge. @container flow-chat-transcript (min-width: 0px) { width: calc(100% - #{$turn-rail-offset + $turn-rail-width} - var(--openbitfun-space-1) - var(--openbitfun-space-2)); - margin-inline-start: max( - calc(#{$turn-rail-offset + $turn-rail-width} + var(--openbitfun-space-1)), - calc((100% - 900px) / 2) - ); } } diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index e348542a0f..a55f3f0cf8 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -20,9 +20,22 @@ display: flex; flex-direction: column; justify-content: flex-end; - padding: 0 var(--openbitfun-space-2); + /* + * The composer card is a panel of the transcript, so it takes the transcript + * content inset. A space-2 inset here stopped the card 4px short of the text + * and tool-card column it sits under. + * + * This zone is centred on the panel, so the transcript scroller has to + * reserve its scrollbar gutter on both edges; a one-sided gutter would centre + * the reading column half a scrollbar to the leading side of this card. + */ + padding: 0 var(--openbitfun-control-flow-chat-content-padding-inline); pointer-events: auto !important; + @media (max-width: 768px) { + padding-inline: var(--openbitfun-control-flow-chat-content-padding-inline-mobile); + } + // Reserve the composer's actual height so the transcript ends above it. &.openbitfun-chat-input-drop-zone--conversation { position: relative; diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts index 8fa6d0748e..18717000b0 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts @@ -220,8 +220,10 @@ describe('composer context track layout', () => { expect(component).toContain('contextBar={workspaceStrip}'); expect(component).toContain(' { + it('reserves the transcript scrollbar gutter on both edges of the scroller', () => { + const stylesheet = readSource('./VirtualMessageList.scss'); + + // Both edges, because the reading column is centred inside the scroller's + // content box while the composer is centred on the panel itself. A + // trailing-edge gutter alone sets the column half a scrollbar to the leading + // side of the composer card, which is the offset that made the two disagree. + expect(stylesheet).toContain('scrollbar-gutter: stable both-edges;'); + expect(stylesheet).not.toMatch(/scrollbar-gutter: stable;/); + }); + + it('centres the composer card on the panel and sizes it as the reading column', () => { + const chatInput = readSource('../ChatInput.scss'); + const transcriptLayout = readSource('../../_transcript-layout.scss'); + + expect(chatInput).toMatch( + /\.openbitfun-context-drop-zone\.openbitfun-chat-input-drop-zone \{[\s\S]*?left: 50%;[\s\S]*?transform: translateX\(-50%\);[\s\S]*?max-width: 900px;/, + ); + expect(transcriptLayout).toMatch( + /@mixin reading-column \{[\s\S]*?max-width: 900px;/, + ); + }); + + it('keeps both reading-column insets equal so the column stays on the panel axis', () => { + const transcriptLayout = readSource('../../_transcript-layout.scss'); + const readingColumn = transcriptLayout.slice( + transcriptLayout.indexOf('@mixin reading-column {'), + transcriptLayout.indexOf('// Transcript containers own the reading column'), + ); + + // A lane that rides on the rail side only (rail lane plus a small opposite + // inset) centres the column just while the centring slack covers the lane. + // Below that panel width the lane wins and moves the column up to 10px off + // the axis the composer, the header, and the welcome surface stay on. + expect(readingColumn).toContain('margin-inline: auto;'); + expect(readingColumn).not.toContain('margin-inline-start: max('); + expect(readingColumn).toMatch( + /width: calc\(100% - #\{\$turn-rail-offset \+ \$turn-rail-width\} - var\(--openbitfun-space-1\) - var\(--openbitfun-space-2\)\);/, + ); + }); + + it('ends the sent-message bubble on the same column content edge as the cards', () => { + const stylesheet = readSource('./UserMessageItem.scss'); + + // The bubble is the one row whose painted surface, not its text, reads as + // the message edge, so its radius must not lift it off the column the tool + // cards and the composer card end on. The attachment gallery is a sibling of + // the bubble and shares that edge. + expect(stylesheet).toContain('margin-inline: auto 0;'); + expect(stylesheet).not.toContain('margin-inline: auto calc(-1 * var(--_user-message-radius));'); + expect(stylesheet).not.toContain('margin-inline-end: calc(-1 * var(--_user-message-radius));'); + }); +}); 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 d1501fddee..235b5a5bfe 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -232,7 +232,14 @@ min-height: 0; overflow-y: auto; padding: 2px var(--openbitfun-space-2) var(--openbitfun-space-1); - scrollbar-gutter: stable; + /* + * No reserved scrollbar gutter. `scrollbar-gutter: stable` reserves the + * platform scrollbar width on the inline end only, so the section dividers + * ended 8px from the leading edge and 23px from the trailing one. The list + * inset (space-2) plus the row inset (space-1) is exactly the panel header's + * space-3 inset, and that chain, not scrollbar geometry, owns this panel's + * insets. + */ } &__session-overview-section { diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss index 1064c96f2f..e68b560700 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss @@ -16,8 +16,11 @@ .user-message-item { box-sizing: border-box; /* - * The shell owns the same reading column as replies. Extend the bubble by - * its radius so the rounded surface and message text meet that column. + * The shell owns the same reading column as replies. Keep the bubble surface + * on that column's content edge — the edge the tool cards and the composer + * card end on. Extending the bubble by its radius instead (so the rounded + * surface overhung the column while its text met the column) left the sent + * message one radius past the trailing edge of both. */ width: fit-content; max-width: min(72%, 48rem); @@ -26,7 +29,7 @@ border: none; border-radius: var(--_user-message-radius); margin-block: 0; - margin-inline: auto calc(-1 * var(--_user-message-radius)); + margin-inline: auto 0; box-shadow: none; transition: opacity 140ms ease; /* @@ -323,14 +326,14 @@ } } -// Image thumbnails (WeChat-style). +// Image thumbnails (WeChat-style). The gallery is a sibling of the bubble, so it +// ends on the same reading-column content edge the bubble surface ends on. .user-message-item__images { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin-block: 0 6px; - margin-inline-end: calc(-1 * var(--_user-message-radius)); } .user-message-item__image-thumb { diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts b/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts index 370966764d..deb8b8a306 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItemActions.test.ts @@ -106,7 +106,10 @@ describe('UserMessageItem metadata visibility', () => { const bubble = extractBlock(stylesheet, '\n.user-message-item {'); expect(bubble).toContain('padding: 0.46rem var(--_user-message-padding-inline);'); expect(shell).toContain('--_user-message-padding-inline: var(--_user-message-radius);'); - expect(bubble).toContain('margin-inline: auto calc(-1 * var(--_user-message-radius));'); + // The bubble's surface ends on the reading column's content edge instead of + // one radius past it, so a sent message lines up with the tool cards and the + // composer card that share that edge. + expect(bubble).toContain('margin-inline: auto 0;'); expect(meta).toContain('padding-inline: 0;'); expect(meta).toContain('padding-block: calc(var(--openbitfun-space-1) / 2) 0;'); expect(meta).toContain('pointer-events: auto;'); 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 41e44a279a..d2969e6376 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -36,12 +36,21 @@ height: 100%; position: relative; z-index: 1; // Keep scroll content below overlays. + // // Reserve the native scrollbar even when collapsed content fits. Some - // desktop WebViews do not reserve custom scrollbar space with gutter alone. + // desktop WebViews do not reserve custom scrollbar space with overflow + // alone. + // + // Both edges, not the trailing edge only. The reading column is centred + // inside this content box, while the composer is centred on the panel, so a + // one-sided gutter moved the whole column — cards, text, tool metadata — + // half a scrollbar toward the leading edge and left it off the composer's + // edge. Reserving the same width on both edges puts the column back on the + // panel axis the composer and the header already share. overflow-y: scroll; overflow-x: hidden; overflow-anchor: none; - scrollbar-gutter: stable; + scrollbar-gutter: stable both-edges; // Fade the transcript pixels themselves at both viewport edges: content // entering beneath the top chrome appears gradually, while content leaving diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 97ac4d444d..224d654e4a 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -332,10 +332,15 @@ function normalizeBoundaryResult( * Whether a pointer press landed on the scroller's scrollbar rather than on the * transcript. * - * `clientWidth` stops at the scrollbar, so anything past the content box's - * trailing edge is the bar or its track. Measured on WebView2: a 10px gutter, - * a press on the transcript at `clientX` 1497 against a content box ending at - * 1641, and presses on the bar at 1643-1647. + * `clientWidth` stops at the reserved scrollbar gutter, so the content box's + * trailing edge falls one gutter width short of the bar. Everything past that + * edge — the reserved track and the bar — is a scrollbar press, never a press + * on the transcript column, which is inset well inside the content box. + * + * Measured on WebView2 with a one-sided 10px gutter: a press on the transcript + * at `clientX` 1497, the content box ending at 1641, and presses on the bar at + * 1643-1647. Reserving the gutter on both edges moves the boundary one gutter + * width earlier, which only widens the band that counts as a bar press. * * Chromium does dispatch `pointerdown` for a scrollbar press. WebKit-backed * builds draw overlay scrollbars that take no layout width, leaving no gutter diff --git a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx index 50d7c52d4b..02b428633d 100644 --- a/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx +++ b/src/web-ui/src/flow_chat/components/session-menu/SessionMenu.tsx @@ -164,26 +164,35 @@ export const SessionMenu: React.FC = ({ onOpenChange }) => { data-openbitfun-part="scroll" aria-label={t('session.switchSession')} > - {sessions.map((session) => ( - switchSession(e, session.sessionId)} - > - {resolveSessionTitle(session, t)} - - - ))} + {sessions.map((session) => { + const titleNumber = titleNumbers?.get(session.sessionId); + return ( + : undefined} + onMouseDown={(e) => switchSession(e, session.sessionId)} + > + {resolveSessionTitle(session, t)} + + ); + })} , getAppearanceOverlayHost(), diff --git a/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx index ebc8b5b016..6581493440 100644 --- a/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx +++ b/src/web-ui/src/tools/editor/components/EditorBreadcrumb.tsx @@ -316,10 +316,15 @@ export const EditorBreadcrumb: React.FC = ({ setCurrentDirPath(dirPath); try { if (!workspaceId) throw new Error('Workspace ID is required to browse an editor directory'); - const fileTree = await workspaceAPI.getFileTree(workspaceId, dirPath, 1); - const rootNode = fileTree?.[0]; - const children = rootNode?.children || []; - + // Root cause: the previous getFileTree(workspace, dir, 1) built a directory + // tree and wrapped it in a synthetic root node that the breadcrumb discarded, + // so opening the dropdown fetched far more than the single directory level it + // renders. explorerGetChildren is the single-level children read already backing + // the explorer; it resolves the same workspace connection (local or SSH) and + // keeps the same read-failure behavior, so a failed read still surfaces as an + // empty menu instead of fabricated segments. + const children = await workspaceAPI.explorerGetChildren(workspaceId, dirPath); + const items: FileItem[] = children .filter((entry: any) => { const name = entry.name || '';