From 50735b1432ca9c4a19f98ed96442883f4b15413b Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 18:33:44 +0800 Subject: [PATCH 01/15] fix(ui): make canvas tab action buttons round again The tab strip paints every tab as a pill, but the close/unpin control in each tab is the design system's default square IconButton, so a rectangle sits at the trailing end of a rounded row. Select the circle shape explicitly instead of relying on the default, so the button matches the pill it sits in. --- .../src/app/components/panels/content-canvas/tab-bar/TabBar.tsx | 1 + 1 file changed, 1 insertion(+) 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')} From f1f0a4ffd911dc2102ecb3e24e00259df098e3d7 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 18:33:47 +0800 Subject: [PATCH 02/15] fix(ui): restore search group spacing and the back button radius Two inset decisions in the global search surface: Groups in flow lost all vertical separation: the modal/embedded host pinned margin-top to 0 for every group so the overview grid could own its own separation with gap, but the same rule also served the query and drilldown states, which stack in flow and have no gap. They now keep the shared flow token, and only the overview grid clears it, above the 700px breakpoint where the grid is actually a grid. The drilldown back button carried a -5px leading margin for optical alignment against the old host padding. Above 980px the modal host gives the results viewport no inline-start padding, so that negative margin crossed the clip line and cut the pill's leading radius. Drop the optical offset now that the host owns the inset. --- .../app/global-search/GlobalSearchRoot.scss | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) 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, From 944ee2def593dc094915f0ec52c56a11c60420dc Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 19:18:24 +0800 Subject: [PATCH 03/15] fix(flowchat): align the composer card with the transcript content column The composer card ended 4px inside the text and tool-card column of the transcript above it, so the input box and the content it belongs to had two different baselines. The drop zone carried its own space-2 inline inset while the transcript column uses --openbitfun-control-flow-chat-content-padding-inline (12px, with the mobile variant at 768px and below). Take the transcript content inset instead of a second, narrower one, exactly as the shared content-column mixin in _transcript-layout.scss does. --- src/web-ui/src/flow_chat/components/ChatInput.scss | 11 ++++++++++- .../components/ChatInputWorkspaceStripLayout.test.ts | 4 +++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index e348542a0f..65eea44dbb 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -20,9 +20,18 @@ 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. + */ + 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(' Date: Mon, 21 Sep 2026 19:18:28 +0800 Subject: [PATCH 04/15] fix(flowchat): stop reserving a scrollbar gutter in the session overview The session overview list reserved a scrollbar gutter, so its section dividers ended 8px from the leading edge but 23px from the trailing one and the panel read as off-centre. scrollbar-gutter: stable reserves the platform scrollbar width on the inline end of that one scrolling list. The list inset (space-2) plus the row inset (space-1) is exactly the panel header's space-3 inset, so that chain owns this panel's insets and the reserved gutter only breaks it. --- .../src/flow_chat/components/modern/FlowChatHeader.scss | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 { From cebe7a9b6c31c9387c2091682da73770c1e2335d Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 19:18:31 +0800 Subject: [PATCH 05/15] fix(flowchat): keep the session index in the menu row's trailing slot The session index in the floating session menu wrapped onto its own line and jammed against the row edge once it grew past one digit. The index was a MenuItem child, so ActionItem nested it inside the label's overflow slot, which is a block box and has no trailing column to hold it. Render it through ActionItem's metadata slot instead: that slot is the row's trailing cell (flex 0 0 auto, no wrap), so the menu reserves and aligns the index without ever pushing the label. --- .../components/session-menu/SessionMenu.tsx | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) 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(), From 2532a5c10fd23558d21548400472789400e80aed Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 19:18:35 +0800 Subject: [PATCH 06/15] fix(ui): end session rows and the group toggle on the workspace action column Session rows and the rows of workspace cards above them disagree on where their trailing buttons end: a session three-dot button sat 4px left of the workspace one, and the section header's trailing icons sat left of both. Workspace rows end their trailing actions 4px inside the row box (the workspace item's inline padding plus the actions region's own right offset). Session rows still used the 8px reading gutter for padding-right, so drop that to space-1, which is the inset the workspace column already resolves to. The show-more toggle flushed its bare 12px chevron to that same padding edge, so the glyph's centre landed half a cell right of the dots above it. Give the toggle the row's 20px trailing cell and wrap its state icon in it, so the cell ends on the shared inset and the glyph centres inside the column. --- .../sections/sessions/SessionsSection.scss | 15 ++++++++++- .../sections/sessions/SessionsSection.tsx | 20 ++++++++------ .../sessions/SessionsSectionLayout.test.ts | 26 +++++++++++++++++++ 3 files changed, 52 insertions(+), 9 deletions(-) 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/, + ); + }); }); From 1815ef6802e948121272d5fa7aea2a4645fb2124 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 19:18:39 +0800 Subject: [PATCH 07/15] fix(ui): keep the miniapp row pill on the full row width In the small-app rows the hover and selected pill stopped 12px short of the row's right edge, leaving the trailing bubbles outside the highlighted area. Every nav row paints that pill on the ActionItem trigger, but this row's root also laid its trailing actions region out in flow, so the root gap plus that region's own inset pushed the trigger off the row edge and the pill stopped with it. Trail the region as an overlay, anchored on the row, which keeps the bubbles on their existing gutter while the pill spans the full width. --- .../src/app/components/NavPanel/NavPanel.scss | 11 ++++++++++- .../components/NavPanel/NavPanelLayout.test.ts | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 7a91775c60..5ac0cc3baf 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 { 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..2e168b4321 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts @@ -163,4 +163,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'); + }); }); From da6f52a0f67c4769aa258a1c7a3179214960ac75 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 19:18:42 +0800 Subject: [PATCH 08/15] fix(ui): hug the ecosystem product logo box to its icon The product identity block pinned its logo to a fixed 56px box, so the 38px product icon carried 9px of dead space on each side. That dead space added itself to the identity gap, which put the disc 25px away from the product name and made the icon look off-centre inside its own slot. Let the box hug the icon and tighten the identity gap to space-3, so the space the identity reads is the space it declares. --- .../EcosystemCompatibilityScene.scss | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 { From 41196d053c17a9c9e57b2a63e772a30ba6e10f13 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 19:18:45 +0800 Subject: [PATCH 09/15] perf(editor): read one directory level for the breadcrumb dropdown Clicking a folder in the file-view breadcrumb stalled for a long time before its dropdown appeared. getFileTree(workspace, dir, 1) builds a directory tree and wraps it in a synthetic root node that the breadcrumb then discarded, so 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 remote, and keeps the same read-failure behavior, so an unreadable directory still yields an empty menu instead of fabricated entries. --- .../tools/editor/components/EditorBreadcrumb.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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 || ''; From ad30c81a24861f1b720eb66478bb076c65a1f885 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 19:18:49 +0800 Subject: [PATCH 10/15] fix(ui): redraw the thinking indicator glyph as vector art The thinking indicator is a seven-node network glyph shipped as a raster mask. In the sidebar and status slots it scaled into soft edges and its darkest ink only reached 204 of 255 opacity, so it read lighter and blurrier than the icons next to it. Redraw it as vector art on the reviewed 40x40 grid: eight stroke segments at stroke-width 2 and seven r=4 nodes, all currentColor so the Icon mask contract still holds. Refresh the reviewed-asset fingerprint with it. --- .../src/components/Icon/assets/thinking.svg | 23 +++++++++++++++---- .../ui/tests/fixtures/icon-assets.json | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) 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", From b3191bebefaac44910445a1e7726926879b2bd64 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 22:03:05 +0800 Subject: [PATCH 11/15] fix(flowchat): centre the transcript column on the panel axis The composer card and the transcript column still disagreed after the inline inset was unified: the input sat about 5px to the right of the tool cards it belongs to, which is half a platform scrollbar. The composer drop zone is centred on the panel (left: 50% plus translateX), but the reading column is centred inside the scroller's content box, and `scrollbar-gutter: stable` reserves that gutter on the trailing edge only. The whole column, cards and text and tool metadata alike, was therefore pushed half a scrollbar toward the leading side while the composer stayed on the panel axis. Reserve the gutter on both edges instead. Chromium paints the bar in the trailing gutter either way, so the scrollbar-press test that reads `clientWidth` still finds it (one gutter width early, which only widens the band it calls the bar), and WebKit builds draw overlay scrollbars with no layout gutter at all, so both cards already share one axis there. Measured in headless Chromium at the pane width the report came from: `stable` gives the tool card [54, 930] against the composer's [59, 935]; `stable both-edges` gives [59, 935] for both. FlowChatColumnAxis.test.ts holds the two rules that make that true. --- .../src/flow_chat/components/ChatInput.scss | 4 +++ .../modern/FlowChatColumnAxis.test.ts | 35 +++++++++++++++++++ .../components/modern/VirtualMessageList.scss | 13 +++++-- .../components/modern/VirtualMessageList.tsx | 13 ++++--- 4 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 65eea44dbb..a55f3f0cf8 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -24,6 +24,10 @@ * 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; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts new file mode 100644 index 0000000000..de4dff70a7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts @@ -0,0 +1,35 @@ +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'); +} + +describe('FlowChat transcript column axis', () => { + 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;/, + ); + }); +}); 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 From 063d036d168d83f6c7919c229d90858102b8b2f8 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 22:34:34 +0800 Subject: [PATCH 12/15] fix(flowchat): keep the reading column on the panel axis at every width The reading column reserved the turn-rail lane on the rail side only (28px) plus a small opposite inset (8px) and centred itself with `margin-inline-start: max(lane, (100% - 900px) / 2)`. That centring holds only while the centring slack covers the lane. Below a 976px panel the lane won, so the whole column - cards, text, tool metadata - sat up to 10px to the trailing side of the axis the composer, the header, and the welcome surface stay centred on. Split the lane evenly instead: `width: calc(100% - 36px)` with `margin-inline: auto`, so both insets are 18px and the column is centred at every panel width. The rail still clears the column content: the content starts at 18px of inset plus 12px of content padding, so 30px, past the rail's 24px extent (4px offset + 20px width). --- .../src/flow_chat/_transcript-layout.scss | 18 ++++++++++++------ .../modern/FlowChatColumnAxis.test.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) 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/modern/FlowChatColumnAxis.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts index de4dff70a7..00ad024aff 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts @@ -32,4 +32,22 @@ describe('FlowChat transcript column axis', () => { /@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\)\);/, + ); + }); }); From b56ec8b006c764b91b80e181bc676ba6dfaec116 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 22:34:47 +0800 Subject: [PATCH 13/15] fix(flowchat): end the sent message bubble on the reading column edge The user bubble extended itself one radius past the reading column (`margin-inline: auto calc(-1 * radius)`) so that its text, not its surface, met the column edge. The painted surface is what reads as the message edge, so every sent message hung one radius to the trailing side of the tool cards and the composer card that share that column, and it read as the message sitting right of the input. End the bubble surface on the column content edge the cards use, and move the attachment gallery with it: the gallery is a sibling of the bubble and carried the same overhang. The edit state keeps its symmetric bleed, because that state grows the bubble to carry the edit composer. --- .../components/modern/FlowChatColumnAxis.test.ts | 12 ++++++++++++ .../components/modern/UserMessageItem.scss | 13 ++++++++----- .../modern/UserMessageItemActions.test.ts | 5 ++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts index 00ad024aff..5f2ab0fed8 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatColumnAxis.test.ts @@ -50,4 +50,16 @@ describe('FlowChat transcript column axis', () => { /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/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;'); From dd1debaa597ab86107a9e1035c2c95b3e1cb4d20 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 22:52:09 +0800 Subject: [PATCH 14/15] fix(nav): end the section header actions on the row action column The sessions section header and the workspace rows below it paint their trailing icons 3px apart. The rows end their action cluster 11px from the section box: 6px of list inset (`padding: 2px 6px`), plus the row's own transparent 1px border, plus `right: 4px` on the absolutely positioned action cluster. The header only reserved 8px of padding inside its 6px margin, so its icons stopped at 14px and the last glyph landed 3px further in than the workspace row's. Both edges belong to one shared action column, which is also what the nested session rows use for their trailing cell, so give up exactly the row border width on the header's trailing edge instead of treating it as reading gutter. The leading edge keeps its 8px: it aligns the header label with the row labels, not with an action column. Measured in headless Chromium against the compiled stylesheets at the reported panel width: header action box right inset 14px and glyph 18px become 11px and 15px, matching the workspace row action (11px / 15px) and the session row trailing cell (11px). NavPanelLayout.test.ts holds the pair of declarations that keep the two stylesheets on that one column. --- .../src/app/components/NavPanel/NavPanel.scss | 7 ++++ .../NavPanel/NavPanelLayout.test.ts | 42 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 5ac0cc3baf..84b65173e5 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -2581,6 +2581,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 2e168b4321..f996cb9388 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,40 @@ 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 workspaceItemBlock = extractBlock(workspaceListStylesheet, '&__workspace-item'); + const workspaceActionsBlock = extractBlock(workspaceListStylesheet, '&__workspace-item-actions'); + + // 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);'); + }); + it('keeps the sessions section header static and visually flat', () => { const stylesheet = readNavPanelStylesheet(); const sectionHeaderBlock = extractBlock(stylesheet, '&__section-header'); From 245cb1aae72d5802eb071ddaafdc5ac6b8aa37b5 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 21 Sep 2026 23:01:33 +0800 Subject: [PATCH 15/15] fix(nav): stack the section header icons on the workspace row columns Matching the trailing edge was only one third of the fix: the header's action cluster pitched its icons 26px apart (22px box + 4px gap) while the workspace row pitches its actions 24px apart (20px box + 4px gap). One shared right edge therefore left the header's first and second icons 4px and 2px off the columns the row uses, which is what still reads as two different axes. Put the header cluster on the row's compact grid instead of the 22px `--_nav-icon-slot-size` slot: a 20px box with the existing 4px gap. The `__workspace-action-wrap` around the folder-plus action follows, or it would reserve a 22px slot that its 20px button does not fill and push that icon back off the column. The `size="sm"` glyph stays 14px and stays centred in the smaller box, so only the hover surface and the pitch change. Measured in headless Chromium against the compiled stylesheets at the reported panel width, header action box centre versus workspace row action box centre: -8/-6/-4px before, -5/-3/-1px with the trailing-edge fix alone, and 0/0/0px after this change. NavPanelLayout.test.ts now pins both halves of the grid (box and gap on each side) next to the shared trailing inset, so either side drifting fails the test. --- .../src/app/components/NavPanel/NavPanel.scss | 16 +++++++++---- .../NavPanel/NavPanelLayout.test.ts | 24 ++++++++++++++++--- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 84b65173e5..5ec24f7f9c 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -945,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 { @@ -964,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. */ 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 f996cb9388..2a91eebb54 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts @@ -80,8 +80,12 @@ describe('NavPanel layout styles', () => { 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. @@ -97,6 +101,14 @@ describe('NavPanel layout styles', () => { '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', () => { @@ -109,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;'); });