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
2 changes: 1 addition & 1 deletion src/web-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@
<body>
<script type="module" src="/src/main.tsx"></script>
<div id="root"></div>
<div id="openbitfun-startup-overlay">
<div id="openbitfun-startup-overlay" data-tauri-drag-region>
<div class="splash-screen">
<div
class="window-controls openbitfun-startup-window-controls"
Expand Down
32 changes: 13 additions & 19 deletions src/web-ui/src/app/components/SceneTopBar/SceneTopBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,25 +163,19 @@ describe('SceneTopBar', () => {
expect(maximize).toHaveBeenCalledTimes(3);
});

it('keeps the single tab label draggable and updates the boundary when another tab opens or closes', async () => {
const toolbar = renderBar();
const label = () => toolbar.querySelector('[role="tab"] [data-openbitfun-part="label"] span')!;
await mouseDown(label());
doubleClick(label());
expect(startDragging).toHaveBeenCalledOnce();
expect(maximize).toHaveBeenCalledOnce();

renderBar(2);
await mouseDown(label());
doubleClick(label());
expect(startDragging).toHaveBeenCalledOnce();
expect(maximize).toHaveBeenCalledOnce();

renderBar(1);
await mouseDown(label());
doubleClick(label());
expect(startDragging).toHaveBeenCalledTimes(2);
expect(maximize).toHaveBeenCalledTimes(2);
it('excludes the whole tab from window gestures as other tabs open and close', async () => {
for (const tabCount of [1, 2, 1]) {
const toolbar = renderBar(tabCount);
const tab = toolbar.querySelector('[role="tab"]')!;
const label = tab.querySelector('[data-openbitfun-part="label"] span')!;
const item = tab.closest('[data-openbitfun-part="item"]')!;
for (const target of [tab, label, item]) {
await mouseDown(target);
doubleClick(target);
}
expect(startDragging).not.toHaveBeenCalled();
expect(maximize).not.toHaveBeenCalled();
}
});

it('leaves multi-tab labels and item padding to tab interaction', async () => {
Expand Down
20 changes: 8 additions & 12 deletions src/web-ui/src/app/components/SceneTopBar/SceneTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,17 @@ const INTERACTIVE_SELECTOR =

function blocksWindowChromeInteraction(
event: React.MouseEvent<HTMLDivElement>,
allowTabDragging: boolean,
): boolean {
const target = event.target;
if (event.defaultPrevented || !(target instanceof Element) || !event.currentTarget.contains(target)) {
return true;
}

// Tab count changes the tab hit target, never the surrounding window chrome.
if (!allowTabDragging && target.closest('[data-openbitfun-component="tab-group"] [data-openbitfun-part="item"]')) {
// The whole tab, including item padding, belongs to tab interaction.
if (target.closest('[data-openbitfun-component="tab-group"] [data-openbitfun-part="item"]')) {
return true;
}
const interactive = target.closest(INTERACTIVE_SELECTOR);
return interactive !== null && !(allowTabDragging && interactive.getAttribute('role') === 'tab');
return target.closest(INTERACTIVE_SELECTOR) !== null;
}

interface SceneTopBarProps {
Expand All @@ -45,25 +43,23 @@ const SceneTopBar: React.FC<SceneTopBarProps> = ({
onClose,
isMaximized = false,
}) => {
const openTabCount = useSceneStore(state => state.openTabs.length);
const hasTabs = openTabCount > 0;
const isSingleTab = openTabCount <= 1;
const hasTabs = useSceneStore(state => state.openTabs.length > 0);
const canDragWindow = supportsNativeWindowDragging();
const hasWindowControls = Boolean(onMinimize && onMaximize && onClose);

const handleMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!canDragWindow || event.button !== 0 || event.detail > 1) return;
if (blocksWindowChromeInteraction(event, isSingleTab)) return;
if (blocksWindowChromeInteraction(event)) return;

void startNativeWindowDragging().catch(error => {
log.debug('startDragging failed', { error });
});
}, [canDragWindow, isSingleTab]);
}, [canDragWindow]);

const handleDoubleClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!canDragWindow || event.button !== 0 || blocksWindowChromeInteraction(event, isSingleTab)) return;
if (!canDragWindow || event.button !== 0 || blocksWindowChromeInteraction(event)) return;
onMaximize?.();
}, [canDragWindow, isSingleTab, onMaximize]);
}, [canDragWindow, onMaximize]);

return (
<Toolbar
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/app/scenes/session/ChatPane.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
.openbitfun-chat-pane__content {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
Expand All @@ -16,6 +18,8 @@

.openbitfun-chat-pane__chat-container {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
Expand Down
27 changes: 27 additions & 0 deletions src/web-ui/src/flow_chat/_transcript-layout.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,30 @@
// The transcript reserves a lane for turn navigation before sizing its column.
// Keep the lane geometry shared with the rail so narrow panes cannot overlap it.
$turn-rail-offset: 4px;
$turn-rail-width: 30px;

@mixin viewport {
container: flow-chat-transcript / inline-size;
}

@mixin reading-column {
box-sizing: border-box;
min-width: 0;
width: 100%;
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.
@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)
);
}
}

