Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@
align-items: center;
}

.endAction > :where(button, [role="button"]) {
/* Composed IconButtons own their variants, geometry and interaction states. */
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])) {
display: inline-flex;
align-items: center;
justify-content: center;
Expand All @@ -170,21 +171,21 @@
color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard);
}

.endAction > :where(button, [role="button"]):hover {
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])):hover {
color: var(--openbitfun-color-action-neutral-content);
background: var(--openbitfun-color-action-neutral-surface-hover);
}

.endAction > :where(button, [role="button"]):active {
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])):active {
background: var(--openbitfun-color-action-neutral-surface-pressed);
}

.endAction > :where(button, [role="button"]):focus-visible {
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])):focus-visible {
outline: var(--openbitfun-focus-width) solid var(--openbitfun-color-focus-ring);
outline-offset: var(--openbitfun-focus-offset);
}

.endAction > :where(button, [role="button"]):disabled {
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])):disabled {
color: var(--openbitfun-color-action-neutral-content-disabled);
cursor: not-allowed;
}
Expand All @@ -204,7 +205,7 @@
@media (prefers-reduced-motion: reduce) {
.tab,
.endAction,
.endAction > :where(button, [role="button"]) {
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])) {
transition: none;
}
}
Expand All @@ -224,7 +225,7 @@
outline-color: Highlight;
}

