Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions design-system/packages/ui/src/components/Icon/assets/thinking.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion design-system/packages/ui/tests/fixtures/icon-assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"name": "thinking",
"node": "user:thinking-icon-2026-09-17",
"asset": "thinking.svg",
"sha256": "e09f657a1c22ed3cba5508ed4542e8733d01449c144e62e9552b14037c7c326e"
"sha256": "edd429f85a8df3a939f6dd2e20438d58a0bef4d31a406fcdc230d71e5bdc1679"
},
{
"name": "ultimate",
Expand Down
34 changes: 28 additions & 6 deletions src/web-ui/src/app/components/NavPanel/NavPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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. */
Expand Down Expand Up @@ -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:
Expand Down
84 changes: 81 additions & 3 deletions src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*\\{(?<body>[\\s\\S]*?)\\n\\s*\\}`));
Expand Down Expand Up @@ -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*\{(?<body>[^}]*)\}/g)]
.map(match => match.groups?.body ?? '')
.filter(body => body.includes('padding-right:'));
const itemsOverrides = [...navStylesheet.matchAll(/&__items\s*\{(?<body>[^}]*)\}/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');
Expand All @@ -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;');
});
Expand Down Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2057,7 +2057,9 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
<span className="openbitfun-nav-panel__inline-toggle-count" aria-hidden>
+{topLevelSessions.length - sessionDisplayLimit}
</span>
<Icon name="chevron-down" size="xs" className="openbitfun-nav-panel__inline-toggle-chevron" aria-hidden />
<span className="openbitfun-nav-panel__inline-toggle-trailing">
<Icon name="chevron-down" size="xs" className="openbitfun-nav-panel__inline-toggle-chevron" aria-hidden />
</span>
</button>
)}

Expand All @@ -2082,13 +2084,15 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
+{expandToggleLabels.remainingCount}
</span>
)}
{metadataPageState.isLoading ? (
<Loader2 className="openbitfun-nav-panel__inline-toggle-spinner" aria-hidden />
) : expandToggleLabels.remainingCount === null ? (
<Icon name="chevron-up" size="xs" className="openbitfun-nav-panel__inline-toggle-chevron" aria-hidden />
) : (
<Icon name="chevron-down" size="xs" className="openbitfun-nav-panel__inline-toggle-chevron" aria-hidden />
)}
<span className="openbitfun-nav-panel__inline-toggle-trailing">
{metadataPageState.isLoading ? (
<Loader2 className="openbitfun-nav-panel__inline-toggle-spinner" aria-hidden />
) : expandToggleLabels.remainingCount === null ? (
<Icon name="chevron-up" size="xs" className="openbitfun-nav-panel__inline-toggle-chevron" aria-hidden />
) : (
<Icon name="chevron-down" size="xs" className="openbitfun-nav-panel__inline-toggle-chevron" aria-hidden />
)}
</span>
</button>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export const TabBar: React.FC<TabBarProps> = ({
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')}
Expand Down
31 changes: 27 additions & 4 deletions src/web-ui/src/app/global-search/GlobalSearchRoot.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,23 @@
flex: 1 1 240px;
align-items: center;
min-inline-size: 0;
gap: var(--openbitfun-space-4);
gap: var(--openbitfun-space-3);

> div {
flex: 1;
min-inline-size: 0;
}
}

/*
* 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 {
Expand Down
Loading
Loading