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/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import {
retryStartupAfterModuleLoadFailure,
} from './startup/startupModuleRecovery';
import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode/ToolbarModeProvider';
import { RealtimeVoiceCallProvider } from '../flow_chat/components/voice/RealtimeVoiceCallContext';
import { RealtimeVoiceCallProvider } from '../flow_chat/components/voice/RealtimeVoiceCallProvider';
import type { AgentCompanionPetCommand } from './services/agentCompanionPetCommands';
import AskUserAnnouncer from './components/NavPanel/AskUserAnnouncer';
import { handleBrowserShortcut } from './browserShortcutPolicy';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,7 @@
import type { DeviceOverviewDevice } from '../deviceInterconnectionOverview';
import macbookAir from '../assets/macbook-air.png';

type DeviceArtworkKind = 'device' | 'server' | 'macbook-air';

export function getDeviceArtworkKind(
device: Pick<DeviceOverviewDevice, 'kind' | 'name'>,
): DeviceArtworkKind {
if (device.kind === 'execution-host') return 'server';
// Device names can identify a model, but the controller's OS cannot identify
// a remote machine. Unrecognized names deliberately use neutral artwork.
if (device.kind === 'desktop' && /\bmacbook[\s._-]*air\b/i.test(device.name)) {
return 'macbook-air';
}
return 'device';
}
import { getDeviceArtworkKind } from './deviceArtworkKind';

export function DeviceArtwork({ device }: { device: DeviceOverviewDevice }) {
const artwork = getDeviceArtworkKind(device);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
type DeviceInterconnectionOverview,
type DeviceInterconnectionOverviewInput,
} from '../deviceInterconnectionOverview';
import { getDeviceArtworkKind } from './DeviceArtwork';
import { getDeviceArtworkKind } from './deviceArtworkKind';
import DeviceStatusControl from './DeviceStatusControl';