// Transcript containers own the reading column; leaf content adds no inset.
// User-message decoration may extend into this gutter without moving its text.
@mixin content-column {
Expand Down
25 changes: 22 additions & 3 deletions src/web-ui/src/flow_chat/components/ChatInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
@use '@/shared/styles/surface-recipes' as surfaces;

.openbitfun-context-drop-zone.openbitfun-chat-input-drop-zone {
box-sizing: border-box;
position: absolute;
flex: 0 0 auto;
bottom: var(--openbitfun-space-6);
Expand All @@ -13,6 +14,7 @@
width: 100%;
height: auto;
min-height: 0;
min-width: 0;
max-width: 900px;
z-index: var(--openbitfun-layer-overlay);
display: flex;
Expand Down Expand Up @@ -301,7 +303,7 @@

}

@media (max-width: 640px) {
@container chat-input (max-width: 640px) {
.openbitfun-chat-input__voice-cluster--recording {
min-width: 220px;
}
Expand All @@ -327,7 +329,11 @@
}

.openbitfun-chat-input {
// The off-screen capsule measurement clones this root, so responsive controls
// see the same local width there as in main, floating, and native windows.
container: chat-input / inline-size;
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
transform: translateY(0);
Expand Down Expand Up @@ -501,7 +507,7 @@
// Product controls fill the stable public ChatComposer slots. The package
// owns the 42px compact / 120px expanded surface geometry; Web UI owns concrete menus and
// lifecycle states.
&__composer-surface {
&__composer > [data-openbitfun-part='body'] > [data-openbitfun-part='surface'] {
.openbitfun-chat-input__actions-left,
.openbitfun-chat-input__actions-right {
display: flex;
Expand Down Expand Up @@ -545,6 +551,7 @@
}

.openbitfun-model-selector__trigger {
min-width: 0;
height: var(--openbitfun-control-chat-composer-control-height);
padding: 0 var(--openbitfun-space-1) 0 var(--openbitfun-space-2);
border: 0;
Expand All @@ -555,7 +562,11 @@
}

.openbitfun-model-selector__name {
max-width: 160px;
max-width: min(160px, 22cqi);
}

.openbitfun-model-selector__trigger-reasoning {
max-width: min(72px, 12cqi);
}

.openbitfun-reasoning-preset-selector[data-openbitfun-presentation='label']
Expand Down Expand Up @@ -590,6 +601,14 @@
}

&[data-openbitfun-layout='compact'] {
// Keep the editing track usable in smaller panes. The reasoning setting
// remains available in the model menu when its inline summary cannot fit.
@container chat-input (max-width: 360px) {
.openbitfun-model-selector__trigger-reasoning {
display: none;
}
}

.openbitfun-chat-input__actions-left,
.openbitfun-chat-input__actions-right {
height: 100%;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ measured message even when no valid timestamp is available. The timestamp and
actions remain visible at rest, without requiring hover or keyboard focus.
`_transcript-layout.scss` owns the reading-column inset shared by user-message
shells, model rounds, Explore regions, and the runtime-status footer.
The messages viewport enables a shared outer column rule for virtual rows and
the runtime footer: reserve the turn rail's offset and hit area plus space-1 on
the leading side, and only space-2 on the trailing side. Columns remain centered
when there is room for the full 900px reading width. This rule is local to the
transcript; the composer and welcome surface keep their existing widths, and
embedded transcripts without a rail keep their own layout. The scroller remains
full width.
The shared content-padding token defaults to 0.75rem on wide and narrow surfaces,
keeping the reading column compact while leaving room for decoration and targets.
The bubble extends into that gutter by its corner radius. Its horizontal border
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
@use '../../transcript-layout' as transcriptLayout;

.flowchat-turn-rail {
position: absolute;
left: 4px;
left: transcriptLayout.$turn-rail-offset;
top: 20%;
bottom: 20%;
z-index: 8;
width: 30px;
width: transcriptLayout.$turn-rail-width;
display: flex;
align-items: center;
pointer-events: none;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Modern FlowChat container styles.
*/
@use '../../transcript-layout' as transcriptLayout;

.modern-flowchat-container.flow-chat-typography {
font-family:var(--openbitfun-type-body-sm-font-family);
Expand All @@ -19,6 +20,8 @@
position: relative; // Positioning context for the permission panel and overlays.

&__messages {
@include transcriptLayout.viewport;

// Header and messages stack vertically; messages take the remaining space.
position: relative;
flex: 1 1 auto;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,9 @@
}

&--footer &__content {
@include transcriptLayout.reading-column;
@include transcriptLayout.content-column;
width: 100%;
max-width: 900px;
margin: 0 auto;
margin-block: 0;
padding-block: 0;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,21 @@
*/
@use '../../flat-markdown-surfaces' as flatMarkdownSurfaces;
@use '../../markdown-typography' as markdownTypography;
@use '../../transcript-layout' as transcriptLayout;

.virtual-item-wrapper {
@include flatMarkdownSurfaces.apply;
@include markdownTypography.apply;
@include transcriptLayout.reading-column;

width: 100%;
/*
* Single reading column shared by every item type (model rounds, explore
* groups, user messages, notices). Matches ChatInput's 900px so headers,
* `>` collapse rows, and message text all sit on the same leading edge.
* transcript-layout owns the shared content inset; user-bubble decoration
* extends into that gutter without shifting the reading column.
*/
max-width: 900px;
margin: 0 auto;
box-sizing: border-box;
margin-block: 0;
min-height: 1px;
padding: 0;
position: relative;
Expand Down Expand Up @@ -86,10 +85,6 @@
margin-bottom: 0;
}

@media (max-width: 768px) {
max-width: 100%;
}

/*
* No mount animation survives inside a virtualized row.
*
Expand Down
Loading