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) => (
-
- ))}
+ {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 || '';