.endAction > :where(button, [role="button"]):focus-visible {
.endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])):focus-visible {
outline-color: Highlight;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,7 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
key={sessionId}
sessionId={sessionId}
autoFocus={isActive}
renderingActive={isActive}
closeBehavior="detach"
onClose={() => onContentChange?.(null)}
resizeSuspended={!isActive || terminalResizeSuspended}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ export const TabBar: React.FC<TabBarProps> = ({
data-motion="none"
shape="circle"
size="xs"
variant={tab.state === 'pinned' ? 'quiet' : 'outline'}
aria-label={`${t(tab.state === 'pinned' ? 'tabs.unpin' : 'tabs.close')} ${tab.title}`}
title={t(tab.state === 'pinned' ? 'tabs.unpin' : 'tabs.close')}
icon={<Icon name={tab.state === 'pinned' ? 'pin' : 'xmark'} size="xs" />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,14 @@ export const TabOverflowMenu: React.FC<TabOverflowMenuProps> = ({
data-openbitfun-product-component="canvas-tab-overflow"
data-openbitfun-product-part="trigger"
size="sm"
variant="text"
variant="outline"
labelBehavior="static"
aria-label={tooltipContent}
aria-haspopup="menu"
aria-expanded={isOpen}
onClick={handleButtonClick}
leadingIcon={hasMissionControl ? <LayoutGrid size={14} /> : <Icon name="chevron-down" size="sm" />}
leadingIcon={hasMissionControl ? <Icon glyph={LayoutGrid} size="sm" /> : undefined}
trailingIcon={<Icon name={isOpen ? 'chevron-up' : 'chevron-down'} size="sm" />}
>
<span data-openbitfun-product-component="canvas-tab-overflow" data-openbitfun-product-part="badge">
+{overflowTabs.length}
Expand All @@ -188,9 +190,10 @@ export const TabOverflowMenu: React.FC<TabOverflowMenuProps> = ({
data-openbitfun-product-component="canvas-tab-overflow"
data-openbitfun-product-part="trigger"
size="sm"
variant="outline"
aria-label={tooltipContent}
onClick={handleButtonClick}
icon={<LayoutGrid size={14} />}
icon={<Icon glyph={LayoutGrid} size="sm" />}
/>
)}
</Tooltip>
Expand All @@ -211,7 +214,7 @@ export const TabOverflowMenu: React.FC<TabOverflowMenuProps> = ({
<MenuItem
className="canvas-tab-overflow-menu__mission-control-row canvas-tab-overflow-menu__mission-control"
onClick={handleMissionControlClick}
leading={<LayoutGrid size={14} />}
leading={<Icon glyph={LayoutGrid} size="sm" />}
shortcut={<KeyHint>⌘.</KeyHint>}
>
<span>{t('tabs.missionControl')}</span>
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/app/scenes/terminal/TerminalScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const TerminalScene: React.FC<TerminalSceneProps> = ({ isActive = true }) => {
key={activeSessionId}
sessionId={activeSessionId}
autoFocus={isActive}
renderingActive={isActive}
showToolbar
showStatusBar
onClose={handleClose}
Expand Down
18 changes: 18 additions & 0 deletions src/web-ui/src/app/startup/MainApplicationRoot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import App from '../App';
import { WorkspaceProvider } from '@/infrastructure/contexts/WorkspaceProvider';
import { PeerDeviceProvider } from '@/infrastructure/peer-device/PeerDeviceContext';
import { PeerHostInvokeBridge } from '@/infrastructure/peer-device/PeerHostInvokeBridge';
import { PeerDirectoryPickerHost } from '@/infrastructure/peer-device/PeerDirectoryPickerHost';

/** Loaded only by the workbench window, never by the desktop companion. */
export default function MainApplicationRoot() {
return (
<WorkspaceProvider>
<PeerDeviceProvider>
<PeerHostInvokeBridge />
<PeerDirectoryPickerHost />
<App />
</PeerDeviceProvider>
</WorkspaceProvider>
);
}
9 changes: 7 additions & 2 deletions src/web-ui/src/app/startup/startupPerformanceContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,12 +847,17 @@ describe('startup performance contract', () => {
expect(source).toContain("import('@/flow_chat/utils/agentCompanionActivity')");
expect(source).toContain("import('@/flow_chat/services/AgentCompanionActivityBridge')");
expect(source).toContain("import('./services/openAgentCompanionSession')");
expect(staticImportSpecifiers(mainSource)).toContain(
expect(staticImportSpecifiers(mainSource)).not.toContain(
'./app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet'
);
expect(dynamicImportSpecifiers(mainSource)).not.toContain(
expect(dynamicImportSpecifiers(mainSource)).toContain(
'./app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet'
);
expect(staticImportSpecifiers(mainSource)).not.toContain('./app/App');
expect(staticImportSpecifiers(mainSource)).not.toContain('./infrastructure/contexts/WorkspaceProvider');
expect(dynamicImportSpecifiers(mainSource)).toContain('./app/startup/MainApplicationRoot');
expect(mainSource).toContain('let windowModule;');
expect(mainSource.indexOf('let windowModule')).toBeLessThan(mainSource.indexOf('await initializeBeforeRender()'));
expect(source).toContain("listen(\n 'agent-companion://ready'");
expect(source).toContain("emit('agent-companion://settings-updated', settings)");
expect(source).toContain('emitAgentCompanionActivity(buildAgentCompanionActivity())');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ vi.mock('../../store/FlowChatStore', () => ({
}),
},
flowChatStore: {
retainSessionHistory: () => () => {},
clearSessionUnreadCompletion: vi.fn(),
ensurePersistedSessionMetadata: (...args: unknown[]) =>
panelMocks.ensurePersistedSessionMetadata(...args),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ const BtwSessionPanelContent: React.FC<BtwSessionPanelProps & { viewState: BtwPa
viewState,
}) => {
const { t } = useTranslation('flow-chat');
useEffect(() => childSessionId ? flowChatStore.retainSessionHistory(childSessionId) : undefined, [childSessionId]);
const { childSession, parentMetadata, reviewTaskOutcome } = useBtwSessionState(
childSessionId, parentSessionId, viewKind === 'review-check',
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ vi.mock('@/infrastructure/markdown', () => ({
ThinkingMarkdownRenderer: ({ content }: { content: string }) => <div>{content}</div>,
}));
vi.mock('../../store/FlowChatStore', () => {
const store = { getState: () => ({ sessions: new Map() }) };
const store = {
getState: () => ({ sessions: new Map() }),
retainSessionHistory: () => () => {},
};
return { flowChatStore: store, FlowChatStore: { getInstance: () => store } };
});
vi.mock('@/shared/notification-system', () => ({
Expand Down
6 changes: 4 additions & 2 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
buildModelRoundItemGroups,
buildInlineToolGroupData,
getModelRoundActiveItems,
isModelRoundGroupingDisabled,
type ModelRoundItemGroup,
} from '../../grouping/roundGroups';
import { FlowGroupRenderer } from './FlowGroupRenderer';
Expand Down Expand Up @@ -462,6 +463,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(

// Collect settled exploration while keeping narrative and critical items
// in their original transcript positions.
const disableExploreGrouping = isModelRoundGroupingDisabled(round);
const groupedItems = useMemo(() => {
if (projectedGroups) return projectedGroups;
const visibleItems = isTurnComplete
Expand All @@ -470,10 +472,10 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
return buildModelRoundItemGroups({
items: visibleItems,
isStreaming: round.isStreaming,
disableExploreGrouping: round.renderHints?.disableExploreGrouping === true,
disableExploreGrouping,
isCollapsibleTool,
});
}, [isTurnComplete, projectedGroups, round.isStreaming, round.renderHints?.disableExploreGrouping, sortedItems]);
}, [disableExploreGrouping, isTurnComplete, projectedGroups, round.isStreaming, sortedItems]);

const groupSummary = useMemo(
() => renderTraceEnabled ? summarizeModelRoundItemGroups(groupedItems) : null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,16 @@ interface FlowChatHistoryPresentationState extends SessionHistoryPresentation {

export interface SessionViewportState {
snapshot: FlowChatViewportSnapshot | null;
historyPresentation: FlowChatHistoryPresentationState | null;
historyPresentation: Omit<FlowChatHistoryPresentationState, 'turns'> | null;
viewportIntent: FlowChatViewportIntent | null;
}

function historyBookmark(presentation: SessionViewportState['historyPresentation']): SessionViewportState['historyPresentation'] {
return presentation ? {
sessionId: presentation.sessionId, revision: presentation.revision, range: { ...presentation.range },
} : null;
}

type FlowChatViewportIntent =
| {
kind: 'live-tail';
Expand Down Expand Up @@ -308,12 +314,17 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
const canonicalVirtualItems = useVirtualItems();
const activeSession = useActiveSession();
const surfaceScope = getActiveSurfaceScope();
useLayoutEffect(() => activeSession?.sessionId
? flowChatStore.retainSessionHistory(activeSession.sessionId)
: undefined, [activeSession?.sessionId, surfaceScope.epoch]);
const submittedPreviews = useSyncExternalStore(
subscribeSubmittedMessagePreviews,
() => getSubmittedMessagePreviews(surfaceScope, activeSession?.sessionId ?? ''),
() => getSubmittedMessagePreviews(surfaceScope, ''),
);
const [historyPresentation, setHistoryPresentation] = useState<FlowChatHistoryPresentationState | null>(null);
const [historyRestore, setHistoryRestore] = useState<{ sessionId: string; status: 'loading' | 'failed' } | null>(null);
const [historyRestoreRetry, setHistoryRestoreRetry] = useState(0);
const [viewportIntent, setViewportIntent] = useState<FlowChatViewportIntent | null>(null);
const [continuousProjectionSessionId, setContinuousProjectionSessionId] = useState<string | null>(null);
const [historyBoundaryState, setHistoryBoundaryState] = useState<FlowChatHistoryBoundaryState>(
Expand Down Expand Up @@ -566,15 +577,18 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
patch: Partial<SessionViewportState>,
) => {
const previous = sessionViewportStateRef.current.get(sessionId);
sessionViewportStateRef.current.delete(sessionId);
sessionViewportStateRef.current.set(sessionId, {
snapshot: patch.snapshot !== undefined ? patch.snapshot : previous?.snapshot ?? null,
historyPresentation: patch.historyPresentation !== undefined
? patch.historyPresentation
? historyBookmark(patch.historyPresentation)
: previous?.historyPresentation ?? null,
viewportIntent: patch.viewportIntent !== undefined
? patch.viewportIntent
: previous?.viewportIntent ?? null,
});
// Keep lightweight reader state for the view's lifetime. Evicting a body
// must not also discard a user's position after visiting many sessions.
}, []);

const acceptViewportSnapshot = useCallback((snapshot: FlowChatViewportSnapshot) => {
Expand Down Expand Up @@ -625,7 +639,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
if (sessionId !== activeSessionIdRef.current) return cached ?? null;
return {
snapshot: virtualListRef.current?.captureViewportSnapshot() ?? cached?.snapshot ?? null,
historyPresentation: historyPresentationRef.current,
historyPresentation: historyBookmark(historyPresentationRef.current),
viewportIntent: viewportIntentRef.current,
};
}), [surfaceScope.epoch, surfaceScope.surfaceId, activeSession?.sessionId, viewScope]);
Expand Down Expand Up @@ -714,6 +728,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
options?: { discardRecentHistory?: boolean },
) => {
historyPresentationOwnerGenerationRef.current += 1;
setHistoryRestore(null);
const retainContinuousProjection = (
options?.discardRecentHistory !== true
&& activeSession?.sessionId === sessionId
Expand Down Expand Up @@ -770,7 +785,12 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
activeSessionIdRef.current = sessionId ?? null;
const remembered = sessionId ? sessionViewportStateRef.current.get(sessionId) : undefined;
historyPresentationOwnerGenerationRef.current += 1;
const restoredHistoryPresentation = remembered?.historyPresentation ?? null;
const bookmark = remembered?.historyPresentation;
setHistoryRestore(null);
const cached = sessionId && bookmark
? flowChatStore.reactivateSessionHistoryWindow(sessionId, bookmark.range)
: null;
const restoredHistoryPresentation = cached && bookmark ? { ...cached, sessionId: bookmark.sessionId, revision: bookmark.revision } : null;
historyPresentationRef.current = restoredHistoryPresentation;
setHistoryPresentation(restoredHistoryPresentation);
setContinuousProjectionSessionId(null);
Expand All @@ -791,7 +811,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE);
historyBoundaryRequestsRef.current = { before: null, after: null };
if (sessionId) {
if (!restoredHistoryPresentation) {
if (!restoredHistoryPresentation && !bookmark) {
flowChatStore.restoreSessionTailPresentation(sessionId);
}
traceViewport({
Expand All @@ -806,7 +826,31 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
}),
});
}
}, [activeSession?.sessionId, rememberSessionViewportState, updateViewportIntent, restoreRevision]);
if (sessionId && bookmark && !cached && activeSession?.historyState === 'ready') {
setHistoryRestore({ sessionId, status: 'loading' });
const generation = historyPresentationOwnerGenerationRef.current;
let cancelled = false;
void flowChatStore.restoreSessionHistoryWindow(sessionId, bookmark.range, () =>
!cancelled && surfaceScope.isCurrent() && activeSessionIdRef.current === sessionId
&& historyPresentationOwnerGenerationRef.current === generation,
).then(presentation => {
if (cancelled || !surfaceScope.isCurrent() || activeSessionIdRef.current !== sessionId
|| historyPresentationOwnerGenerationRef.current !== generation) return;
if (!presentation) throw new Error('The saved history range is no longer available');
const next = { ...presentation, sessionId, revision: bookmark.revision + 1 };
historyPresentationRef.current = next;
setHistoryPresentation(next);
setHistoryRestore(null);
}).catch(error => {
if (cancelled || !surfaceScope.isCurrent() || activeSessionIdRef.current !== sessionId
|| historyPresentationOwnerGenerationRef.current !== generation) return;
log.warn('Failed to restore reader history range', { sessionId, error });
setHistoryRestore({ sessionId, status: 'failed' });
setHistoryBoundaryState({ before: 'error', after: 'error' });
});
return () => { cancelled = true; };
}
}, [activeSession?.sessionId, activeSession?.historyState, rememberSessionViewportState, updateViewportIntent, restoreRevision, historyRestoreRetry, surfaceScope]);

useEffect(() => {
const retainedSessionId = continuousProjectionSessionId;
Expand Down Expand Up @@ -1212,8 +1256,9 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
const shouldScheduleBackgroundCommandSnapshotAfterPaint =
historyInitialContentKey !== null &&
historyInitialContentPostPaintKey === historyInitialContentKey;
const activeHistoryRestore = historyRestore?.sessionId === activeSession?.sessionId ? historyRestore : null;
const showFailedHistoryPlaceholder =
showHistoryPlaceholder && historyState === 'failed';
(showHistoryPlaceholder && historyState === 'failed') || activeHistoryRestore?.status === 'failed';
const showHistoryOpenIntentOverlay =
pendingHistoryOpenSession !== null &&
(
Expand All @@ -1222,9 +1267,11 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
);
const shouldBlockHistoryTransitionInteraction =
shouldBlockHistoryInitialContentInteraction ||
activeHistoryRestore?.status === 'loading' ||
showHistoryOpenIntentOverlay;
const showHistoryLoadingLayer =
!showHistoryOpenIntentOverlay && !showFailedHistoryPlaceholder && showHistoryPlaceholder;
!showHistoryOpenIntentOverlay && !showFailedHistoryPlaceholder
&& (showHistoryPlaceholder || activeHistoryRestore?.status === 'loading');
useEffect(() => {
if (!showHistoryLoadingLayer || !activeSession?.sessionId) {
return;
Expand Down Expand Up @@ -2006,8 +2053,12 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
const handleRetryHistoryLoad = useCallback(() => {
const sessionId = activeSession?.sessionId;
if (!sessionId) return;
if (historyRestore?.sessionId === sessionId && historyRestore.status === 'failed') {
setHistoryRestoreRetry(retry => retry + 1);
return;
}
void FlowChatManager.getInstance().switchChatSession(sessionId);
}, [activeSession?.sessionId]);
}, [activeSession?.sessionId, historyRestore]);

const handleHistoryWindowBoundaryIntent = useCallback((
direction: SessionHistoryWindowDirection,
Expand Down Expand Up @@ -2763,7 +2814,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
state="failed"
onRetry={handleRetryHistoryLoad}
/>
) : virtualItems.length === 0 ? (
) : activeHistoryRestore?.status === 'loading' ? null : virtualItems.length === 0 ? (
showHistoryPlaceholder || showHistoryOpenIntentOverlay ? null : (
emptyState !== undefined ? emptyState : (
<WelcomePanel
Expand Down
Loading
Loading