const state = vi.hoisted(() => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { DeviceOverviewDevice } from '../deviceInterconnectionOverview';

type DeviceArtworkKind = 'device' | 'server' | 'macbook-air';

export function getDeviceArtworkKind(
device: Pick<DeviceOverviewDevice, 'kind' | 'name'>,
): DeviceArtworkKind {
if (device.kind === 'execution-host') return 'server';
// Device names can identify a model, but the controller's OS cannot identify
// a remote machine. Unrecognized names deliberately use neutral artwork.
if (device.kind === 'desktop' && /\bmacbook[\s._-]*air\b/i.test(device.name)) {
return 'macbook-air';
}
return 'device';
}
16 changes: 1 addition & 15 deletions src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,13 @@ import React, {
type ReactNode,
} from 'react';
import type { SceneTabId } from '../SceneBar/types';
import { SceneChromeContext, type SceneChromeContextValue } from './sceneChromeContext';

interface SceneChromeContributionRecord {
owner: symbol;
content: ReactNode;
}

interface SceneChromeContextValue {
activeSceneId: SceneTabId | null;
setContribution: (
sceneId: SceneTabId,
owner: symbol,
content: ReactNode,
) => void;
removeContribution: (sceneId: SceneTabId, owner: symbol) => void;
}

const SceneChromeContext = createContext<SceneChromeContextValue | null>(null);
const SceneChromeContentContext = createContext<ReactNode>(null);

interface SceneChromeProviderProps {
Expand Down Expand Up @@ -117,7 +107,3 @@ export const SceneChromeContribution: React.FC<SceneChromeContributionProps> = (

return null;
};

export function useSceneChromeContext(): SceneChromeContextValue | null {
return useContext(SceneChromeContext);
}
2 changes: 1 addition & 1 deletion src/web-ui/src/app/components/SceneTopBar/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ export {
SceneChromeContribution,
SceneChromeHost,
SceneChromeProvider,
useSceneChromeContext,
} from './SceneChrome';
export { useSceneChromeContext } from './sceneChromeContext';
17 changes: 17 additions & 0 deletions src/web-ui/src/app/components/SceneTopBar/sceneChromeContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { SceneTabId } from '../SceneBar/types';

export interface SceneChromeContextValue {
activeSceneId: SceneTabId | null;
setContribution: (
sceneId: SceneTabId,
owner: symbol,
content: ReactNode,
) => void;
removeContribution: (sceneId: SceneTabId, owner: symbol) => void;
}

export const SceneChromeContext = createContext<SceneChromeContextValue | null>(null);
export function useSceneChromeContext(): SceneChromeContextValue | null {
return useContext(SceneChromeContext);
}
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,7 @@ const FlexiblePanel: React.FC<ExtendedFlexiblePanelProps> = memo(({
return (
<React.Suspense fallback={<div className="openbitfun-flexible-panel__loading" data-openbitfun-component="flexible-panel" data-openbitfun-part="loading" data-openbitfun-state="loading">{t('flexiblePanel.loading.taskDetail')}</div>}>
<BtwSessionPanel
isActive={isActive}
childSessionId={content.data?.childSessionId}
parentSessionId={content.data?.parentSessionId}
workspacePath={content.data?.workspacePath || workspacePath}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,11 @@ export const EditorGroup: React.FC<EditorGroupProps> = ({

// Tabs to render (active + cached). Hidden terminal tabs stay mounted so
// reopening a terminal reuses the xterm buffer instead of replaying history.
// Child-session tabs retain only their lightweight wrapper; that wrapper
// unmounts its transcript while inactive and owns reading state until close.
const tabsToRender = useMemo(() => {
const result = group.tabs.filter(t =>
(!t.isHidden && (t.id === group.activeTabId || cachedTabsRef.current.has(t.id))) ||
(!t.isHidden && (t.content.type === 'btw-session' || t.id === group.activeTabId || cachedTabsRef.current.has(t.id))) ||
(t.isHidden && isKeepAliveTerminalTab(t))
);
return result;
Expand Down
1 change: 0 additions & 1 deletion src/web-ui/src/app/scenes/browser/HtmlPreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ const HtmlPreviewPanel: React.FC<HtmlPreviewPanelProps> = ({
}
};
// The session is intentionally recreated only when the opened file scope changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filePath, workspacePath, remoteConnectionId]);

if (error) {
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/app/scenes/session/SessionScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,7 @@ const SessionScene: React.FC<SessionSceneProps> = ({
<AuxPane
ref={auxPaneRef}
workspacePath={workspacePath}
isSceneActive={isActive}
isSceneActive={isActive && !state.layout.rightPanelCollapsed}
terminalResizeSuspended={isRightPanelTransitioning || isDraggingRight}
/>
</div>
Expand Down
7 changes: 2 additions & 5 deletions src/web-ui/src/flow_chat/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,8 @@ import {
import { createLogger } from '@/shared/utils/logger';
import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition';
import { AcpModeSelector } from './AcpModeSelector';
import {
ReasoningIntensityMark,
presetDisplayLabel,
reasoningIntensityLevel,
} from './ReasoningPresetSelector';
import { ReasoningIntensityMark } from './ReasoningPresetSelector';
import { presetDisplayLabel, reasoningIntensityLevel } from './reasoningPresetPresentation';
import {
getRecentReasoningPreset,
setRecentReasoningPreset,
Expand Down
110 changes: 2 additions & 108 deletions src/web-ui/src/flow_chat/components/ReasoningPresetSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ import { OverflowText, Menu, MenuItem } from '@openbitfun/ui';
import { Tooltip } from '@openbitfun/ui';
import { RetainedMountBoundary } from '@/shared/presence';
import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost';
import type {
ReasoningCatalogProjection,
ReasoningPresetDescriptor,
} from '@/infrastructure/config/types';
import type { ReasoningCatalogProjection } from '@/infrastructure/config/types';
import { getModelSelectorDropdownLayout } from './modelSelectorDropdownPosition';
import { presetLabel, presetDisplayLabel, reasoningIntensityLevel, type ReasoningIntensityLevel } from './reasoningPresetPresentation';
import './ReasoningPresetSelector.scss';

interface ReasoningPresetSelectorProps {
Expand All @@ -26,110 +24,6 @@ interface ReasoningPresetSelectorProps {
onSelect: (presetId: string | null) => void | Promise<void>;
}

function presetLabel(
preset: ReasoningPresetDescriptor,
t: ReturnType<typeof useTranslation>['t'],
): string {
return t(`reasoningEffort.${preset.id}`, { defaultValue: preset.label || preset.id });
}

export function presetDisplayLabel(
preset: ReasoningPresetDescriptor,
t: ReturnType<typeof useTranslation>['t'],
): string {
const fallback = presetLabel(preset, t);
const semanticKey = presetSemanticKey(preset);
return semanticKey
? t(`reasoningSelector.levels.${semanticKey}`, { defaultValue: fallback })
: fallback;
}

type ReasoningIntensityLevel = 0 | 1 | 2 | 3 | 4;

type ReasoningPresetSemanticKey =
| 'off'
| 'on'
| 'minimal'
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max';

const REASONING_PRESET_SEMANTIC_KEYS = new Set<ReasoningPresetSemanticKey>([
'off',
'on',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
]);

function asReasoningPresetSemanticKey(value: string): ReasoningPresetSemanticKey | undefined {
const normalized = value.trim().toLowerCase();
if (normalized === 'none') return 'off';
return REASONING_PRESET_SEMANTIC_KEYS.has(normalized as ReasoningPresetSemanticKey)
? normalized as ReasoningPresetSemanticKey
: undefined;
}

function presetSemanticKey(
preset: ReasoningPresetDescriptor,
): ReasoningPresetSemanticKey | undefined {
const idKey = asReasoningPresetSemanticKey(preset.id);
if (idKey) return idKey;

// Generated presets can prefix or replace their semantic id (for example
// effort-off and budget-max). Their action is the stable meaning to localize.
// Custom presets keep their authored label instead of being renamed by an
// implementation detail in their request actions.
if (preset.source === 'model_config') return undefined;

for (const action of preset.actions) {
if (action.type === 'toggle') return action.enabled ? 'on' : 'off';
if (action.type === 'effort') {
const effortKey = asReasoningPresetSemanticKey(action.value);
if (effortKey) return effortKey;
}
if (action.type === 'budget_tokens') {
if (preset.id.toLowerCase().includes('max')) return 'max';
if (preset.id.toLowerCase().includes('high')) return 'high';
}
}

return undefined;
}

function presetDisablesReasoning(preset: ReasoningPresetDescriptor): boolean {
if (presetSemanticKey(preset) === 'off') return true;
return preset.actions.some(action => (
action.type === 'toggle' && !action.enabled
));
}

export function reasoningIntensityLevel(
preset: ReasoningPresetDescriptor | undefined,
orderedPresets: ReasoningPresetDescriptor[],
): ReasoningIntensityLevel {
if (!preset) return 0;
if (presetDisablesReasoning(preset)) return 0;

const activePresets = orderedPresets.filter(item => !presetDisablesReasoning(item));
const activeIndex = activePresets.findIndex(item => item.id === preset.id);
if (activeIndex < 0) return 0;
if (activePresets.length === 1) return 1;

// The catalog may merge toggle, effort and token-budget presets. Ranking by
// the catalog order keeps the visual series monotonic even when ids come from
// different action families (off, on, low, high, max).
return Math.min(
4,
Math.max(1, Math.round((activeIndex / (activePresets.length - 1)) * 3) + 1),
) as 1 | 2 | 3 | 4;
}

interface ReasoningIntensityMarkProps {
level: ReasoningIntensityLevel;
compact?: boolean;
Expand Down
26 changes: 26 additions & 0 deletions src/web-ui/src/flow_chat/components/btw/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Embedded session panels

The transcript uses `BtwVirtualSessionList` and the shared FlowChat virtualizer.
Keep primary-session selection, paging, and input-footer policy out of this panel.
`useBtwSessionState` selects the child Session, parent title/workspace/connection
metadata, and only for `review-check`, the linked Task's derived outcome. Ordinary
panes must not read the parent's transcript. Keep snapshots stable for unrelated
store updates and parent streaming, and selection synchronous on ID/view changes.

Inactive tabs retain only the `BtwSessionPanel` wrapper and its lightweight view
state. The content, session subscriptions, observers, and scroll callbacks unmount
immediately, including when the auxiliary pane collapses. Keep background task
execution and permission mailboxes outside this lifetime. Preserve exploration
expansion and stable-item reading anchors; following readers return to the latest
output. A successfully restored review location must not reload stale persisted
UI state on each tab switch. Closing a tab releases its wrapper and view state.

For transcript windowing, scrolling, or review integration changes, run:

```bash
pnpm --dir src/web-ui run test:run src/flow_chat/components/btw/useBtwSessionState.test.tsx src/flow_chat/components/btw/useBtwPanelViewport.test.tsx src/flow_chat/components/btw/BtwVirtualSessionList.test.tsx src/flow_chat/components/btw/BtwSessionPanel.review-action.test.tsx src/flow_chat/components/btw/BtwSessionPanelLayout.test.ts
```

Native WebView2 checks remain manual: long subagent transcripts, streaming tail
follow, upward scrolling, tool expansion, sidebar resizing, and cached-tab return.
Report memory measurements and remote scenarios as unverified until exercised.
Loading