From 4076d9f80595eb22682a7c0f8b9ac5726e35d83f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:35:00 +0800 Subject: [PATCH 01/52] fix(workspace): keep the opened workspace order when one activates Activating an already opened workspace moved it to the front of the host's opened list, so every re-read reordered the sidebar and overwrote the order users arranged. Newly opened workspaces still go first; recency stays in the recent list. --- .../core/src/service/workspace/manager.rs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index eae16a178f..21f1ba1f70 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -1496,9 +1496,17 @@ impl WorkspaceManager { self.opened_workspace_ids.first().cloned() } - /// Ensures a workspace stays in the opened list. + /// Ensures a workspace is in the opened list. A newly opened workspace goes + /// first; an already opened one keeps its place, so activating a workspace + /// never reorders the list users arrange (recency lives in the recent list). fn ensure_workspace_open(&mut self, workspace_id: &str) { - self.opened_workspace_ids.retain(|id| id != workspace_id); + if self + .opened_workspace_ids + .iter() + .any(|id| id == workspace_id) + { + return; + } self.opened_workspace_ids .insert(0, workspace_id.to_string()); } @@ -1693,6 +1701,16 @@ mod tests { ); } + #[test] + fn activating_an_opened_workspace_keeps_the_opened_order() { + let mut manager = super::WorkspaceManager::new(super::WorkspaceManagerConfig::default()); + manager.opened_workspace_ids = vec!["a".into(), "b".into(), "c".into()]; + manager.ensure_workspace_open("c"); + assert_eq!(manager.opened_workspace_ids, vec!["a", "b", "c"]); + manager.ensure_workspace_open("d"); + assert_eq!(manager.opened_workspace_ids, vec!["d", "a", "b", "c"]); + } + #[tokio::test] async fn port_scoped_connections_open_distinct_workspaces_beside_legacy_records() { use super::{ From 7a221abe1dc155e806fb4932100dc8906538caa7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:35:25 +0800 Subject: [PATCH 02/52] feat(web-ui): read other devices' workspace and session catalogs Add a lazily retained, read-only catalog of every online same-account host (get_opened_workspaces and list_persisted_sessions_page over raw HostInvoke reads), kept current by debounced @host/catalog invalidations: session-only hints skip the workspace list, refreshes are held while the window is hidden or a cross-device open runs, and notifications are coalesced. Catalog pages seed each background device's FlowChat container through the shared metadata projection; seeds are promoted before any open and never replace a loaded session. A per-device display order keeps workspaces in place across hosts that still reorder on activation, and remembered totals keep session counts stable while readers restart. A DeviceOpenIntent describes a running cross-device open for the other owners. --- .../modern/HistorySessionLoadingBoundary.tsx | 45 +- .../modern/ModernFlowChatContainer.tsx | 9 +- .../services/backgroundSurfaceSeeding.ts | 69 ++ .../services/crossDeviceSessions.test.ts | 46 ++ .../flow_chat/services/crossDeviceSessions.ts | 71 ++ .../flow_chat/services/openBtwSession.test.ts | 1 + .../services/sessionActivation.test.ts | 1 + .../flow_chat/services/sessionActivation.ts | 6 + .../services/sessionNavStatusService.ts | 4 + .../services/sessionOpenIntent.test.ts | 9 + .../flow_chat/services/sessionOpenIntent.ts | 22 +- .../services/workspaceSessionTotals.test.ts | 28 + .../services/workspaceSessionTotals.ts | 36 + .../src/flow_chat/store/FlowChatStore.test.ts | 77 ++ .../src/flow_chat/store/FlowChatStore.ts | 328 +++++++-- .../PeerDeviceSurfaceController.ts | 28 +- .../peer-device/deviceCatalogTransport.ts | 92 +++ .../peer-device/deviceOpenIntent.ts | 38 + .../peer-device/deviceSurfaceRouting.ts | 2 + .../deviceWorkspaceCatalog.test.ts | 316 +++++++++ .../peer-device/deviceWorkspaceCatalog.ts | 658 ++++++++++++++++++ .../peer-device/useDeviceWorkspaceCatalog.ts | 39 ++ .../business/workspaceDisplayOrder.test.ts | 28 + .../business/workspaceDisplayOrder.ts | 44 ++ .../services/business/workspaceManager.ts | 13 +- src/web-ui/src/shared/types/global-state.ts | 2 +- 26 files changed, 1905 insertions(+), 107 deletions(-) create mode 100644 src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts create mode 100644 src/web-ui/src/flow_chat/services/crossDeviceSessions.test.ts create mode 100644 src/web-ui/src/flow_chat/services/crossDeviceSessions.ts create mode 100644 src/web-ui/src/flow_chat/services/workspaceSessionTotals.test.ts create mode 100644 src/web-ui/src/flow_chat/services/workspaceSessionTotals.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/deviceOpenIntent.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/useDeviceWorkspaceCatalog.ts create mode 100644 src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts create mode 100644 src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts diff --git a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx index 1b9a9be309..baea7e427d 100644 --- a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx +++ b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx @@ -147,26 +147,7 @@ export function HistorySessionLoadingBoundary({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [identity, loading, hasTranscript, disabled]); - const skeleton = ( - - ); + const skeleton = ; const overlayProps = { className: 'history-session-loading__overlay', hidden: !loading && phase === 'revealing', @@ -199,3 +180,27 @@ export function HistorySessionLoadingBoundary({ ); } + +/** The history loading skeleton, shared with presentations that precede a session (a device switch). */ +export function HistorySessionSkeleton() { + return ( + + ); +} diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 0b27526459..f906cd9135 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -520,7 +520,11 @@ export const ModernFlowChatContainer: React.FC = ( const { requests: permissionRequests } = usePermissionRequests(activeSession?.sessionId); const visibleTurnInfo = useVisibleTurnInfo(); const [queuedTurnNavigation, setQueuedTurnNavigation] = useState(null); - const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); + // A view mounted mid-open (for example by a device switch) adopts it. + const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(() => { + const transition = getHistorySessionOpenTransitionSnapshot(); + return transition ? { sessionId: transition.sessionId, sessionTitle: transition.sessionTitle, maxMs: transition.maxMs } : null; + }); const [searchOpenRequest, setSearchOpenRequest] = useState(0); const backgroundCommandActivities = useBackgroundCommandActivityStore(state => state.activities); const [stoppingBackgroundCommandIds, setStoppingBackgroundCommandIds] = useState>(() => new Set()); @@ -885,6 +889,7 @@ export const ModernFlowChatContainer: React.FC = ( setPendingHistoryOpenSession({ sessionId: detail.sessionId, sessionTitle: detail.sessionTitle, + maxMs: detail.maxMs, }); startupTrace.markPhase('historical_session_open_intent_overlay', { sessionId: detail.sessionId, @@ -917,7 +922,7 @@ export const ModernFlowChatContainer: React.FC = ( } return current; }); - }, 4000); + }, pendingHistoryOpenSession.maxMs ?? 4000); return () => { window.clearTimeout(timeoutId); diff --git a/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts new file mode 100644 index 0000000000..1b636c93d9 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts @@ -0,0 +1,69 @@ +/** + * Projects the device catalog into each background device's FlowChat + * container. + * + * Navigation draws a background device from its container with exactly the + * live list's hierarchy, sort and filters, and a switch finds the device's + * sessions already present. The live list therefore paints the same rows in + * the same order the moment a device is rendered, and opening one of them + * needs no extra read. + */ + +import { getActiveSurfaceId, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { flowChatStore, type BackgroundSessionPage } from '../store/FlowChatStore'; +import { DEFAULT_SESSION_VIEW_KEY, rememberWorkspaceSessionTotal } from './workspaceSessionTotals'; + +let installs = 0; +/** Page record arrays already projected, per surface; the catalog replaces arrays on change. */ +const seededRecords = new Map>(); +let teardown: (() => void) | null = null; +export function seedBackgroundSurface(surfaceId: DeviceSurfaceId): void { + const snapshot = deviceWorkspaceCatalog.getSnapshot(surfaceId); + if (snapshot.status === 'idle') { + flowChatStore.discardSeededSessions(surfaceId); + seededRecords.delete(surfaceId); + return; + } + const pages: BackgroundSessionPage[] = []; + let seen = seededRecords.get(surfaceId); + if (!seen) seededRecords.set(surfaceId, seen = new WeakSet()); + for (const workspace of snapshot.workspaces) { + const page = snapshot.sessions[workspace.id]; + if (!page || page.status === 'idle') continue; + // A notification for another page, a status flip or a total does not + // re-project rows that were already seeded. + if (seen.has(page.records)) continue; + seen.add(page.records); + if (page.total !== null) rememberWorkspaceSessionTotal(surfaceId, workspace.id, DEFAULT_SESSION_VIEW_KEY, page.total); + const remote = workspace.workspaceKind === 'remote'; + pages.push({ + workspaceId: workspace.id, + workspacePath: workspace.rootPath, + remoteConnectionId: remote ? workspace.connectionId : undefined, + remoteSshHost: remote ? workspace.sshHost : undefined, + sessions: page.records, + }); + } + if (surfaceId !== getActiveSurfaceId()) flowChatStore.seedSurfaceSessions(surfaceId, pages); +} + +/** Reference-counted; navigation installs it while it lists several devices. */ +export function installBackgroundSurfaceSeeding(): () => void { + installs += 1; + if (installs === 1) { + const unsubscribe = deviceWorkspaceCatalog.subscribe(seedBackgroundSurface); + for (const surfaceId of deviceWorkspaceCatalog.loadedSurfaces()) seedBackgroundSurface(surfaceId); + teardown = unsubscribe; + } + let released = false; + return () => { + if (released) return; + released = true; + installs -= 1; + if (installs === 0) { + teardown?.(); + teardown = null; + } + }; +} diff --git a/src/web-ui/src/flow_chat/services/crossDeviceSessions.test.ts b/src/web-ui/src/flow_chat/services/crossDeviceSessions.test.ts new file mode 100644 index 0000000000..fb81803a35 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/crossDeviceSessions.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const catalog = vi.hoisted(() => ({ + snapshots: {} as Record, + surfaces: [] as string[], +})); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: (surfaceId: string) => catalog.snapshots[surfaceId] ?? { workspaces: [], sessions: {} }, + loadedSurfaces: () => catalog.surfaces, + subscribe: () => () => undefined, + }, +})); + +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; +import { sessionActivityStore } from '../store/sessionActivityStore'; +import { listBackgroundDeviceSessions, listCrossDeviceSessions } from './crossDeviceSessions'; + +const summary = (sessionId: string, execution: string) => ({ sessionId, execution, pendingApprovals: 0, pendingQuestions: 0 }); + +describe('crossDeviceSessions', () => { + afterEach(() => { + catalog.snapshots = {}; + catalog.surfaces = []; + resetDeviceSurfaceForTest(); + }); + + it('prefers the host summary over observed events, which only fill gaps', () => { + sessionActivityStore.applyRead(sessionActivityStore.beginRead('peer-a'), [summary('s-1', 'running'), summary('s-2', 'running')]); + catalog.snapshots['peer-a'] = { + workspaces: [{ id: 'ws-1' }], + sessions: { 'ws-1': { sessions: [{ sessionId: 's-1' }, { sessionId: 's-2' }], activities: { 's-1': summary('s-1', 'idle') } } }, + }; + const rows = listCrossDeviceSessions('peer-a'); + expect(rows.map(row => [row.session.sessionId, row.activity?.execution])).toEqual([['s-1', 'idle'], ['s-2', 'running']]); + }); + + it('lists every loaded device except the rendered one', () => { + catalog.surfaces = ['local', 'peer-a']; + catalog.snapshots['local'] = { workspaces: [{ id: 'w' }], sessions: { w: { sessions: [{ sessionId: 'l-1' }], activities: {} } } }; + catalog.snapshots['peer-a'] = { workspaces: [{ id: 'w' }], sessions: { w: { sessions: [{ sessionId: 'p-1' }], activities: {} } } }; + activateSurface('peer-a'); + expect(listBackgroundDeviceSessions().map(row => row.session.sessionId)).toEqual(['l-1']); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/crossDeviceSessions.ts b/src/web-ui/src/flow_chat/services/crossDeviceSessions.ts new file mode 100644 index 0000000000..0ee580607f --- /dev/null +++ b/src/web-ui/src/flow_chat/services/crossDeviceSessions.ts @@ -0,0 +1,71 @@ +/** + * Sessions of devices this window is not rendering, with their activity. + * + * The rendered device has live containers. Every other device is known through + * the sidebar device catalog, whose pages carry the host's activity summaries + * and are refreshed by the host's catalog invalidations (turn started, ended, + * state changed). Desktop hosts do not fan agentic events out to controllers, + * so a catalog summary is the freshest fact about such a device; local events + * observed while a peer is rendered only fill sessions the catalog lacks. + */ + +import { getActiveSurfaceId, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import type { SessionActivitySummary, SessionMetadata } from '@/shared/types/session-history'; +import { sessionActivityStore } from '../store/sessionActivityStore'; + +export interface CrossDeviceSession { + surfaceId: DeviceSurfaceId; + workspaceId: string; + session: SessionMetadata; + activity?: SessionActivitySummary; +} + +/** Catalog summary first (refreshed from the host), then observed events. */ +export function crossDeviceActivity( + surfaceId: DeviceSurfaceId, + sessionId: string, + catalogSummary: SessionActivitySummary | undefined, +): SessionActivitySummary | undefined { + return catalogSummary ?? sessionActivityStore.get(sessionId, surfaceId)?.summary; +} + +export function listCrossDeviceSessions(surfaceId: DeviceSurfaceId): CrossDeviceSession[] { + const snapshot = deviceWorkspaceCatalog.getSnapshot(surfaceId); + const rows: CrossDeviceSession[] = []; + for (const workspace of snapshot.workspaces) { + const page = snapshot.sessions[workspace.id]; + if (!page) continue; + for (const session of page.sessions) { + rows.push({ + surfaceId, + workspaceId: workspace.id, + session, + activity: crossDeviceActivity(surfaceId, session.sessionId, page.activities[session.sessionId]), + }); + } + } + return rows; +} + +/** Every loaded device except the one the window renders. */ +export function listBackgroundDeviceSessions(): CrossDeviceSession[] { + const rendered = getActiveSurfaceId(); + return deviceWorkspaceCatalog.loadedSurfaces() + .filter(surfaceId => surfaceId !== rendered) + .flatMap(listCrossDeviceSessions); +} + +/** `surfaceId` limits notifications to one device (a device header). */ +export function subscribeCrossDeviceSessions(listener: () => void, surfaceId?: DeviceSurfaceId): () => void { + const unsubscribeCatalog = deviceWorkspaceCatalog.subscribe(changed => { + if (!surfaceId || changed === surfaceId) listener(); + }); + const unsubscribeActivity = sessionActivityStore.subscribe(changed => { + if (changed !== getActiveSurfaceId() && (!surfaceId || changed === surfaceId)) listener(); + }); + return () => { + unsubscribeCatalog(); + unsubscribeActivity(); + }; +} diff --git a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts index b117995da8..99a4debe69 100644 --- a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts +++ b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts @@ -93,6 +93,7 @@ vi.mock('../store/FlowChatStore', () => ({ sessions, activeSessionId, }), + hasUnpromotedSeed: () => false, ensurePersistedSessionMetadata: (...args: unknown[]) => mocks.ensurePersistedSessionMetadata(...args), addExternalSession: (...args: unknown[]) => mocks.addExternalSession(...args), diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts index 0de85dcd26..bc728173df 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -34,6 +34,7 @@ vi.mock('@/app/stores/sceneStore', () => ({ vi.mock('../store/FlowChatStore', () => ({ flowChatStore: { getState: () => mocks.flowChatState, + hasUnpromotedSeed: () => false, }, })); diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts index e47b522c44..6a09d48171 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -37,6 +37,12 @@ export async function activateMainSession(sessionId: string, options?: SessionAc const request = ++activationRequest; const isCurrent = () => scope.isCurrent() && request === activationRequest && (options?.isCurrent?.() ?? true); if (!isCurrent()) return false; + // A session first seen on a background device is promoted (model window, + // state machine) before anything opens it, whichever entry point is used. + if (flowChatStore.hasUnpromotedSeed(sessionId)) { + await flowChatStore.promoteSeededSessions(); + if (!isCurrent()) return false; + } const session = flowChatStore.getState().sessions.get(sessionId); const workspace = session && resolveSessionSceneWorkspace(session, workspaceManager.getState().openedWorkspaces.values()); const workspaceId = options?.workspaceId ?? workspace?.id; diff --git a/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts b/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts index 33b8bf9440..a8b38a361f 100644 --- a/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts +++ b/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts @@ -312,6 +312,10 @@ export const sessionNavStatusService = { }, getSortTimestamp: (session: Session): number => ordering.get(getActiveSurfaceId(), session.sessionId)?.sortTimestamp ?? session.lastFinishedAt ?? session.createdAt, + /** The same order key for a device the window is not rendering. */ + getSurfaceSortTimestamp: (surfaceId: DeviceSurfaceId, session: Session): number => ordering.get(surfaceId, session.sessionId)?.sortTimestamp + ?? session.lastFinishedAt ?? session.createdAt, + isSurfaceRunning: (surfaceId: DeviceSurfaceId, sessionId: string): boolean => ordering.get(surfaceId, sessionId)?.status.kind === 'running', isRunning: (sessionId: string): boolean => ordering.get(getActiveSurfaceId(), sessionId)?.status.kind === 'running', getSnapshot: (sessionId: string): SessionNavStatus => rows.get(sessionId)?.status ?? IDLE, subscribe(sessionId: string, notify: () => void): () => void { diff --git a/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts b/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts index 1e777842f6..8cf34e0bcd 100644 --- a/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionOpenIntent.test.ts @@ -110,4 +110,13 @@ describe('sessionOpenIntent', () => { expect(shouldShowHistorySessionOpenIntent(metadataOnlySession, { isRunning: true })).toBe(false); expect(shouldShowHistorySessionOpenIntent(renderableSession)).toBe(false); }); + + it('keeps a cross-device open overlay for its longer window, with its title', () => { + vi.useFakeTimers(); + dispatchHistorySessionOpenIntent('remote-1', 'Remote', 20_000); + vi.advanceTimersByTime(10_000); + expect(getHistorySessionOpenTransitionSnapshot()).toMatchObject({ sessionId: 'remote-1', sessionTitle: 'Remote', maxMs: 20_000 }); + vi.advanceTimersByTime(10_000); + expect(getHistorySessionOpenTransitionSnapshot()).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts b/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts index c3c5dceb23..9a5d7ea4b6 100644 --- a/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts +++ b/src/web-ui/src/flow_chat/services/sessionOpenIntent.ts @@ -9,18 +9,22 @@ const RECENT_HISTORY_OPEN_INTENT_MS = 750; const HISTORY_SESSION_OPEN_TRANSITION_MAX_MS = 4_000; let recentHistoryOpenIntent: { sessionId: string; atMs: number } | null = null; -let activeHistorySessionOpenTransition: { sessionId: string; atMs: number } | null = null; +let activeHistorySessionOpenTransition: HistorySessionOpenTransitionSnapshot | null = null; let activeHistorySessionOpenTransitionTimer: ReturnType | null = null; const transitionListeners = new Set<() => void>(); export interface HistorySessionOpenIntentDetail { sessionId: string; sessionTitle?: string; + /** How long the overlay may wait; a cross-device open first renders another device. */ + maxMs?: number; } export interface HistorySessionOpenTransitionSnapshot { sessionId: string; atMs: number; + sessionTitle?: string; + maxMs: number; } const nowMs = (): number => ( @@ -72,14 +76,18 @@ export function shouldShowHistorySessionOpenIntent( return session.historyState === 'ready' && session.contextRestoreState === 'pending'; } -export function dispatchHistorySessionOpenIntent(sessionId: string, sessionTitle?: string): void { +export function dispatchHistorySessionOpenIntent( + sessionId: string, + sessionTitle?: string, + maxMs = HISTORY_SESSION_OPEN_TRANSITION_MAX_MS, +): void { const atMs = nowMs(); beginHistorySessionDiagnostics(sessionId, 'history_open_intent_dispatched'); recentHistoryOpenIntent = { sessionId, atMs, }; - activeHistorySessionOpenTransition = { sessionId, atMs }; + activeHistorySessionOpenTransition = { sessionId, atMs, sessionTitle, maxMs }; clearHistorySessionOpenTransitionTimer(); activeHistorySessionOpenTransitionTimer = setTimeout(() => { if ( @@ -87,13 +95,13 @@ export function dispatchHistorySessionOpenIntent(sessionId: string, sessionTitle activeHistorySessionOpenTransition.atMs === atMs ) { recordHistorySessionDiagnosticEvent(sessionId, 'history_open_transition_expired', { - durationMs: HISTORY_SESSION_OPEN_TRANSITION_MAX_MS, + durationMs: maxMs, }); activeHistorySessionOpenTransition = null; activeHistorySessionOpenTransitionTimer = null; notifyHistorySessionOpenTransitionListeners(); } - }, HISTORY_SESSION_OPEN_TRANSITION_MAX_MS); + }, maxMs); notifyHistorySessionOpenTransitionListeners(); if (typeof window === 'undefined') { @@ -102,7 +110,7 @@ export function dispatchHistorySessionOpenIntent(sessionId: string, sessionTitle window.dispatchEvent(new CustomEvent( HISTORY_SESSION_OPEN_INTENT_EVENT, - { detail: { sessionId, sessionTitle } }, + { detail: { sessionId, sessionTitle, maxMs } }, )); } @@ -137,7 +145,7 @@ export function getHistorySessionOpenTransitionSnapshot(): HistorySessionOpenTra return null; } - if (nowMs() - transition.atMs > HISTORY_SESSION_OPEN_TRANSITION_MAX_MS) { + if (nowMs() - transition.atMs > transition.maxMs) { return null; } diff --git a/src/web-ui/src/flow_chat/services/workspaceSessionTotals.test.ts b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.test.ts new file mode 100644 index 0000000000..b0cbda96fe --- /dev/null +++ b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SESSION_VIEW_KEY, + forgetSurfaceSessionTotals, + knownWorkspaceSessionTotal, + presentedSessionTotal, + rememberWorkspaceSessionTotal, +} from './workspaceSessionTotals'; + +describe('workspaceSessionTotals', () => { + it('keeps the last settled total while a catalog reader restarts', () => { + // Reader restarted: only the 5 seeded rows are countable. + expect(presentedSessionTotal(false, 5, 8)).toBe(8); + // A settled read is authoritative, also when the total shrank. + expect(presentedSessionTotal(true, 6, 8)).toBe(6); + expect(presentedSessionTotal(false, 5, undefined)).toBe(5); + }); + + it('remembers totals per device, workspace and view', () => { + rememberWorkspaceSessionTotal('peer-a', 'w', DEFAULT_SESSION_VIEW_KEY, 8); + rememberWorkspaceSessionTotal('peer-b', 'w', DEFAULT_SESSION_VIEW_KEY, 3); + expect(knownWorkspaceSessionTotal('peer-a', 'w', DEFAULT_SESSION_VIEW_KEY)).toBe(8); + expect(knownWorkspaceSessionTotal('peer-a', 'w', 'filtered')).toBeUndefined(); + forgetSurfaceSessionTotals('peer-a'); + expect(knownWorkspaceSessionTotal('peer-a', 'w', DEFAULT_SESSION_VIEW_KEY)).toBeUndefined(); + expect(knownWorkspaceSessionTotal('peer-b', 'w', DEFAULT_SESSION_VIEW_KEY)).toBe(3); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/workspaceSessionTotals.ts b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.ts new file mode 100644 index 0000000000..8ff0309eb0 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/workspaceSessionTotals.ts @@ -0,0 +1,36 @@ +/** + * Last known top-level session total of a workspace view, per device. + * + * Navigation reloads a workspace's catalog whenever its reader restarts (a + * device switch, a refresh after a local change, a new activation epoch). + * While it reloads, only the loaded rows are countable, so the "show more" row + * and its count would vanish and come back. Both projections of a device (the + * background rows and the live list) read and write this one value, so the row + * keeps the last host-reported total until a newer one arrives. + */ + +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; + +/** The view every host total without a query describes. */ +export const DEFAULT_SESSION_VIEW_KEY = 'default'; + +const totals = new Map(); +const key = (surfaceId: DeviceSurfaceId, workspaceId: string, viewKey: string) => JSON.stringify([surfaceId, workspaceId, viewKey]); + +export function rememberWorkspaceSessionTotal(surfaceId: DeviceSurfaceId, workspaceId: string, viewKey: string, total: number): void { + totals.set(key(surfaceId, workspaceId, viewKey), total); +} + +export function knownWorkspaceSessionTotal(surfaceId: DeviceSurfaceId, workspaceId: string, viewKey: string): number | undefined { + return totals.get(key(surfaceId, workspaceId, viewKey)); +} + +export function forgetSurfaceSessionTotals(surfaceId: DeviceSurfaceId): void { + const prefix = JSON.stringify([surfaceId]).slice(0, -1) + ','; + for (const entry of Array.from(totals.keys())) if (entry.startsWith(prefix)) totals.delete(entry); +} + +/** The total to present: settled reads are authoritative, restarts keep the last one. */ +export function presentedSessionTotal(settled: boolean, liveTotal: number, remembered: number | undefined): number { + return settled ? liveTotal : Math.max(liveTotal, remembered ?? 0); +} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 21442f1407..eb9e842bdb 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -9308,3 +9308,80 @@ describe('FlowChatStore reconstructable history residency', () => { expect(flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns).toBe(liveTurns); }); }); + +describe('background device session seeding', () => { + const metadata = (sessionId: string, extra: Record = {}) => ({ + sessionId, sessionName: sessionId, agentType: 'agentic', modelName: 'm', createdAt: 10, lastActiveAt: 20, + turnCount: 1, messageCount: 1, toolCallCount: 0, status: 'active', tags: [], workspaceId: 'peer-ws', ...extra, + }) as never; + const page = (sessions: never[]) => [{ workspaceId: 'peer-ws', workspacePath: '/srv/app', sessions }]; + + afterEach(() => { + activateSurface(LOCAL_SURFACE_ID); + flowChatStore.discardSurfaceState('peer-seed'); + }); + + it('projects a background device into its own container, never the rendered one', () => { + const listener = vi.fn(); + const unsubscribe = flowChatStore.subscribeSurfaceSessions(listener); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1'), metadata('archived', { status: 'archived' })])); + flowChatStore.seedSurfaceSessions(LOCAL_SURFACE_ID, page([metadata('local-1')])); + unsubscribe(); + const seeded = flowChatStore.getSurfaceSessions('peer-seed'); + expect([...seeded.keys()]).toEqual(['s-1']); + expect(seeded.get('s-1')).toMatchObject({ workspaceId: 'peer-ws', historyState: 'metadata-only', dialogTurns: [] }); + expect(flowChatStore.getState().sessions.has('local-1')).toBe(false); + expect(listener).toHaveBeenCalledWith('peer-seed'); + // An identical page does not notify again. + listener.mockClear(); + flowChatStore.subscribeSurfaceSessions(listener); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + expect(listener).not.toHaveBeenCalled(); + }); + + it('finds the seeds after a switch and rebuilds them through the metadata path', async () => { + workspaceFixtures.set('peer-ws', { id: 'peer-ws', rootPath: '/srv/app', workspaceKind: 'normal' }); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + activateSurface('peer-seed'); + expect(flowChatStore.getState().sessions.get('s-1')?.workspaceId).toBe('peer-ws'); + await flowChatStore.promoteSeededSessions(); + expect(stateMachineManagerMock.getOrCreate).toHaveBeenCalledWith('s-1'); + expect(flowChatStore.getState().sessions.get('s-1')).toMatchObject({ workspaceId: 'peer-ws', historyState: 'metadata-only' }); + workspaceFixtures.delete('peer-ws'); + }); + + it('never replaces a seeded session once it has history', async () => { + workspaceFixtures.set('peer-ws', { id: 'peer-ws', rootPath: '/srv/app', workspaceKind: 'normal' }); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + activateSurface('peer-seed'); + flowChatStore.setState(state => { + const sessions = new Map(state.sessions); + sessions.set('s-1', { ...sessions.get('s-1')!, historyState: 'ready', dialogTurns: [{ id: 'turn' } as never] }); + return { ...state, sessions }; + }); + await flowChatStore.promoteSeededSessions(); + expect(flowChatStore.getState().sessions.get('s-1')?.dialogTurns).toHaveLength(1); + workspaceFixtures.delete('peer-ws'); + }); + + it('refreshes navigation facts of a loaded background session without dropping its state', () => { + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + activateSurface('peer-seed'); + flowChatStore.setState(state => { + const sessions = new Map(state.sessions); + sessions.set('s-1', { ...sessions.get('s-1')!, historyState: 'ready', dialogTurns: [{ id: 'turn' } as never] }); + return { ...state, sessions }; + }); + activateSurface(LOCAL_SURFACE_ID); + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1', { sessionName: 'Renamed', lastActiveAt: 99 })])); + const session = flowChatStore.getSurfaceSessions('peer-seed').get('s-1'); + expect(session).toMatchObject({ title: 'Renamed', lastActiveAt: 99 }); + expect(session?.dialogTurns).toHaveLength(1); + }); + + it('drops only seeds when the account changes', () => { + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); + flowChatStore.discardSeededSessions('peer-seed'); + expect(flowChatStore.getSurfaceSessions('peer-seed').size).toBe(0); + }); +}); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index e8a36b44fb..cea18c57dc 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -72,6 +72,7 @@ import type { SessionContextUsage, SessionActivitySummary, SessionKind, + SessionMetadata, SessionTurnCatalog, } from '@/shared/types/session-history'; import { @@ -2111,6 +2112,29 @@ interface SurfaceStateContainer { /** Sessions rebuilding from a restarted host stream until its replay is ready. */ readonly relayResyncingSessions: Set; readonly relaySessionHistory: Map; + /** + * Sessions projected from a background device's catalog while this surface + * was not rendered. They carry no history or state machine yet; the first + * metadata pass after activation rebuilds them through the normal path. + */ + readonly seededSessions: Map; +} + +interface SeededSessionRecord { + metadata: SessionMetadata; + workspaceId: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +/** One workspace page of a background device's catalog. */ +export interface BackgroundSessionPage { + workspaceId: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; + sessions: readonly SessionMetadata[]; } function createSurfaceStateContainer(surfaceId: DeviceSurfaceId): SurfaceStateContainer { @@ -2134,6 +2158,7 @@ function createSurfaceStateContainer(surfaceId: DeviceSurfaceId): SurfaceStateCo relayPendingRecordTurns: new Map(), relayResyncingSessions: new Set(), relaySessionHistory: new Map(), + seededSessions: new Map(), }; } @@ -2141,6 +2166,91 @@ const isTerminalRelayTurnStatus = (status: DialogTurn['status']): boolean => ( status === 'completed' || status === 'cancelled' || status === 'error' ); +/** + * The FlowChat projection of one persisted session record, before any history + * is loaded. Shared by metadata pages and background-device seeding so a + * session looks the same whichever path put it in a surface container. + */ +function sessionFromPersistedMetadata(metadata: any, context: { + workspaceId?: string; + projectWorkspaceId?: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; + maxContextTokens: number; +}): Session { + const { workspaceId, projectWorkspaceId, workspacePath, maxContextTokens } = context; + const relationship = deriveSessionRelationshipFromMetadata(metadata); + const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); + const titleState = deriveSessionTitleStateFromMetadata(metadata); + const hasDynamicDefaultTitle = titleState.titleSource === 'i18n'; + const remoteScope = persistedSessionRemoteScope(metadata, context.remoteConnectionId, context.remoteSshHost); + const persistedCurrentContextUsage = persistedCurrentContextUsageValue(metadata); + const restoredAgentType = resolveRestoredAgentType(metadata.agentType); + const restoredCurrentTokenUsage = isAcpAgentType(restoredAgentType) + ? undefined + : deriveRestoredCurrentTokenUsage(persistedCurrentContextUsage); + + return { + sessionId: metadata.sessionId, + workspaceId, projectWorkspaceId, + title: titleState.title, + titleSource: titleState.titleSource, + titleI18nKey: titleState.titleI18nKey, + titleI18nParams: titleState.titleI18nParams, + workspaceSessionNumber: titleState.workspaceSessionNumber, + titleStatus: hasDynamicDefaultTitle ? undefined : 'generated', + dialogTurns: [], + status: 'idle', + persistedStatus: metadata.status, + persistedTurnCount: metadata.turnCount, + importedSource: metadata.customMetadata?.importedFrom, + externalHistory: externalHistoryFromMetadata(metadata.customMetadata), + externalBranchOf: metadata.customMetadata?.externalBranchOf, + config: { + agentType: restoredAgentType, + modelName: metadata.modelName, + workspaceId, projectWorkspaceId, + workspacePath: metadata.workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, + executionTarget: metadata.executionTarget, + }, + createdAt: metadata.createdAt, + lastActiveAt: metadata.lastActiveAt, + lastFinishedAt, + error: null, + isHistorical: true, + historyState: 'metadata-only', + todos: (metadata as any).todos || [], + maxContextTokens, + currentTokenUsage: restoredCurrentTokenUsage, + mode: restoredAgentType, + lastUserDialogMode: metadata.lastUserDialogAgentType, + lastSubmittedMode: metadata.lastSubmittedAgentType, + workspacePath: (metadata as any).workspacePath || workspacePath, + projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, + remoteConnectionId: remoteScope.remoteConnectionId, + remoteSshHost: remoteScope.remoteSshHost, + workspaceHostname: metadata.workspaceHostname, + parentSessionId: relationship.parentSessionId, + sessionKind: relationship.sessionKind, + parentToolCallId: relationship.parentToolCallId, + subagentType: relationship.subagentType, + continuationPolicy: relationship.continuationPolicy, + btwThreads: [], + btwOrigin: relationship.btwOrigin, + hasUnreadCompletion: metadata.unreadCompletion, + needsUserAttention: metadata.needsUserAttention, + deepReviewRunManifest: metadata.deepReviewRunManifest, + reviewTargetEvidence: metadata.reviewTargetEvidence, + isTransient: false, + }; +} + +const EMPTY_SESSION_MAP: ReadonlyMap = new Map(); +/** Placeholder until promotion reads the device's model configuration. */ +const DEFAULT_SEEDED_CONTEXT_TOKENS = 128128; + export class FlowChatStore { private static instance: FlowChatStore; private surfaceContainers = new Map(); @@ -7751,7 +7861,8 @@ export class FlowChatStore { try { logPersistedDispatchMetadataOverlap(metadata, 'metadata-page'); scope.assertCurrent('processPersistedSessionMetadata'); - const existingSession = this.state.sessions.get(metadata.sessionId); + const seededSession = this.isRebuildableSeed(metadata.sessionId); + const existingSession = seededSession ? undefined : this.state.sessions.get(metadata.sessionId); if ((existingSession?.workspaceId || existingSession?.config.workspaceId) && !(existingSession?.sessionKind === 'subagent' && existingSession.continuationPolicy === undefined) && !existingSession?.externalHistory && !existingSession?.externalBranchOf @@ -7793,19 +7904,17 @@ export class FlowChatStore { workspacePath: metadata.projectWorkspacePath || workspacePath, remoteConnectionId, remoteSshHost, }, legacyRecords)?.id; const relationship = deriveSessionRelationshipFromMetadata(metadata); - const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); const titleState = deriveSessionTitleStateFromMetadata(metadata); - const hasDynamicDefaultTitle = titleState.titleSource === 'i18n'; const remoteScope = persistedSessionRemoteScope( metadata, remoteConnectionId, remoteSshHost, ); - const persistedCurrentContextUsage = persistedCurrentContextUsageValue(metadata); this.setState(prev => { if (!scope.isCurrent()) return prev; - const existing = prev.sessions.get(metadata.sessionId); + const existing = seededSession ? undefined : prev.sessions.get(metadata.sessionId); + this.activeSurface.seededSessions.delete(metadata.sessionId); if (existing) { const continuationPolicy = existing.continuationPolicy ?? relationship.continuationPolicy; const importedSource = metadata.customMetadata?.importedFrom ?? existing.importedSource; @@ -7836,65 +7945,9 @@ export class FlowChatStore { return { ...prev, sessions }; } - const restoredAgentType = resolveRestoredAgentType(metadata.agentType); - const restoredCurrentTokenUsage = isAcpAgentType(restoredAgentType) - ? undefined - : deriveRestoredCurrentTokenUsage(persistedCurrentContextUsage); - - const session: Session = { - sessionId: metadata.sessionId, - workspaceId, projectWorkspaceId, - title: titleState.title, - titleSource: titleState.titleSource, - titleI18nKey: titleState.titleI18nKey, - titleI18nParams: titleState.titleI18nParams, - workspaceSessionNumber: titleState.workspaceSessionNumber, - titleStatus: hasDynamicDefaultTitle ? undefined : 'generated', - dialogTurns: [], - status: 'idle', - persistedStatus: metadata.status, - persistedTurnCount: metadata.turnCount, - importedSource: metadata.customMetadata?.importedFrom, - externalHistory: externalHistoryFromMetadata(metadata.customMetadata), - externalBranchOf: metadata.customMetadata?.externalBranchOf, - config: { - agentType: restoredAgentType, - modelName: metadata.modelName, - workspaceId, projectWorkspaceId, - workspacePath: metadata.workspacePath || workspacePath, - projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, - executionTarget: metadata.executionTarget, - }, - createdAt: metadata.createdAt, - lastActiveAt: metadata.lastActiveAt, - lastFinishedAt, - error: null, - isHistorical: true, - historyState: 'metadata-only', - todos: (metadata as any).todos || [], - maxContextTokens, - currentTokenUsage: restoredCurrentTokenUsage, - mode: restoredAgentType, - lastUserDialogMode: metadata.lastUserDialogAgentType, - lastSubmittedMode: metadata.lastSubmittedAgentType, - workspacePath: (metadata as any).workspacePath || workspacePath, - projectWorkspacePath: metadata.projectWorkspacePath || workspacePath, - remoteConnectionId: remoteScope.remoteConnectionId, - remoteSshHost: remoteScope.remoteSshHost, - workspaceHostname: metadata.workspaceHostname, - parentSessionId: relationship.parentSessionId, - sessionKind: relationship.sessionKind, - parentToolCallId: relationship.parentToolCallId, - subagentType: relationship.subagentType, - continuationPolicy: relationship.continuationPolicy, - btwThreads: [], - btwOrigin: relationship.btwOrigin, - hasUnreadCompletion: metadata.unreadCompletion, - needsUserAttention: metadata.needsUserAttention, - deepReviewRunManifest: metadata.deepReviewRunManifest, - reviewTargetEvidence: metadata.reviewTargetEvidence, - isTransient: false, - }; + const session = sessionFromPersistedMetadata(metadata, { + workspaceId, projectWorkspaceId, workspacePath, remoteConnectionId, remoteSshHost, maxContextTokens, + }); const newSessions = new Map(prev.sessions); newSessions.set(metadata.sessionId, session); @@ -7926,6 +7979,153 @@ export class FlowChatStore { } } + /** Whether the rendered surface still holds an unpromoted background seed. */ + public hasUnpromotedSeed(sessionId: string): boolean { + return this.isRebuildableSeed(sessionId); + } + + /** A seed may be replaced only until the session has history or runtime state. */ + private isRebuildableSeed(sessionId: string): boolean { + const container = this.activeSurface; + if (!container.seededSessions.has(sessionId)) return false; + const session = container.state.sessions.get(sessionId); + if (session && session.dialogTurns.length === 0 && session.historyState === 'metadata-only') return true; + container.seededSessions.delete(sessionId); + return false; + } + + private surfaceSessionListeners = new Set<(surfaceId: DeviceSurfaceId) => void>(); + + /** Sessions of any surface's container, for background-device navigation. */ + public getSurfaceSessions(surfaceId: DeviceSurfaceId): ReadonlyMap { + return this.surfaceContainers.get(surfaceId)?.state.sessions ?? EMPTY_SESSION_MAP; + } + + /** Fires when a surface's sessions change: background seeding and active-state updates. */ + public subscribeSurfaceSessions(listener: (surfaceId: DeviceSurfaceId) => void): () => void { + this.surfaceSessionListeners.add(listener); + return () => { this.surfaceSessionListeners.delete(listener); }; + } + + private notifySurfaceSessions(surfaceId: DeviceSurfaceId): void { + for (const listener of Array.from(this.surfaceSessionListeners)) { + try { + listener(surfaceId); + } catch (error) { + log.warn('Surface session listener threw', { error }); + } + } + } + + /** + * Project a background device's catalog pages into that device's container, + * using the same Session projection as a metadata page. Navigation then + * draws the device the same way before and after it is rendered, and a + * switch finds its sessions already present. The rendered surface is never + * seeded: its live reads own it. + */ + public seedSurfaceSessions(surfaceId: DeviceSurfaceId, pages: readonly BackgroundSessionPage[]): void { + if (surfaceId === getActiveSurfaceId()) return; + let container = this.surfaceContainers.get(surfaceId); + if (!container) { + container = createSurfaceStateContainer(surfaceId); + this.surfaceContainers.set(surfaceId, container); + } + const sessions = new Map(container.state.sessions); + let changed = false; + for (const page of pages) { + for (const metadata of page.sessions) { + if (metadata.status === 'archived') continue; + const existing = sessions.get(metadata.sessionId); + let seeded = container.seededSessions.has(metadata.sessionId); + if (seeded && existing && (existing.dialogTurns.length > 0 || existing.historyState !== 'metadata-only')) { + // Opened since it was seeded: it is a loaded session now. + container.seededSessions.delete(metadata.sessionId); + seeded = false; + } + if (existing && !seeded) { + // A session this window loaded keeps its state; only the catalog + // facts navigation shows are refreshed. + const titleState = deriveSessionTitleStateFromMetadata(metadata); + const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); + if (existing.title === titleState.title && existing.lastActiveAt === metadata.lastActiveAt + && existing.lastFinishedAt === lastFinishedAt && existing.persistedStatus === metadata.status) continue; + sessions.set(metadata.sessionId, { + ...existing, title: titleState.title, titleSource: titleState.titleSource, + titleI18nKey: titleState.titleI18nKey, titleI18nParams: titleState.titleI18nParams, + lastActiveAt: metadata.lastActiveAt, lastFinishedAt, persistedStatus: metadata.status, + }); + changed = true; + continue; + } + const record: SeededSessionRecord = { + metadata, workspaceId: metadata.workspaceId ?? page.workspaceId, workspacePath: page.workspacePath, + remoteConnectionId: page.remoteConnectionId, remoteSshHost: page.remoteSshHost, + }; + const previous = container.seededSessions.get(metadata.sessionId); + if (previous && JSON.stringify(previous.metadata) === JSON.stringify(metadata)) continue; + container.seededSessions.set(metadata.sessionId, record); + sessions.set(metadata.sessionId, sessionFromPersistedMetadata(metadata, { + workspaceId: record.workspaceId, + projectWorkspaceId: metadata.projectWorkspaceId ?? record.workspaceId, + workspacePath: record.workspacePath, + remoteConnectionId: record.remoteConnectionId, + remoteSshHost: record.remoteSshHost, + maxContextTokens: DEFAULT_SEEDED_CONTEXT_TOKENS, + })); + changed = true; + } + } + if (!changed) return; + container.state = { ...container.state, sessions }; + this.notifySurfaceSessions(surfaceId); + } + + /** + * Rebuild the rendered surface's seeded sessions through the normal + * metadata path (model context window, state machines) before they are + * opened. Untouched seeds only; a session that already has history stays. + */ + public async promoteSeededSessions(): Promise { + const container = this.activeSurface; + const byWorkspace = new Map(); + for (const [sessionId, record] of container.seededSessions) { + if (!this.isRebuildableSeed(sessionId)) continue; + const group = byWorkspace.get(record.workspaceId) ?? []; + group.push(record); + byWorkspace.set(record.workspaceId, group); + } + for (const records of byWorkspace.values()) { + const [first] = records; + await this.processPersistedSessionMetadataList( + records.map(record => record.metadata), first.workspacePath, first.remoteConnectionId, first.remoteSshHost, + ); + } + } + + /** Drop seeds of a surface (account change); loaded sessions are untouched. */ + public discardSeededSessions(surfaceId: DeviceSurfaceId): void { + const container = this.surfaceContainers.get(surfaceId); + if (!container || container.seededSessions.size === 0 || surfaceId === getActiveSurfaceId()) return; + const sessions = new Map(container.state.sessions); + for (const sessionId of container.seededSessions.keys()) sessions.delete(sessionId); + container.seededSessions.clear(); + container.state = { ...container.state, sessions }; + this.notifySurfaceSessions(surfaceId); + } + + /** + * Adopt metadata the rendered device's host already returned (for example a + * device catalog page read just before this device was rendered), so a + * cross-device open needs no second round trip. Records are host-authored; + * they go through the same projection as a metadata page. + */ + public async adoptHostSessionMetadata(workspaceId: string, sessions: readonly SessionMetadata[]): Promise { + if (sessions.length === 0) return; + const { workspacePath, remoteConnectionId, remoteSshHost } = this.workspaceForId(workspaceId); + await this.processPersistedSessionMetadataList([...sessions], workspacePath, remoteConnectionId, remoteSshHost); + } + /** * Opt-in archived projection for navigation views. Normal session hydration * keeps archived records out of the working set until this is requested. diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts index 375014b7bd..e4187d79cb 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts @@ -427,18 +427,22 @@ export class PeerDeviceSurfaceController { // catalog is authoritative. this.dependencies.setPeerDeviceModeActiveFlag(true); - await this.awaitIntent( - intent, - this.dependencies.reloadConfig(activationScope), - activationScope, - 'reload device surface config', - ); - await this.awaitIntent( - intent, - this.dependencies.rebootstrapWorkspaces(activationScope), - activationScope, - 'rebootstrap device surface workspaces', - ); + // Config and the workspace catalog are independent reads of the same host; + // fetching them together halves the round trips a switch waits for. + await Promise.all([ + this.awaitIntent( + intent, + this.dependencies.reloadConfig(activationScope), + activationScope, + 'reload device surface config', + ), + this.awaitIntent( + intent, + this.dependencies.rebootstrapWorkspaces(activationScope), + activationScope, + 'rebootstrap device surface workspaces', + ), + ]); if (target.deviceId === null) { await this.awaitIntent( diff --git a/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts b/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts new file mode 100644 index 0000000000..8158da2f55 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts @@ -0,0 +1,92 @@ +/** + * Read-only product reads on a device the window is not rendering. + * + * The product transport is bound to the rendered surface: a peer adapter that + * is not rendered refuses requests, and `api.invoke` targets whatever device is + * rendered. The sidebar device catalog must read every other device without + * changing what the window draws, so it talks to each host directly: + * + * - a peer through a raw `host_invoke` device RPC (no control attachment, no + * event fan-out; the host's Product Operation Registry still decides what + * it may run); + * - this machine through its own Tauri transport. + * + * Only the catalog reads below may use this path. Mutations always go through + * the rendered surface after activation so they keep Peer Device Mode's + * capability gates, permission mailboxes and surface fences. + */ + +import { TauriTransportAdapter } from '@/infrastructure/api/adapters/tauri-adapter'; +import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { + translateAgentIdentityCommand, + translateAgentIdentityResponse, +} from '../../../../shared/agent-harness/wire'; + +export const DEVICE_CATALOG_READ_COMMANDS = new Set([ + 'peer_mode_ping', + 'get_opened_workspaces', + 'list_persisted_sessions_page', +]); + +const DEVICE_CATALOG_RPC_TIMEOUT_MS = 15_000; + +interface HostInvokeEnvelope { + resp?: string; + ok?: boolean; + value?: unknown; + error?: string; + message?: string; +} + +/** A host answered and refused the command; the link itself is healthy. */ +export class DeviceCatalogCommandError extends Error { + constructor(message: string, readonly command: string) { + super(message); + this.name = 'DeviceCatalogCommandError'; + } +} + +let localTransport: TauriTransportAdapter | null = null; + +/** Never disposed with a surface: background reads outlive every switch. */ +export function deviceCatalogLocalTransport(): TauriTransportAdapter { + localTransport ??= new TauriTransportAdapter(); + return localTransport; +} + +/** + * Run one catalog read on `deviceId`, or on this machine when it is `null`. + */ +export async function invokeOnDevice( + deviceId: string | null, + command: string, + args: Record = {}, +): Promise { + if (!DEVICE_CATALOG_READ_COMMANDS.has(command)) { + throw new Error(`'${command}' is not a device catalog read`); + } + if (deviceId === null) { + return deviceCatalogLocalTransport().request(command, args); + } + const raw = await remoteConnectAPI.accountDeviceRpc( + deviceId, + JSON.stringify({ + cmd: 'host_invoke', + command, + args: translateAgentIdentityCommand(command, args, 'legacy'), + }), + DEVICE_CATALOG_RPC_TIMEOUT_MS, + ); + const envelope = JSON.parse(raw) as HostInvokeEnvelope; + if (envelope.resp === 'error') { + throw new Error(envelope.message || `Device '${command}' failed`); + } + if (envelope.resp !== 'host_invoke_result') { + throw new Error(`Unexpected device RPC response for '${command}': ${envelope.resp || 'unknown'}`); + } + if (!envelope.ok) { + throw new DeviceCatalogCommandError(envelope.error || `Device '${command}' failed`, command); + } + return translateAgentIdentityResponse(command, envelope.value, args, 'canonical') as T; +} diff --git a/src/web-ui/src/infrastructure/peer-device/deviceOpenIntent.ts b/src/web-ui/src/infrastructure/peer-device/deviceOpenIntent.ts new file mode 100644 index 0000000000..8cccb58fe5 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceOpenIntent.ts @@ -0,0 +1,38 @@ +import { create } from 'zustand'; +import type { DeviceSurfaceId } from './deviceSurface'; + +/** + * The cross-device open the user just asked for. Set on click, before the + * device switch starts, and cleared when the open settles. + * + * Several owners read it: the clicked row highlights from it, navigation keeps + * drawing background projections until it ends (one hand-over, after the + * open), the device catalog defers its refreshes so they never compete with + * the open for the relay link, and the switch lands on the session scene + * instead of an empty workbench. + */ +export interface DeviceOpenIntent { + surfaceId: DeviceSurfaceId; + /** `session:`, `assistant:` or `new:`. */ + key: string; + /** Present when the open ends in a known session. */ + sessionId?: string; + title?: string; + startedAt: number; +} + +export const useDeviceOpenIntent = create<{ intent: DeviceOpenIntent | null }>(() => ({ intent: null })); + +export function beginDeviceOpen(intent: Omit): DeviceOpenIntent { + const next = { ...intent, startedAt: typeof performance !== 'undefined' ? performance.now() : Date.now() }; + useDeviceOpenIntent.setState({ intent: next }); + return next; +} + +export function endDeviceOpen(intent: DeviceOpenIntent): void { + if (useDeviceOpenIntent.getState().intent === intent) useDeviceOpenIntent.setState({ intent: null }); +} + +export function getDeviceOpenIntent(): DeviceOpenIntent | null { + return useDeviceOpenIntent.getState().intent; +} diff --git a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts index fb53133ee3..83356fb732 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceSurfaceRouting.ts @@ -47,6 +47,8 @@ const SURFACE_SCOPED_EVENTS = new Set([ 'ai://model-catalog-updated', 'session_title_generated', 'workspace-catalog-changed', + // Catalog stream hint of one host; the sidebar device catalog observes it. + 'host-catalog-changed', 'cron://jobs-changed', ]); diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts new file mode 100644 index 0000000000..4fa1ec5c23 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts @@ -0,0 +1,316 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); +vi.mock('./deviceCatalogTransport', () => ({ invokeOnDevice: vi.fn(), deviceCatalogLocalTransport: vi.fn() })); +vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: {} })); + +import { + DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS, + DEVICE_CATALOG_RELEASE_GRACE_MS, + DeviceWorkspaceCatalog, + type DeviceWorkspaceCatalogIO, +} from './deviceWorkspaceCatalog'; +import { routeSurfaceEvent, setActiveSurfaceDeviceId } from './deviceSurfaceRouting'; +import { activateSurface, resetDeviceSurfaceForTest, surfaceIdForDevice } from './deviceSurface'; +import { beginDeviceOpen, endDeviceOpen } from './deviceOpenIntent'; + +const workspace = (id: string, extra: Record = {}) => ({ + id, name: id, rootPath: `/home/me/${id}`, workspaceType: 'other', workspaceKind: 'normal', + languages: [], openedAt: '', lastAccessed: '', tags: [], ...extra, +}); + +const session = (sessionId: string, extra: Record = {}) => ({ + sessionId, sessionName: sessionId, agentType: 'agentic', modelName: 'm', createdAt: 1, lastActiveAt: 1, + turnCount: 1, messageCount: 1, toolCallCount: 0, status: 'active', tags: [], ...extra, +}); + +async function flush(): Promise { + for (let i = 0; i < 10; i += 1) await Promise.resolve(); +} + +describe('DeviceWorkspaceCatalog', () => { + let invoke: ReturnType; + let io: DeviceWorkspaceCatalogIO; + let catalog: DeviceWorkspaceCatalog; + let capabilities: Record; + let workspaces: unknown[]; + + beforeEach(() => { + vi.useFakeTimers(); + resetDeviceSurfaceForTest(); + capabilities = { workspace_id_references_v1: true }; + workspaces = [workspace('ws-1'), workspace('assistant-1', { workspaceKind: 'assistant' })]; + invoke = vi.fn(async (_device: string | null, command: string) => { + if (command === 'peer_mode_ping') return { capabilities }; + if (command === 'get_opened_workspaces') return workspaces; + if (command === 'list_persisted_sessions_page') { + return { + sessions: [ + session('s-1'), + session('child', { relationship: { parentSessionId: 's-1' } }), + session('sub', { sessionKind: 'subagent' }), + session('archived', { status: 'archived' }), + ], + totalTopLevelCount: 1, loadedTopLevelCount: 1, hasMore: false, + activities: [{ sessionId: 's-1', execution: 'running', pendingApprovals: 0, pendingQuestions: 0 }], + }; + } + throw new Error(`unexpected ${command}`); + }); + io = { + invoke: invoke as DeviceWorkspaceCatalogIO['invoke'], + subscribeCatalog: vi.fn(async () => 'sub-1'), + unsubscribeCatalog: vi.fn(async () => undefined), + listen: vi.fn(() => () => undefined), + }; + catalog = new DeviceWorkspaceCatalog(io); + }); + + afterEach(() => { + catalog.dispose(); + setActiveSurfaceDeviceId(null); + resetDeviceSurfaceForTest(); + vi.useRealTimers(); + }); + + it('reads nothing until a section retains the device', async () => { + expect(catalog.getSnapshot('peer-a').status).toBe('idle'); + expect(invoke).not.toHaveBeenCalled(); + const release = catalog.retain('peer-a'); + await flush(); + expect(invoke.mock.calls.map(call => call[1])).toEqual(['peer_mode_ping', 'get_opened_workspaces']); + expect(invoke.mock.calls.every(call => call[0] === 'peer-a')).toBe(true); + expect(catalog.getSnapshot('peer-a').workspaces.map(item => item.id)).toEqual(['ws-1', 'assistant-1']); + expect(io.subscribeCatalog).toHaveBeenCalledWith('peer-a'); + expect(catalog.getSnapshot('peer-a').live).toBe(true); + release(); + }); + + it('loads sessions only for an expanded workspace and keeps top-level rows', async () => { + catalog.retain('peer-a'); + await flush(); + expect(invoke).not.toHaveBeenCalledWith('peer-a', 'list_persisted_sessions_page', expect.anything()); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'list_persisted_sessions_page', { + request: { workspace_id: 'ws-1', limit: 8 }, + }); + const sessions = catalog.getSessions('peer-a', 'ws-1'); + expect(sessions.sessions.map(item => item.sessionId)).toEqual(['s-1']); + expect(sessions.activities['s-1']?.execution).toBe('running'); + }); + + it('addresses a pre-ID host by its own workspace root', async () => { + capabilities = {}; + workspaces = [workspace('ws-remote', { workspaceKind: 'remote', connectionId: 'conn-1', sshHost: 'box' })]; + catalog.retain('peer-old'); + await flush(); + catalog.retainSessions('peer-old', 'ws-remote'); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-old', 'list_persisted_sessions_page', { + request: { workspace_path: '/home/me/ws-remote', remote_connection_id: 'conn-1', remote_ssh_host: 'box', limit: 8 }, + }); + }); + + it('reads this machine through the local transport without a stream', async () => { + catalog.retain('local'); + await flush(); + expect(invoke.mock.calls.map(call => [call[0], call[1]])).toEqual([[null, 'get_opened_workspaces']]); + expect(io.subscribeCatalog).not.toHaveBeenCalled(); + }); + + it('coalesces a burst of host hints into one debounced read', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + for (let i = 0; i < 5; i += 1) { + routeSurfaceEvent('host-catalog-changed', { sessionsRevision: i, __openbitfunSourceDeviceId: 'peer-a' }); + } + expect(invoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'get_opened_workspaces')).toHaveLength(1); + }); + + it('ignores hints from the rendered device and from devices nobody shows', async () => { + catalog.retain('peer-a'); + await flush(); + activateSurface(surfaceIdForDevice('peer-a')); + setActiveSurfaceDeviceId('peer-a'); + // An activation refreshes every catalog once; hints after that are the subject. + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + invoke.mockClear(); + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-b' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS * 2); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('keeps the last catalog on failure and recovers on retry', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockImplementation(async () => { throw new Error('host unreachable'); }); + await catalog.retry('peer-a'); + expect(catalog.getSnapshot('peer-a').workspaces).toHaveLength(2); + expect(catalog.getSnapshot('peer-a').error).toBe('host unreachable'); + invoke.mockImplementation(async (_device: string | null, command: string) => ( + command === 'get_opened_workspaces' ? [workspace('ws-2')] : { capabilities } + )); + await catalog.retry('peer-a'); + expect(catalog.getSnapshot('peer-a').error).toBeUndefined(); + expect(catalog.getSnapshot('peer-a').workspaces.map(item => item.id)).toEqual(['ws-2']); + }); + + it('reports an error state when the first read fails', async () => { + invoke.mockRejectedValue(new Error('offline')); + catalog.retain('peer-a'); + await flush(); + expect(catalog.getSnapshot('peer-a').status).toBe('error'); + }); + + it('stops the host stream only after the release grace period', async () => { + const release = catalog.retain('peer-a'); + await flush(); + release(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_RELEASE_GRACE_MS - 1); + expect(io.unsubscribeCatalog).not.toHaveBeenCalled(); + const again = catalog.retain('peer-a'); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_RELEASE_GRACE_MS); + expect(io.unsubscribeCatalog).not.toHaveBeenCalled(); + again(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_RELEASE_GRACE_MS); + expect(io.unsubscribeCatalog).toHaveBeenCalledWith('sub-1'); + }); + + it('falls back to visibility refresh for a host without a catalog stream', async () => { + io.subscribeCatalog = vi.fn(async () => { throw new Error('host_stream_v1 unsupported'); }); + catalog.retain('peer-a'); + await flush(); + expect(catalog.getSnapshot('peer-a').live).toBe(false); + invoke.mockClear(); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + }); + + it('re-reads open sections under a new account', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + catalog.reset(); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'peer_mode_ping', {}); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + }); + + it('keeps every workspace current for device status without expanding it', async () => { + workspaces = [workspace('ws-1'), workspace('ws-2'), workspace('ws-3')]; + const release = catalog.retainActivity('peer-a'); + await flush(); + const sessionReads = invoke.mock.calls.filter(call => call[1] === 'list_persisted_sessions_page'); + expect(sessionReads.map(call => call[2].request.workspace_id).sort()).toEqual(['ws-1', 'ws-2', 'ws-3']); + expect(catalog.loadedSurfaces()).toEqual(['peer-a']); + + // An expanded row reuses the page the activity refresh keeps current. + invoke.mockClear(); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + expect(invoke).not.toHaveBeenCalled(); + + // A host hint refreshes all of them once, after the debounce. + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'list_persisted_sessions_page')).toHaveLength(3); + release(); + }); + + it('refreshes catalogs once a device switch lands', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + activateSurface(surfaceIdForDevice('peer-b')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + }); + + it('holds refreshes while a cross-device open runs and catches up once after it', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + const intent = beginDeviceOpen({ surfaceId: 'peer-b', key: 'session:s' }); + activateSurface(surfaceIdForDevice('peer-b')); + routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS * 4); + expect(invoke).not.toHaveBeenCalled(); + endDeviceOpen(intent); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'get_opened_workspaces')).toHaveLength(1); + }); + + it('refreshes only session pages when the hint says the workspace list is unchanged', async () => { + catalog.retainActivity('peer-a'); + await flush(); + const hint = (workspacesRevision: number) => routeSurfaceEvent('host-catalog-changed', + { sessionsRevision: Math.random(), workspacesRevision, __openbitfunSourceDeviceId: 'peer-a' }); + hint(1); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + invoke.mockClear(); + hint(1); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.map(call => call[1])).not.toContain('get_opened_workspaces'); + expect(invoke.mock.calls.map(call => call[1])).toContain('list_persisted_sessions_page'); + invoke.mockClear(); + hint(2); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.map(call => call[1])).toContain('get_opened_workspaces'); + }); + + it('reads nothing while the window is hidden and catches up once when shown', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + for (let i = 0; i < 3; i += 1) routeSurfaceEvent('host-catalog-changed', { __openbitfunSourceDeviceId: 'peer-a' }); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS * 3); + expect(invoke).not.toHaveBeenCalled(); + visibility.mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke.mock.calls.filter(call => call[1] === 'get_opened_workspaces')).toHaveLength(1); + visibility.mockRestore(); + }); + + it('does not re-read the device that just became rendered', async () => { + catalog.retain('peer-a'); + await flush(); + invoke.mockClear(); + activateSurface(surfaceIdForDevice('peer-a')); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('coalesces the updates of a read into fewer notifications', async () => { + workspaces = [workspace('ws-1'), workspace('ws-2'), workspace('ws-3')]; + const listener = vi.fn(); + catalog.subscribe(listener); + catalog.retainActivity('peer-a'); + await flush(); + await flush(); + // Unbatched, a read of three workspaces publishes 9 updates (status, list, + // and loading/ready per page); updates within one task share a notification. + expect(listener.mock.calls.length).toBeLessThan(9); + }); +}); diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts new file mode 100644 index 0000000000..d909278159 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts @@ -0,0 +1,658 @@ +/** + * Workspace and session catalog of devices the window is not rendering. + * + * The sidebar lists every online host of the account in place. The rendered + * device keeps its live workspace container; every other device is described + * by this read-only catalog, which is loaded lazily and kept current by host + * invalidations: + * + * - Nothing is read until a section is expanded (`retain`). Sessions of a + * workspace are read only while that workspace is expanded. + * - A peer's `@host/catalog` stream sends revision hints (no data). Local + * catalog and lifecycle events cover this machine and attached peers. + * Hints are debounced, then coalesced into one in-flight read per device. + * - A host that cannot stream (older build) is re-read when the window becomes + * visible again; there is no polling. + * - A failed read keeps the last rendered catalog and exposes the error for + * retry. An offline device is never substituted by a cache of another host. + * + * Opening anything from this catalog activates the owning surface first, so + * every operation keeps Peer Device Mode's ownership and capability rules. + */ + +import { upgradeLegacyWorktreeReferences } from '@/infrastructure/api/service-api/legacyWorkspaceCompatibility'; +import type { WorkspaceInfo as APIWorkspaceInfo } from '@/infrastructure/api/service-api/GlobalAPI'; +import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; +import { remoteConnectAPI } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { mapWorkspaceInfo, type WorkspaceInfo } from '@/shared/types/global-state'; +import type { SessionActivitySummary, SessionMetadata } from '@/shared/types/session-history'; +import { createLogger } from '@/shared/utils/logger'; +import { InvalidationSync } from '../../../../shared/relay-transport/InvalidationSync'; +import { HOST_CATALOG_ID } from '../../../../shared/relay-transport/HostStream'; +import { + getActiveSurfaceId, + isLocalSurface, + onSurfaceActivated, + surfaceIdForDevice, + type DeviceSurfaceId, +} from './deviceSurface'; +import { observeSurfaceEvents } from './deviceSurfaceRouting'; +import { deviceCatalogLocalTransport, invokeOnDevice } from './deviceCatalogTransport'; +import { stableWorkspaceOrder } from '@/infrastructure/services/business/workspaceDisplayOrder'; +import { getDeviceOpenIntent, useDeviceOpenIntent } from './deviceOpenIntent'; + +const log = createLogger('DeviceWorkspaceCatalog'); + +// The live list's first rows (3) plus one "show more" step (5): the same rows +// draw at once, and the first expansion needs no read. +export const DEVICE_CATALOG_SESSION_PAGE = 8; +export const DEVICE_CATALOG_SESSION_STEP = 20; +const MAX_REFRESHED_SESSIONS = 200; +const MAX_PAGES_PER_READ = 5; +/** Session reads one device runs at once; a refresh never floods its relay link. */ +const SESSION_READ_CONCURRENCY = 2; +export const DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS = 300; +export const DEVICE_CATALOG_RELEASE_GRACE_MS = 30_000; + +/** + * Events that can change a host's workspace list, session list or ordering. + * `host-catalog-changed` is the peer catalog stream hint; the rest are the + * lifecycle events this machine (and any attached peer) already fans out. + */ +const CATALOG_INVALIDATING_EVENTS = new Set([ + 'host-catalog-changed', + 'workspace-catalog-changed', + 'agentic://session-created', + 'agentic://session-deleted', + 'agentic://dialog-turn-started', + 'agentic://dialog-turn-completed', + 'session_title_generated', +]); + +export type DeviceCatalogStatus = 'idle' | 'loading' | 'ready' | 'error'; + +export interface DeviceCatalogSessions { + readonly status: DeviceCatalogStatus; + /** Top-level sessions in host order, children excluded. */ + readonly sessions: readonly SessionMetadata[]; + /** Every non-archived record of the read, children included (navigation projection). */ + readonly records: readonly SessionMetadata[]; + readonly activities: Readonly>; + readonly hasMore: boolean; + /** Top-level count when the host reported a complete count. */ + readonly total: number | null; + readonly limit: number; + readonly error?: string; +} + +export interface DeviceCatalogSnapshot { + readonly status: DeviceCatalogStatus; + readonly workspaces: readonly WorkspaceInfo[]; + readonly error?: string; + /** The host streams catalog hints; false means it refreshes on visibility. */ + readonly live: boolean; + readonly sessions: Readonly>; +} + +const EMPTY_SNAPSHOT: DeviceCatalogSnapshot = Object.freeze({ + status: 'idle', + workspaces: [], + live: false, + sessions: {}, +}); + +const EMPTY_SESSIONS: DeviceCatalogSessions = Object.freeze({ + status: 'idle', + sessions: [], + records: [], + activities: {}, + hasMore: false, + total: null, + limit: DEVICE_CATALOG_SESSION_PAGE, +}); + +interface Entry { + readonly surfaceId: DeviceSurfaceId; + readonly deviceId: string | null; + readonly generation: number; + snapshot: DeviceCatalogSnapshot; + retainCount: number; + releaseTimer: ReturnType | null; + debounceTimer: ReturnType | null; + readonly workspaceRetain: Map; + /** Keep every workspace's first page current (device status, companion). */ + activityRetain: number; + readonly sync: InvalidationSync; + /** `workspace_id_references_v1`; null until the host answered a ping. */ + workspaceIdReferences: boolean | null; + streamSubscriptionId: string | null; + streamPending: boolean; + stopped: boolean; + /** Whether the next read must re-read the workspace list or only session pages. */ + pendingScope: RefreshScope; + /** Last `workspacesRevision` a host hint carried. */ + workspacesRevision: unknown; +} + +/** `sessions`: workspace list unchanged, refresh session pages only. */ +type RefreshScope = 'sessions' | 'all'; +const mergeScope = (a: RefreshScope | undefined, b: RefreshScope): RefreshScope => (a === 'all' || b === 'all' ? 'all' : 'sessions'); + +type Listener = (surfaceId: DeviceSurfaceId) => void; + +export interface DeviceWorkspaceCatalogIO { + invoke: (deviceId: string | null, command: string, args: Record) => Promise; + subscribeCatalog: (deviceId: string) => Promise; + unsubscribeCatalog: (subscriptionId: string) => Promise; + /** Registers the native listeners that feed `observeSurfaceEvents`. */ + listen: (event: string, callback: (payload: unknown) => void) => () => void; +} + +const defaultIO: DeviceWorkspaceCatalogIO = { + invoke: invokeOnDevice, + subscribeCatalog: deviceId => remoteConnectAPI.subscribeSession(deviceId, HOST_CATALOG_ID), + unsubscribeCatalog: subscriptionId => remoteConnectAPI.unsubscribeSession(subscriptionId), + listen: (event, callback) => deviceCatalogLocalTransport().listen(event, callback), +}; + +function isTopLevel(session: SessionMetadata): boolean { + return session.sessionKind !== 'subagent' && !session.relationship?.parentSessionId; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export class DeviceWorkspaceCatalog { + private readonly entries = new Map(); + private readonly listeners = new Set(); + private generation = 0; + private teardownEvents: (() => void) | null = null; + private readonly deferred = new Map(); + private readonly notifyQueue = new Set(); + + constructor(private readonly io: DeviceWorkspaceCatalogIO = defaultIO) {} + + getSnapshot(surfaceId: DeviceSurfaceId): DeviceCatalogSnapshot { + return this.entries.get(surfaceId)?.snapshot ?? EMPTY_SNAPSHOT; + } + + getSessions(surfaceId: DeviceSurfaceId, workspaceId: string): DeviceCatalogSessions { + return this.getSnapshot(surfaceId).sessions[workspaceId] ?? EMPTY_SESSIONS; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + /** + * Keep a device catalog loaded while a section shows it. The first retain + * reads the host; releasing keeps the last catalog for an instant reopen and + * stops the host stream after a grace period. + */ + retain(surfaceId: DeviceSurfaceId): () => void { + this.installEvents(); + const entry = this.entryFor(surfaceId); + entry.retainCount += 1; + if (entry.releaseTimer !== null) { + clearTimeout(entry.releaseTimer); + entry.releaseTimer = null; + } + if (entry.retainCount === 1) { + this.startStream(entry); + void this.invalidateNow(entry); + } + let released = false; + return () => { + if (released) return; + released = true; + // Look the entry up again: an account reset may have replaced it. + const current = this.entries.get(surfaceId); + if (current) this.release(current); + }; + } + + /** + * Keep the latest sessions of every workspace on a device current, so its + * running, waiting and unread sessions are known without expanding anything. + * Implies `retain`. Reads stay invalidation-driven and debounced. + */ + retainActivity(surfaceId: DeviceSurfaceId): () => void { + const releaseCatalog = this.retain(surfaceId); + const entry = this.entryFor(surfaceId); + entry.activityRetain += 1; + if (entry.activityRetain === 1 && entry.snapshot.status === 'ready') { + void this.loadSessionPages(entry, entry.snapshot.workspaces.map(workspace => workspace.id), true); + } + let released = false; + return () => { + if (released) return; + released = true; + const current = this.entries.get(surfaceId); + if (current) current.activityRetain = Math.max(0, current.activityRetain - 1); + releaseCatalog(); + }; + } + + /** Surfaces whose catalog is loaded, for cross-device consumers. */ + loadedSurfaces(): DeviceSurfaceId[] { + return Array.from(this.entries.values()) + .filter(entry => entry.retainCount > 0 && entry.snapshot.status !== 'idle') + .map(entry => entry.surfaceId); + } + + /** Keep one workspace's sessions loaded while it is expanded. */ + retainSessions(surfaceId: DeviceSurfaceId, workspaceId: string): () => void { + const entry = this.entryFor(surfaceId); + const count = entry.workspaceRetain.get(workspaceId) ?? 0; + entry.workspaceRetain.set(workspaceId, count + 1); + const current = entry.snapshot.sessions[workspaceId]; + // A page the activity refresh already keeps current is not read again. + if (count === 0 && (!current || current.status === 'idle' || current.status === 'error')) { + void this.loadSessions(entry, workspaceId, current?.limit ?? DEVICE_CATALOG_SESSION_PAGE); + } + let released = false; + return () => { + if (released) return; + released = true; + const current = this.entries.get(surfaceId); + if (!current) return; + const remaining = (current.workspaceRetain.get(workspaceId) ?? 1) - 1; + if (remaining <= 0) current.workspaceRetain.delete(workspaceId); + else current.workspaceRetain.set(workspaceId, remaining); + }; + } + + /** Read at least `limit` top-level sessions (default: one more step). */ + loadMoreSessions(surfaceId: DeviceSurfaceId, workspaceId: string, limit?: number): Promise { + const entry = this.entries.get(surfaceId); + if (!entry) return Promise.resolve(); + const current = entry.snapshot.sessions[workspaceId] ?? EMPTY_SESSIONS; + const target = Math.min(limit ?? current.limit + DEVICE_CATALOG_SESSION_STEP, MAX_REFRESHED_SESSIONS); + if (target <= current.limit && current.status !== 'error') return Promise.resolve(); + return this.loadSessions(entry, workspaceId, target); + } + + /** Explicit retry after a visible failure; bypasses the debounce. */ + retry(surfaceId: DeviceSurfaceId): Promise { + const entry = this.entries.get(surfaceId); + if (!entry) return Promise.resolve(); + if (!entry.snapshot.live) this.startStream(entry); + entry.pendingScope = 'all'; + return this.invalidateNow(entry); + } + + /** Coalesce a burst of host hints into one read. */ + invalidate(surfaceId: DeviceSurfaceId, scope: RefreshScope = 'all'): void { + const entry = this.entries.get(surfaceId); + if (!entry || entry.retainCount === 0 || entry.stopped) return; + // A cross-device open owns the relay link until it settles, and a hidden + // window shows nothing: navigation keeps what it has and catches up once, + // right after. + if (getDeviceOpenIntent() || (typeof document !== 'undefined' && document.visibilityState === 'hidden')) { + this.deferred.set(surfaceId, mergeScope(this.deferred.get(surfaceId), scope)); + return; + } + entry.pendingScope = mergeScope(entry.debounceTimer !== null ? entry.pendingScope : undefined, scope); + if (entry.debounceTimer !== null) clearTimeout(entry.debounceTimer); + entry.debounceTimer = setTimeout(() => { + entry.debounceTimer = null; + void this.invalidateNow(entry); + }, DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + } + + /** + * Account change: no catalog survives another identity. Sections that are + * still open read their device again under the new account. + */ + reset(): void { + this.generation += 1; + const previous = Array.from(this.entries.values()); + this.entries.clear(); + for (const old of previous) { + this.stop(old); + if (old.retainCount > 0) { + const entry = this.entryFor(old.surfaceId); + entry.retainCount = old.retainCount; + entry.activityRetain = old.activityRetain; + for (const [workspaceId, count] of old.workspaceRetain) entry.workspaceRetain.set(workspaceId, count); + this.startStream(entry); + void this.invalidateNow(entry); + } + this.notify(old.surfaceId); + } + } + + private entryFor(surfaceId: DeviceSurfaceId): Entry { + const existing = this.entries.get(surfaceId); + if (existing) return existing; + const entry: Entry = { + surfaceId, + deviceId: isLocalSurface(surfaceId) ? null : surfaceId, + generation: this.generation, + snapshot: EMPTY_SNAPSHOT, + retainCount: 0, + releaseTimer: null, + debounceTimer: null, + workspaceRetain: new Map(), + activityRetain: 0, + sync: new InvalidationSync(() => this.read(entry)), + workspaceIdReferences: isLocalSurface(surfaceId) ? true : null, + streamSubscriptionId: null, + streamPending: false, + stopped: false, + pendingScope: 'all', + workspacesRevision: undefined, + }; + this.entries.set(surfaceId, entry); + return entry; + } + + private release(entry: Entry): void { + entry.retainCount = Math.max(0, entry.retainCount - 1); + if (entry.retainCount > 0 || entry.stopped) return; + if (entry.debounceTimer !== null) { + clearTimeout(entry.debounceTimer); + entry.debounceTimer = null; + } + entry.releaseTimer = setTimeout(() => { + entry.releaseTimer = null; + if (entry.retainCount === 0) this.stopStream(entry); + }, DEVICE_CATALOG_RELEASE_GRACE_MS); + } + + private isCurrent(entry: Entry): boolean { + return !entry.stopped && entry.generation === this.generation && this.entries.get(entry.surfaceId) === entry; + } + + private update(entry: Entry, patch: Partial): void { + if (!this.isCurrent(entry)) return; + entry.snapshot = { ...entry.snapshot, ...patch }; + this.notify(entry.surfaceId); + } + + private updateSessions(entry: Entry, workspaceId: string, patch: Partial): void { + if (!this.isCurrent(entry)) return; + const current = entry.snapshot.sessions[workspaceId] ?? EMPTY_SESSIONS; + this.update(entry, { sessions: { ...entry.snapshot.sessions, [workspaceId]: { ...current, ...patch } } }); + } + + /** + * One notification per surface per task: a read updates status, workspaces + * and every session page in quick succession, and subscribers (rows, device + * status, companion, seeding) only need the settled result. + */ + private notify(surfaceId: DeviceSurfaceId): void { + const first = this.notifyQueue.size === 0; + this.notifyQueue.add(surfaceId); + if (!first) return; + queueMicrotask(() => { + const surfaces = Array.from(this.notifyQueue); + this.notifyQueue.clear(); + for (const changed of surfaces) { + for (const listener of Array.from(this.listeners)) { + try { + listener(changed); + } catch (error) { + log.warn('Device catalog listener threw', error); + } + } + } + }); + } + + private invalidateNow(entry: Entry): Promise { + if (!this.isCurrent(entry)) return Promise.resolve(); + return entry.sync.invalidate(); + } + + private async read(entry: Entry): Promise { + if (!this.isCurrent(entry)) return; + const scope = entry.pendingScope; + entry.pendingScope = 'sessions'; + // A session-only change (turn started/ended, title) leaves the workspace + // list as it is: refresh the session pages without re-reading it. + if (scope === 'sessions' && entry.snapshot.status === 'ready' && !entry.snapshot.error) { + const workspaces = entry.snapshot.workspaces.map(workspace => workspace.id); + const targets = entry.activityRetain > 0 ? workspaces + : Array.from(entry.workspaceRetain.keys()).filter(workspaceId => workspaces.includes(workspaceId)); + await this.loadSessionPages(entry, targets, false); + return; + } + this.update(entry, { status: entry.snapshot.status === 'ready' ? 'ready' : 'loading', error: undefined }); + try { + await this.negotiate(entry); + const raw = await this.io.invoke(entry.deviceId, 'get_opened_workspaces', { request: {} }); + if (!this.isCurrent(entry)) return; + // The same display order the live list of this device uses. + const workspaces = stableWorkspaceOrder(entry.surfaceId, + upgradeLegacyWorktreeReferences(Array.isArray(raw) ? raw : []).map(mapWorkspaceInfo)); + const opened = new Set(workspaces.map(workspace => workspace.id)); + const sessions = Object.fromEntries(Object.entries(entry.snapshot.sessions) + .filter(([workspaceId]) => opened.has(workspaceId))); + this.update(entry, { status: 'ready', workspaces, sessions, error: undefined }); + const targets = entry.activityRetain > 0 + ? workspaces.map(workspace => workspace.id) + : Array.from(entry.workspaceRetain.keys()).filter(workspaceId => opened.has(workspaceId)); + await this.loadSessionPages(entry, targets, false); + } catch (error) { + if (!this.isCurrent(entry)) return; + log.warn('Failed to read device workspace catalog', { surfaceId: entry.surfaceId, error }); + // Keep what was rendered; the section offers a retry. + this.update(entry, { status: entry.snapshot.workspaces.length > 0 ? 'ready' : 'error', error: errorText(error) }); + } + } + + /** Learn whether the host addresses sessions by workspace id. */ + private async negotiate(entry: Entry): Promise { + if (entry.workspaceIdReferences !== null) return; + const ping = await this.io.invoke<{ capabilities?: Record } | undefined>( + entry.deviceId, 'peer_mode_ping', {}, + ); + entry.workspaceIdReferences = ping?.capabilities?.workspace_id_references_v1 === true; + } + + private workspaceRequest(entry: Entry, workspace: WorkspaceInfo): Record { + if (entry.workspaceIdReferences) return { workspace_id: workspace.id }; + // Pre-ID hosts address sessions by their own root; never a controller path. + const remote = workspace.workspaceKind === 'remote'; + return { + workspace_path: workspace.rootPath, + remote_connection_id: remote ? workspace.connectionId : undefined, + remote_ssh_host: remote ? workspace.sshHost : undefined, + }; + } + + /** Refresh several workspaces with bounded concurrency. */ + private async loadSessionPages(entry: Entry, workspaceIds: readonly string[], onlyMissing: boolean): Promise { + const queue = workspaceIds.filter(workspaceId => !onlyMissing + || this.getSessions(entry.surfaceId, workspaceId).status === 'idle'); + const worker = async () => { + for (let workspaceId = queue.shift(); workspaceId !== undefined; workspaceId = queue.shift()) { + if (!this.isCurrent(entry)) return; + await this.loadSessions(entry, workspaceId, this.getSessions(entry.surfaceId, workspaceId).limit); + } + }; + await Promise.all(Array.from({ length: Math.min(SESSION_READ_CONCURRENCY, queue.length) }, worker)); + } + + private async loadSessions(entry: Entry, workspaceId: string, limit: number): Promise { + if (!this.isCurrent(entry)) return; + const workspace = entry.snapshot.workspaces.find(candidate => candidate.id === workspaceId); + if (!workspace) return; + this.updateSessions(entry, workspaceId, { status: 'loading', limit, error: undefined }); + try { + await this.negotiate(entry); + const sessions: SessionMetadata[] = []; + const records: SessionMetadata[] = []; + const activities: Record = {}; + let cursor: string | undefined; + let hasMore = false; + let total: number | null = null; + for (let page = 0; page < MAX_PAGES_PER_READ; page += 1) { + const result = await this.io.invoke(entry.deviceId, 'list_persisted_sessions_page', { + request: { + ...this.workspaceRequest(entry, workspace), + limit, + ...(cursor ? { cursor } : {}), + }, + }); + if (!this.isCurrent(entry)) return; + for (const session of result?.sessions ?? []) { + if (session.status === 'archived') continue; + records.push(session); + if (isTopLevel(session)) sessions.push(session); + } + for (const activity of result?.activities ?? []) activities[activity.sessionId] = activity; + hasMore = result?.hasMore === true; + cursor = result?.nextCursor; + total = typeof result?.totalTopLevelCount === 'number' ? result.totalTopLevelCount : null; + if (!hasMore || !cursor || sessions.length >= limit) break; + } + this.updateSessions(entry, workspaceId, { + status: 'ready', + sessions: sessions.slice(0, limit), + records, + activities, + hasMore: hasMore || sessions.length > limit, + total, + limit, + error: undefined, + }); + } catch (error) { + if (!this.isCurrent(entry)) return; + log.warn('Failed to read device sessions', { surfaceId: entry.surfaceId, workspaceId, error }); + const current = this.getSessions(entry.surfaceId, workspaceId); + this.updateSessions(entry, workspaceId, { + status: current.sessions.length > 0 ? 'ready' : 'error', + error: errorText(error), + }); + } + } + + private startStream(entry: Entry): void { + if (entry.deviceId === null || entry.streamSubscriptionId !== null || entry.streamPending) return; + entry.streamPending = true; + const deviceId = entry.deviceId; + void this.io.subscribeCatalog(deviceId).then(subscriptionId => { + entry.streamPending = false; + if (!this.isCurrent(entry) || entry.retainCount === 0) { + void this.io.unsubscribeCatalog(subscriptionId).catch(() => undefined); + return; + } + entry.streamSubscriptionId = subscriptionId; + this.update(entry, { live: true }); + }, error => { + entry.streamPending = false; + // An older host has no catalog stream; visibility refresh covers it. + log.info('Device catalog stream unavailable', { deviceId, error: errorText(error) }); + this.update(entry, { live: false }); + }); + } + + private stopStream(entry: Entry): void { + const subscriptionId = entry.streamSubscriptionId; + entry.streamSubscriptionId = null; + if (subscriptionId) { + void this.io.unsubscribeCatalog(subscriptionId).catch(error => { + log.warn('Device catalog unsubscribe failed', { surfaceId: entry.surfaceId, error }); + }); + } + if (entry.snapshot.live) this.update(entry, { live: false }); + } + + private stop(entry: Entry): void { + this.stopStream(entry); + entry.stopped = true; + entry.sync.stop(); + if (entry.debounceTimer !== null) clearTimeout(entry.debounceTimer); + if (entry.releaseTimer !== null) clearTimeout(entry.releaseTimer); + entry.debounceTimer = null; + entry.releaseTimer = null; + } + + private installEvents(): void { + if (this.teardownEvents) return; + const disposers: Array<() => void> = []; + // Observers only see events that have a native listener registered. + disposers.push(this.io.listen('host-catalog-changed', () => undefined)); + disposers.push(this.io.listen('account://login-state', () => this.reset())); + disposers.push(this.io.listen('account://session-sync-error', payload => { + const event = payload as { sessionId?: string; targetDeviceId?: string } | null; + if (event?.sessionId !== HOST_CATALOG_ID || !event.targetDeviceId) return; + const entry = this.entries.get(surfaceIdForDevice(event.targetDeviceId)); + if (!entry) return; + entry.streamSubscriptionId = null; + this.update(entry, { live: false }); + })); + disposers.push(observeSurfaceEvents((event, sourceDeviceId, payload) => { + if (!CATALOG_INVALIDATING_EVENTS.has(event)) return; + const surfaceId = surfaceIdForDevice(sourceDeviceId); + // The rendered device is drawn from its live container. + if (surfaceId === getActiveSurfaceId()) return; + this.invalidate(surfaceId, this.hintScope(surfaceId, event, payload)); + })); + const flushDeferred = () => { + if (this.deferred.size === 0) return; + const pending = Array.from(this.deferred); + this.deferred.clear(); + for (const [surfaceId, scope] of pending) this.invalidate(surfaceId, scope); + }; + disposers.push(useDeviceOpenIntent.subscribe(state => { + if (!state.intent) flushDeferred(); + })); + // The device just left was not refreshed while rendered; read it again. + // The device now rendered is drawn from its live list and needs no read. + disposers.push(onSurfaceActivated(() => { + const rendered = getActiveSurfaceId(); + for (const entry of this.entries.values()) { + if (entry.surfaceId !== rendered) this.invalidate(entry.surfaceId); + } + })); + if (typeof document !== 'undefined') { + const onVisible = () => { + if (document.visibilityState !== 'visible') return; + flushDeferred(); + for (const entry of this.entries.values()) { + if (!entry.snapshot.live) this.invalidate(entry.surfaceId); + } + }; + document.addEventListener('visibilitychange', onVisible); + disposers.push(() => document.removeEventListener('visibilitychange', onVisible)); + } + this.teardownEvents = () => { + for (const dispose of disposers) dispose(); + }; + } + + /** + * The catalog stream hint names which revision moved: an unchanged + * workspaces revision means only session pages need a read. Lifecycle events + * are session facts; a workspace catalog event changes the list. + */ + private hintScope(surfaceId: DeviceSurfaceId, event: string, payload: unknown): RefreshScope { + if (event === 'workspace-catalog-changed') return 'all'; + if (event !== 'host-catalog-changed') return 'sessions'; + const entry = this.entries.get(surfaceId); + const revision = (payload as { workspacesRevision?: unknown } | null)?.workspacesRevision; + if (!entry || revision === undefined) return 'all'; + const changed = entry.workspacesRevision !== undefined && entry.workspacesRevision !== revision; + const first = entry.workspacesRevision === undefined; + entry.workspacesRevision = revision; + return changed || first ? 'all' : 'sessions'; + } + + /** Tests only. */ + dispose(): void { + this.generation += 1; + for (const entry of this.entries.values()) this.stop(entry); + this.entries.clear(); + this.teardownEvents?.(); + this.teardownEvents = null; + this.listeners.clear(); + } +} + +/** Window-wide: sections mount and unmount, the catalog cache does not. */ +export const deviceWorkspaceCatalog = new DeviceWorkspaceCatalog(); diff --git a/src/web-ui/src/infrastructure/peer-device/useDeviceWorkspaceCatalog.ts b/src/web-ui/src/infrastructure/peer-device/useDeviceWorkspaceCatalog.ts new file mode 100644 index 0000000000..4a65b5a255 --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/useDeviceWorkspaceCatalog.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react'; +import type { DeviceSurfaceId } from './deviceSurface'; +import { + deviceWorkspaceCatalog, + type DeviceCatalogSessions, + type DeviceCatalogSnapshot, +} from './deviceWorkspaceCatalog'; + +function useCatalogSubscription(surfaceId: DeviceSurfaceId) { + return useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); +} + +/** Read a device catalog; it is loaded only while `enabled` (the section is open). */ +export function useDeviceWorkspaceCatalog(surfaceId: DeviceSurfaceId, enabled: boolean): DeviceCatalogSnapshot { + useEffect(() => (enabled ? deviceWorkspaceCatalog.retain(surfaceId) : undefined), [surfaceId, enabled]); + const subscribe = useCatalogSubscription(surfaceId); + const read = useCallback(() => deviceWorkspaceCatalog.getSnapshot(surfaceId), [surfaceId]); + return useSyncExternalStore(subscribe, read, read); +} + +/** Sessions of one catalog workspace, read only while it is expanded. */ +export function useDeviceCatalogSessions( + surfaceId: DeviceSurfaceId, + workspaceId: string, + enabled: boolean, +): DeviceCatalogSessions { + useEffect( + () => (enabled ? deviceWorkspaceCatalog.retainSessions(surfaceId, workspaceId) : undefined), + [surfaceId, workspaceId, enabled], + ); + const subscribe = useCatalogSubscription(surfaceId); + const read = useCallback( + () => deviceWorkspaceCatalog.getSessions(surfaceId, workspaceId), + [surfaceId, workspaceId], + ); + return useSyncExternalStore(subscribe, read, read); +} diff --git a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts new file mode 100644 index 0000000000..97e8d0747e --- /dev/null +++ b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { resetWorkspaceDisplayOrdersForTest, setWorkspaceDisplayOrder, stableWorkspaceOrder } from './workspaceDisplayOrder'; + +const list = (...ids: string[]) => ids.map(id => ({ id })); +const ids = (items: Array<{ id: string }>) => items.map(item => item.id); + +describe('stableWorkspaceOrder', () => { + afterEach(resetWorkspaceDisplayOrdersForTest); + + it('keeps the shown order when an older host moves the active workspace to the front', () => { + expect(ids(stableWorkspaceOrder('peer', list('openbitfun', 'repos')))).toEqual(['openbitfun', 'repos']); + // Activating repos on the host reordered its list. + expect(ids(stableWorkspaceOrder('peer', list('repos', 'openbitfun')))).toEqual(['openbitfun', 'repos']); + }); + + it('places a new workspace where the host put it and drops closed ones', () => { + stableWorkspaceOrder('peer', list('a', 'b', 'c')); + expect(ids(stableWorkspaceOrder('peer', list('new', 'c', 'a')))).toEqual(['new', 'a', 'c']); + }); + + it('keeps devices independent and lets an explicit reorder replace the order', () => { + stableWorkspaceOrder('local', list('a', 'b')); + stableWorkspaceOrder('peer', list('b', 'a')); + setWorkspaceDisplayOrder('local', ['b', 'a']); + expect(ids(stableWorkspaceOrder('local', list('a', 'b')))).toEqual(['b', 'a']); + expect(ids(stableWorkspaceOrder('peer', list('a', 'b')))).toEqual(['b', 'a']); + }); +}); diff --git a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts new file mode 100644 index 0000000000..bb31c94c18 --- /dev/null +++ b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts @@ -0,0 +1,44 @@ +/** + * One display order of opened workspaces per device surface. + * + * Hosts before this change move a workspace to the front of their opened list + * whenever it becomes active, so every re-read (a device switch, a catalog + * refresh, another controller's activation) could reorder the sidebar. The + * window keeps the order it last showed for each device and applies it to + * every host list, live or background, so both projections of a device agree + * and activation never moves a row. Workspaces new to the list keep the place + * the host gave them relative to their neighbours; an explicit reorder + * replaces the order. + */ + +const orders = new Map(); + +export function setWorkspaceDisplayOrder(surfaceId: string, workspaceIds: readonly string[]): void { + orders.set(surfaceId, [...workspaceIds]); +} + +export function stableWorkspaceOrder(surfaceId: string, hostWorkspaces: readonly T[]): T[] { + const remembered = orders.get(surfaceId); + if (!remembered) { + setWorkspaceDisplayOrder(surfaceId, hostWorkspaces.map(workspace => workspace.id)); + return [...hostWorkspaces]; + } + const byId = new Map(hostWorkspaces.map(workspace => [workspace.id, workspace])); + const result: T[] = remembered.flatMap(id => (byId.has(id) ? [byId.get(id)!] : [])); + const placed = new Set(result.map(workspace => workspace.id)); + hostWorkspaces.forEach((workspace, hostIndex) => { + if (placed.has(workspace.id)) return; + // Before every shown workspace the host lists after it, else at the end. + const after = new Set(hostWorkspaces.slice(hostIndex + 1).map(candidate => candidate.id)); + const first = result.findIndex(candidate => after.has(candidate.id)); + const index = first === -1 ? result.length : first; + result.splice(index, 0, workspace); + placed.add(workspace.id); + }); + setWorkspaceDisplayOrder(surfaceId, result.map(workspace => workspace.id)); + return result; +} + +export function resetWorkspaceDisplayOrdersForTest(): void { + orders.clear(); +} diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index 69d6ebd947..350239ffdb 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -1,5 +1,6 @@ +import { setWorkspaceDisplayOrder, stableWorkspaceOrder } from './workspaceDisplayOrder'; import type { RemoteWorkspaceSnapshot, WorkspaceInfo, @@ -183,6 +184,11 @@ class WorkspaceManager { }); } + /** Opened workspaces last loaded for a surface, rendered or not. */ + public getSurfaceOpenedWorkspaces(surfaceId: DeviceSurfaceId): WorkspaceInfo[] { + return Array.from(this.surfaceContainers.get(surfaceId)?.state.openedWorkspaces.values() ?? []); + } + private surfaceContainer(surfaceId: DeviceSurfaceId): WorkspaceSurfaceContainer { const existing = this.surfaceContainers.get(surfaceId); if (existing) { @@ -447,7 +453,10 @@ class WorkspaceManager { error: string | null, event?: WorkspaceEvent ): void { - const openedWorkspaceMap = this.buildOpenedWorkspaceMap(openedWorkspaces); + // Every list a host returns is shown in this surface's one display order. + const openedWorkspaceMap = this.buildOpenedWorkspaceMap( + stableWorkspaceOrder(getActiveSurfaceId(), openedWorkspaces), + ); const resolvedCurrentWorkspace = currentWorkspace ? openedWorkspaceMap.get(currentWorkspace.id) ?? currentWorkspace @@ -1502,6 +1511,7 @@ class WorkspaceManager { return; } + setWorkspaceDisplayOrder(getActiveSurfaceId(), reorderedOpenedWorkspaces.map(workspace => workspace.id)); this.updateWorkspaceState( previousCurrentWorkspace, previousRecentWorkspaces, @@ -1518,6 +1528,7 @@ class WorkspaceManager { throw error; } const errorMessage = error instanceof Error ? error.message : String(error); + setWorkspaceDisplayOrder(getActiveSurfaceId(), previousOpenedWorkspaces.map(workspace => workspace.id)); const rollbackEventWorkspace = previousCurrentWorkspace ?? workspaceMap.get(sourceWorkspaceId) ?? previousOpenedWorkspaces[0] diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 0277d757c4..8ec29c685c 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -359,7 +359,7 @@ function mapWorkspaceWorktree( }; } -function mapWorkspaceInfo(workspace: APIWorkspaceInfo): WorkspaceInfo { +export function mapWorkspaceInfo(workspace: APIWorkspaceInfo): WorkspaceInfo { return { id: workspace.id, name: workspace.name, From 3d4bbeab8d22f06e09654100bbf5595431f5b114 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:35:26 +0800 Subject: [PATCH 03/52] feat(web-ui): list every device's workspaces and sessions in the sidebar The session list groups workspaces by device in a fixed order. The rendered device keeps its live list; other devices draw from their seeded containers with the live list's hierarchy, ordering, filters, disclosure and row marks, and offer the same workspace actions. Acting on another device renders it first: the clicked row and the session tab are held through the switch, and a curtain covers other switching actions so the workbench never flashes. Lists show three sessions and add five per "show more", with one step read ahead; collapsing a workspace starts it over. Device headers name the platform or CLI. --- .../NavPanel/MainNav.projectCreation.test.tsx | 10 + .../src/app/components/NavPanel/MainNav.tsx | 32 +- .../components/DeviceStatusControl.tsx | 8 + .../devices/BackgroundSessionList.test.tsx | 94 ++++++ .../devices/BackgroundSessionList.tsx | 270 +++++++++++++++ .../devices/DeviceCatalogSection.test.tsx | 66 ++++ .../sections/devices/DeviceCatalogSection.tsx | 311 ++++++++++++++++++ .../devices/DeviceWorkspaceSections.scss | 112 +++++++ .../devices/DeviceWorkspaceSections.test.tsx | 80 +++++ .../devices/DeviceWorkspaceSections.tsx | 195 +++++++++++ .../devices/catalogSessionStatus.test.ts | 26 ++ .../sections/devices/catalogSessionStatus.ts | 40 +++ .../sections/devices/openOnDevice.test.ts | 150 +++++++++ .../NavPanel/sections/devices/openOnDevice.ts | 166 ++++++++++ .../devices/useSidebarDevices.test.ts | 12 + .../sections/devices/useSidebarDevices.ts | 118 +++++++ .../sections/devices/useSurfaceSessions.ts | 18 + .../sessions/SessionStatusIndicator.tsx | 30 +- .../sections/sessions/SessionsSection.tsx | 167 +++++----- .../sessions/SessionsSectionLayout.test.ts | 2 +- .../sessions/sessionNavExpand.test.ts | 81 ++--- .../sections/sessions/sessionNavExpand.ts | 65 ++-- .../sessions/sessionNavHierarchy.test.ts | 14 +- .../sections/sessions/sessionRowBadges.ts | 20 ++ .../sessions/useSessionNavExpansion.test.tsx | 96 +++--- .../sessions/useSessionNavExpansion.ts | 79 +++-- .../sections/workspaces/WorkspaceItem.tsx | 10 + .../workspaces/WorkspaceListSection.test.tsx | 5 +- .../workspaces/WorkspaceListSection.tsx | 45 +-- .../workspaces/pendingWorkspaceAction.test.ts | 12 + .../workspaces/pendingWorkspaceAction.ts | 30 ++ .../workspaces/workspaceDisclosure.ts | 41 +++ .../NavPanel/unifiedSessionCreation.test.ts | 15 +- .../NavPanel/workspaceSessionView.ts | 15 + .../src/app/hooks/useSessionTabLabels.ts | 17 +- src/web-ui/src/app/scenes/SceneViewport.tsx | 2 + .../device-switch/DeviceSwitchCurtain.scss | 46 +++ .../DeviceSwitchCurtain.test.tsx | 47 +++ .../device-switch/DeviceSwitchCurtain.tsx | 67 ++++ .../src/app/services/sessionSceneLifecycle.ts | 6 + src/web-ui/src/app/stores/sceneStore.test.ts | 18 + src/web-ui/src/app/stores/sceneStore.ts | 48 ++- src/web-ui/src/locales/en-US/common.json | 31 ++ src/web-ui/src/locales/zh-CN/common.json | 31 ++ src/web-ui/src/locales/zh-TW/common.json | 31 ++ 45 files changed, 2462 insertions(+), 317 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.test.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.test.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.scss create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/useSurfaceSessions.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowBadges.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceDisclosure.ts create mode 100644 src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.scss create mode 100644 src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.test.tsx create mode 100644 src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx index 5cbd7bfb11..fb1757b5cf 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.projectCreation.test.tsx @@ -56,6 +56,16 @@ vi.mock('./components/StickySectionHeader', () => ({ default: ({ children }: Rea vi.mock('./components/WorkspaceSessionGroupingToggle', () => ({ default: () => null })); vi.mock('./components/WorkspaceSessionFilterMenu', () => ({ default: () => null })); vi.mock('./sections/workspaces/WorkspaceListSection', () => ({ default: () => null })); +vi.mock('./sections/devices/DeviceWorkspaceSections', () => ({ default: () => null })); +vi.mock('./sections/devices/useSidebarDevices', () => ({ useSidebarDevices: () => [] })); +vi.mock('./sections/devices/openOnDevice', () => ({ activateDeviceSlot: vi.fn() })); +vi.mock('@/infrastructure/account/deviceDirectory', async importOriginal => ({ + ...await importOriginal(), + useDeviceDirectory: (select?: (state: { devices: never[]; localId: string | null; status: 'idle' }) => unknown) => { + const directory = { devices: [], localId: null, status: 'idle' as const }; + return select ? select(directory) : directory; + }, +})); vi.mock('./components/MiniAppShortcuts', () => ({ default: () => })); vi.mock('@/features/ssh-remote', () => ({ useSSHRemoteContext: () => state.remote, diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index f0de86d6ed..10a0322469 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -7,7 +7,7 @@ * 3. Unified Sessions (all or grouped by project / assistant) */ -import React, { Suspense, useCallback, useState, useEffect, useRef, useSyncExternalStore } from 'react'; +import React, { Suspense, useCallback, useMemo, useState, useEffect, useRef, useSyncExternalStore } from 'react'; import { OverflowText, Icon, @@ -29,7 +29,12 @@ import StickySectionHeader from './components/StickySectionHeader'; import WorkspaceSessionGroupingToggle from './components/WorkspaceSessionGroupingToggle'; import WorkspaceSessionFilterMenu from './components/WorkspaceSessionFilterMenu'; import { useMiniAppNavigation } from '@/app/scenes/miniapps/miniAppNavigationStore'; -import WorkspaceListSection from './sections/workspaces/WorkspaceListSection'; +import DeviceWorkspaceSections from './sections/devices/DeviceWorkspaceSections'; +import { useSidebarDevices } from './sections/devices/useSidebarDevices'; +import { activateDeviceSlot } from './sections/devices/openOnDevice'; +import { useDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import type { NewProjectDeviceOption, NewProjectDeviceTarget } from '../NewProjectDialog/NewProjectDialog'; import { useSceneStore } from '../../stores/sceneStore'; import { useMiniAppCatalogSync } from '../../scenes/miniapps/hooks/useMiniAppCatalogSync'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; @@ -190,6 +195,25 @@ const MainNav: React.FC = () => { return () => window.removeEventListener('nav:new-project', onNewProject); }, []); + const peerDevice = usePeerDeviceModeOptional(); + const signedIn = useDeviceDirectory(state => state.localId !== null); + const deviceSlots = useSidebarDevices(t('deviceOverview.thisDevice')); + const deviceOptions = useMemo(() => (signedIn + ? deviceSlots.filter(slot => !slot.isLocal).map(slot => ({ + surfaceId: slot.surfaceId, + name: slot.name, + hostKind: slot.hostKind, + rendered: slot.rendered, + controllable: slot.controllable, + })) + : undefined), [deviceSlots, signedIn]); + /** Creation happens on the rendered device; render the chosen one first. */ + const handleSelectCreationDevice = useCallback(async (target: NewProjectDeviceTarget) => { + const slot = deviceSlots.find(candidate => (target === 'local' ? candidate.isLocal : candidate.surfaceId === target)); + if (!slot) throw new Error(t('newWorkspace.deviceError')); + return await activateDeviceSlot(peerDevice, slot) === 'activated'; + }, [deviceSlots, peerDevice, t]); + const handleCreateAssistant = useCallback(async () => { const scope = getActiveSurfaceScope(); const workspace = await workspaceManager.createAssistantWorkspace(); @@ -310,7 +334,7 @@ const MainNav: React.FC = () => { />
- +
@@ -327,6 +351,8 @@ const MainNav: React.FC = () => { onPickRemoteFolder={handlePickRemoteFolder} onSelectRemoteHost={handleSelectRemoteHost} onCreateAssistant={showAssistantChoice ? handleCreateAssistant : undefined} + onSelectDevice={showAssistantChoice && peerDevice ? handleSelectCreationDevice : undefined} + deviceOptions={deviceOptions} defaultParentPath={currentWorkspace && !isRemoteWorkspace(currentWorkspace) ? currentWorkspace.rootPath : undefined} data-testid="nav-workspace-dialog" /> diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index 2d60d8ae67..1ed2c3d262 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -6,6 +6,8 @@ import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { LOCAL_SURFACE_ID, surfaceIdForDevice } from '@/infrastructure/peer-device/deviceSurface'; import { useAccountIdentity } from '@/infrastructure/account-identity'; import { remoteConnectAPI, type AccountDeviceInfo } from '@/infrastructure/api/service-api/RemoteConnectAPI'; import { api } from '@/infrastructure/api/service-api/ApiClient'; @@ -247,6 +249,11 @@ const DeviceStatusControl: React.FC = ({ attachment.deviceId === target.device_id && attachment.health === 'ready' )); setSwitchingAction(target.device_id === availableTargets.localId || alreadyConnected ? 'switch' : 'connect'); + // The workbench fades behind a curtain while it rebuilds for the target. + const intent = beginDeviceOpen({ + surfaceId: target.device_id === availableTargets.localId ? LOCAL_SURFACE_ID : surfaceIdForDevice(target.device_id), + key: `switch:${target.device_id}`, + }); try { if (target.device_id === availableTargets.localId) await peerContext.switchToLocal('manual'); else await peerContext.switchToDevice(target.device_id, resolveDeviceName(target.device_id, target.device_alias ?? target.device_name ?? target.device_id)); @@ -254,6 +261,7 @@ const DeviceStatusControl: React.FC = ({ } catch (error) { warning(error instanceof Error ? error.message : String(error)); } finally { + endDeviceOpen(intent); switchingRef.current = false; setSwitchingAction(null); } diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.test.tsx new file mode 100644 index 0000000000..9efc1fd259 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.test.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/infrastructure/i18n', async importOriginal => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})); +const catalogState = vi.hoisted(() => ({ snapshot: { sessions: {} as Record } })); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: () => catalogState.snapshot, + subscribe: () => () => undefined, + loadMoreSessions: vi.fn(), + }, +})); +vi.mock('@/flow_chat/services/workspaceSessionTotals', async importOriginal => ({ + ...await importOriginal(), + knownWorkspaceSessionTotal: () => 8, +})); + +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; +import { BackgroundSessionList } from './BackgroundSessionList'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const slot: SidebarDeviceSlot = { + surfaceId: 'peer-list', deviceId: 'peer-list', name: 'Studio', isLocal: false, rendered: false, hostKind: 'desktop', controllable: true, +}; +const record = (sessionId: string, createdAt: number, lastFinishedAt: number) => ({ + sessionId, sessionName: sessionId, agentType: 'agentic', modelName: 'm', createdAt, lastActiveAt: lastFinishedAt, + lastFinishedAt, turnCount: 1, messageCount: 1, toolCallCount: 0, status: 'active', tags: [], workspaceId: 'ws', +}) as never; + +describe('BackgroundSessionList', () => { + let host: HTMLDivElement; + let root: Root; + const actions = { pendingKey: null, run: vi.fn() }; + const rows = () => [...host.querySelectorAll('[data-testid="nav-device-session-item"]')].map(row => row.getAttribute('data-session-id')); + const render = () => act(() => root.render()); + + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + flowChatStore.seedSurfaceSessions('peer-list', [{ workspaceId: 'ws', workspacePath: '/w', sessions: [ + record('oldest', 1, 100), record('newest-finished', 2, 900), record('middle', 3, 500), + record('created-late', 9, 200), record('s5', 4, 300), record('s6', 5, 50), + ] }]); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + flowChatStore.discardSurfaceState('peer-list'); + useWorkspaceSessionViewStore.getState().setOrdering('updated'); + }); + + it('orders by the live list rules and shows its first level', () => { + useWorkspaceSessionViewStore.getState().setOrdering('updated'); + render(); + expect(rows()).toEqual(['newest-finished', 'middle', 's5']); + const toggle = host.querySelector('[data-testid="nav-device-session-list-toggle"]'); + expect(toggle?.getAttribute('data-session-nav-toggle-action')).toBe('show-more'); + expect(toggle?.textContent).toContain('+5'); + }); + + it('follows the ordering preference', () => { + useWorkspaceSessionViewStore.getState().setOrdering('created'); + render(); + expect(rows()).toEqual(['created-late', 's6', 's5']); + }); + + it('expands like the live list', () => { + render(); + act(() => host.querySelector('[data-testid="nav-device-session-list-toggle"]')!.click()); + expect(rows()).toHaveLength(6); + }); + + it('limits a flat device list to three rows with show more', () => { + act(() => root.render()); + expect(rows()).toHaveLength(3); + expect(host.querySelector('[data-testid="nav-device-session-list-toggle"]')).not.toBeNull(); + }); + + it('labels flat rows with their workspace like the live list', () => { + act(() => root.render()); + const names = [...host.querySelectorAll('.openbitfun-nav-panel__inline-item-workspace-name')].map(node => node.textContent); + expect(names).toEqual(['OpenBitFun', 'OpenBitFun', 'OpenBitFun']); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx new file mode 100644 index 0000000000..94a32aa20c --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx @@ -0,0 +1,270 @@ +/** + * Session rows of a device the window is not rendering (or is still switching + * to), drawn from that device's own FlowChat container. + * + * Everything that decides which rows appear and in what order is the live + * list's: the same hierarchy, comparator, view filters, first-level limit and + * "show more" rule. A device therefore looks identical before, during and + * after a switch, and the hand-over to the live list moves nothing. + */ + +import React, { useCallback, useMemo, useSyncExternalStore } from 'react'; +import { Icon, OverflowText, StatusPill } from '@openbitfun/ui'; +import { Loader2 } from 'lucide-react'; +import { useI18n, i18nService } from '@/infrastructure/i18n'; +import { surfaceScopedKey, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { sessionNavStatusService } from '@/flow_chat/services/sessionNavStatusService'; +import { knownWorkspaceSessionTotal } from '@/flow_chat/services/workspaceSessionTotals'; +import { resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import type { SessionActivitySummary, SessionMetadata } from '@/shared/types/session-history'; +import { + compareWorkspaceNavSessions, + matchesWorkspaceSessionView, + matchesWorkspaceSourceAgent, + sessionSourceAgentLabel, + useWorkspaceSessionViewStore, + workspaceSessionViewKey, +} from '../../workspaceSessionView'; +import { getWorkspaceSessionHierarchy } from '../sessions/sessionNavHierarchy'; +import { + getSessionDisplayLimit, + getSessionExpandToggleState, + nextSessionDisplayCount, + SESSIONS_PREFETCH_COUNT, +} from '../sessions/sessionNavExpand'; +import { useSessionNavExpansion } from '../sessions/useSessionNavExpansion'; +import { isWorkspaceSessionsCollapsed } from '../workspaces/workspaceDisclosure'; +import { SessionStatusMark } from '../sessions/SessionStatusIndicator'; +import { useCatalogSessionStatus } from './catalogSessionStatus'; +import { openDeviceSession } from './openOnDevice'; +import { useSurfaceSessions } from './useSurfaceSessions'; +import { getChildSessionBadge } from '../sessions/sessionRowBadges'; +import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; +import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers'; +import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; +import { sessionBelongsToWorkspaceNavRow } from '@/flow_chat/utils/sessionOrdering'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +export interface BackgroundActions { + pendingKey: string | null; + run: (key: string, action: () => Promise, failureKey: string, extra?: { sessionId?: string; title?: string }) => void; +} + +const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); + +function useCatalogActivities(surfaceId: DeviceSurfaceId, workspaceIds: readonly string[]) { + const subscribe = useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); + const read = useCallback(() => deviceWorkspaceCatalog.getSnapshot(surfaceId), [surfaceId]); + const snapshot = useSyncExternalStore(subscribe, read, read); + return useMemo(() => { + const activities: Record = {}; + let loading = false; + let hasMore = false; + let limit = 0; + for (const workspaceId of workspaceIds) { + const page = snapshot.sessions[workspaceId]; + if (!page) continue; + Object.assign(activities, page.activities); + loading ||= page.status === 'loading' && page.records.length === 0; + hasMore ||= page.hasMore; + limit = Math.max(limit, page.limit); + } + return { activities, loading, hasMore, limit }; + }, [snapshot, workspaceIds]); +} + +const BackgroundSessionRow: React.FC<{ + slot: SidebarDeviceSlot; + session: Session; + level: 0 | 1; + activity?: SessionActivitySummary; + actions: BackgroundActions; + flat: boolean; + /** Owning workspace name, shown in the flat view like the live row. */ + workspaceName?: string; + titleNumber?: string; +}> = ({ slot, session, level, activity, actions, flat, workspaceName, titleNumber }) => { + const peer = usePeerDeviceModeOptional(); + const status = useCatalogSessionStatus(slot.surfaceId, session.sessionId, activity); + const title = getTitle(session); + const relationship = resolveSessionRelationship(session); + const isChildSession = level === 1 && relationship.displayAsChild; + const key = `session:${session.sessionId}`; + const opening = actions.pendingKey === key; + const workspaceId = session.workspaceId ?? session.config.workspaceId ?? ''; + const open = () => actions.run(key, () => openDeviceSession(peer, slot, workspaceId, { + sessionId: session.sessionId, + } as SessionMetadata, title), 'nav.sessions.openFailed', { sessionId: session.sessionId, title }); + return ( +
{ + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + open(); + }} + > + + + + + {title} + + + {session.importedSource ? ( + + {sessionSourceAgentLabel(session.importedSource)} + + ) : null} + {isChildSession ? ( + + {getChildSessionBadge(relationship.kind)} + + ) : null} + {flat && workspaceName && !isChildSession ? ( + {workspaceName} + ) : null} + + + +
+ {opening + ? + : } +
+
+ ); +}; + +/** + * `workspaceIds` holds one project for the grouped view, every project for the + * flat view (`flat`), mirroring the live `SessionsSection` scopes. + */ +export const BackgroundSessionList: React.FC<{ + slot: SidebarDeviceSlot; + workspaceIds: readonly string[]; + actions: BackgroundActions; + flat?: boolean; + /** Workspace id to name, for the flat view's workspace labels. */ + workspaceNames?: ReadonlyMap; +}> = ({ slot, workspaceIds, actions, flat = false, workspaceNames }) => { + const { t } = useI18n('common'); + const sessions = useSurfaceSessions(slot.surfaceId); + const titleNumbers = useSessionTitleNumbers(sessions); + const ordering = useWorkspaceSessionViewStore(state => state.ordering); + const show = useWorkspaceSessionViewStore(state => state.show); + const filters = useWorkspaceSessionViewStore(state => state.filters); + const sourceAgent = useWorkspaceSessionViewStore(state => state.sourceAgent); + const { activities, loading, hasMore, limit: loadedLimit } = useCatalogActivities(slot.surfaceId, workspaceIds); + const primaryWorkspaceId = workspaceIds[0] ?? ''; + const { displayCount, setDisplayCount } = useSessionNavExpansion( + `${surfaceScopedKey(slot.surfaceId, flat ? undefined : primaryWorkspaceId)}:${sourceAgent}`, + // Unmounted by a collapse: reopening starts at the first rows. + { shouldHandOff: () => flat || !isWorkspaceSessionsCollapsed(slot.surfaceId, primaryWorkspaceId) }, + ); + + const isRunning = useCallback((session: Session) => ( + activities[session.sessionId]?.execution === 'running' + || sessionNavStatusService.isSurfaceRunning(slot.surfaceId, session.sessionId) + ), [activities, slot.surfaceId]); + + const { visibleItems, total } = useMemo(() => { + const { topLevelSessions, childrenByParent } = getWorkspaceSessionHierarchy( + show === 'archived' ? [...sessions.values()].filter(session => session.persistedStatus === 'archived') : sessions.values(), + workspaceIds, + ); + const compare = (left: Session, right: Session) => compareWorkspaceNavSessions( + left, right, ordering, getTitle, isRunning, + session => sessionNavStatusService.getSurfaceSortTimestamp(slot.surfaceId, session), + ); + const matches = (session: Session) => matchesWorkspaceSourceAgent(session, sourceAgent) + && matchesWorkspaceSessionView(session, show, filters, isRunning(session)); + const parents = [...topLevelSessions].sort(compare).filter(matches); + const limit = getSessionDisplayLimit({ + loadedTopLevelCount: parents.length, + displayCount, + // Each device's flat list shows its first rows plus "show more". + showAllWithoutLimit: false, + }); + const items: Array<{ session: Session; level: 0 | 1 }> = []; + for (const parent of parents.slice(0, limit)) { + items.push({ session: parent, level: 0 }); + for (const child of [...(childrenByParent.get(parent.sessionId) ?? [])].sort(compare).filter(matches)) { + items.push({ session: child, level: 1 }); + } + } + const reported = workspaceIds.length === 1 + ? knownWorkspaceSessionTotal(slot.surfaceId, primaryWorkspaceId, workspaceSessionViewKey(sourceAgent, show, filters)) : undefined; + return { visibleItems: items, total: Math.max(parents.length, reported ?? 0), shown: limit }; + }, [displayCount, filters, isRunning, ordering, primaryWorkspaceId, sessions, show, slot.surfaceId, sourceAgent, workspaceIds]); + + const toggle = getSessionExpandToggleState(total, displayCount); + const handleToggle = () => { + const next = nextSessionDisplayCount(displayCount, total); + setDisplayCount(next); + // Keep one step loaded ahead, like the live list. + const wanted = next + SESSIONS_PREFETCH_COUNT; + if (wanted > loadedLimit && hasMore) { + for (const workspaceId of workspaceIds) void deviceWorkspaceCatalog.loadMoreSessions(slot.surfaceId, workspaceId, wanted); + } + }; + const remaining = toggle.action === 'show-more' ? toggle.remainingCount : null; + + return ( +
+ {visibleItems.map(({ session, level }) => ( + sessionBelongsToWorkspaceNavRow(session, id)) ?? '') : undefined} /> + ))} + {visibleItems.length === 0 && (loading ? ( +
+
+ ) : ( +
{t('nav.sessions.noSessions')}
+ ))} + {toggle.shouldRender && ( + + )} +
+ ); +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.test.tsx new file mode 100644 index 0000000000..fab3fde22d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.test.tsx @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/infrastructure/i18n', async importOriginal => ({ + ...await importOriginal(), + useI18n: () => ({ t: (key: string) => key }), +})); +vi.mock('@/infrastructure/peer-device/useDeviceWorkspaceCatalog', () => ({ + useDeviceWorkspaceCatalog: () => ({ + status: 'ready', live: true, sessions: {}, + workspaces: [ + { id: 'assistant', name: 'Fu', rootPath: '/a', workspaceKind: 'assistant', tags: [], languages: [] }, + { id: 'project', name: 'OpenBitFun', rootPath: '/p', workspaceKind: 'normal', tags: [], languages: [] }, + ], + }), +})); +vi.mock('./BackgroundSessionList', () => ({ + BackgroundSessionList: () =>
, +})); +const open = vi.hoisted(() => ({ action: vi.fn(async () => undefined), resources: vi.fn(async () => undefined) })); +vi.mock('./openOnDevice', () => ({ + createDeviceSession: vi.fn(async () => undefined), + openDeviceAssistant: vi.fn(async () => undefined), + openDeviceWorkspaceAction: open.action, + openDeviceProjectResources: open.resources, +})); + +import DeviceCatalogSection from './DeviceCatalogSection'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const slot = { surfaceId: 'peer', deviceId: 'peer', name: 'Win', isLocal: false, rendered: false, hostKind: 'desktop' as const, os: 'Windows', controllable: true }; + +describe('DeviceCatalogSection rows', () => { + let host: HTMLDivElement; + let root: Root; + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + act(() => root.render()); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + it('keeps a project row actions inside its header card, never next to session rows', () => { + const project = host.querySelector('[data-workspace-id="project"]')!; + const card = project.querySelector('.openbitfun-nav-panel__workspace-item-card')!; + const actions = project.querySelector('.openbitfun-nav-panel__workspace-item-actions')!; + expect(card.contains(actions)).toBe(true); + expect(actions.closest('[data-testid="session-list"]')).toBeNull(); + expect([...actions.querySelectorAll('button')].map(button => button.getAttribute('data-testid'))).toEqual([ + 'nav-device-workspace-menu-btn', 'nav-device-project-resources-btn', 'nav-device-new-session-btn', + ]); + }); + + it('offers the live rows actions and runs them on the owning device', async () => { + const project = host.querySelector('[data-workspace-id="project"]')!; + await act(async () => { project.querySelector('[data-testid="nav-device-workspace-menu-btn"]')!.click(); }); + expect(open.action).toHaveBeenCalledWith(null, slot, 'project', 'menu'); + const assistant = host.querySelector('[data-workspace-id="assistant"]')!; + expect(assistant.querySelector('[data-testid="nav-device-workspace-menu-btn"]')).not.toBeNull(); + expect(assistant.querySelector('[data-testid="nav-device-assistant-history-btn"]')).not.toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx new file mode 100644 index 0000000000..9de56432b0 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx @@ -0,0 +1,311 @@ +/** + * Workspaces and sessions of a device this window is not rendering. + * + * Rows mirror the live workspace list (same classes, ordering and grouping). + * The data comes from the read-only device catalog; acting on a row renders + * that device first and then runs the local handler (see `openOnDevice`). + */ + +import React, { useCallback, useMemo } from 'react'; +import { ActionItem, Button, Icon, OverflowText, Tooltip } from '@openbitfun/ui'; +import { History, Loader2, MessageCirclePlus, Settings2 } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; +import { isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { peerConnectionManager } from '@/infrastructure/peer-device/PeerConnectionManager'; +import { useDeviceWorkspaceCatalog } from '@/infrastructure/peer-device/useDeviceWorkspaceCatalog'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { WorkspaceIconArt } from '@/app/components/WorkspaceIcon/WorkspaceIcon'; +import { AssistantAvatar } from '@/app/components/AssistantAvatar'; +import { notificationService } from '@/shared/notification-system'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; +import { resolveAssistantName } from '@/shared/utils/assistantName'; +import { projectWorkspaceBackedSessionGroups } from '../../sessionNavigationProjection'; +import { remoteWorkspaceBadge } from '../workspaces/remoteWorkspaceBadge'; +import { createDeviceSession, openDeviceAssistant, openDeviceProjectResources, openDeviceWorkspaceAction } from './openOnDevice'; +import { beginDeviceOpen, endDeviceOpen, useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; +import { BackgroundSessionList, type BackgroundActions } from './BackgroundSessionList'; +import { setWorkspaceSessionsCollapsed, useWorkspaceSessionsCollapsed } from '../workspaces/workspaceDisclosure'; +import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; + +type CatalogActions = BackgroundActions; + +/** + * Opening an entry renders another device first, which takes a moment. The + * intent is global so the clicked row stays highlighted with progress for the + * whole open, other owners (catalog refresh, scene reset, list hand-over) can + * wait for it, and a second click is ignored. + */ +function useCatalogActions(surfaceId: string): CatalogActions { + const { t } = useI18n('common'); + const intent = useDeviceOpenIntent(state => state.intent); + const run = useCallback((key, action, failureKey, extra) => { + if (useDeviceOpenIntent.getState().intent) return; + const next = beginDeviceOpen({ surfaceId, key, ...extra }); + void action().catch(error => { + if (isSurfaceChangedError(error)) return; + notificationService.error(error instanceof Error && error.message ? error.message : t(failureKey), { duration: 4000 }); + }).finally(() => endDeviceOpen(next)); + }, [surfaceId, t]); + return { pendingKey: intent?.surfaceId === surfaceId ? intent.key : null, run }; +} + +const CatalogAssistantRow: React.FC<{ slot: SidebarDeviceSlot; workspace: WorkspaceInfo; actions: CatalogActions }> = ({ + slot, workspace, actions, +}) => { + const { t } = useI18n('common'); + const peer = usePeerDeviceModeOptional(); + const name = resolveAssistantName(workspace); + const key = `assistant:${workspace.id}`; + const open = () => actions.run(key, () => openDeviceAssistant(peer, slot, workspace.id, () => { + notificationService.info(t('nav.sessions.defaultUnavailable')); + }), 'nav.sessions.openFailed'); + return ( +
+
+ + + { event.stopPropagation(); open(); }}> + {name} + + +
event.stopPropagation()}> + + + + + + + +
+
+
+ ); +}; + +const CatalogProjectRow: React.FC<{ slot: SidebarDeviceSlot; workspace: WorkspaceInfo; actions: CatalogActions; single: boolean }> = ({ + slot, workspace, actions, single, +}) => { + const { t } = useI18n('common'); + const peer = usePeerDeviceModeOptional(); + // The live row reads the same window-wide disclosure, so a switch keeps it. + const expanded = !useWorkspaceSessionsCollapsed(slot.surfaceId, workspace.id); + const remote = workspace.workspaceKind === WorkspaceKind.Remote; + const badge = remote ? remoteWorkspaceBadge(workspace) : undefined; + const toggle = () => setWorkspaceSessionsCollapsed(slot.surfaceId, workspace.id, expanded); + return ( +
+
+ +
+
+
+ + { event.stopPropagation(); toggle(); }}> + + {workspace.name} + + + + {badge && ( + + {badge.label} + + )} +
+
+
+ {/* Inside the card, like the live row, so the actions centre on it. */} +
event.stopPropagation()}> +
+ + + + + + + +
+
+
+ {expanded && ( +
+
+ +
+
+ )} +
+ ); +}; + +/** + * Attach the control link while the pointer is over a device's list, so a + * click only has to render it. Attaching neither renders nor mutates the host, + * and the link is kept for later switches anyway. + */ +const PREWARM_HOVER_MS = 200; +let prewarmTimer: ReturnType | null = null; + +function prewarmDeviceLink(slot: SidebarDeviceSlot): void { + if (slot.isLocal || !slot.deviceId || !slot.controllable || peerConnectionManager.has(slot.deviceId)) return; + cancelPrewarm(); + const deviceId = slot.deviceId; + // Only a pointer that rests on the list attaches; passing over it does not. + prewarmTimer = setTimeout(() => { + prewarmTimer = null; + if (!peerConnectionManager.has(deviceId)) void peerConnectionManager.connect(deviceId, slot.name).catch(() => undefined); + }, PREWARM_HOVER_MS); +} + +function cancelPrewarm(): void { + if (prewarmTimer !== null) clearTimeout(prewarmTimer); + prewarmTimer = null; +} + +/** + * Workspaces of a background device, in the host's order: the device catalog + * when it has read the host, else the list this window last loaded for it. + */ +function useBackgroundWorkspaces(slot: SidebarDeviceSlot, catalogWorkspaces: readonly WorkspaceInfo[]): readonly WorkspaceInfo[] { + return useMemo( + () => catalogWorkspaces.length > 0 ? catalogWorkspaces : workspaceManager.getSurfaceOpenedWorkspaces(slot.surfaceId), + [catalogWorkspaces, slot.surfaceId], + ); +} + +/** Body of a background (or still-switching) device section. Mirrors `WorkspaceListSection`'s structure. */ +const DeviceCatalogSection: React.FC<{ slot: SidebarDeviceSlot }> = ({ slot }) => { + const { t } = useI18n('common'); + const catalog = useDeviceWorkspaceCatalog(slot.surfaceId, slot.controllable); + const workspaces = useBackgroundWorkspaces(slot, catalog.workspaces); + const actions = useCatalogActions(slot.surfaceId); + const grouping = useWorkspaceSessionViewStore(state => state.grouping); + const groups = useMemo(() => projectWorkspaceBackedSessionGroups(workspaces, 'all'), [workspaces]); + const assistants = groups.filter(group => group.kind === 'assistant'); + const projects = groups.filter(group => group.kind === 'project'); + const projectIds = useMemo(() => projects.map(group => group.workspace.id), [projects]); + const projectNames = useMemo(() => new Map(projects.map(group => [group.workspace.id, group.workspace.name])), [projects]); + + if (!slot.controllable) { + return
+ {t('deviceOverview.deviceClientIncompatible')} +
; + } + return ( +
prewarmDeviceLink(slot)} + onPointerLeave={cancelPrewarm} + aria-busy={catalog.status === 'loading' || undefined}> + {catalog.status === 'loading' && workspaces.length === 0 && ( +
+
+ )} + {catalog.status === 'ready' && workspaces.length === 0 && ( +
{t('nav.workspaces.emptySessionGroups')}
+ )} + {catalog.error && workspaces.length === 0 && ( + + )} + {assistants.map(group => ( +
+ +
+ ))} + {projects.length > 0 && ( +
+
+ {grouping === 'all' ? ( +
+ +
+ ) : projects.map(group => ( +
+ +
+ ))} +
+
+ )} +
+ ); +}; + +export default DeviceCatalogSection; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.scss b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.scss new file mode 100644 index 0000000000..1ba93d5ccd --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.scss @@ -0,0 +1,112 @@ +/** + * Device sections of the session list. Rows inside a section reuse the + * workspace and session row styles; only the device header is new. + */ + +.openbitfun-nav-panel { + &__device-sections { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__device-section { + display: flex; + flex-direction: column; + min-width: 0; + + & + & { + margin-top: var(--openbitfun-space-4); + } + } + + // A group title: same size as workspace names but muted and semibold, with + // air above it. Workspace rows stay the brightest text in the list. + &__device-header { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 28px; + // The name starts where the assistant avatar does: the assistant row's + // padding (space-1), its border (1px) and the open button's inset (4px). + margin: 0 var(--openbitfun-space-1) 0 calc(var(--openbitfun-space-1) + 1px); + padding: 0 var(--openbitfun-space-2) 0 4px; + border: none; + border-radius: var(--openbitfun-radius-base); + background: transparent; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-label-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + text-align: start; + cursor: pointer; + transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + + &:hover { + color: var(--openbitfun-color-content-primary); + } + + &:focus-visible { + outline: 2px solid var(--openbitfun-color-accent-default); + outline-offset: -2px; + } + + .openbitfun-nav-panel__device-section:not(.is-expanded) > & { + opacity: 0.7; + } + } + + &__device-name { + flex: 0 1 auto; + min-width: 0; + } + + // A quiet capsule: muted fill with text in the sidebar's own color, so it + // reads below the device name in every theme. + &__device-pill { + flex: 0 0 auto; + padding: 0 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--openbitfun-color-content-muted) 55%, transparent); + color: var(--openbitfun-color-surface-chrome); + font-size: var(--openbitfun-type-meta-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: 16px; + letter-spacing: normal; + } + + // The rendered-device mark matches the capsule's weight, not the accent. + &__device-current { + flex: 0 0 auto; + inline-size: 5px; + block-size: 5px; + border-radius: 50%; + background: color-mix(in srgb, var(--openbitfun-color-content-muted) 75%, transparent); + } + + &__device-spinner-wrap { + display: inline-flex; + flex: 0 0 auto; + margin-inline-start: auto; + } + + &__device-spinner { + flex: 0 0 auto; + color: var(--openbitfun-color-content-secondary); + opacity: var(--openbitfun-opacity-muted); + animation: openbitfun-nav-session-spin 1s linear infinite; + } + + &__device-body { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__device-note { + margin: 0 var(--openbitfun-space-1); + padding: var(--openbitfun-space-1) var(--openbitfun-space-2); + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-meta-font-size); + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx new file mode 100644 index 0000000000..84a46918ea --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const slots = vi.hoisted(() => ({ value: [] as unknown[] })); +const live = vi.hoisted(() => ({ loading: false })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('./useSidebarDevices', async importOriginal => ({ + ...await importOriginal(), + useSidebarDevices: () => slots.value, +})); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useWorkspaceContext: () => ({ loading: live.loading }) })); +vi.mock('../workspaces/WorkspaceListSection', () => ({ default: () =>
})); +vi.mock('./DeviceCatalogSection', () => ({ + default: ({ slot }: { slot: { surfaceId: string } }) =>
, +})); +vi.mock('@/infrastructure/peer-device/deviceWorkspaceCatalog', () => ({ + deviceWorkspaceCatalog: { + getSnapshot: () => ({ status: 'ready', workspaces: [{ id: 'w' }], sessions: {} }), + subscribe: () => () => undefined, + retainActivity: () => () => undefined, + }, +})); +vi.mock('@/flow_chat/services/backgroundSurfaceSeeding', () => ({ installBackgroundSurfaceSeeding: () => () => undefined })); + +import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; +import DeviceWorkspaceSections from './DeviceWorkspaceSections'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const slot = (surfaceId: string, rendered: boolean) => ({ + surfaceId, deviceId: surfaceId, name: surfaceId, isLocal: surfaceId === 'local', rendered, hostKind: 'desktop', controllable: true, +}); + +describe('DeviceWorkspaceSections hand-over', () => { + let host: HTMLDivElement; + let root: Root; + const render = () => act(() => root.render()); + const body = (surfaceId: string) => host.querySelector(`[data-device-surface="${surfaceId}"] .openbitfun-nav-panel__device-body > *`)?.getAttribute('data-testid'); + + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + live.loading = false; + }); + afterEach(() => { act(() => root.unmount()); host.remove(); }); + + it('keeps every device on its background rows until the open settles, then hands over once', () => { + const intent = beginDeviceOpen({ surfaceId: 'peer-b', key: 'session:s', sessionId: 's' }); + slots.value = [slot('local', false), slot('peer-b', true)]; + render(); + expect(body('local')).toBe('background-list'); + expect(body('peer-b')).toBe('background-list'); + act(() => endDeviceOpen(intent)); + expect(body('peer-b')).toBe('live-list'); + expect(body('local')).toBe('background-list'); + }); + + it('waits for the rendered device to finish loading before showing its live rows', () => { + live.loading = true; + slots.value = [slot('local', true), slot('peer-b', false)]; + render(); + expect(body('local')).toBe('background-list'); + live.loading = false; + render(); + expect(body('local')).toBe('live-list'); + }); + + it('keeps device sections in a fixed order whichever device is rendered', () => { + slots.value = [slot('local', true), slot('peer-b', false)]; + render(); + const order = () => [...host.querySelectorAll('[data-testid="nav-device-section"]')].map(node => node.getAttribute('data-device-surface')); + expect(order()).toEqual(['local', 'peer-b']); + slots.value = [slot('local', false), slot('peer-b', true)]; + render(); + expect(order()).toEqual(['local', 'peer-b']); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx new file mode 100644 index 0000000000..12d4dde803 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx @@ -0,0 +1,195 @@ +/** + * The session list grouped by device. + * + * Exactly one device is rendered by the window (Peer Device Mode). Its section + * is the live `WorkspaceListSection`; every other online host of the account + * gets a section fed by the read-only device catalog, loaded only once the + * section is expanded. Sections keep a fixed order, so opening something on + * another device changes the rendered device without moving the list. + * + * With a single device the list renders exactly as before, without headers. + */ + +import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useSyncExternalStore } from 'react'; +import { Icon, OverflowText } from '@openbitfun/ui'; +import { Loader2 } from 'lucide-react'; +import { useI18n } from '@/infrastructure/i18n'; +import { getActiveSurfaceId, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import WorkspaceListSection from '../workspaces/WorkspaceListSection'; +import DeviceCatalogSection from './DeviceCatalogSection'; +import { useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { installBackgroundSurfaceSeeding } from '@/flow_chat/services/backgroundSurfaceSeeding'; +import { devicePlatform, useDeviceSectionDisclosure, useSidebarDevices, type SidebarDeviceSlot } from './useSidebarDevices'; +import './DeviceWorkspaceSections.scss'; + +interface DeviceWorkspaceSectionsProps { + scrollRootRef?: React.RefObject; +} + +function useCatalogHasWorkspaces(surfaceId: string): boolean { + const subscribe = useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); + const read = useCallback(() => deviceWorkspaceCatalog.getSnapshot(surfaceId).workspaces.length > 0 + || workspaceManager.getSurfaceOpenedWorkspaces(surfaceId).length > 0, [surfaceId]); + return useSyncExternalStore(subscribe, read, read); +} + +function useCatalogLoading(surfaceId: string, rendered: boolean): boolean { + const subscribe = useCallback((notify: () => void) => deviceWorkspaceCatalog.subscribe(changed => { + if (changed === surfaceId) notify(); + }), [surfaceId]); + const read = useCallback( + () => !rendered && deviceWorkspaceCatalog.getSnapshot(surfaceId).status === 'loading', + [rendered, surfaceId], + ); + return useSyncExternalStore(subscribe, read, read); +} + +/** + * Hold the list's height and scroll offset while a cross-device open runs. + * Sections hand over between catalog and live rows during a switch; without + * the hold, a momentarily shorter list clamps the scroll position to the top. + */ +function useSwitchLayoutHold( + containerRef: React.RefObject, + scrollRootRef?: React.RefObject, +): void { + const switching = useDeviceOpenIntent(state => state.intent !== null); + const held = useRef<{ scrollTop: number; userScrolled: boolean } | null>(null); + useLayoutEffect(() => { + const container = containerRef.current; + const scrollRoot = scrollRootRef?.current; + if (!container) return; + if (switching) { + container.style.minHeight = `${container.offsetHeight}px`; + const hold = { scrollTop: scrollRoot?.scrollTop ?? 0, userScrolled: false }; + held.current = hold; + const onWheel = () => { hold.userScrolled = true; }; + scrollRoot?.addEventListener('wheel', onWheel, { passive: true }); + scrollRoot?.addEventListener('touchmove', onWheel, { passive: true }); + return () => { + scrollRoot?.removeEventListener('wheel', onWheel); + scrollRoot?.removeEventListener('touchmove', onWheel); + }; + } + const hold = held.current; + held.current = null; + if (!hold) return; + // Let the live rows settle, then restore the offset and release the floor. + let frame = 0; + let frames = 0; + const settle = () => { + if (scrollRoot && !hold.userScrolled && scrollRoot.scrollTop !== hold.scrollTop) scrollRoot.scrollTop = hold.scrollTop; + if (++frames < 20) frame = requestAnimationFrame(settle); + else container.style.minHeight = ''; + }; + frame = requestAnimationFrame(settle); + return () => { cancelAnimationFrame(frame); container.style.minHeight = ''; }; + }, [containerRef, scrollRootRef, switching]); +} + +const DeviceSection: React.FC<{ + slot: SidebarDeviceSlot; + scrollRootRef?: React.RefObject; +}> = ({ slot, scrollRootRef }) => { + const { t } = useI18n('common'); + const bodyId = useId(); + const explicit = useDeviceSectionDisclosure(state => state.expanded[slot.surfaceId]); + const setExpanded = useDeviceSectionDisclosure(state => state.setExpanded); + // Every device starts open: its sessions coexist with this machine's. + const expanded = explicit ?? true; + const opening = useDeviceOpenIntent(state => state.intent?.surfaceId === slot.surfaceId); + const loading = useCatalogLoading(slot.surfaceId, slot.rendered) || (opening && !slot.rendered); + // While the rendered device's live catalog hydrates after a switch, keep + // drawing its warm device catalog so the list neither empties nor jumps. + const { loading: liveLoading } = useWorkspaceContext(); + const hasCatalog = useCatalogHasWorkspaces(slot.surfaceId); + // During a cross-device open every device draws its background projection, + // which uses the live list's data and rules; the rendered device hands over + // to its live list once, after the open, so its reads never delay the open. + const switching = useDeviceOpenIntent(state => state.intent !== null); + const showLive = slot.rendered && ((!switching && !liveLoading) || !hasCatalog); + // CLI hosts say so; client hosts name their platform. + const platform = devicePlatform(slot.os); + const hostTag = slot.hostKind === 'cli' ? t('nav.devices.hostCli') + : platform ? t(`nav.devices.platform.${platform}`) : null; + const toggleLabel = t(expanded ? 'nav.devices.collapse' : 'nav.devices.expand', { name: slot.name }); + + return ( +
+ + {expanded && ( +
+ {showLive + ? + : } +
+ )} +
+ ); +}; + +const DeviceWorkspaceSections: React.FC = ({ scrollRootRef }) => { + const { t } = useI18n('common'); + const slots = useSidebarDevices(t('deviceOverview.thisDevice')); + const activeSurfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); + const setExpanded = useDeviceSectionDisclosure(state => state.setExpanded); + + // Every listed host keeps its session statuses current (debounced host + // invalidations), whether or not its section is open. The rendered device is + // kept warm too, so leaving it shows its catalog at once instead of a reload. + const backgroundSurfaces = slots.filter(slot => slot.controllable && slots.length > 1) + .map(slot => slot.surfaceId).join('\n'); + const multiDevice = slots.length > 1; + useEffect(() => (multiDevice ? installBackgroundSurfaceSeeding() : undefined), [multiDevice]); + useEffect(() => { + if (!backgroundSurfaces) return; + const releases = backgroundSurfaces.split('\n').map(surfaceId => deviceWorkspaceCatalog.retainActivity(surfaceId)); + return () => { for (const release of releases) release(); }; + }, [backgroundSurfaces]); + + // Whatever renders a device (this list, the device switcher, a create flow) + // reveals its section, so the opened item is never hidden behind a header. + useEffect(() => { + if (useDeviceSectionDisclosure.getState().expanded[activeSurfaceId] === false) { + setExpanded(activeSurfaceId, true); + } + }, [activeSurfaceId, setExpanded]); + + const containerRef = useRef(null); + useSwitchLayoutHold(containerRef, scrollRootRef); + + if (slots.length <= 1) { + return ; + } + return ( +
+ {slots.map(slot => )} +
+ ); +}; + +export default DeviceWorkspaceSections; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.test.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.test.ts new file mode 100644 index 0000000000..f291ccd7cb --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { catalogSessionStatus } from './catalogSessionStatus'; + +const summary = (extra: Record) => ({ + sessionId: 's', execution: 'idle', pendingApprovals: 0, pendingQuestions: 0, ...extra, +}); + +describe('catalogSessionStatus', () => { + it('prioritizes interactions that block the host', () => { + expect(catalogSessionStatus(summary({ execution: 'running', pendingApprovals: 2 }))).toEqual({ kind: 'approval', pendingCount: 2 }); + expect(catalogSessionStatus(summary({ execution: 'running', pendingQuestions: 1 })).kind).toBe('input'); + }); + + it('maps execution and unread results', () => { + expect(catalogSessionStatus(summary({ execution: 'running' })).kind).toBe('running'); + expect(catalogSessionStatus(summary({ execution: 'queued' })).kind).toBe('queued'); + expect(catalogSessionStatus(summary({ unreadCompletion: 'completed' })).kind).toBe('unread'); + expect(catalogSessionStatus(summary({ unreadCompletion: 'interrupted' })).kind).toBe('stopped'); + expect(catalogSessionStatus(summary({ unreadCompletion: 'error' })).kind).toBe('error'); + }); + + it('is idle without a summary', () => { + expect(catalogSessionStatus(undefined).kind).toBe('idle'); + expect(catalogSessionStatus(summary({})).kind).toBe('idle'); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.ts new file mode 100644 index 0000000000..b19dc25d13 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/catalogSessionStatus.ts @@ -0,0 +1,40 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { sessionActivityStore } from '@/flow_chat/store/sessionActivityStore'; +import { crossDeviceActivity } from '@/flow_chat/services/crossDeviceSessions'; +import type { SessionNavStatus } from '@/flow_chat/utils/sessionNavStatus'; +import type { SessionActivitySummary } from '@/shared/types/session-history'; + +const IDLE: SessionNavStatus = { kind: 'idle', pendingCount: 0 }; + +/** + * Nav status of a session that has no live container on this window. Only the + * host's activity summary is known, so the projection is the summary's. + */ +export function catalogSessionStatus(summary: SessionActivitySummary | undefined): SessionNavStatus { + if (!summary) return IDLE; + if (summary.pendingApprovals > 0) return { kind: 'approval', pendingCount: summary.pendingApprovals }; + if (summary.pendingQuestions > 0) return { kind: 'input', pendingCount: summary.pendingQuestions }; + if (summary.execution === 'running') return { kind: 'running', pendingCount: 0 }; + if (summary.execution === 'queued') return { kind: 'queued', pendingCount: 0 }; + if (summary.execution === 'error' || summary.unreadCompletion === 'error') return { kind: 'error', pendingCount: 0 }; + if (summary.unreadCompletion === 'interrupted') return { kind: 'stopped', pendingCount: 0 }; + if (summary.unreadCompletion === 'completed') return { kind: 'unread', pendingCount: 0 }; + return IDLE; +} + +/** + * The host's catalog summary is refreshed on every lifecycle invalidation, so + * it wins; observed events only fill a session the catalog has no summary for. + */ +export function useCatalogSessionStatus( + surfaceId: DeviceSurfaceId, + sessionId: string, + pageSummary: SessionActivitySummary | undefined, +): SessionNavStatus { + const subscribe = useCallback((notify: () => void) => sessionActivityStore.subscribe((changedSurface, changedSession) => { + if (changedSurface === surfaceId && changedSession === sessionId) notify(); + }), [sessionId, surfaceId]); + const read = useCallback(() => crossDeviceActivity(surfaceId, sessionId, pageSummary), [pageSummary, sessionId, surfaceId]); + return catalogSessionStatus(useSyncExternalStore(subscribe, read, read)); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts new file mode 100644 index 0000000000..b8babe89ea --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + sessions: new Map(), + adopt: vi.fn(async (_workspaceId: string, sessions: Array<{ sessionId: string }>) => { + for (const session of sessions) mocks.sessions.set(session.sessionId, session); + }), + intent: vi.fn(), + hold: vi.fn(), + clearIntent: vi.fn(), + promote: vi.fn(async () => undefined), + ensureMetadata: vi.fn(async () => true), + clearUnread: vi.fn(), + openMainSession: vi.fn(async () => undefined), + createChatDraft: vi.fn(async () => 'new-session'), + openLatestAssistant: vi.fn(async () => undefined), + workspaces: new Map(), +})); + +vi.mock('@/flow_chat/store/FlowChatStore', () => ({ + flowChatStore: { + ensurePersistedSessionMetadata: mocks.ensureMetadata, + clearSessionUnreadCompletion: mocks.clearUnread, + adoptHostSessionMetadata: mocks.adopt, + promoteSeededSessions: mocks.promote, + getState: () => ({ sessions: mocks.sessions }), + }, +})); +vi.mock('@/flow_chat/services/sessionOpenIntent', () => ({ + dispatchHistorySessionOpenIntent: mocks.intent, + clearHistorySessionOpenTransition: mocks.clearIntent, +})); +vi.mock('@/app/stores/sceneStore', () => ({ useSceneStore: { getState: () => ({ holdSessionScene: mocks.hold }) } })); +vi.mock('@/infrastructure/i18n', () => ({ i18nService: { t: (key: string) => key } })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn() }) })); +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ flowChatManager: { createChatDraft: mocks.createChatDraft } })); +vi.mock('@/flow_chat/services/sessionActivation', () => ({ openMainSession: mocks.openMainSession })); +vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ + workspaceManager: { getState: () => ({ openedWorkspaces: mocks.workspaces }), setActiveWorkspace: vi.fn() }, +})); +vi.mock('@/app/utils/projectSessionWorkspace', () => ({ + findReusableEmptySessionId: () => null, + flowChatSessionConfigForWorkspace: (workspace: { id: string }) => ({ workspaceId: workspace.id }), +})); +vi.mock('../workspaces/openLatestAssistantSession', () => ({ openLatestAssistantSession: mocks.openLatestAssistant })); +vi.mock('@/shared/types', () => ({ WorkspaceKind: { Assistant: 'assistant', Normal: 'normal', Remote: 'remote' } })); + +import { activateSurface, resetDeviceSurfaceForTest, surfaceIdForDevice } from '@/infrastructure/peer-device/deviceSurface'; +import type { PeerDeviceContextValue } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { activateDeviceSlot, createDeviceSession, openDeviceAssistant, openDeviceSession } from './openOnDevice'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +const peerSlot: SidebarDeviceSlot = { + surfaceId: 'peer-a', deviceId: 'peer-a', name: 'Studio Mac', isLocal: false, + rendered: false, hostKind: 'desktop', controllable: true, +}; +const localSlot: SidebarDeviceSlot = { ...peerSlot, surfaceId: 'local', deviceId: 'me', name: 'Laptop', isLocal: true }; + +function peerContext(outcome: 'activated' | 'superseded' = 'activated') { + return { + switchToDevice: vi.fn(async (deviceId: string) => { + if (outcome === 'activated') activateSurface(surfaceIdForDevice(deviceId)); + return outcome; + }), + switchToLocal: vi.fn(async () => { + if (outcome === 'activated') activateSurface(surfaceIdForDevice(null)); + return outcome; + }), + } as unknown as PeerDeviceContextValue & { + switchToDevice: ReturnType; + switchToLocal: ReturnType; + }; +} + +describe('openOnDevice', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetDeviceSurfaceForTest(); + mocks.workspaces.clear(); + mocks.sessions.clear(); + mocks.workspaces.set('ws-1', { id: 'ws-1', workspaceKind: 'normal', rootPath: '/p' }); + }); + + it('does not switch when the device is already rendered', async () => { + const peer = peerContext(); + expect(await activateDeviceSlot(peer, localSlot)).toBe('activated'); + expect(peer.switchToLocal).not.toHaveBeenCalled(); + expect(peer.switchToDevice).not.toHaveBeenCalled(); + }); + + it('shows the open overlay at once, promotes seeds after the switch, and opens a seeded session without a read', async () => { + mocks.sessions.set('s-1', { sessionId: 's-1' }); + const peer = peerContext(); + await openDeviceSession(peer, peerSlot, 'ws-1', { sessionId: 's-1' }, 'Title'); + expect(mocks.hold).toHaveBeenCalledWith({ surfaceId: 'peer-a', workspaceKey: '["workspace","ws-1"]', sessionId: 's-1' }); + expect(mocks.hold.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); + expect(mocks.intent.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); + expect(peer.switchToDevice).toHaveBeenCalledWith('peer-a', 'Studio Mac'); + expect(mocks.promote).toHaveBeenCalledOnce(); + expect(mocks.adopt).not.toHaveBeenCalled(); + expect(mocks.ensureMetadata).not.toHaveBeenCalled(); + expect(mocks.openMainSession).toHaveBeenCalledWith('s-1', expect.objectContaining({ workspaceId: 'ws-1' })); + }); + + it('adopts the row record when the session was not seeded yet', async () => { + await openDeviceSession(peerContext(), peerSlot, 'ws-1', { sessionId: 's-2', createdAt: 1 } as never); + expect(mocks.adopt).toHaveBeenCalledWith('ws-1', [{ sessionId: 's-2', createdAt: 1 }]); + expect(mocks.openMainSession).toHaveBeenCalledWith('s-2', expect.anything()); + }); + + it('fails loudly and clears the overlay when the host no longer has the session', async () => { + mocks.adopt.mockImplementationOnce(async () => undefined); + await expect(openDeviceSession(peerContext(), peerSlot, 'ws-1', { sessionId: 'gone', createdAt: 1 } as never)) + .rejects.toThrow('common:nav.devices.sessionUnavailable'); + expect(mocks.clearIntent).toHaveBeenCalledWith('gone'); + expect(mocks.openMainSession).not.toHaveBeenCalled(); + }); + + it('returns to this machine through the local switch', async () => { + activateSurface(surfaceIdForDevice('peer-a')); + const peer = peerContext(); + mocks.sessions.set('s-1', { sessionId: 's-1' }); + await openDeviceSession(peer, localSlot, 'ws-1', { sessionId: 's-1' } as never); + expect(peer.switchToLocal).toHaveBeenCalledOnce(); + expect(mocks.openMainSession).toHaveBeenCalledOnce(); + }); + + it('opens nothing when a newer switch superseded this one', async () => { + const peer = peerContext('superseded'); + await openDeviceSession(peer, peerSlot, 'ws-1', { sessionId: 's-1' } as never); + expect(mocks.clearIntent).toHaveBeenCalledWith('s-1'); + await openDeviceAssistant(peer, peerSlot, 'ws-1'); + await createDeviceSession(peer, peerSlot, 'ws-1'); + expect(mocks.adopt).not.toHaveBeenCalled(); + expect(mocks.openLatestAssistant).not.toHaveBeenCalled(); + expect(mocks.createChatDraft).not.toHaveBeenCalled(); + }); + + it('creates a session with the live workspace of the rendered device', async () => { + const peer = peerContext(); + await createDeviceSession(peer, peerSlot, 'ws-1'); + expect(mocks.createChatDraft).toHaveBeenCalledWith({ workspaceId: 'ws-1' }, undefined); + expect(mocks.openMainSession).toHaveBeenCalledWith('new-session', expect.objectContaining({ workspaceId: 'ws-1' })); + }); + + it('fails loudly when the workspace is gone on the device', async () => { + const peer = peerContext(); + await expect(createDeviceSession(peer, peerSlot, 'missing')).rejects.toThrow('missing'); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts new file mode 100644 index 0000000000..d74bba30e7 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts @@ -0,0 +1,166 @@ +/** + * Open catalog entries of a device the window is not rendering. + * + * The catalog is read-only. Acting on an entry first renders its device through + * the Peer Device Mode surface switch, then runs the exact local handler on the + * now-rendered surface, so ownership, capability gates, permission prompts and + * surface fences are the ones remote control already uses. + */ + +import { getActiveSurfaceId, getActiveSurfaceScope, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; +import { i18nService } from '@/infrastructure/i18n'; +import { clearHistorySessionOpenTransition, dispatchHistorySessionOpenIntent } from '@/flow_chat/services/sessionOpenIntent'; +import type { SessionMetadata } from '@/shared/types/session-history'; +import { createLogger } from '@/shared/utils/logger'; +import type { PeerDeviceContextValue } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { openMainSession } from '@/flow_chat/services/sessionActivation'; +import { findReusableEmptySessionId, flowChatSessionConfigForWorkspace } from '@/app/utils/projectSessionWorkspace'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; +import { openLatestAssistantSession } from '../workspaces/openLatestAssistantSession'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { projectResourceSceneId } from '@/app/scenes/project-resources/projectResourceTarget'; +import { + requestWorkspaceAction, + usePendingWorkspaceAction, + type PendingWorkspaceActionKind, +} from '../workspaces/pendingWorkspaceAction'; +import { sessionSceneWorkspaceKey } from '@/app/services/sessionSceneTarget'; +import type { SidebarDeviceSlot } from './useSidebarDevices'; + +const log = createLogger('OpenOnDevice'); + +const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()); + +export type DeviceActivationResult = 'activated' | 'superseded'; + +/** + * Render `slot`'s device. Returns `superseded` when a newer switch won. + * + * The device's sessions are already in its container (seeded from the device + * catalog), so nothing is read here beyond the switch itself. Seeds are + * promoted through the normal metadata path before anything opens them. + */ +export async function activateDeviceSlot( + peer: PeerDeviceContextValue | null, + slot: Pick, +): Promise { + if (getActiveSurfaceId() === slot.surfaceId) return 'activated'; + if (!peer) throw new Error('Peer Device Mode is unavailable in this window'); + if (!slot.isLocal && !slot.deviceId) throw new Error('Device id is unavailable'); + const startedAt = now(); + const outcome = slot.isLocal + ? await peer.switchToLocal('sidebar') + : await peer.switchToDevice(slot.deviceId!, slot.name); + log.info('Device surface rendered for open', { surfaceId: slot.surfaceId, outcome, switchMs: Math.round(now() - startedAt) }); + if (outcome === 'activated') { + const promoteStartedAt = now(); + try { + await flowChatStore.promoteSeededSessions(); + } catch (error) { + if (isSurfaceChangedError(error)) throw error; + log.warn('Failed to promote seeded sessions', { surfaceId: slot.surfaceId, error }); + } + log.info('Seeded sessions promoted', { surfaceId: slot.surfaceId, promoteMs: Math.round(now() - promoteStartedAt) }); + } + return outcome; +} + +/** The live record of a workspace on the rendered surface. */ +function liveWorkspace(workspaceId: string): WorkspaceInfo { + const workspace = workspaceManager.getState().openedWorkspaces.get(workspaceId); + if (!workspace) throw new Error(`Workspace is unavailable on this device: ${workspaceId}`); + return workspace; +} + +const activateWorkspace = (workspaceId: string) => workspaceManager.setActiveWorkspace(workspaceId); + +/** A cross-device open shows the main-area session overlay for this long at most. */ +const CROSS_DEVICE_OPEN_OVERLAY_MS = 20_000; + +export async function openDeviceSession( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, + session: Pick & Partial, + title?: string, +): Promise { + const sessionId = session.sessionId; + const startedAt = now(); + // From the click on, the main area shows this session's tab and its loading + // presentation; the tab is held through the switch until the open settles. + if (getActiveSurfaceId() !== slot.surfaceId) { + useSceneStore.getState().holdSessionScene({ + surfaceId: slot.surfaceId, workspaceKey: sessionSceneWorkspaceKey(workspaceId), sessionId, + }); + dispatchHistorySessionOpenIntent(sessionId, title, CROSS_DEVICE_OPEN_OVERLAY_MS); + } + if (await activateDeviceSlot(peer, slot) !== 'activated') { + clearHistorySessionOpenTransition(sessionId); + return; + } + const scope = getActiveSurfaceScope(); + if (!flowChatStore.getState().sessions.has(sessionId) && session.createdAt !== undefined) { + // Not seeded yet (opened before the catalog read finished): the row's own + // record is the host's, so no second read is needed. + await flowChatStore.adoptHostSessionMetadata(workspaceId, [session as SessionMetadata]); + scope.assertCurrent('adopt device catalog session'); + } + if (!flowChatStore.getState().sessions.has(sessionId)) { + clearHistorySessionOpenTransition(sessionId); + throw new Error(i18nService.t('common:nav.devices.sessionUnavailable')); + } + dispatchHistorySessionOpenIntent(sessionId, title, CROSS_DEVICE_OPEN_OVERLAY_MS); + flowChatStore.clearSessionUnreadCompletion(sessionId); + await openMainSession(sessionId, { workspaceId, activateWorkspace, isCurrent: scope.isCurrent }); + log.info('Cross-device session opened', { surfaceId: slot.surfaceId, sessionId, totalMs: Math.round(now() - startedAt) }); +} + +export async function openDeviceAssistant( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, + onDefaultUnavailable?: () => void, +): Promise { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + await openLatestAssistantSession(liveWorkspace(workspaceId), activateWorkspace, onDefaultUnavailable); +} + +/** Render the device, then let its live row run the action (menu, history). */ +export async function openDeviceWorkspaceAction( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, + kind: PendingWorkspaceActionKind, +): Promise { + requestWorkspaceAction({ surfaceId: slot.surfaceId, workspaceId, kind }); + if (await activateDeviceSlot(peer, slot) !== 'activated') usePendingWorkspaceAction.setState({ action: null }); +} + +export async function openDeviceProjectResources( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, +): Promise { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + liveWorkspace(workspaceId); + useSceneStore.getState().openScene(projectResourceSceneId({ surfaceId: getActiveSurfaceId(), workspaceId })); +} + +export async function createDeviceSession( + peer: PeerDeviceContextValue | null, + slot: SidebarDeviceSlot, + workspaceId: string, +): Promise { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + const scope = getActiveSurfaceScope(); + const workspace = liveWorkspace(workspaceId); + const mode = workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined; + // Same reuse rule as the local "new session" action. + const sessionId = findReusableEmptySessionId(workspace, mode) + ?? await flowChatManager.createChatDraft(flowChatSessionConfigForWorkspace(workspace), mode); + scope.assertCurrent('open new device session'); + await openMainSession(sessionId, { workspaceId, activateWorkspace, isCurrent: scope.isCurrent }); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.test.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.test.ts new file mode 100644 index 0000000000..6d0577c729 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { devicePlatform } from './useSidebarDevices'; + +describe('devicePlatform', () => { + it('names the platform of reported OS strings and user agents', () => { + expect(devicePlatform('macOS 15.1')).toBe('mac'); + expect(devicePlatform('MacIntel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('mac'); + expect(devicePlatform('Windows 11')).toBe('windows'); + expect(devicePlatform('Ubuntu 24.04')).toBe('linux'); + expect(devicePlatform(null)).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.ts new file mode 100644 index 0000000000..83d2b863a1 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/useSidebarDevices.ts @@ -0,0 +1,118 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { useDeviceDirectory, isDeviceControllable, resolveDeviceNameFrom } from '@/infrastructure/account/deviceDirectory'; +import { getActiveSurfaceId, LOCAL_SURFACE_ID, onSurfaceActivated, surfaceIdForDevice, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { reportedHostKind, type DeviceOverviewHostKind } from '../../deviceInterconnectionOverview'; + +/** One device in the sidebar session list. */ +export interface SidebarDeviceSlot { + surfaceId: DeviceSurfaceId; + /** Account device id; null only while the local id is not known yet. */ + deviceId: string | null; + name: string; + isLocal: boolean; + /** The window renders this device; its slot uses the live workspace list. */ + rendered: boolean; + hostKind: DeviceOverviewHostKind | null; + /** Reported operating system, when known. */ + os: string | null; + /** Relay-confirmed mutual control; this machine is never gated by its own flag. */ + controllable: boolean; +} + +export type DevicePlatform = 'mac' | 'windows' | 'linux'; + +/** Platform of a reported OS string ("macOS 15", "Windows 11", user agent, ...). */ +export function devicePlatform(os: string | null | undefined): DevicePlatform | null { + const value = os?.toLowerCase() ?? ''; + if (/mac|darwin/.test(value)) return 'mac'; + if (/win/.test(value)) return 'windows'; + if (/linux|ubuntu|debian|fedora|arch|centos/.test(value)) return 'linux'; + return null; +} + +/** Devices that report a controller kind have no workspaces to list. */ +const CONTROLLER_KINDS = new Set(['mobile', 'watch']); + +/** + * The sidebar's device order: this machine first, then every online host of + * the account by name. The order never depends on which device is rendered, + * so opening something on another device does not move any section. + * + * Returns a single slot when there is nothing else to show, so a signed-out or + * single-device window renders exactly the plain workspace list. + */ +export function useSidebarDevices(localLabel: string): SidebarDeviceSlot[] { + const directory = useDeviceDirectory(); + const peer = usePeerDeviceModeOptional(); + const activeSurfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); + const renderedPeer = peer?.peerMode.active ? peer.peerMode : null; + const attachments = peer?.attachments; + + return useMemo(() => { + const localId = directory.localId; + const local = localId ? directory.devices.find(device => device.device_id === localId) : undefined; + const slots: SidebarDeviceSlot[] = [{ + surfaceId: LOCAL_SURFACE_ID, + deviceId: localId, + name: local ? resolveDeviceNameFrom(directory.devices, local.device_id) : localLabel, + isLocal: true, + rendered: activeSurfaceId === LOCAL_SURFACE_ID, + hostKind: reportedHostKind(local?.device_kind) ?? 'desktop', + os: local?.device_os ?? (typeof navigator === 'undefined' ? null : `${navigator.platform ?? ''} ${navigator.userAgent ?? ''}`), + controllable: true, + }]; + const peers: SidebarDeviceSlot[] = directory.devices + .filter(device => device.device_id !== localId && device.online + && !CONTROLLER_KINDS.has(device.device_kind?.trim().toLowerCase() ?? '')) + .map(device => { + const surfaceId = surfaceIdForDevice(device.device_id); + const linkKind = attachments?.find(attachment => attachment.deviceId === device.device_id)?.capabilities?.hostKind; + return { + surfaceId, + deviceId: device.device_id, + name: resolveDeviceNameFrom(directory.devices, device.device_id), + isLocal: false, + rendered: activeSurfaceId === surfaceId, + hostKind: linkKind ?? reportedHostKind(device.device_kind), + os: device.device_os ?? null, + controllable: isDeviceControllable(device), + }; + }); + // Presence can lag behind a live control link; the rendered peer stays listed. + if (renderedPeer && !peers.some(slot => slot.deviceId === renderedPeer.deviceId)) { + peers.push({ + surfaceId: surfaceIdForDevice(renderedPeer.deviceId), + deviceId: renderedPeer.deviceId, + name: resolveDeviceNameFrom(directory.devices, renderedPeer.deviceId, renderedPeer.deviceName), + isLocal: false, + rendered: true, + hostKind: attachments?.find(attachment => attachment.deviceId === renderedPeer.deviceId)?.capabilities?.hostKind ?? null, + os: directory.devices.find(device => device.device_id === renderedPeer.deviceId)?.device_os ?? null, + controllable: true, + }); + } + peers.sort((a, b) => a.name.localeCompare(b.name) || a.surfaceId.localeCompare(b.surfaceId)); + return [...slots, ...peers]; + }, [activeSurfaceId, attachments, directory.devices, directory.localId, localLabel, renderedPeer]); +} + +interface DeviceSectionDisclosureState { + /** Explicit choices only; absent means open. */ + expanded: Record; + setExpanded: (surfaceId: DeviceSurfaceId, expanded: boolean) => void; +} + +export const useDeviceSectionDisclosure = create()(persist( + set => ({ + expanded: {}, + setExpanded: (surfaceId, expanded) => set(state => ({ expanded: { ...state.expanded, [surfaceId]: expanded } })), + }), + { + name: 'openbitfun.sidebar-device-sections.v1', + storage: createJSONStorage(() => localStorage), + partialize: state => ({ expanded: state.expanded }), + }, +)); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/useSurfaceSessions.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/useSurfaceSessions.ts new file mode 100644 index 0000000000..31dc9e3e0b --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/useSurfaceSessions.ts @@ -0,0 +1,18 @@ +import { useCallback, useSyncExternalStore } from 'react'; +import { getActiveSurfaceId, onSurfaceActivated, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; + +/** A surface's sessions; re-renders on seeding and on rendered-surface updates. */ +export function useSurfaceSessions(surfaceId: DeviceSurfaceId): ReadonlyMap { + const subscribe = useCallback((notify: () => void) => { + const unsubscribeSurface = flowChatStore.subscribeSurfaceSessions(changed => { if (changed === surfaceId) notify(); }); + // Rendered-surface updates (streaming included) matter only while this + // device is the rendered one; selectors keep other devices quiet. + const unsubscribeActive = flowChatStore.subscribe(() => { if (getActiveSurfaceId() === surfaceId) notify(); }); + const unsubscribeActivation = onSurfaceActivated(notify); + return () => { unsubscribeSurface(); unsubscribeActive(); unsubscribeActivation(); }; + }, [surfaceId]); + const read = useCallback(() => flowChatStore.getSurfaceSessions(surfaceId), [surfaceId]); + return useSyncExternalStore(subscribe, read, read); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx index 69968eefc7..f6faa6b250 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx @@ -3,6 +3,7 @@ import { Icon } from '@openbitfun/ui'; import { CircleAlert, CircleCheck, CirclePause, CircleStop, CloudOff, Hand, Loader2, MessageCircleQuestion } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { useSessionNavStatus } from '@/flow_chat/hooks/useSessionNavStatus'; +import type { SessionNavStatus } from '@/flow_chat/utils/sessionNavStatus'; import './SessionStatusIndicator.scss'; const presentation = { @@ -17,20 +18,15 @@ const presentation = { syncing: { glyph: CloudOff, tone: 'secondary', label: 'nav.sessions.status.syncing' }, } as const; -export const SessionStatusIndicator = memo(function SessionStatusIndicator({ - sessionId, +/** The status cell for a status the caller already derived. */ +export const SessionStatusMark = memo(function SessionStatusMark({ + status, idleFallback, }: { - sessionId: string; - /** - * Rendered in the same trailing cell while the session has nothing to report, - * so a second row mark can sit on the row's right edge without claiming a - * second cell. The cell still yields to the row menu on hover or focus. - */ + status: SessionNavStatus; idleFallback?: ReactNode; }) { const { t } = useI18n('common'); - const status = useSessionNavStatus(sessionId); const appearance = status.kind === 'idle' ? null : presentation[status.kind]; const label = appearance ? status.kind === 'approval' && status.pendingCount > 1 @@ -66,3 +62,19 @@ export const SessionStatusIndicator = memo(function SessionStatusIndicator({ ) : null; }); + +export const SessionStatusIndicator = memo(function SessionStatusIndicator({ + sessionId, + idleFallback, +}: { + sessionId: string; + /** + * Rendered in the same trailing cell while the session has nothing to report, + * so a second row mark can sit on the row's right edge without claiming a + * second cell. The cell still yields to the row menu on hover or focus. + */ + idleFallback?: ReactNode; +}) { + const status = useSessionNavStatus(sessionId); + return ; +}); 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 02c2f82b0e..db5f936ade 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 @@ -50,6 +50,7 @@ import { matchesWorkspaceSourceAgent, sessionSourceAgentLabel, useWorkspaceSessionViewStore, + workspaceSessionViewKey, } from '../../workspaceSessionView'; import { stateMachineManager } from '@/flow_chat/state-machine'; import { sessionNavStatusService } from '@/flow_chat/services/sessionNavStatusService'; @@ -57,6 +58,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { isDefaultSessionTitle, resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers'; import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; +import { getChildSessionBadge } from './sessionRowBadges'; import { useSessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; import { getActiveSurfaceId, getActiveSurfaceScope, isSurfaceChangedError, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; import { EXTERNAL_SESSION_NAMES, isExternalSessionEcosystem } from '@/shared/types/external-session'; @@ -67,6 +69,7 @@ import ArchivedSessionMenuItems from './ArchivedSessionMenuItems'; import { captureWorkspaceSessionCatalogQuery, countPersistedWorkspaceCatalogSessions, resolveWorkspaceSessionCatalogSource, workspaceSessionCatalogQuery, workspaceSessionCatalogSources, workspaceSessionCatalogTotal } from './workspaceSessionCatalogQuery'; import { useWorkspaceSessionDiscovery } from './useWorkspaceSessionDiscovery'; import { useSessionNavExpansion } from './useSessionNavExpansion'; +import { knownWorkspaceSessionTotal, presentedSessionTotal, rememberWorkspaceSessionTotal } from '@/flow_chat/services/workspaceSessionTotals'; import { isSessionNavRowActive } from './sessionNavSelection'; import { isSessionRowPointerTarget } from './sessionOpenPointer'; import { @@ -110,8 +113,10 @@ import { import { getSessionDisplayLimit, getSessionExpandToggleState, - SESSIONS_LEVEL_0, - SESSIONS_LEVEL_1, + nextSessionDisplayCount, + SESSIONS_INITIAL_COUNT, + SESSIONS_LEVEL_2_PAGE, + SESSIONS_PREFETCH_COUNT, } from './sessionNavExpand'; import { useSessionRowRemovalTransition } from './sessionRowShift'; import { SessionStatusIndicator } from './SessionStatusIndicator'; @@ -125,7 +130,7 @@ const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/sched type HistoryOpenIntentDispatchResult = 'none' | 'dispatched' | 'already-pending'; /** Page size for the fully-expanded (level 2) session list. */ -const SESSIONS_LEVEL_2_PAGE = 200; + const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); @@ -142,22 +147,6 @@ function DefaultSessionTitlePreview({ sessionId, createdAt }: Pick { - const normalizedKind = - kind === 'review' || kind === 'deep_review' || kind === 'subagent' - ? kind - : 'btw'; - const fallback = normalizedKind === 'deep_review' - ? 'Strict' - : normalizedKind === 'review' - ? 'Review' - : normalizedKind === 'subagent' - ? 'Agent' - : 'btw'; - return i18nService.t(`flow-chat:childSession.kinds.${normalizedKind}.short`, { - defaultValue: fallback, - }); -}; const getReviewActivityBadge = (kind: 'review' | 'deep_review'): string => i18nService.t( @@ -196,6 +185,8 @@ interface SessionsSectionProps { workspaceScopes?: WorkspaceSessionScope[]; /** Navigation geometry for nested workspace rows versus the flat aggregate list. */ layout?: 'nested' | 'flat'; + /** Flat lists of a multi-device sidebar show three rows plus "show more". */ + limitFlatRows?: boolean; /** Close a containing history surface after explicit session navigation. */ onSessionOpened?: () => void; /** Assistant history owns persistence; ordinary project rows do not expose this action. */ @@ -222,6 +213,7 @@ const SessionsSection: React.FC = ({ useWorkspaceViewPreferences = false, workspaceScopes, layout = 'nested', + limitFlatRows = false, onSessionOpened, defaultSessionAction, }) => { @@ -242,7 +234,8 @@ const SessionsSection: React.FC = ({ ? storedSessionFilters : DEFAULT_WORKSPACE_SESSION_FILTERS; const includeArchivedSessions = sessionShow === 'archived' || !sessionFilters.hideArchived; - const showAllWithoutLimit = layout === 'flat' && Boolean(workspaceScopes?.length); + const isAggregateList = layout === 'flat' && Boolean(workspaceScopes?.length); + const showAllWithoutLimit = isAggregateList && !limitFlatRows; const sessionListClassName = `openbitfun-nav-panel__inline-list${layout === 'flat' ? ' is-flat-workspace-view' : ''}`; const { setActiveWorkspace, openWorkspace, openedWorkspacesList, currentWorkspace } = useWorkspaceContext(); const activeTabId = useSceneStore(s => s.activeTabId); @@ -262,9 +255,12 @@ const SessionsSection: React.FC = ({ const dispatchTransportByJobId = useDispatchJobStore(state => state.transportByJobId); const [editingSessionId, setEditingSessionId] = useState(null); const [editingTitle, setEditingTitle] = useState(''); - const { expandLevel, level2DisplayCount, setExpandLevel, setLevel2DisplayCount } = useSessionNavExpansion( + const isVisibleRef = useRef(isVisible); + isVisibleRef.current = isVisible; + const { displayCount, setDisplayCount } = useSessionNavExpansion( `${surfaceScopedKey(getActiveSurfaceId(), workspaceId)}:${sourceAgent}`, - SESSIONS_LEVEL_2_PAGE, + // Collapsing a workspace and opening it again starts at the first rows. + { visible: isVisible, shouldHandOff: () => isVisibleRef.current }, ); const surface = getActiveSurfaceScope(); const initialMetadataKey = surface.key(workspaceId, surface.epoch); @@ -282,10 +278,17 @@ const SessionsSection: React.FC = ({ isLoading: boolean; failedScopeCount: number; }>(() => ({ - isLoading: showAllWithoutLimit && isVisible, + isLoading: isAggregateList && isVisible, failedScopeCount: 0, })); const [aggregateReloadRequestId, setAggregateReloadRequestId] = useState(0); + /** Host-reported top-level total of a limited flat list. */ + const [aggregateTotal, setAggregateTotal] = useState(0); + // Rows a limited flat list keeps loaded: shown rows plus one step. It only + // grows, so collapsing never re-reads. + const aggregateTargetRef = useRef(0); + aggregateTargetRef.current = Math.max(aggregateTargetRef.current, displayCount + SESSIONS_PREFETCH_COUNT); + const aggregateTarget = aggregateTargetRef.current; const [catalogRefreshVersion, setCatalogRefreshVersion] = useState(0); const onDiscoveryCompleted = useCallback(() => { setCatalogRefreshVersion(value => value + 1); @@ -403,7 +406,8 @@ const SessionsSection: React.FC = ({ onPage: observeDiscovery, query: catalogQuery, captureQuery: captureCatalogQuery, - targetCount: expandLevel === 2 ? level2DisplayCount : expandLevel === 1 ? SESSIONS_LEVEL_1 : SESSIONS_LEVEL_0, + // One step ahead of what is shown, so "show more" reveals loaded rows. + targetCount: displayCount + SESSIONS_PREFETCH_COUNT, firstPage: metadataPageState.loadedScope === initialMetadataKey ? metadataPageState.firstPage : undefined, getKnownCount: getKnownCatalogCount, }); @@ -501,19 +505,26 @@ const SessionsSection: React.FC = ({ setAggregateLoadState({ isLoading: true, failedScopeCount: 0 }); const loadAllScopes = async () => { - const results = await Promise.allSettled(workspaceScopes.map(async scope => { + const totals: number[] = []; + const results = await Promise.allSettled(workspaceScopes.map(async (scope, index) => { let cursor: string | undefined; do { + // A limited list needs each workspace's newest rows only: the merged + // top rows come from them, and the host totals give the exact rest. const page = await flowChatStore.loadSessionMetadataPage( - scope.workspaceId, SESSIONS_LEVEL_2_PAGE, cursor, + scope.workspaceId, limitFlatRows ? aggregateTarget : SESSIONS_LEVEL_2_PAGE, cursor, 'sessions_nav_all_grouping', { fresh: aggregateReloadRequestId > 0 }, ); if (cancelled) return; observeDiscovery(scope.workspaceId, page); - cursor = page.hasMore ? page.nextCursor : undefined; + totals[index] = page.totalTopLevelCount; + cursor = page.hasMore && !limitFlatRows ? page.nextCursor : undefined; } while (cursor); })); + if (!cancelled && limitFlatRows) { + setAggregateTotal(totals.reduce((sum, total) => sum + (total ?? 0), 0)); + } if (cancelled) return; @@ -542,7 +553,9 @@ const SessionsSection: React.FC = ({ return () => { cancelled = true; }; }, [ aggregateReloadRequestId, + aggregateTarget, isVisible, + limitFlatRows, observeDiscovery, workspaceScopes, workspaceScopesKey, @@ -607,7 +620,7 @@ const SessionsSection: React.FC = ({ } initialMetadataLoadKeyRef.current = initialMetadataKey; - const page = await loadMetadataPage(SESSIONS_LEVEL_0, undefined, source); + const page = await loadMetadataPage(SESSIONS_INITIAL_COUNT + SESSIONS_PREFETCH_COUNT, undefined, source); if (!page && initialMetadataLoadKeyRef.current === initialMetadataKey) { initialMetadataLoadKeyRef.current = null; } @@ -686,19 +699,19 @@ const SessionsSection: React.FC = ({ const handler = () => { metadataLoadRequestIdRef.current += 1; setCatalogRefreshVersion(value => value + 1); - setExpandLevel(0); + setDisplayCount(SESSIONS_INITIAL_COUNT); setMetadataPageState({ loadedScope: null, isLoading: false, loadError: false, }); if (isVisible && workspaceId) { - void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_post_archive'); + void loadMetadataPage(SESSIONS_INITIAL_COUNT + SESSIONS_PREFETCH_COUNT, undefined, 'sessions_nav_post_archive'); } }; window.addEventListener('openbitfun:session-archived', handler); return () => window.removeEventListener('openbitfun:session-archived', handler); - }, [isVisible, workspaceId, loadMetadataPage, setExpandLevel]); + }, [isVisible, workspaceId, loadMetadataPage, setDisplayCount]); const closeSessionMenu = useCallback(() => { setOpenMenuSessionId(null); @@ -815,15 +828,30 @@ const SessionsSection: React.FC = ({ const sessionDisplayLimit = useMemo(() => { return getSessionDisplayLimit({ loadedTopLevelCount: topLevelSessions.length, - expandLevel, - level2DisplayCount, + displayCount, showAllWithoutLimit, }); - }, [topLevelSessions.length, expandLevel, level2DisplayCount, showAllWithoutLimit]); + }, [topLevelSessions.length, displayCount, showAllWithoutLimit]); // Only an acknowledged workspace query provides a scoped total. Include // newly-created local rows while their metadata publication is in flight. - const totalTopLevelSessionCount = workspaceSessionCatalogTotal(topLevelSessions.length, catalog); + const liveTopLevelTotal = Math.max( + workspaceSessionCatalogTotal(topLevelSessions.length, catalog), + limitFlatRows && isAggregateList ? aggregateTotal : 0, + ); + // While the catalog reader restarts (device switch, refresh, new epoch) only + // loaded rows are countable; keep the last settled total so the "show more" + // row and its count never vanish and come back. + const catalogSettled = catalog.complete && !catalog.loading; + const totalViewKey = workspaceSessionViewKey(sourceAgent, sessionShow, sessionFilters); + const rememberedTotal = workspaceId && !workspaceScopes?.length + ? knownWorkspaceSessionTotal(surface.surfaceId, workspaceId, totalViewKey) : undefined; + const totalTopLevelSessionCount = presentedSessionTotal(catalogSettled, liveTopLevelTotal, rememberedTotal); + useEffect(() => { + if (catalogSettled && workspaceId && !workspaceScopes?.length) { + rememberWorkspaceSessionTotal(surface.surfaceId, workspaceId, totalViewKey, liveTopLevelTotal); + } + }, [catalogSettled, liveTopLevelTotal, surface.surfaceId, totalViewKey, workspaceId, workspaceScopes?.length]); const knownCatalogCount = getKnownCatalogCount(); useEffect(() => { // Page hydration updates the baseline inside the reader. A later local @@ -832,8 +860,9 @@ const SessionsSection: React.FC = ({ && catalog.knownCount !== knownCatalogCount) setCatalogRefreshVersion(value => value + 1); }, [catalog.complete, catalog.knownCount, catalog.loading, catalog.paged, isVisible, knownCatalogCount]); const isSessionCountComplete = !workspaceId || Boolean(workspaceScopes?.length) - || (catalog.complete && !discovery.pending && !discovery.failed); - const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, expandLevel); + || (catalog.complete && !discovery.pending && !discovery.failed) + || (!catalogSettled && rememberedTotal !== undefined); + const expandToggleState = getSessionExpandToggleState(totalTopLevelSessionCount, displayCount); // The visible label stays short ("Show more") and the remaining count rides in // a trailing `+N` chip; screen readers get the full sentence via aria-label. // Keyed off `action` so the label, the chevron direction and the CSS hook on @@ -844,21 +873,13 @@ const SessionsSection: React.FC = ({ remainingCount: number | null; } => { if (expandToggleState.action === 'show-more') { - const count = expandToggleState.collapsedRemainingCount; + const count = expandToggleState.remainingCount; return { label: t('nav.sessions.showMoreLabel'), ariaLabel: isSessionCountComplete ? t('nav.sessions.showMore', { count }) : t('nav.sessions.showMoreLabel'), remainingCount: isSessionCountComplete ? count : null, }; } - if (expandToggleState.action === 'show-all') { - const count = expandToggleState.expandedRemainingCount; - return { - label: t('nav.sessions.showAllLabel'), - ariaLabel: isSessionCountComplete ? t('nav.sessions.showAll', { count }) : t('nav.sessions.showAllLabel'), - remainingCount: isSessionCountComplete ? count : null, - }; - } return { label: t('nav.sessions.showLess'), ariaLabel: t('nav.sessions.showLess'), @@ -866,8 +887,7 @@ const SessionsSection: React.FC = ({ }; }, [ expandToggleState.action, - expandToggleState.collapsedRemainingCount, - expandToggleState.expandedRemainingCount, + expandToggleState.remainingCount, isSessionCountComplete, t, ]); @@ -1268,16 +1288,8 @@ const SessionsSection: React.FC = ({ ); const handleExpandToggle = useCallback(() => { - if (expandLevel === 0) { - setExpandLevel(1); - return; - } - if (expandLevel === 1 && totalTopLevelSessionCount > SESSIONS_LEVEL_1) { - setExpandLevel(2); - return; - } - setExpandLevel(0); - }, [expandLevel, setExpandLevel, totalTopLevelSessionCount]); + setDisplayCount(nextSessionDisplayCount(displayCount, totalTopLevelSessionCount)); + }, [displayCount, setDisplayCount, totalTopLevelSessionCount]); const sourceFilterErrorStatus = discovery.failed ? ( @@ -1299,7 +1311,7 @@ const SessionsSection: React.FC = ({ )}
) : null; - const aggregateLoadStatus = showAllWithoutLimit + const aggregateLoadStatus = isAggregateList ? aggregateLoadState.isLoading ? (
= ({ return (
- {sourceFilterErrorStatus ?? backgroundLoadStatus} + {/* A refresh of a populated list must not push its rows down. */} + {sourceFilterErrorStatus} {visibleItems.map(({ session, level }) => { const isEditing = editingSessionId === session.sessionId; const isArchived = session.persistedStatus === 'archived'; @@ -1978,42 +1991,15 @@ const SessionsSection: React.FC = ({ {aggregateLoadStatus} - {!showAllWithoutLimit && expandLevel === 2 && totalTopLevelSessionCount > sessionDisplayLimit && ( - - )} - {!showAllWithoutLimit && expandToggleState.shouldRender && ( - - - + ; } const render = (scope: string, count: number) => act(() => { root.render(); }); - const click = (action: string) => act(() => { container.querySelector(`[data-action="${action}"]`)!.click(); }); + const click = () => act(() => { container.querySelector('[data-action="toggle"]')!.click(); }); const count = () => Number(container.querySelector('output')?.textContent); beforeEach(() => { @@ -38,40 +35,55 @@ describe('source-filter session expansion', () => { container.remove(); }); - it('starts each agent filter with five rows, including the first render after an expanded view', () => { - render('local:project:all', 350); - expect(count()).toBe(5); - click('all'); - expect(count()).toBe(200); - render('local:project:codex', 300); - expect(renders.filter(render => render.scope === 'local:project:codex').every(render => render.rows === 5)).toBe(true); - expect(count()).toBe(5); - click('more'); - expect(count()).toBe(10); - render('local:project:zcode', 50); - expect(count()).toBe(5); - render('local:project:codex', 300); - expect(count()).toBe(5); - render('local:project:all', 350); - expect(count()).toBe(5); + it('starts every view compact, including the first render after an expanded view', () => { + render('local:project:all', 30); + expect(count()).toBe(3); + click(); + click(); + expect(count()).toBe(13); + render('local:project:codex', 30); + expect(renders.filter(entry => entry.scope === 'local:project:codex').every(entry => entry.rows === 3)).toBe(true); + }); + + it('adds five per click and collapses once everything is shown', () => { + render('local:project:all', 12); + click(); + expect(count()).toBe(8); + click(); + expect(count()).toBe(12); + click(); + expect(count()).toBe(3); + }); + + it('hands the count to the list that replaces it after a device switch', () => { + render('peer:project:all', 30); + click(); + act(() => root.unmount()); + root = createRoot(container); + render('peer:project:all', 30); + expect(count()).toBe(8); + act(() => root.unmount()); + root = createRoot(container); + render('peer:project:other', 30); + expect(count()).toBe(3); + }); + + it('starts over at three rows when a collapsed workspace opens again', () => { + render('local:project:all', 30); + click(); + expect(count()).toBe(8); + act(() => { root.render(); }); + act(() => { root.render(); }); + expect(count()).toBe(3); }); - it('keeps newly loaded metadata folded and expands only through the list controls', () => { - render('local:project:codex', 3); + it('does not hand a collapsed list count to the next mount', () => { + act(() => { root.render(); }); + click(); + act(() => { root.render(); }); + act(() => root.unmount()); + root = createRoot(container); + render('peer:project:all', 30); expect(count()).toBe(3); - render('local:project:codex', 600); - expect(count()).toBe(5); - click('more'); - expect(count()).toBe(10); - click('all'); - expect(count()).toBe(200); - click('next'); - expect(count()).toBe(400); - click('less'); - expect(count()).toBe(5); - click('all'); - expect(count()).toBe(200); - render('peer:project:codex', 600); - expect(count()).toBe(5); }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts index 47c1002be9..7ca18b421b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts @@ -1,39 +1,62 @@ -import { useCallback, useEffect, useState, type SetStateAction } from 'react'; -import type { SessionExpandLevel } from './sessionNavExpand'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { SESSIONS_INITIAL_COUNT } from './sessionNavExpand'; + +/** + * A device switch remounts a workspace's rows (background projection to live + * list and back). The count a list showed when it unmounted is handed to the + * next list mounting with the same scope shortly after, so "show more" stays + * expanded through the switch. A scope change inside one list still starts + * compact. + */ +const HANDOFF_MS = 30_000; +const handoffs = new Map(); + +function takeHandoff(scope: string) { + const entry = handoffs.get(scope); + handoffs.delete(scope); + return entry && Date.now() - entry.at <= HANDOFF_MS ? entry : undefined; +} /** Data discovery never expands the list. Each workspace/source view starts * compact, even when another view already has hundreds of metadata rows. */ -export function useSessionNavExpansion(scope: string, pageSize: number) { - const [state, setState] = useState({ scope, level: 0 as SessionExpandLevel, displayCount: pageSize }); +export function useSessionNavExpansion( + scope: string, + options: { + /** A collapsed list starts over at the first rows when reopened. */ + visible?: boolean; + /** Checked on unmount: only a remount (device switch) keeps the count. */ + shouldHandOff?: () => boolean; + } = {}, +) { + const { visible = true } = options; + const shouldHandOff = useRef(options.shouldHandOff); + shouldHandOff.current = options.shouldHandOff; + const [state, setState] = useState(() => ({ + scope, displayCount: takeHandoff(scope)?.displayCount ?? SESSIONS_INITIAL_COUNT, + })); + const latest = useRef(state); + latest.current = state; + useEffect(() => () => { + const { scope: last, displayCount } = latest.current; + if (displayCount > SESSIONS_INITIAL_COUNT && (shouldHandOff.current?.() ?? true)) { + handoffs.set(last, { displayCount, at: Date.now() }); + } + }, []); useEffect(() => { - setState(previous => previous.scope === scope ? previous : { scope, level: 0, displayCount: pageSize }); - }, [pageSize, scope]); - - const setExpandLevel = useCallback((level: SessionExpandLevel) => { - setState(previous => ({ - scope, - level, - displayCount: level === 2 && previous.scope === scope ? previous.displayCount : pageSize, - })); - }, [pageSize, scope]); + if (!visible) setState(previous => previous.displayCount === SESSIONS_INITIAL_COUNT ? previous : { scope, displayCount: SESSIONS_INITIAL_COUNT }); + }, [scope, visible]); + useEffect(() => { + setState(previous => previous.scope === scope ? previous : { scope, displayCount: SESSIONS_INITIAL_COUNT }); + }, [scope]); - const setLevel2DisplayCount = useCallback((next: SetStateAction) => { - setState(previous => { - const current = previous.scope === scope ? previous.displayCount : pageSize; - return { - scope, - level: previous.scope === scope ? previous.level : 0, - displayCount: typeof next === 'function' ? next(current) : next, - }; - }); - }, [pageSize, scope]); + const setDisplayCount = useCallback((displayCount: number) => { + setState({ scope, displayCount: Math.max(SESSIONS_INITIAL_COUNT, displayCount) }); + }, [scope]); return { // Limit the very first render after a filter/device switch, before effects // run, so the previous view cannot briefly mount its entire expanded list. - expandLevel: state.scope === scope ? state.level : 0 as SessionExpandLevel, - level2DisplayCount: state.scope === scope ? state.displayCount : pageSize, - setExpandLevel, - setLevel2DisplayCount, + displayCount: state.scope === scope ? state.displayCount : SESSIONS_INITIAL_COUNT, + setDisplayCount, }; } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 5fc0fe4ec2..01be39365e 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -26,6 +26,7 @@ import { InputDialog } from '@/app/components/InputDialog'; import { WorkspaceIcon } from '@/app/components/WorkspaceIcon/WorkspaceIcon'; import { AssistantAvatar } from '@/app/components/AssistantAvatar'; import { openLatestAssistantSession } from './openLatestAssistantSession'; +import { takeWorkspaceAction, usePendingWorkspaceAction } from './pendingWorkspaceAction'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceId, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; @@ -153,6 +154,15 @@ const WorkspaceItem: React.FC = ({ const [localSessionsCollapsed, setSessionsCollapsed] = useState(false); const sessionsCollapsed = controlledSessionsCollapsed ?? localSessionsCollapsed; const [assistantHistoryOpen, setAssistantHistoryOpen] = useState(false); + // An action started on this workspace's row of another device, before the + // device was rendered, runs here once. + const pendingAction = usePendingWorkspaceAction(state => state.action); + useEffect(() => { + if (!pendingAction) return; + const kind = takeWorkspaceAction(getActiveSurfaceId(), workspace.id); + if (kind === 'menu') { setMenuContextPoint(null); setMenuOpen(true); } + if (kind === 'assistant-history') setAssistantHistoryOpen(true); + }, [pendingAction, workspace.id]); const assistantHistoryId = useId(); const openingAssistantRef = useRef(false); const collapseAllRequestId = useWorkspaceSessionViewStore(state => state.collapseAllRequestId); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx index 6dcafe8a73..dccf49bbdc 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.test.tsx @@ -37,7 +37,10 @@ vi.mock('@/app/stores/sceneStore', async () => { return { useSceneStore: create(() => ({ activeTabId: null as SceneTabId | null, openTabs: [] as SceneTab[] })) }; }); vi.mock('../../workspaceSessionView', () => ({ - useWorkspaceSessionViewStore: (selector: (value: typeof state) => unknown) => selector(state), + useWorkspaceSessionViewStore: Object.assign( + (selector: (value: typeof state) => unknown) => selector(state), + { getState: () => state }, + ), })); vi.mock('./WorkspaceItem', () => ({ default: ({ workspace, isActive, isSelected, draggable, isDragging, onDragStart, onDragEnd, sessionsCollapsed, onSessionsCollapsedChange }: { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx index b168e0b494..8ce7921d45 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.tsx @@ -6,6 +6,7 @@ import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; import { getActiveSurfaceId, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; import { notificationService } from '@/shared/notification-system'; import WorkspaceItem from './WorkspaceItem'; +import { setWorkspaceSessionsCollapsed, useWorkspaceSessionsCollapsed } from './workspaceDisclosure'; import { remoteWorkspaceBadges } from './remoteWorkspaceBadge'; import SessionViewTransition from './SessionViewTransition'; import SessionsSection, { type WorkspaceSessionScope } from '../sessions/SessionsSection'; @@ -22,6 +23,8 @@ import './WorkspaceListSection.scss'; interface WorkspaceListSectionProps { variant: SessionNavigationScope; scrollRootRef?: React.RefObject; + /** Several devices share the sidebar: the flat list shows three rows per device. */ + limitFlatRows?: boolean; } type WorkspaceDragPosition = 'before' | 'after'; @@ -43,7 +46,24 @@ const WORKSPACE_DROP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; * never joins the workspace order (see `pinnedGroups`). */ -const WorkspaceListSection: React.FC = ({ variant, scrollRootRef }) => { +/** A workspace row whose project disclosure lives in the window-wide store. */ +const ProjectWorkspaceItem: React.FC & { + surfaceId: string; + isProject: boolean; +}> = ({ surfaceId, isProject, ...props }) => { + const collapsed = useWorkspaceSessionsCollapsed(surfaceId, props.workspace.id); + return ( + setWorkspaceSessionsCollapsed(surfaceId, props.workspace.id, value) + : undefined} + /> + ); +}; + +const WorkspaceListSection: React.FC = ({ variant, scrollRootRef, limitFlatRows = false }) => { const { t } = useI18n('common'); const { openedWorkspacesList, @@ -65,11 +85,6 @@ const WorkspaceListSection: React.FC = ({ variant, sc activeTab?.contentId ? state.resources[activeTab.contentId]?.scope : undefined )); const surfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); - const collapseAllRequestId = useWorkspaceSessionViewStore(state => state.collapseAllRequestId); - const [projectDisclosure, setProjectDisclosure] = useState<{ - requestId: number; - values: Record; - }>({ requestId: collapseAllRequestId, values: {} }); // Refs for values that must be read inside event handlers without stale closures const draggedWorkspaceIdRef = useRef(null); @@ -348,9 +363,6 @@ const WorkspaceListSection: React.FC = ({ variant, sc */ const renderGroupRow = (group: WorkspaceBackedSessionGroup, pinned: boolean) => { const workspace = group.workspace; - const disclosureKey = JSON.stringify([surfaceId, workspace.id]); - const sessionsCollapsed = (projectDisclosure.requestId === collapseAllRequestId - ? projectDisclosure.values[disclosureKey] : undefined) ?? (collapseAllRequestId > 0); const isActive = isWorkspaceBackedSessionGroupActive(workspace, activeWorkspace); const isSelected = isWorkspaceBackedSessionGroupSelected(workspace, { activeTab, contentScope, surfaceId, activeWorkspaceId, @@ -382,7 +394,9 @@ const WorkspaceListSection: React.FC = ({ variant, sc onDragLeave={draggable ? handleDragLeave(workspace.id) : undefined} onDrop={draggable ? (event) => { void handleDrop(workspace.id)(event); } : undefined} > - = ({ variant, sc isDragging={draggedWorkspaceId === workspace.id} onDragStart={draggable ? handleDragStart(workspace.id) : undefined} onDragEnd={draggable ? resetDragState : undefined} - sessionsCollapsed={group.kind === 'project' ? sessionsCollapsed : undefined} - onSessionsCollapsedChange={group.kind === 'project' ? collapsed => { - setProjectDisclosure(current => ({ - requestId: collapseAllRequestId, - values: { - ...(current.requestId === collapseAllRequestId ? current.values : {}), - [disclosureKey]: collapsed, - }, - })); - } : undefined} /> {dropTarget?.workspaceId === workspace.id ? ( diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.test.ts new file mode 100644 index 0000000000..83709df1e6 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { requestWorkspaceAction, takeWorkspaceAction } from './pendingWorkspaceAction'; + +describe('pendingWorkspaceAction', () => { + it('runs once, on the row of the same device and workspace only', () => { + requestWorkspaceAction({ surfaceId: 'peer', workspaceId: 'w', kind: 'menu' }); + expect(takeWorkspaceAction('local', 'w')).toBeNull(); + expect(takeWorkspaceAction('peer', 'other')).toBeNull(); + expect(takeWorkspaceAction('peer', 'w')).toBe('menu'); + expect(takeWorkspaceAction('peer', 'w')).toBeNull(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts new file mode 100644 index 0000000000..7a8f989428 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand'; +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; + +/** + * An action the user started on another device's workspace row (its "more" + * menu, the assistant history). The device is rendered first; the live row + * for the same workspace then runs the action once, so the menu or panel is + * the real one with that device's capabilities. + */ +export type PendingWorkspaceActionKind = 'menu' | 'assistant-history'; + +interface PendingWorkspaceAction { + surfaceId: DeviceSurfaceId; + workspaceId: string; + kind: PendingWorkspaceActionKind; +} + +export const usePendingWorkspaceAction = create<{ action: PendingWorkspaceAction | null }>(() => ({ action: null })); + +export function requestWorkspaceAction(action: PendingWorkspaceAction): void { + usePendingWorkspaceAction.setState({ action }); +} + +/** Take the action if it belongs to this row; it runs at most once. */ +export function takeWorkspaceAction(surfaceId: DeviceSurfaceId, workspaceId: string): PendingWorkspaceActionKind | null { + const { action } = usePendingWorkspaceAction.getState(); + if (!action || action.surfaceId !== surfaceId || action.workspaceId !== workspaceId) return null; + usePendingWorkspaceAction.setState({ action: null }); + return action.kind; +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceDisclosure.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceDisclosure.ts new file mode 100644 index 0000000000..d82c51ef07 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceDisclosure.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; +import type { DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; +import { useWorkspaceSessionViewStore } from '../../workspaceSessionView'; + +/** + * Which project rows are collapsed, per device. Window-wide rather than + * component state: a device switch remounts its list, and the live and + * background projections of one device must agree on what is open. + * + * A "collapse all" request collapses every row until the user opens one again, + * matching the previous component-local behaviour. + */ +export const workspaceDisclosureKey = (surfaceId: DeviceSurfaceId, workspaceId: string) => JSON.stringify([surfaceId, workspaceId]); + +const useWorkspaceDisclosureStore = create<{ requestId: number; values: Record }>(() => ({ + requestId: 0, + values: {}, +})); + +export function useWorkspaceSessionsCollapsed(surfaceId: DeviceSurfaceId, workspaceId: string): boolean { + const collapseAllRequestId = useWorkspaceSessionViewStore(state => state.collapseAllRequestId); + const value = useWorkspaceDisclosureStore(state => ( + state.requestId === collapseAllRequestId ? state.values[workspaceDisclosureKey(surfaceId, workspaceId)] : undefined + )); + return value ?? collapseAllRequestId > 0; +} + +export function isWorkspaceSessionsCollapsed(surfaceId: DeviceSurfaceId, workspaceId: string): boolean { + const collapseAllRequestId = useWorkspaceSessionViewStore.getState().collapseAllRequestId; + const state = useWorkspaceDisclosureStore.getState(); + const value = state.requestId === collapseAllRequestId ? state.values[workspaceDisclosureKey(surfaceId, workspaceId)] : undefined; + return value ?? collapseAllRequestId > 0; +} + +export function setWorkspaceSessionsCollapsed(surfaceId: DeviceSurfaceId, workspaceId: string, collapsed: boolean): void { + const requestId = useWorkspaceSessionViewStore.getState().collapseAllRequestId; + useWorkspaceDisclosureStore.setState(state => ({ + requestId, + values: { ...(state.requestId === requestId ? state.values : {}), [workspaceDisclosureKey(surfaceId, workspaceId)]: collapsed }, + })); +} diff --git a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts index 8a6b34a188..3d6857e74d 100644 --- a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts +++ b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts @@ -98,9 +98,12 @@ describe('unified project session creation', () => { expect(source('../../navigation/sidebarContributions.ts')).toContain("'todos', 'surface.todos.open'"); expect(mainNav).not.toContain("new Set(['sessions'])"); expect(mainNav).toContain('label={t(\'nav.items.sessions\')}'); - const sessionLists = mainNav.match(/]*\/>/g) ?? []; - expect(sessionLists).toHaveLength(1); - expect(sessionLists[0]).toMatch(/\svariant\s*=\s*["']all["']/); + // One session region; it is grouped by device when other hosts are online. + expect(mainNav.match(/]*\/>/g) ?? []).toHaveLength(1); + const deviceSections = source('./sections/devices/DeviceWorkspaceSections.tsx'); + const sessionLists = deviceSections.match(/]*\/>/g) ?? []; + expect(sessionLists.length).toBeGreaterThan(0); + for (const list of sessionLists) expect(list).toMatch(/\svariant\s*=\s*["']all["']/); expect(workspaceList).toContain('variant: SessionNavigationScope'); expect(workspaceList).toContain("variant === 'all'"); expect(workspaceList).toContain('openedWorkspacesList'); @@ -239,8 +242,10 @@ describe('unified project session creation', () => { expect(sessionsSection).toContain('const archivedCatalog = useArchivedSessionMetadata({'); expect(archivedMetadata).toContain('flowChatStore.loadArchivedSessionMetadata('); expect(sessionsSection).toContain("layout === 'flat' ? ' is-flat-workspace-view'"); - expect(sessionsSection).toContain("const showAllWithoutLimit = layout === 'flat'"); - expect(sessionsSection).toContain('!showAllWithoutLimit && expandLevel === 2'); + expect(sessionsSection).toContain("const isAggregateList = layout === 'flat'"); + expect(sessionsSection).toContain('const showAllWithoutLimit = isAggregateList && !limitFlatRows'); + // One toggle adds rows step by step; there is no separate load-more row. + expect(sessionsSection).not.toContain('nav-session-list-load-more'); expect(sessionsSection).toContain('!showAllWithoutLimit && expandToggleState.shouldRender'); expect(sessionsSection).toContain('openbitfun-nav-panel__inline-item-workspace-name'); }); diff --git a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts index 46bfa0efae..366c219127 100644 --- a/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts +++ b/src/web-ui/src/app/components/NavPanel/workspaceSessionView.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { DEFAULT_SESSION_VIEW_KEY } from '@/flow_chat/services/workspaceSessionTotals'; import { createJSONStorage, persist } from 'zustand/middleware'; import type { Session } from '@/flow_chat/types/flow-chat'; import { isWorktreeIsolatedSession } from '@/flow_chat/utils/sessionOrdering'; @@ -256,6 +257,20 @@ export function matchesWorkspaceSessionView( return true; } +/** + * Identity of a session view for remembered totals. The default view (all + * sources, all sessions, no filters) is the one a plain host total describes. + */ +export function workspaceSessionViewKey( + sourceAgent: WorkspaceSessionSourceAgent, + show: WorkspaceSessionShow, + filters: WorkspaceSessionFilters, +): string { + return sourceAgent === 'all' && show === 'all' && !hasWorkspaceSessionFilters(filters) + ? DEFAULT_SESSION_VIEW_KEY + : JSON.stringify([sourceAgent, show, filters]); +} + export function hasWorkspaceSessionFilters(filters: WorkspaceSessionFilters): boolean { return filters.hiddenStatuses.length > 0 || filters.hiddenEnvironments.length > 0 diff --git a/src/web-ui/src/app/hooks/useSessionTabLabels.ts b/src/web-ui/src/app/hooks/useSessionTabLabels.ts index fa2e2675be..1c8a197046 100644 --- a/src/web-ui/src/app/hooks/useSessionTabLabels.ts +++ b/src/web-ui/src/app/hooks/useSessionTabLabels.ts @@ -16,12 +16,17 @@ function readSessionTabLabels( translate: (key: string, options?: Record) => string, formatNumber: (number: number, options?: Intl.NumberFormatOptions) => string, ): Record { - const source = flowChatStore.getState(); const labels: Record = {}; - const numbers = sessionTitleNumbers(source.sessions.values()); + const numbersBySurface = new Map>(); for (const tab of tabs) { - if (!tab.session || tab.session.surfaceId !== getActiveSurfaceId()) continue; - const session = source.sessions.get(tab.session.sessionId); + if (!tab.session) continue; + // A tab held for a cross-device open names its device's session. + const sessions = tab.session.surfaceId === getActiveSurfaceId() + ? flowChatStore.getState().sessions + : flowChatStore.getSurfaceSessions(tab.session.surfaceId); + let numbers = numbersBySurface.get(tab.session.surfaceId); + if (!numbers) numbersBySurface.set(tab.session.surfaceId, numbers = sessionTitleNumbers(sessions.values())); + const session = sessions.get(tab.session.sessionId); if (!session) continue; const number = numbers.get(session.sessionId); labels[tab.id] = { @@ -33,7 +38,9 @@ function readSessionTabLabels( } function subscribeToLabels(notify: () => void): () => void { - return flowChatStore.subscribe(notify); + const unsubscribe = flowChatStore.subscribe(notify); + const unsubscribeSurfaces = flowChatStore.subscribeSurfaceSessions(notify); + return () => { unsubscribe(); unsubscribeSurfaces(); }; } /** A synchronous resource projection with stable snapshots during streaming. */ diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index d1389b3914..27ff3ace3b 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -10,6 +10,7 @@ */ import { lazyWithRecovery } from '@/shared/utils/lazyWithRecovery'; +import { DeviceSwitchCurtain } from './device-switch/DeviceSwitchCurtain'; import React, { Suspense, useCallback, @@ -218,6 +219,7 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering ); })}
+
); }; diff --git a/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.scss b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.scss new file mode 100644 index 0000000000..4afdc9f143 --- /dev/null +++ b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.scss @@ -0,0 +1,46 @@ +.openbitfun-device-switch-curtain { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + background: var(--openbitfun-color-surface-scene); + animation: openbitfun-device-switch-curtain-in var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard) both; + + &[data-phase='leaving'] { + pointer-events: none; + animation: openbitfun-device-switch-curtain-out 220ms var(--openbitfun-motion-easing-standard) both; + } + + // Fast switches finish before the spinner would appear: no flicker. + &__spinner { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + opacity: 0; + animation: openbitfun-device-switch-curtain-spinner 200ms var(--openbitfun-motion-easing-standard) 300ms forwards; + } + + @media (prefers-reduced-motion: reduce) { + animation: none; + + &[data-phase='leaving'] { + animation: none; + opacity: 0; + } + } +} + +@keyframes openbitfun-device-switch-curtain-spinner { + to { opacity: var(--openbitfun-opacity-muted); } +} + +@keyframes openbitfun-device-switch-curtain-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes openbitfun-device-switch-curtain-out { + from { opacity: 1; } + to { opacity: 0; } +} diff --git a/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.test.tsx b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.test.tsx new file mode 100644 index 0000000000..6dcfb98fa2 --- /dev/null +++ b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); + +import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { DEVICE_SWITCH_CURTAIN_LEAVE_MS, DeviceSwitchCurtain } from './DeviceSwitchCurtain'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('DeviceSwitchCurtain', () => { + let host: HTMLDivElement; + let root: Root; + const curtain = () => host.querySelector('[data-testid="device-switch-curtain"]'); + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'requestAnimationFrame', 'cancelAnimationFrame'] }); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + act(() => root.render()); + }); + afterEach(() => { act(() => root.unmount()); host.remove(); vi.useRealTimers(); }); + + it('covers the workbench for the whole open and fades out after the target painted', () => { + expect(curtain()).toBeNull(); + let intent!: ReturnType; + act(() => { intent = beginDeviceOpen({ surfaceId: 'peer', key: 'new:w' }); }); + expect(curtain()?.getAttribute('data-phase')).toBe('covering'); + act(() => endDeviceOpen(intent)); + // Still covering until two frames passed. + expect(curtain()?.getAttribute('data-phase')).toBe('covering'); + act(() => { vi.advanceTimersByTime(40); }); + expect(curtain()?.getAttribute('data-phase')).toBe('leaving'); + act(() => { vi.advanceTimersByTime(DEVICE_SWITCH_CURTAIN_LEAVE_MS); }); + expect(curtain()).toBeNull(); + }); + + it('leaves existing-session opens to the held session tab and its own skeleton', () => { + let intent!: ReturnType; + act(() => { intent = beginDeviceOpen({ surfaceId: 'peer', key: 'session:s', sessionId: 's' }); }); + expect(curtain()).toBeNull(); + act(() => endDeviceOpen(intent)); + }); +}); diff --git a/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx new file mode 100644 index 0000000000..31e24b9520 --- /dev/null +++ b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx @@ -0,0 +1,67 @@ +/** + * Covers the workbench while a device switch rebuilds it. + * + * A switch resets the workbench and rebuilds it for the target device; without + * a cover that sequence briefly shows the welcome page, a lazy-load spinner or + * the previous scene. The curtain is the scene background: it fades in on the + * click, shows a spinner only if the switch takes a while, and fades out once + * the action settled and the target scene had a frame to paint. + * + * Opening an existing session needs no curtain: its tab is held in front and + * the session view shows its own loading skeleton, in its own layout, so the + * loading presentation never changes size or position mid-open. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { Spinner } from '@openbitfun/ui'; +import { useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { useI18n } from '@/infrastructure/i18n'; +import './DeviceSwitchCurtain.scss'; + +type Phase = 'hidden' | 'covering' | 'leaving'; +export const DEVICE_SWITCH_CURTAIN_LEAVE_MS = 220; + +/** Existing-session opens are presented by the held session tab. */ +const needsCurtain = (key: string) => !key.startsWith('session:'); + +export const DeviceSwitchCurtain: React.FC = () => { + const { t } = useI18n('common'); + const intent = useDeviceOpenIntent(state => (state.intent && needsCurtain(state.intent.key) ? state.intent : null)); + const [phase, setPhase] = useState(intent ? 'covering' : 'hidden'); + const phaseRef = useRef(phase); + phaseRef.current = phase; + const timers = useRef<{ frame: number; timer: ReturnType | null }>({ frame: 0, timer: null }); + + useEffect(() => { + const pending = timers.current; + cancelAnimationFrame(pending.frame); + if (pending.timer !== null) clearTimeout(pending.timer); + pending.timer = null; + if (intent) { + phaseRef.current = 'covering'; + setPhase('covering'); + return; + } + if (phaseRef.current === 'hidden') return; + // Two frames: the scene the open committed has rendered and painted. + pending.frame = requestAnimationFrame(() => { + pending.frame = requestAnimationFrame(() => { + setPhase('leaving'); + pending.timer = setTimeout(() => setPhase('hidden'), DEVICE_SWITCH_CURTAIN_LEAVE_MS); + }); + }); + }, [intent]); + + useEffect(() => () => { + cancelAnimationFrame(timers.current.frame); + if (timers.current.timer !== null) clearTimeout(timers.current.timer); + }, []); + + if (phase === 'hidden') return null; + return ( +
+
+
+ ); +}; diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index beeb41a5ac..1fb87e6a14 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -3,6 +3,7 @@ import { startAutoSync } from '@/flow_chat/services/storeSync'; import { workspaceManager, type WorkspaceEvent } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceId, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; import { createLogger } from '@/shared/utils/logger'; +import { useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; import { isSessionSceneId, type SessionSceneTarget } from '../components/SceneBar/types'; import { startSessionPaneLayoutSync } from '../scenes/session/sessionPaneLayoutSync'; @@ -116,9 +117,14 @@ export function startSessionSceneLifecycle(): () => void { const unsubscribeSessions = flowChatStore.subscribe(() => reconcile()); const unsubscribeScenes = useSceneStore.subscribe(() => reconcile()); const unsubscribeWorkspaces = workspaceManager.addEventListener(reconcile); + // A held cross-device tab is released (or adopted) when its open settles. + const unsubscribeDeviceOpen = useDeviceOpenIntent.subscribe((state, previous) => { + if (previous.intent && !state.intent) reconcile(); + }); reconcile(); return () => { + unsubscribeDeviceOpen(); unsubscribeSessions(); unsubscribeScenes(); unsubscribeWorkspaces(); diff --git a/src/web-ui/src/app/stores/sceneStore.test.ts b/src/web-ui/src/app/stores/sceneStore.test.ts index 2dc949c451..79f9cb06d7 100644 --- a/src/web-ui/src/app/stores/sceneStore.test.ts +++ b/src/web-ui/src/app/stores/sceneStore.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { recordInteractionModality } from '@/shared/utils/motionPreference'; +import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; import { registerSessionSceneNavigation, useSceneStore } from './sceneStore'; import { getSessionSceneTabId } from '../components/SceneBar/types'; import { useSettingsStore } from '../scenes/settings/settingsStore'; @@ -85,6 +86,23 @@ describe('sceneStore transition snapshots', () => { expect(useSceneStore.getState().openTabs).toEqual([]); }); + it('holds a cross-device session tab through both switch resets and reconciliation until the open settles', () => { + const target = { surfaceId: 'peer-b', workspaceKey: '["workspace","w"]', sessionId: 'remote' }; + const intent = beginDeviceOpen({ surfaceId: 'peer-b', key: 'session:remote', sessionId: 'remote' }); + useSceneStore.getState().openScene('git'); + useSceneStore.getState().holdSessionScene(target); + const heldId = useSceneStore.getState().activeTabId; + expect(useSceneStore.getState().openTabs.find(tab => tab.id === heldId)?.session).toEqual(target); + // The runtime reset, then the lifecycle's restoring reset on the new surface. + useSceneStore.getState().resetForPeerSwitch(); + useSceneStore.getState().resetForPeerSwitch(true); + useSceneStore.getState().reconcileSessionScenes(new Map()); + expect(useSceneStore.getState().activeTabId).toBe(heldId); + endDeviceOpen(intent); + useSceneStore.getState().reconcileSessionScenes(new Map()); + expect(useSceneStore.getState().openTabs.some(tab => tab.id === heldId)).toBe(false); + }); + it('does not add a failed session activation to the recent list', async () => { useSceneStore.getState().openSessionScene(sessionTarget); const stop = registerSessionSceneNavigation({ diff --git a/src/web-ui/src/app/stores/sceneStore.ts b/src/web-ui/src/app/stores/sceneStore.ts index 482934a45e..31fbbd13a6 100644 --- a/src/web-ui/src/app/stores/sceneStore.ts +++ b/src/web-ui/src/app/stores/sceneStore.ts @@ -41,6 +41,7 @@ import { useContentResourceStore } from '../workbench/contentResourceStore'; import { requestContentClose } from '../workbench/contentResourceLifecycle'; import { requestMiniAppClose } from '../scenes/miniapps/miniAppLifecycle'; import { getActiveSurfaceId, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { getDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; import { SCENE_TAB_REGISTRY, getSceneDef, @@ -154,6 +155,20 @@ interface SceneState extends ScenePaneState { goForward: () => void; /** Reset tabs/history when entering or exiting Peer Device Mode. */ resetForPeerSwitch: (restoreContent?: boolean) => void; + /** + * Show a session of another device in the primary pane at once, before its + * device is rendered. The tab survives the switch and session reconciliation + * while the cross-device open that owns it runs (see `DeviceOpenIntent`), so + * the session's loading presentation stays on screen the whole time. + */ + holdSessionScene: (target: SessionSceneTarget) => void; +} + +/** The session tab a running cross-device open shows; never reconciled away. */ +function isHeldSessionTab(tab: SceneTab): boolean { + const intent = getDeviceOpenIntent(); + return Boolean(intent?.sessionId && tab.session + && tab.session.sessionId === intent.sessionId && tab.session.surfaceId === intent.surfaceId); } function buildDefaultTabs(): SceneTab[] { @@ -299,6 +314,10 @@ export const useSceneStore = create((set, get) => ({ tabs.set(tab.id, tab); continue; } + if (isHeldSessionTab(tab)) { + tabs.set(tab.id, tab); + continue; + } const target = tab.session && targets.get(tab.session.sessionId); if (!target) continue; const id = getSessionSceneTabId(target); @@ -425,6 +444,24 @@ export const useSceneStore = create((set, get) => ({ goBack: () => navigateHistory(-1), goForward: () => navigateHistory(1), + holdSessionScene: (target) => { + navigationRequest++; + const state = get(); + const id = getSessionSceneTabId(target); + const previous = state.openTabs.find(tab => tab.id === id); + const tab: SceneTab = { + ...previous, id, pane: 'primary', session: target, lastUsed: Date.now(), + sessionHistory: [target.sessionId, ...(previous?.sessionHistory ?? []).filter(sessionId => sessionId !== target.sessionId)], + }; + const openTabs = [...state.openTabs.filter(candidate => candidate.id !== id), tab]; + const panes = reconcilePaneState({ ...state, openTabs, focusedPane: 'primary', + paneSelections: { ...state.paneSelections, primary: sceneSelection(id) } }); + set({ ...projectPaneState(panes), pendingTabId: null, + ...pushHistory(state.navHistory, state.navCursor, id), + navigationMotion: 'instant', navigationSequence: state.navigationSequence + 1, + }); + }, + resetForPeerSwitch: (restoreContent = false) => { navigationRequest++; abandonSettingsDraftsForContextSwitch(); @@ -435,8 +472,15 @@ export const useSceneStore = create((set, get) => ({ ? useContentResourceStore.getState().resources[contentTabs[0].contentId]?.scope.surfaceId : undefined; if (origin) contentSurfaceTabs.set(origin, { ...projectPaneState(state), openTabs: contentTabs }); const saved = restoreContent ? contentSurfaceTabs.get(getActiveSurfaceId()) : undefined; - const tabs = saved?.openTabs.filter(tab => tab.contentId && useContentResourceStore.getState().resources[tab.contentId]) ?? buildDefaultTabs(); - const panes = saved ? reconcilePaneState({ ...saved, openTabs: tabs }) : createPaneState(tabs, tabs[0]?.id ?? null); + const restored = saved?.openTabs.filter(tab => tab.contentId && useContentResourceStore.getState().resources[tab.contentId]) + ?? buildDefaultTabs(); + // A cross-device open keeps its session in front through the switch. + const held = state.openTabs.find(isHeldSessionTab); + const tabs = held ? [...restored, held] : restored; + const panes = held + ? reconcilePaneState({ ...(saved ?? state), openTabs: tabs, focusedPane: 'primary', + paneSelections: { ...(saved ?? state).paneSelections, primary: sceneSelection(held.id) } }) + : saved ? reconcilePaneState({ ...saved, openTabs: tabs }) : createPaneState(tabs, tabs[0]?.id ?? null); const activeTabId = focusedScene(panes); set({ ...projectPaneState(panes), pendingTabId: null, navHistory: activeTabId ? [activeTabId] : [], navCursor: activeTabId ? 0 : -1, diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 597cb3f776..cfc02262ac 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -64,6 +64,24 @@ "remoteConnectRequiresWorkspace": "Open a workspace before using remote control." }, "nav": { + "devices": { + "thisDevice": "This device", + "current": "Shown in this window", + "expand": "Expand {{name}}", + "collapse": "Collapse {{name}}", + "hostDesktop": "Desktop", + "hostCli": "CLI", + "platform": { + "mac": "Mac", + "windows": "Win", + "linux": "Linux" + }, + "loadFailed": "Couldn't load this device's workspaces", + "sessionsLoadFailed": "Couldn't load sessions", + "noSessions": "No sessions yet", + "sessionUnavailable": "This session is no longer on that device.", + "connecting": "Connecting to {{name}}…" + }, "aria": { "navControl": "Navigation controls", "mainNav": "Main navigation" @@ -2326,6 +2344,19 @@ "modifiedDragUnavailable": "Dragging with modifier keys is not supported in this preview." }, "newWorkspace": { + "localProjectTitle": "Project on this device", + "localProjectDescription": "Use existing folders on this device, or start a new project.", + "deviceProjectTitle": "Project on another device", + "deviceProjectDescription": "Create it on another online device signed in to your account. Everything works as it does here.", + "createDeviceProject": "Create a project on another device", + "chooseDevice": "Choose a device", + "chooseDeviceDescription": "The project is created and stored on the selected device.", + "noOtherDevices": "No other devices are online. Sign in to the same account in OpenBitFun on another device to choose it here.", + "signInForDevices": "Sign in to create projects on your other online devices.", + "createProjectOn": "Create a project on {{name}}", + "deviceCurrent": "Shown now", + "connecting": "Connecting…", + "deviceError": "Couldn't connect to this device. Try again later.", "title": "Create", "description": "Choose what you want to create.", "assistantTitle": "Personal assistant", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index c8ba9d855a..1b964e6307 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -64,6 +64,24 @@ "remoteConnectRequiresWorkspace": "请先打开工作区再使用远程控制。" }, "nav": { + "devices": { + "thisDevice": "本机", + "current": "当前窗口正在显示", + "expand": "展开 {{name}}", + "collapse": "收起 {{name}}", + "hostDesktop": "桌面版", + "hostCli": "CLI", + "platform": { + "mac": "Mac", + "windows": "Win", + "linux": "Linux" + }, + "loadFailed": "无法读取该设备的工作区", + "sessionsLoadFailed": "无法读取会话", + "noSessions": "暂无会话", + "sessionUnavailable": "该设备上已找不到这个会话。", + "connecting": "正在连接 {{name}}…" + }, "aria": { "navControl": "导航控制", "mainNav": "主导航" @@ -2326,6 +2344,19 @@ "modifiedDragUnavailable": "共享预览暂不支持同时按住修饰键拖动。" }, "newWorkspace": { + "localProjectTitle": "本机工程", + "localProjectDescription": "在这台设备上关联已有文件夹,或创建全新工程。", + "deviceProjectTitle": "其他设备上的工程", + "deviceProjectDescription": "在同账号的其他在线设备上创建,操作与本机一致。", + "createDeviceProject": "在其他设备上创建工程", + "chooseDevice": "选择设备", + "chooseDeviceDescription": "工程会创建并保存在所选设备上。", + "noOtherDevices": "暂无其他在线设备。在其他设备上登录同一账号并打开 OpenBitFun 后,即可在这里选择。", + "signInForDevices": "登录账号后,可在同账号的其他在线设备上创建工程。", + "createProjectOn": "在 {{name}} 上创建工程", + "deviceCurrent": "当前显示", + "connecting": "正在连接…", + "deviceError": "无法连接该设备,请稍后重试。", "title": "创建", "description": "选择要创建的内容。", "assistantTitle": "个人助理", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 08e48d5dcf..6758b911b0 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -64,6 +64,24 @@ "remoteConnectRequiresWorkspace": "請先開啟工作區再使用遠端控制。" }, "nav": { + "devices": { + "thisDevice": "本機", + "current": "目前視窗正在顯示", + "expand": "展開 {{name}}", + "collapse": "收合 {{name}}", + "hostDesktop": "桌面版", + "hostCli": "CLI", + "platform": { + "mac": "Mac", + "windows": "Win", + "linux": "Linux" + }, + "loadFailed": "無法讀取該裝置的工作區", + "sessionsLoadFailed": "無法讀取會話", + "noSessions": "尚無會話", + "sessionUnavailable": "該裝置上已找不到這個會話。", + "connecting": "正在連線 {{name}}…" + }, "aria": { "navControl": "導航控制", "mainNav": "主導航" @@ -2326,6 +2344,19 @@ "modifiedDragUnavailable": "共享預覽暫不支援同時按住修飾鍵拖曳。" }, "newWorkspace": { + "localProjectTitle": "本機專案", + "localProjectDescription": "在這台裝置上關聯現有資料夾,或建立全新專案。", + "deviceProjectTitle": "其他裝置上的專案", + "deviceProjectDescription": "在同帳號的其他線上裝置上建立,操作與本機一致。", + "createDeviceProject": "在其他裝置上建立專案", + "chooseDevice": "選擇裝置", + "chooseDeviceDescription": "專案會建立並儲存在所選裝置上。", + "noOtherDevices": "暫無其他線上裝置。在其他裝置上登入同一帳號並開啟 OpenBitFun 後,即可在這裡選擇。", + "signInForDevices": "登入帳號後,可在同帳號的其他線上裝置上建立專案。", + "createProjectOn": "在 {{name}} 上建立專案", + "deviceCurrent": "目前顯示", + "connecting": "正在連線…", + "deviceError": "無法連線到該裝置,請稍後重試。", "title": "建立", "description": "選擇要建立的內容。", "assistantTitle": "個人助理", From 0e0d4366fb61f52e784e475b6bf418e7a0561716 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:35:26 +0800 Subject: [PATCH 04/52] feat(web-ui): create projects on another device The create dialog offers a personal assistant, a project on this device, or a project on another online device. Choosing a device renders it before the same project form opens, so folders, recent workspaces and sources belong to that device. The assistant is always created on this machine. --- .../NewProjectDialog/NewProjectDialog.scss | 37 ++++ .../NewProjectDialog.test.tsx | 65 +++++++ .../NewProjectDialog/NewProjectDialog.tsx | 163 ++++++++++++++++-- 3 files changed, 251 insertions(+), 14 deletions(-) diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss index d4eb78a90f..81fd3b2bf2 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss @@ -38,6 +38,37 @@ min-inline-size: 0; } + &__choices--three { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + &__devices { + display: flex; + flex-direction: column; + gap: var(--openbitfun-space-2); + min-inline-size: 0; + max-block-size: 360px; + overflow-y: auto; + } + + &__device-trigger { + justify-content: flex-start; + padding: var(--openbitfun-space-3) var(--openbitfun-space-4); + gap: var(--openbitfun-space-3); + } + + &__device-name { + color: var(--openbitfun-color-content-primary); + font-family: var(--openbitfun-type-body-sm-font-family); + font-size: var(--openbitfun-type-body-sm-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-sm-line-height); + } + + &__device-spinner { + animation: new-project-dialog-spin 1s linear infinite; + } + &__choice-card { min-block-size: 160px; @@ -191,3 +222,9 @@ grid-template-columns: minmax(0, 1fr); } } + +@keyframes new-project-dialog-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx index 9f05b4a04a..c77f063073 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx @@ -518,4 +518,69 @@ describe('NewProjectDialog composition', () => { expect(createAssistant).not.toHaveBeenCalled(); expect(close).not.toHaveBeenCalled(); }); + + describe('creating on another device', () => { + const devices = [ + { surfaceId: 'peer-mac', name: 'Studio Mac', hostKind: 'desktop' as const, rendered: false, controllable: true }, + { surfaceId: 'peer-old', name: 'Old Laptop', hostKind: 'desktop' as const, rendered: false, controllable: false }, + ]; + const deviceOption = (surfaceId: string) => document.querySelector( + `[data-testid="new-project-device-option"][data-device-surface="${surfaceId}"] button, button[data-testid="new-project-device-option"][data-device-surface="${surfaceId}"]`, + )!; + const open = async (extra: Record) => { + await act(async () => root.render()); + }; + + it('renders the chosen device before showing the same project form there', async () => { + const selectDevice = vi.fn(async (target: string) => { + activateSurface(surfaceIdForDevice(target === 'local' ? null : target)); + return true; + }); + await open({ onSelectDevice: selectDevice, deviceOptions: devices }); + expect(document.querySelectorAll('[data-openbitfun-component="action-card"] button')).toHaveLength(3); + await act(async () => { document.querySelector('[data-testid="nav-session-group-add-device-project"]')!.click(); }); + expect(document.querySelector('[data-testid="new-project-device-list"]')).not.toBeNull(); + expect(deviceOption('peer-old').disabled).toBe(true); + expect(selectDevice).not.toHaveBeenCalled(); + + await act(async () => { deviceOption('peer-mac').click(); }); + expect(selectDevice).toHaveBeenCalledWith('peer-mac'); + expect(nameInput()).toBe(document.activeElement); + expect(document.body.textContent).toContain('newWorkspace.createProjectOn'); + enterName('Remote app'); + await add('/Users/me/app'); + await click('newWorkspace.createProjectOn'); + expect(confirm).toHaveBeenCalledWith({ name: 'Remote app', folders: [{ path: '/Users/me/app' }] }); + }); + + it('keeps the device list when the switch is superseded or fails', async () => { + const selectDevice = vi.fn().mockResolvedValueOnce(false).mockRejectedValueOnce(new Error('Device offline')); + await open({ onSelectDevice: selectDevice, deviceOptions: devices }); + await act(async () => { document.querySelector('[data-testid="nav-session-group-add-device-project"]')!.click(); }); + await act(async () => { deviceOption('peer-mac').click(); }); + expect(nameInput()).toBeNull(); + await act(async () => { deviceOption('peer-mac').click(); }); + expect(document.querySelector('[role="alert"]')?.textContent).toContain('Device offline'); + expect(document.querySelector('[data-testid="new-project-device-list"]')).not.toBeNull(); + }); + + it('explains an empty or signed-out device list', async () => { + await open({ onSelectDevice: vi.fn(), deviceOptions: [] }); + await act(async () => { document.querySelector('[data-testid="nav-session-group-add-device-project"]')!.click(); }); + expect(document.body.textContent).toContain('newWorkspace.noOtherDevices'); + await open({ onSelectDevice: vi.fn(), deviceOptions: undefined }); + expect(document.body.textContent).toContain('newWorkspace.signInForDevices'); + }); + + it('creates the personal assistant on this machine', async () => { + const order: string[] = []; + const selectDevice = vi.fn(async (target: string) => { order.push(`select:${target}`); return true; }); + const createAssistant = vi.fn(async () => { order.push('create'); }); + await open({ onSelectDevice: selectDevice, deviceOptions: devices, onCreateAssistant: createAssistant }); + await choose('assistant'); + expect(order).toEqual(['select:local', 'create']); + expect(close).toHaveBeenCalledOnce(); + }); + }); }); diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx index ab36ba4028..f1ffaedcfb 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx @@ -3,7 +3,7 @@ import { ActionCard, Button, Card, CardBody, Dialog, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogHeading, DialogTitle, Icon, IconButton, Input, OverflowText, Tooltip, } from '@openbitfun/ui'; -import { FolderPlus, Server } from 'lucide-react'; +import { FolderPlus, Loader2, Monitor, MonitorSmartphone, Server, SquareTerminal } from 'lucide-react'; import React, { useState, useCallback, useEffect, useId, useMemo, useRef } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; @@ -20,6 +20,20 @@ import './NewProjectDialog.scss'; const log = createLogger('NewProjectDialog'); +/** Another online host of the account that a project can be created on. */ +export interface NewProjectDeviceOption { + surfaceId: string; + name: string; + hostKind: 'desktop' | 'cli' | null; + /** The window renders this device right now. */ + rendered: boolean; + /** Relay-confirmed mutual control; an incompatible build is listed but disabled. */ + controllable: boolean; +} + +/** `local` is this machine; any other value is a device surface id. */ +export type NewProjectDeviceTarget = 'local' | string; + export interface NewProjectDialogProps { isOpen: boolean; onClose: () => void; @@ -29,11 +43,21 @@ export interface NewProjectDialogProps { onSelectRemoteHost?: (target: WorkspaceConnectionTarget, preset?: WorkspaceConnectionPreset) => Promise | null>; /** Sidebar entry starts with a choice when assistant creation is available. */ onCreateAssistant?: () => Promise; + /** + * Render the device a new item is created on before its form opens, so the + * folder picker, recent workspaces and sources all belong to that device. + * Resolves false when a newer device switch superseded this one. Absent + * keeps the rendered device and hides the other-device choice. + */ + onSelectDevice?: (target: NewProjectDeviceTarget) => Promise; + /** Other online hosts of the account; undefined while signed out. */ + deviceOptions?: readonly NewProjectDeviceOption[]; 'data-testid'?: string; } export const NewProjectDialog: React.FC = ({ - isOpen, onClose, onConfirm, defaultParentPath, onPickRemoteFolder, onSelectRemoteHost, onCreateAssistant, 'data-testid': testId, + isOpen, onClose, onConfirm, defaultParentPath, onPickRemoteFolder, onSelectRemoteHost, onCreateAssistant, + onSelectDevice, deviceOptions, 'data-testid': testId, }) => { const { t } = useI18n('common'); const [folders, setFolders] = useState([]); @@ -48,7 +72,11 @@ export const NewProjectDialog: React.FC = ({ const [isCreating, setIsCreating] = useState(false); const [isSelecting, setIsSelecting] = useState(false); const [error, setError] = useState(''); - const [step, setStep] = useState<'choice' | 'project'>(onCreateAssistant ? 'choice' : 'project'); + const [step, setStep] = useState<'choice' | 'device' | 'project'>(onCreateAssistant ? 'choice' : 'project'); + /** Device chosen through the other-device entry; null for this machine. */ + const [projectDevice, setProjectDevice] = useState(null); + const [selectingDevice, setSelectingDevice] = useState(null); + const deviceChoiceRef = useRef(null); const nameInputRef = useRef(null); const assistantChoiceRef = useRef(null); const submissionPending = useRef(false); @@ -57,7 +85,8 @@ export const NewProjectDialog: React.FC = ({ const formId = useId(); const sourceLabelId = useId(); const nameHintId = useId(); - const busy = isCreating || isSelecting; + const busy = isCreating || isSelecting || selectingDevice !== null; + const offersDevices = Boolean(onCreateAssistant && onSelectDevice); const name = projectName.trim(); const needsName = folders.length !== 1; const invalidDirectoryName = folders.length === 0 && (/[\\/]/.test(name) || name === '.' || name === '..'); @@ -91,6 +120,8 @@ export const NewProjectDialog: React.FC = ({ setSelectedHostOption(undefined); setConnectedRemote(undefined); setError(''); + setProjectDevice(null); + setSelectingDevice(null); setStep(onCreateAssistant ? 'choice' : 'project'); } wasOpen.current = isOpen; @@ -99,6 +130,7 @@ export const NewProjectDialog: React.FC = ({ useEffect(() => { if (!isOpen) return; if (step === 'project') nameInputRef.current?.focus(); + else if (step === 'device') deviceChoiceRef.current?.focus(); else assistantChoiceRef.current?.focus(); }, [isOpen, step]); @@ -135,6 +167,43 @@ export const NewProjectDialog: React.FC = ({ setStep(next); }; + /** + * Render the target device, then continue there. Everything the project form + * reads afterwards (folders, recent workspaces, sources) belongs to it. + */ + const enterDevice = async (target: NewProjectDeviceTarget, device: NewProjectDeviceOption | null): Promise => { + if (!onSelectDevice) return true; + setSelectingDevice(target); + setError(''); + try { + if (!(await onSelectDevice(target))) return false; + formSurface.current = getActiveSurfaceScope(); + setProjectDevice(device); + setFolders([]); + setSource('host'); + setSelectedOption(undefined); + setSelectedHostOption(undefined); + setConnectedRemote(undefined); + return true; + } catch (cause) { + log.error('Failed to render device for creation', cause); + setError(cause instanceof Error && cause.message ? cause.message : t('newWorkspace.deviceError')); + return false; + } finally { + setSelectingDevice(null); + } + }; + + const handleChooseLocalProject = async () => { + if (busy || submissionPending.current) return; + if (await enterDevice('local', null)) setStep('project'); + }; + + const handleChooseDevice = async (device: NewProjectDeviceOption) => { + if (busy || submissionPending.current || !device.controllable) return; + if (await enterDevice(device.surfaceId, device)) setStep('project'); + }; + const handleAddFolder = useCallback(async () => { if (busy || sourceOptions.loading || !sourceReady) return; setIsSelecting(true); @@ -172,6 +241,11 @@ export const NewProjectDialog: React.FC = ({ setIsCreating(true); setError(''); try { + if (onSelectDevice) { + // The personal assistant always lives on this machine. + if (!(await onSelectDevice('local'))) return; + formSurface.current = getActiveSurfaceScope(); + } formSurface.current.assertCurrent('create assistant'); await onCreateAssistant(); formSurface.current.assertCurrent('finish assistant creation'); @@ -183,7 +257,7 @@ export const NewProjectDialog: React.FC = ({ submissionPending.current = false; setIsCreating(false); } - }, [busy, onCreateAssistant, onClose, t]); + }, [busy, onCreateAssistant, onClose, onSelectDevice, t]); const handleConfirm = useCallback(async () => { if (busy || sourceOptions.loading || !sourceReady || submissionPending.current || step !== 'project' || (needsName && !name) || invalidDirectoryName) return; @@ -294,24 +368,30 @@ export const NewProjectDialog: React.FC = ({ {options.error && {options.error}}
; - const projectTitle = t(onCreateAssistant ? 'newWorkspace.createProject' : 'newProject.title'); + const projectTitle = projectDevice + ? t('newWorkspace.createProjectOn', { name: projectDevice.name }) + : t(onCreateAssistant ? 'newWorkspace.createProject' : 'newProject.title'); + const dialogTitle = step === 'choice' ? t('newWorkspace.title') + : step === 'device' ? t('newWorkspace.chooseDevice') : projectTitle; return ( { if (!busy) onClose(); }} - initialFocusRef={step === 'choice' ? assistantChoiceRef : nameInputRef} + initialFocusRef={step === 'choice' ? assistantChoiceRef : step === 'device' ? deviceChoiceRef : nameInputRef} size="md" data-testid={testId}> - {step === 'choice' ? t('newWorkspace.title') : projectTitle} + {dialogTitle} {step === 'choice' && {t('newWorkspace.description')}} + {step === 'device' && {t('newWorkspace.chooseDeviceDescription')}} {step === 'choice' && ( -
+
} aria-label={t('newWorkspace.createAssistant')} aria-busy={isCreating} disabled={busy} @@ -326,14 +406,61 @@ export const NewProjectDialog: React.FC = ({ )} /> } - aria-label={t('newWorkspace.createProject')} disabled={busy} - onClick={() => changeStep('project')} data-testid="nav-session-group-add-project" + aria-label={t('newWorkspace.createProject')} aria-busy={selectingDevice === 'local'} disabled={busy} + onClick={() => { if (offersDevices) void handleChooseLocalProject(); else changeStep('project'); }} + data-testid="nav-session-group-add-project" body={( - {t('newWorkspace.projectTitle')} - {t('newWorkspace.projectDescription')} + {t(offersDevices ? 'newWorkspace.localProjectTitle' : 'newWorkspace.projectTitle')} + + {selectingDevice === 'local' ? t('newWorkspace.connecting') + : t(offersDevices ? 'newWorkspace.localProjectDescription' : 'newWorkspace.projectDescription')} + )} /> + {offersDevices && ( + } + aria-label={t('newWorkspace.createDeviceProject')} disabled={busy} + onClick={() => changeStep('device')} data-testid="nav-session-group-add-device-project" + body={( + + {t('newWorkspace.deviceProjectTitle')} + {t('newWorkspace.deviceProjectDescription')} + + )} /> + )} +
+ )} + {step === 'device' && ( +
+ {deviceOptions === undefined && {t('newWorkspace.signInForDevices')}} + {deviceOptions?.length === 0 && {t('newWorkspace.noOtherDevices')}} + {deviceOptions?.map((device, index) => { + const hostLabel = device.hostKind === 'cli' ? t('nav.devices.hostCli') : device.hostKind === 'desktop' ? t('nav.devices.hostDesktop') : null; + const connecting = selectingDevice === device.surfaceId; + const meta = connecting ? t('newWorkspace.connecting') + : !device.controllable ? t('deviceOverview.deviceClientIncompatible') + : [hostLabel, device.rendered ? t('newWorkspace.deviceCurrent') : null].filter(Boolean).join(' · '); + return ( +
+ } + aria-label={t('newWorkspace.createProjectOn', { name: device.name })} aria-busy={connecting} + disabled={busy || !device.controllable} onClick={() => { void handleChooseDevice(device); }} + data-testid="new-project-device-option" data-device-surface={device.surfaceId} + body={( + + {device.name} + {meta && {meta}} + + )} /> +
+ ); + })}
)} {step === 'project' && ( @@ -418,10 +545,18 @@ export const NewProjectDialog: React.FC = ({ )} {error &&
{error}
} + {step === 'device' && ( + + + + )} {step === 'project' && ( {onCreateAssistant && ( - )} From 81bc2d52d16e2f99de203292c35af94ccc7c479f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:35:26 +0800 Subject: [PATCH 05/52] feat(web-ui): show every device's sessions in the Agent companion The companion lists running, waiting and unread sessions of background devices next to the rendered device's, labelled with their device, and emits only changed payloads. Opening one renders its device first with the same held session tab as the sidebar. --- src/web-ui/src/app/App.tsx | 8 +- .../AgentCompanionDesktopPet.tsx | 7 +- .../app/services/openAgentCompanionSession.ts | 54 ++++++++++++- .../utils/agentCompanionActivity.test.ts | 41 +++++++++- .../flow_chat/utils/agentCompanionActivity.ts | 80 ++++++++++++++++++- 5 files changed, 183 insertions(+), 7 deletions(-) diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 40b785806b..2a219bfcfa 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -776,7 +776,7 @@ function App() { let disposed = false; let unlisten: (() => void) | null = null; void import('@tauri-apps/api/event') - .then(({ listen }) => listen<{ sessionId?: string }>( + .then(({ listen }) => listen<{ sessionId?: string; surfaceId?: string; workspaceId?: string }>( 'agent-companion://open-session', async event => { if (disposed) return; @@ -784,7 +784,11 @@ function App() { if (!sessionId) return; const { openAgentCompanionSession } = await import('./services/openAgentCompanionSession'); - await openAgentCompanionSession(sessionId); + try { + await openAgentCompanionSession(sessionId, event.payload?.surfaceId, event.payload?.workspaceId); + } catch (error) { + log.warn('Failed to open Agent companion session', { sessionId, error }); + } try { await api.invoke('show_main_window'); diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index ccbcc53906..1591c3bf06 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -1014,7 +1014,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { const openTaskSession = async (task: AgentCompanionTaskStatus) => { try { - await emit('agent-companion://open-session', { sessionId: task.sessionId }); + await emit('agent-companion://open-session', { + sessionId: task.sessionId, + ...(task.surfaceId ? { surfaceId: task.surfaceId, workspaceId: task.workspaceId } : {}), + }); await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to open Agent companion task session', { @@ -1126,7 +1129,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { - {statusLabel} + {task.deviceName ? `${statusLabel} · ${task.deviceName}` : statusLabel} {isSingleTask && task.latestOutput && (() => { const typedOutput = typedOutputBySessionId[task.sessionId]; diff --git a/src/web-ui/src/app/services/openAgentCompanionSession.ts b/src/web-ui/src/app/services/openAgentCompanionSession.ts index 4a52bbc84f..cb4c9ade9e 100644 --- a/src/web-ui/src/app/services/openAgentCompanionSession.ts +++ b/src/web-ui/src/app/services/openAgentCompanionSession.ts @@ -4,9 +4,61 @@ import { openMainSession } from '@/flow_chat/services/sessionActivation'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { resolveSessionSceneWorkspace } from './sessionSceneTarget'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { getActiveSurfaceId, getActiveSurfaceScope, isLocalSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; +import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { clearHistorySessionOpenTransition, dispatchHistorySessionOpenIntent } from '@/flow_chat/services/sessionOpenIntent'; +import { useSceneStore } from '../stores/sceneStore'; +import { sessionSceneWorkspaceKey } from './sessionSceneTarget'; -export async function openAgentCompanionSession(sessionId: string): Promise { +/** + * `surfaceId` names a device this window may not be rendering (a companion + * task from another device); that device is rendered first, exactly like + * opening the session from its sidebar section. + */ +export async function openAgentCompanionSession( + sessionId: string, + surfaceId?: string, + sourceWorkspaceId?: string, +): Promise { + if (surfaceId && surfaceId !== getActiveSurfaceId()) { + // Same presentation as a sidebar open: the session's tab and loading state + // are shown at once and held through the device switch. + const intent = beginDeviceOpen({ surfaceId, key: `session:${sessionId}`, sessionId }); + try { + if (sourceWorkspaceId) { + useSceneStore.getState().holdSessionScene({ + surfaceId, workspaceKey: sessionSceneWorkspaceKey(sourceWorkspaceId), sessionId, + }); + } + dispatchHistorySessionOpenIntent(sessionId, undefined, 20_000); + const { peerDeviceSurfaceController } = await import('@/infrastructure/peer-device/peerDeviceSurfaceRuntime'); + const outcome = isLocalSurface(surfaceId) + ? await peerDeviceSurfaceController.switchToLocal('agent_companion') + : await peerDeviceSurfaceController.switchToDevice(surfaceId, resolveDeviceName(surfaceId)); + if (outcome !== 'activated') { + clearHistorySessionOpenTransition(sessionId); + return false; + } + return await openOnRenderedSurface(sessionId, surfaceId, sourceWorkspaceId); + } finally { + endDeviceOpen(intent); + } + } + return openOnRenderedSurface(sessionId, surfaceId, sourceWorkspaceId); +} + +async function openOnRenderedSurface( + sessionId: string, + surfaceId?: string, + sourceWorkspaceId?: string, +): Promise { const flowChatStore = FlowChatStore.getInstance(); + if (surfaceId && sourceWorkspaceId && !flowChatStore.getState().sessions.has(sessionId)) { + const scope = getActiveSurfaceScope(); + await flowChatStore.ensurePersistedSessionMetadata(sessionId, sourceWorkspaceId); + scope.assertCurrent('open companion session on its device'); + } const session = flowChatStore.getState().sessions.get(sessionId); if (!session) { return false; diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts index 73c4000f8b..d1151f5e53 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts @@ -1,10 +1,20 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { flowChatStore } from '../store/FlowChatStore'; import { stateMachineManager } from '../state-machine/SessionStateMachineManager'; import { ProcessingPhase, SessionExecutionEvent, SessionExecutionState } from '../state-machine/types'; import type { DialogTurn, Session } from '../types/flow-chat'; import { buildAgentCompanionActivity } from './agentCompanionActivity'; +const background = vi.hoisted(() => ({ rows: [] as unknown[] })); +vi.mock('../services/crossDeviceSessions', () => ({ + listBackgroundDeviceSessions: () => background.rows, + subscribeCrossDeviceSessions: () => () => undefined, +})); +vi.mock('@/infrastructure/account/deviceDirectory', () => ({ + resolveDeviceName: (id: string) => (id === 'peer-a' ? 'Studio Mac' : id), + useDeviceDirectory: { getState: () => ({ localId: null }) }, +})); + function hasLoneSurrogate(value: string): boolean { for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); @@ -384,4 +394,33 @@ describe('buildAgentCompanionActivity', () => { expect(activity.tasks).toHaveLength(1); expect(activity.tasks[0].state).not.toBe('attention'); }); + + it('adds other devices sessions that need attention, run, or finished unread', () => { + const row = (sessionId: string, activity: Record | undefined) => ({ + surfaceId: 'peer-a', + workspaceId: 'ws-1', + session: { sessionId, sessionName: `Remote ${sessionId}`, turnCount: 1, createdAt: 1, lastActiveAt: 2 }, + activity: activity && { sessionId, execution: 'idle', pendingApprovals: 0, pendingQuestions: 0, ...activity }, + }); + background.rows = [ + row('r-approval', { execution: 'running', pendingApprovals: 1 }), + row('r-running', { execution: 'running' }), + row('r-done', { unreadCompletion: 'completed' }), + row('r-idle', {}), + row('r-unknown', undefined), + ]; + try { + const activity = buildAgentCompanionActivity(); + expect(activity.tasks.map(task => [task.sessionId, task.state])).toEqual([ + ['r-approval', 'attention'], + ['r-running', 'running'], + ['r-done', 'completed'], + ]); + expect(activity.tasks.every(task => task.surfaceId === 'peer-a' && task.workspaceId === 'ws-1' + && task.deviceName === 'Studio Mac' && task.canReply === false)).toBe(true); + expect(activity.mood).toBe('waiting'); + } finally { + background.rows = []; + } + }); }); diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index 3f0634c340..174614764c 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -10,6 +10,15 @@ import { TRANSIENT_TURN_STATUSES, } from './askUserQuestionState'; import { effectiveToolInvocation } from './toolInvocationIdentity'; +import { resolvePersistedSessionTitle } from './sessionTitle'; +import { i18nService } from '@/infrastructure/i18n'; +import { resolveDeviceName, useDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; +import { isLocalSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { + listBackgroundDeviceSessions, + subscribeCrossDeviceSessions, + type CrossDeviceSession, +} from '../services/crossDeviceSessions'; export type AgentCompanionTaskState = | 'running' @@ -35,6 +44,13 @@ export interface AgentCompanionTaskStatus { * parent turn context that only the main window composer can supply. */ canReply?: boolean; + /** + * Set for a session on a device this window is not rendering. Opening it + * renders that device first; replying from the pet is not offered there. + */ + surfaceId?: string; + workspaceId?: string; + deviceName?: string; } export interface AgentCompanionActivityPayload { @@ -360,6 +376,48 @@ function isIndependentCompanionSession(session: Session): boolean { return !resolveSessionRelationship(session).isSubagent; } +function deviceLabel(surfaceId: string): string { + if (!isLocalSurface(surfaceId)) return resolveDeviceName(surfaceId); + const localId = useDeviceDirectory.getState().localId; + return localId ? resolveDeviceName(localId) : i18nService.t('common:nav.devices.thisDevice'); +} + +/** A session on a background device, known only through its host summary. */ +function backgroundDeviceTask(row: CrossDeviceSession): AgentCompanionTaskStatus | null { + const activity = row.activity; + if (!activity) return null; + const at = row.session.lastActiveAt || row.session.createdAt; + const base = { + sessionId: row.session.sessionId, + title: toWellFormedText(resolvePersistedSessionTitle(row.session, key => i18nService.t(key))), + startedAt: at, + updatedAt: at, + canReply: false, + surfaceId: row.surfaceId, + workspaceId: row.workspaceId, + deviceName: toWellFormedText(deviceLabel(row.surfaceId)), + }; + if (activity.pendingApprovals > 0) { + return { ...base, mood: 'waiting', state: 'attention', labelKey: 'agentCompanion.activity.waitingApproval', defaultLabel: 'Waiting for approval' }; + } + if (activity.pendingQuestions > 0) { + return { ...base, mood: 'waiting', state: 'attention', labelKey: 'agentCompanion.activity.needsInput', defaultLabel: 'Needs input' }; + } + if (activity.execution === 'running' || activity.execution === 'queued') { + return { ...base, mood: 'working', state: 'running', labelKey: 'agentCompanion.activity.working', defaultLabel: 'Working' }; + } + if (activity.unreadCompletion === 'completed') { + return { ...base, mood: 'rest', state: 'completed', labelKey: 'agentCompanion.activity.completed', defaultLabel: 'Completed' }; + } + if (activity.unreadCompletion === 'interrupted') { + return { ...base, mood: 'rest', state: 'interrupted', labelKey: 'agentCompanion.activity.interrupted', defaultLabel: 'Interrupted' }; + } + if (activity.unreadCompletion === 'error' || activity.execution === 'error') { + return { ...base, mood: 'rest', state: 'error', labelKey: 'agentCompanion.activity.failed', defaultLabel: 'Failed' }; + } + return null; +} + export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { const sessions = Array.from(FlowChatStore.getInstance().getState().sessions.values()) .filter(isIndependentCompanionSession); @@ -402,6 +460,17 @@ export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { } }); + // Other devices' sessions coexist with this one's in the companion. + const listed = new Set(tasks.map(task => task.sessionId)); + for (const row of listBackgroundDeviceSessions()) { + if (listed.has(row.session.sessionId)) continue; + const task = backgroundDeviceTask(row); + if (task) { + tasks.push(task); + listed.add(task.sessionId); + } + } + if (!tasks.length) { pruneTaskOrder(tasks); return EMPTY_ACTIVITY; @@ -427,17 +496,26 @@ export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { export function subscribeAgentCompanionActivity( listener: (payload: AgentCompanionActivityPayload) => void, ): () => void { + // Stores notify far more often than the companion's facts change (streaming + // tokens, catalog page reads); only a changed payload crosses to its window. + let last = ''; const emitCurrent = () => { - listener(buildAgentCompanionActivity()); + const activity = buildAgentCompanionActivity(); + const key = JSON.stringify(activity); + if (key === last) return; + last = key; + listener(activity); }; const unsubscribeStore = FlowChatStore.getInstance().subscribe(emitCurrent); const unsubscribeMachines = stateMachineManager.subscribeGlobal(emitCurrent); + const unsubscribeDevices = subscribeCrossDeviceSessions(emitCurrent); emitCurrent(); return () => { unsubscribeStore(); unsubscribeMachines(); + unsubscribeDevices(); }; } From 47427c483504e9e4b85a5a3f65a8544439898ea6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:35:26 +0800 Subject: [PATCH 06/52] docs(peer-device): describe the sidebar device catalog Document catalog reads, freshness, background seeding and the cross-device open contract. --- docs/architecture/peer-device-mode.md | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index 4c8a6933a8..a0da2d1d94 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -204,6 +204,72 @@ retain replayable request state and expose it through an attach/snapshot path. A one-shot frontend event plus an unresolved channel is not a complete multi-device implementation. +### Sidebar device catalog + +The session list shows every online host of the account in place, grouped by +device in a fixed order (this machine first, then peers by name). The rendered +device's section is its live workspace container. Every other section, this +machine included while a peer is rendered, is drawn from a read-only catalog +(`infrastructure/peer-device/deviceWorkspaceCatalog.ts`): + +- Every online, compatible host's catalog is kept current while the sidebar + lists it, including the first session page of each of its workspaces + (bounded to two concurrent reads per device). That is what lets device + headers, session rows and the Agent companion show running, waiting and + unread sessions of every device at once. Desktop hosts do not fan + `agentic://` events out to controllers, so the host's catalog summary is the + freshest status for a background device; observed events only fill sessions + the catalog has no summary for. +- A peer is read through a raw `host_invoke` device RPC limited to + `peer_mode_ping`, `get_opened_workspaces` and `list_persisted_sessions_page` + (`deviceCatalogTransport.ts`). It needs no control attachment and does not + fan out events. This machine is read through its own Tauri transport. Session + reads use `workspace_id` only when the host advertises + `workspace_id_references_v1`; older hosts get their own root path. +- Freshness comes from invalidations, not polling. While a peer section is open + the controller subscribes the peer's `@host/catalog` stream. Its + `host-catalog-changed` hint is surface-scoped, so only the catalog observer + sees it. The catalog also observes local and attached-peer lifecycle events. + Hints are debounced (300 ms) and coalesced into one in-flight read per device. + A hint whose `workspacesRevision` did not move refreshes session pages only; + refreshes are held while the window is hidden or a cross-device open runs and + run once afterwards; the device that just became rendered is not re-read; + catalog notifications are coalesced per task. + A host without the stream is re-read when the window becomes visible. The + stream is released 30 s after the section closes. +- A failed read keeps the last rendered catalog and offers a retry. An + incompatible build is listed but not read. + +Navigation draws a background device from its own FlowChat container, seeded +from the catalog through the same metadata-to-Session projection a metadata +page uses (`flowChatStore.seedSurfaceSessions`; never the rendered surface). +Rows use the live list's hierarchy, comparator, view filters, first-level +limit and project disclosure (window-wide, keyed by surface), so a device looks +the same before, during and after a switch. Seeds carry no history or state +machine; they are promoted through the normal metadata path before any open, +and a seed that has been opened is never replaced by a later seed. + +A cross-device open publishes a `DeviceOpenIntent` for its whole duration. While +it is set, every device keeps its background rows (the rendered device hands +over to its live list once, after the open, so the live list's reads never +compete with the open), catalog refreshes are held and run once afterwards, the +session's tab is held in front (`sceneStore.holdSessionScene`) from the click through both switch resets and session-tab reconciliation, +and the main area shows the session's open overlay from the click on. + +Acting on a catalog entry (open a session or assistant, new session) first +renders its device through `switchToDevice` / `switchToLocal`. It then runs the +local handler on the now-rendered surface. Every mutation therefore keeps +Peer Device Mode's ownership, capability gates, permission mailboxes and surface +fences. The catalog never mutates a host. The create dialog does the same: it +renders the chosen device before opening the project form, so folder picking, +recent workspaces and sources belong to that device. The personal assistant is +always created on this machine. + +The Agent companion lists background-device sessions next to the rendered +device's, labelled with their device. Opening one renders its device first; +replying from the companion is offered only for the rendered device. A switch +reloads the target's config and workspace catalog concurrently. + ## Cloud account sync vs Peer Remote | Concern | Account cloud sync | Peer Device Mode | From a0b33b01bb7baceae966471e1a3420fcdb4a76bf Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:41:30 +0800 Subject: [PATCH 07/52] fix(web-ui): forget remembered session totals when the device catalog resets An account change dropped background seeds but kept the remembered totals, so another account's lists could briefly show the previous "+N". --- src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts index 1b636c93d9..96c3f88e9b 100644 --- a/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts +++ b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts @@ -12,7 +12,7 @@ import { getActiveSurfaceId, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; import { flowChatStore, type BackgroundSessionPage } from '../store/FlowChatStore'; -import { DEFAULT_SESSION_VIEW_KEY, rememberWorkspaceSessionTotal } from './workspaceSessionTotals'; +import { DEFAULT_SESSION_VIEW_KEY, forgetSurfaceSessionTotals, rememberWorkspaceSessionTotal } from './workspaceSessionTotals'; let installs = 0; /** Page record arrays already projected, per surface; the catalog replaces arrays on change. */ @@ -21,7 +21,9 @@ let teardown: (() => void) | null = null; export function seedBackgroundSurface(surfaceId: DeviceSurfaceId): void { const snapshot = deviceWorkspaceCatalog.getSnapshot(surfaceId); if (snapshot.status === 'idle') { + // Catalog reset (account change): nothing of the previous read survives. flowChatStore.discardSeededSessions(surfaceId); + forgetSurfaceSessionTotals(surfaceId); seededRecords.delete(surfaceId); return; } From 53286415ad4f1ba04fe098a93deeb94f0c75a4b4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:43:08 +0800 Subject: [PATCH 08/52] fix(web-ui): keep the workspace label visible on hover for rows without actions The flat list hides a row's workspace label on hover so its actions can take the space. Rows of other devices have no actions, so the label just disappeared. Hide it only when the row has actions. --- .../NavPanel/sections/sessions/SessionsSection.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 fffbefb580..14f40e380a 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 @@ -284,8 +284,9 @@ } // Preserve title geometry while the project name yields to row actions. - .openbitfun-nav-panel__inline-item:hover .openbitfun-nav-panel__inline-item-workspace-name, - .openbitfun-nav-panel__inline-item:focus-within .openbitfun-nav-panel__inline-item-workspace-name, + // Rows without actions (other devices) keep their name visible. + .openbitfun-nav-panel__inline-item:has(.openbitfun-nav-panel__inline-item-actions):hover .openbitfun-nav-panel__inline-item-workspace-name, + .openbitfun-nav-panel__inline-item:has(.openbitfun-nav-panel__inline-item-actions):focus-within .openbitfun-nav-panel__inline-item-workspace-name, .openbitfun-nav-panel__inline-item.is-menu-open .openbitfun-nav-panel__inline-item-workspace-name { visibility: hidden; } From e748481fc8beb205b353a738ebed29705b5d743b Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:48:25 +0800 Subject: [PATCH 09/52] fix(web-ui): mark sessions of every device read from the session menu "Mark all read" cleared only the rendered device's sessions. It now also acknowledges the loaded sessions of every other device on their hosts with save_session_metadata limited to the unread and attention fields, clears their rows, statuses and companion entries at once, then re-reads the session pages. Background sessions loaded in this window pick up host read state on reseed. --- docs/architecture/peer-device-mode.md | 5 +++ .../components/WorkspaceSessionFilterMenu.tsx | 7 +++ .../src/flow_chat/store/FlowChatStore.ts | 5 ++- .../peer-device/deviceCatalogTransport.ts | 29 +++++++++++- .../deviceWorkspaceCatalog.test.ts | 23 +++++++++- .../peer-device/deviceWorkspaceCatalog.ts | 44 ++++++++++++++++++- 6 files changed, 108 insertions(+), 5 deletions(-) diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index a0da2d1d94..c10b0a1f19 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -256,6 +256,11 @@ compete with the open), catalog refreshes are held and run once afterwards, the session's tab is held in front (`sceneStore.holdSessionScene`) from the click through both switch resets and session-tab reconciliation, and the main area shows the session's open overlay from the click on. +The one write the catalog sends without rendering a device is "mark all +read": `save_session_metadata` limited to `unreadCompletion` and +`needsUserAttention`, applied optimistically and followed by a session-page +refresh. It touches no workspace, tool or permission. + Acting on a catalog entry (open a session or assistant, new session) first renders its device through `switchToDevice` / `switchToLocal`. It then runs the local handler on the now-rendered surface. Every mutation therefore keeps diff --git a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx index 369c5b892b..adacaf0aad 100644 --- a/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/WorkspaceSessionFilterMenu.tsx @@ -1,3 +1,5 @@ +import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { RotateCcw } from 'lucide-react'; @@ -295,6 +297,11 @@ const WorkspaceSessionFilterMenu: React.FC = () => { { flowChatStore.clearAllSessionUnreadCompletions(); + // The sidebar lists every device: mark theirs read on their hosts too. + const rendered = getActiveSurfaceId(); + for (const surfaceId of deviceWorkspaceCatalog.loadedSurfaces()) { + if (surfaceId !== rendered) void deviceWorkspaceCatalog.markAllRead(surfaceId); + } close(); }} > diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index cea18c57dc..66077e857d 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -8049,11 +8049,14 @@ export class FlowChatStore { const titleState = deriveSessionTitleStateFromMetadata(metadata); const lastFinishedAt = deriveLastFinishedAtFromMetadata(metadata); if (existing.title === titleState.title && existing.lastActiveAt === metadata.lastActiveAt - && existing.lastFinishedAt === lastFinishedAt && existing.persistedStatus === metadata.status) continue; + && existing.lastFinishedAt === lastFinishedAt && existing.persistedStatus === metadata.status + && existing.hasUnreadCompletion === metadata.unreadCompletion + && existing.needsUserAttention === metadata.needsUserAttention) continue; sessions.set(metadata.sessionId, { ...existing, title: titleState.title, titleSource: titleState.titleSource, titleI18nKey: titleState.titleI18nKey, titleI18nParams: titleState.titleI18nParams, lastActiveAt: metadata.lastActiveAt, lastFinishedAt, persistedStatus: metadata.status, + hasUnreadCompletion: metadata.unreadCompletion, needsUserAttention: metadata.needsUserAttention, }); changed = true; continue; diff --git a/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts b/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts index 8158da2f55..f820dda755 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceCatalogTransport.ts @@ -11,8 +11,11 @@ * it may run); * - this machine through its own Tauri transport. * - * Only the catalog reads below may use this path. Mutations always go through - * the rendered surface after activation so they keep Peer Device Mode's + * Only the catalog reads below may use this path, plus one acknowledgement + * write (`acknowledgeOnDevice`): clearing a session's read state touches no + * workspace, tool or permission, and asking the user to render every device + * to mark it read would defeat the action. Every other mutation goes through + * the rendered surface after activation so it keeps Peer Device Mode's * capability gates, permission mailboxes and surface fences. */ @@ -55,6 +58,24 @@ export function deviceCatalogLocalTransport(): TauriTransportAdapter { return localTransport; } +/** The only metadata fields `acknowledgeOnDevice` may write. */ +export const ACKNOWLEDGE_FIELDS = ['unreadCompletion', 'needsUserAttention'] as const; + +/** Clear a session's read state on a device the window may not render. */ +export function acknowledgeOnDevice( + deviceId: string | null, + workspaceRequest: Record, + metadata: Record, +): Promise { + return send(deviceId, 'save_session_metadata', { + request: { + ...workspaceRequest, + metadata: { ...metadata, unreadCompletion: undefined, needsUserAttention: undefined }, + fields: [...ACKNOWLEDGE_FIELDS], + }, + }); +} + /** * Run one catalog read on `deviceId`, or on this machine when it is `null`. */ @@ -66,6 +87,10 @@ export async function invokeOnDevice( if (!DEVICE_CATALOG_READ_COMMANDS.has(command)) { throw new Error(`'${command}' is not a device catalog read`); } + return send(deviceId, command, args); +} + +async function send(deviceId: string | null, command: string, args: Record): Promise { if (deviceId === null) { return deviceCatalogLocalTransport().request(command, args); } diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts index 4fa1ec5c23..8333d2297c 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); -vi.mock('./deviceCatalogTransport', () => ({ invokeOnDevice: vi.fn(), deviceCatalogLocalTransport: vi.fn() })); +vi.mock('./deviceCatalogTransport', () => ({ invokeOnDevice: vi.fn(), acknowledgeOnDevice: vi.fn(), deviceCatalogLocalTransport: vi.fn() })); vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConnectAPI: {} })); import { @@ -62,6 +62,7 @@ describe('DeviceWorkspaceCatalog', () => { }); io = { invoke: invoke as DeviceWorkspaceCatalogIO['invoke'], + acknowledge: vi.fn(async () => undefined), subscribeCatalog: vi.fn(async () => 'sub-1'), unsubscribeCatalog: vi.fn(async () => undefined), listen: vi.fn(() => () => undefined), @@ -313,4 +314,24 @@ describe('DeviceWorkspaceCatalog', () => { // and loading/ready per page); updates within one task share a notification. expect(listener.mock.calls.length).toBeLessThan(9); }); + + it('marks a device read on its host, updating rows at once', async () => { + invoke.mockImplementation(async (_device: string | null, command: string) => { + if (command === 'peer_mode_ping') return { capabilities }; + if (command === 'get_opened_workspaces') return [workspace('ws-1')]; + return { + sessions: [session('done', { unreadCompletion: 'completed' }), session('quiet')], + totalTopLevelCount: 2, loadedTopLevelCount: 2, hasMore: false, + activities: [{ sessionId: 'done', execution: 'idle', pendingApprovals: 0, pendingQuestions: 0, unreadCompletion: 'completed' }], + }; + }); + catalog.retainActivity('peer-a'); + await flush(); + const marked = catalog.markAllRead('peer-a'); + // Optimistic: cleared before the host answers. + expect(catalog.getSessions('peer-a', 'ws-1').activities.done?.unreadCompletion).toBeUndefined(); + expect(await marked).toBe(1); + expect(io.acknowledge).toHaveBeenCalledOnce(); + expect(io.acknowledge).toHaveBeenCalledWith('peer-a', { workspace_id: 'ws-1' }, expect.objectContaining({ sessionId: 'done' })); + }); }); diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts index d909278159..21703a90a8 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts @@ -37,7 +37,7 @@ import { type DeviceSurfaceId, } from './deviceSurface'; import { observeSurfaceEvents } from './deviceSurfaceRouting'; -import { deviceCatalogLocalTransport, invokeOnDevice } from './deviceCatalogTransport'; +import { acknowledgeOnDevice, deviceCatalogLocalTransport, invokeOnDevice } from './deviceCatalogTransport'; import { stableWorkspaceOrder } from '@/infrastructure/services/business/workspaceDisplayOrder'; import { getDeviceOpenIntent, useDeviceOpenIntent } from './deviceOpenIntent'; @@ -142,6 +142,7 @@ type Listener = (surfaceId: DeviceSurfaceId) => void; export interface DeviceWorkspaceCatalogIO { invoke: (deviceId: string | null, command: string, args: Record) => Promise; + acknowledge: (deviceId: string | null, workspaceRequest: Record, metadata: Record) => Promise; subscribeCatalog: (deviceId: string) => Promise; unsubscribeCatalog: (subscriptionId: string) => Promise; /** Registers the native listeners that feed `observeSurfaceEvents`. */ @@ -150,6 +151,7 @@ export interface DeviceWorkspaceCatalogIO { const defaultIO: DeviceWorkspaceCatalogIO = { invoke: invokeOnDevice, + acknowledge: acknowledgeOnDevice, subscribeCatalog: deviceId => remoteConnectAPI.subscribeSession(deviceId, HOST_CATALOG_ID), unsubscribeCatalog: subscriptionId => remoteConnectAPI.unsubscribeSession(subscriptionId), listen: (event, callback) => deviceCatalogLocalTransport().listen(event, callback), @@ -274,6 +276,46 @@ export class DeviceWorkspaceCatalog { return this.loadSessions(entry, workspaceId, target); } + /** + * Mark every loaded session of a device read, on its host. The catalog is + * updated at once (rows, device status, companion), then re-read. Returns + * the number of sessions acknowledged. + */ + async markAllRead(surfaceId: DeviceSurfaceId): Promise { + const entry = this.entries.get(surfaceId); + if (!entry || entry.snapshot.status !== 'ready') return 0; + const targets: Array<{ workspace: WorkspaceInfo; metadata: SessionMetadata }> = []; + for (const workspace of entry.snapshot.workspaces) { + const page = entry.snapshot.sessions[workspace.id]; + if (!page) continue; + for (const metadata of page.records) { + if (metadata.unreadCompletion || metadata.needsUserAttention || page.activities[metadata.sessionId]?.unreadCompletion) { + targets.push({ workspace, metadata }); + } + } + } + if (targets.length === 0) return 0; + const cleared = new Set(targets.map(target => target.metadata.sessionId)); + const sessions = Object.fromEntries(Object.entries(entry.snapshot.sessions).map(([workspaceId, page]) => [workspaceId, { + ...page, + records: page.records.map(record => (cleared.has(record.sessionId) + ? { ...record, unreadCompletion: undefined, needsUserAttention: undefined } : record)), + sessions: page.sessions.map(record => (cleared.has(record.sessionId) + ? { ...record, unreadCompletion: undefined, needsUserAttention: undefined } : record)), + activities: Object.fromEntries(Object.entries(page.activities).map(([sessionId, activity]) => [sessionId, + cleared.has(sessionId) ? { ...activity, unreadCompletion: undefined, unreadTurn: undefined } : activity])), + }])); + this.update(entry, { sessions }); + await this.negotiate(entry); + const results = await Promise.allSettled(targets.map(({ workspace, metadata }) => this.io.acknowledge( + entry.deviceId, this.workspaceRequest(entry, workspace), metadata as unknown as Record, + ))); + const failed = results.filter(result => result.status === 'rejected'); + if (failed.length > 0) log.warn('Some sessions could not be marked read on their device', { surfaceId, failed: failed.length }); + this.invalidate(surfaceId, 'sessions'); + return targets.length - failed.length; + } + /** Explicit retry after a visible failure; bypasses the debounce. */ retry(surfaceId: DeviceSurfaceId): Promise { const entry = this.entries.get(surfaceId); From a2ebd085400fcab31498b6b4e6a8b5b700d406a1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:52:45 +0800 Subject: [PATCH 10/52] fix(web-ui): stop companion bubble glows from being cut at the column edge Bubbles are as wide as their column, which clips sideways, so the tinted drop shadow and the attention pulse ring were cut off flat at the right edge. Give the drop shadow zero horizontal reach so it fades out below the bubble, and pulse the attention ring inside the bubble's edge. --- .../AgentCompanionDesktopPet.scss | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss index f04f849c0e..2295e4d6c4 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -367,11 +367,13 @@ transparent 68% ); /* No border: the bubble is defined by its surface, the state light, the - theme's top highlight and a soft tinted drop shadow. Everything stays - inside the bubble column, which clips sideways. */ + theme's top highlight and a soft tinted drop shadow. The bubble is as + wide as its column, which clips sideways, so every outer light keeps a + zero horizontal reach (blur equals the negative spread): it fades out + below the bubble instead of being cut off at the column's edges. */ --_bubble-edge: var(--openbitfun-shadow-inner-highlight), - 0 10px 20px -12px color-mix(in srgb, var(--_bubble-light) 34%, transparent); + 0 12px 16px -16px color-mix(in srgb, var(--_bubble-light) 40%, transparent); padding: 9px 12px; border: none; border-radius: 14px 14px 4px 14px; @@ -521,7 +523,7 @@ ), var(--_bubble-glow); box-shadow: var(--_bubble-edge), - 0 0 0 0 color-mix(in srgb, var(--openbitfun-color-status-warning-content) 50%, transparent); + inset 0 0 0 1px color-mix(in srgb, var(--openbitfun-color-status-warning-content) 40%, transparent); animation: openbitfun-agent-companion-attention-pulse 1.5s ease-in-out infinite !important; } @@ -575,18 +577,20 @@ } @keyframes openbitfun-agent-companion-attention-pulse { + /* The pulse breathes inside the bubble's edge: an outward ring would be + clipped by the column on both sides. */ 0%, 100% { box-shadow: var(--_bubble-edge), - 0 0 0 0 color-mix(in srgb, var(--openbitfun-color-status-warning-content) 40%, transparent); + inset 0 0 0 1px color-mix(in srgb, var(--openbitfun-color-status-warning-content) 18%, transparent); transform: translateY(0); } 50% { box-shadow: var(--_bubble-edge), - 0 0 0 6px transparent; + inset 0 0 0 1.5px color-mix(in srgb, var(--openbitfun-color-status-warning-content) 55%, transparent); transform: translateY(-1px); } } From f5953975240d362cbaa6f4dd67f6cfaab1f5ec86 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:55:37 +0800 Subject: [PATCH 11/52] style(web-ui): light companion bubbles from the top left Cast the bubble drop shadow down and to the right at 45 degrees, and give the bubble column inline-end room for it (offset by a negative margin, so neither the bubbles nor the pet move). --- .../AgentCompanionDesktopPet.scss | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss index 2295e4d6c4..f77a3e39a9 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -235,8 +235,14 @@ &__bubbles { position: relative; flex-shrink: 0; - width: 180px; - max-width: 180px; + /* Bubbles stay 180px; the extra inline-end room holds their bottom-right + shadow, and the negative margin keeps the pet where it was. */ + width: calc(180px + var(--_bubble-shadow-room)); + max-width: calc(180px + var(--_bubble-shadow-room)); + padding-inline-end: var(--_bubble-shadow-room); + margin-inline-end: calc(-1 * var(--_bubble-shadow-room)); + --_bubble-shadow-room: 14px; + box-sizing: border-box; min-height: 0; max-height: 100vh; /* The slot clips (`overflow: hidden`), so the buffer has to sit inside it: @@ -367,13 +373,12 @@ transparent 68% ); /* No border: the bubble is defined by its surface, the state light, the - theme's top highlight and a soft tinted drop shadow. The bubble is as - wide as its column, which clips sideways, so every outer light keeps a - zero horizontal reach (blur equals the negative spread): it fades out - below the bubble instead of being cut off at the column's edges. */ + theme's top highlight and a soft tinted drop shadow cast down and to + the right at 45 degrees. Its reach stays inside the column: none to the + left, and the column's inline-end room covers the right. */ --_bubble-edge: var(--openbitfun-shadow-inner-highlight), - 0 12px 16px -16px color-mix(in srgb, var(--_bubble-light) 40%, transparent); + 6px 6px 14px -8px color-mix(in srgb, var(--_bubble-light) 42%, transparent); padding: 9px 12px; border: none; border-radius: 14px 14px 4px 14px; From 72026ea0b6702c100a138800fe61b3abd65000a6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 10:56:00 +0800 Subject: [PATCH 12/52] style(web-ui): let the companion bubble shadow spread into the space before the pet Widen the bubble column's inline-end room to 28px and soften the 45 degree shadow so it falls off naturally instead of stopping near the bubble. --- .../AgentCompanionDesktopPet.scss | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss index f77a3e39a9..3618010a71 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -235,13 +235,14 @@ &__bubbles { position: relative; flex-shrink: 0; - /* Bubbles stay 180px; the extra inline-end room holds their bottom-right - shadow, and the negative margin keeps the pet where it was. */ + /* Bubbles stay 180px; the inline-end room (open space before the pet) + holds their bottom-right shadow, and the negative margin keeps the pet + where it was. */ width: calc(180px + var(--_bubble-shadow-room)); max-width: calc(180px + var(--_bubble-shadow-room)); padding-inline-end: var(--_bubble-shadow-room); margin-inline-end: calc(-1 * var(--_bubble-shadow-room)); - --_bubble-shadow-room: 14px; + --_bubble-shadow-room: 28px; box-sizing: border-box; min-height: 0; max-height: 100vh; @@ -378,7 +379,7 @@ left, and the column's inline-end room covers the right. */ --_bubble-edge: var(--openbitfun-shadow-inner-highlight), - 6px 6px 14px -8px color-mix(in srgb, var(--_bubble-light) 42%, transparent); + 10px 10px 24px -10px color-mix(in srgb, var(--_bubble-light) 40%, transparent); padding: 9px 12px; border: none; border-radius: 14px 14px 4px 14px; From 03c2c1c7a57f2c609ac28f91d59e97f4034e55d3 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 11:09:43 +0800 Subject: [PATCH 13/52] fix(web-ui): offer every device the same switch action in the device card The card showed "connect and use" with a link glyph for devices without a background control link and "switch" for attached ones, which looked like a difference between device kinds. Attaching is an implementation detail of the switch; every other device now shows the same switch action. --- .../components/DeviceStatusControl.test.tsx | 6 ++--- .../components/DeviceStatusControl.tsx | 23 ++++++++----------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx index 0350c6ebe7..474913e3df 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.test.tsx @@ -540,9 +540,9 @@ describe('device status card', () => { expect(element('nav-device-status-connect').dataset.openbitfunComponent).toBe('icon-button'); expect(element('nav-device-status-connect').dataset.openbitfunShape).toBe('circle'); expect(element('nav-device-status-connect').dataset.openbitfunVariant).toBe('primary'); - expect(element('nav-device-status-connect').getAttribute('aria-label')).toMatch(/ · deviceOverview\.connectAndUse$/); - // Connecting and switching to an attached device are told apart by glyph. - expect(element('nav-device-status-connect').querySelector('.lucide-link')).not.toBeNull(); + expect(element('nav-device-status-connect').getAttribute('aria-label')).toMatch(/ · deviceOverview\.switchUse$/); + // Attached or not, every device offers the same switch action. + expect(element('nav-device-status-connect').querySelector('.lucide-arrow-right-left')).not.toBeNull(); expect(element('nav-device-status-connect').textContent).toBe(''); state.attachedHostKinds = { 'peer-2': 'cli' }; render(); diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index 1ed2c3d262..ab4a0b53b0 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -1,7 +1,7 @@ import { useDeviceDirectory, resolveDeviceName, isDeviceControllable, deviceClientVersion } from '@/infrastructure/account/deviceDirectory'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { subscribeOverlayInteraction, createOverlayPortal, OverflowText, Button, Card, CardBody, CardHeader, Icon, IconButton, ScrollArea, type IconSize } from '@openbitfun/ui'; -import { ArrowRightLeft, ChevronLeft, ChevronRight, CircleAlert, House, Link, MessageCircle, Monitor, MonitorSmartphone, Smartphone, Undo2, Unplug } from 'lucide-react'; +import { ArrowRightLeft, ChevronLeft, ChevronRight, CircleAlert, House, MessageCircle, Monitor, MonitorSmartphone, Smartphone, Undo2, Unplug } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; import { useAnchoredPopoverPosition } from '@/shared/utils/useAnchoredPopoverPosition'; @@ -109,7 +109,7 @@ const DeviceStatusControl: React.FC = ({ const [switchTargets, setSwitchTargets] = useState<{ accountId: string | number; localId: string; devices: AccountDeviceInfo[]; } | null>(null); - const [switchingAction, setSwitchingAction] = useState<'connect' | 'switch' | 'disconnect' | null>(null); + const [switchingAction, setSwitchingAction] = useState<'switch' | 'disconnect' | null>(null); const switchingDevice = switchingAction !== null; const switchingRef = useRef(false); const activePeerId = peerContext?.peerMode.active ? peerContext.peerMode.deviceId : null; @@ -245,10 +245,7 @@ const DeviceStatusControl: React.FC = ({ // never a control target. This machine is never gated by its own flag. if (target.device_id !== availableTargets.localId && !isDeviceControllable(target)) return; switchingRef.current = true; - const alreadyConnected = peerContext.attachments.some(attachment => ( - attachment.deviceId === target.device_id && attachment.health === 'ready' - )); - setSwitchingAction(target.device_id === availableTargets.localId || alreadyConnected ? 'switch' : 'connect'); + setSwitchingAction('switch'); // The workbench fades behind a curtain while it rebuilds for the target. const intent = beginDeviceOpen({ surfaceId: target.device_id === availableTargets.localId ? LOCAL_SURFACE_ID : surfaceIdForDevice(target.device_id), @@ -367,9 +364,6 @@ const DeviceStatusControl: React.FC = ({ const displayedDevice = previewDevices[previewIndex] ?? overview.primaryDevice; const displayedActive = !displayedDevice.local && displayedDevice.activities.includes('current-use') && overview.peerActive; - const displayedConnected = peerContext?.attachments.some(attachment => ( - attachment.deviceId === carouselDeviceIds[previewIndex] && attachment.health === 'ready' - )) ?? false; const displayedIncompatible = previewIncompatible && !displayedActive; const displayedHasAction = displayedActive || isPreviewing || displayedIncompatible; const displayedControlState = displayedIncompatible ? 'incompatible' @@ -378,13 +372,14 @@ const DeviceStatusControl: React.FC = ({ ? previewIncompatibleNotice : displayedActive ? t('deviceOverview.disconnectActionHint') : displayedDevice.local ? t('deviceOverview.backToThisDevice') - : displayedConnected ? t('deviceOverview.switchUse') : t('deviceOverview.connectAndUse'); + // Attaching is an implementation detail: every device offers the same action. + : t('deviceOverview.switchUse'); const displayedIdleActionText = displayedIncompatible ? t('deviceOverview.unavailableAction') : displayedActive ? t('deviceOverview.disconnectAction') : displayedDevice.local ? t('deviceOverview.returnLocalAction') - : displayedConnected ? t('deviceOverview.switchUse') : t('deviceOverview.connectAndUse'); - const displayedActionText = switchingAction === 'connect' ? t('deviceOverview.connectingAction') - : switchingAction === 'switch' ? t('deviceOverview.switchingAction') + // Attaching is an implementation detail: every device offers the same action. + : t('deviceOverview.switchUse'); + const displayedActionText = switchingAction === 'switch' ? t('deviceOverview.switchingAction') : switchingAction === 'disconnect' ? t('deviceOverview.disconnectingAction') : displayedIdleActionText; @@ -602,7 +597,7 @@ const DeviceStatusControl: React.FC = ({ ? : displayedActive ? : displayedDevice.local ? - : displayedConnected ? : } + : } aria-label={`${displayedDevice.name} · ${displayedActionText}`} title={switchingDevice ? displayedActionText : displayedActionLabel} // An incompatible device stays focusable so its version notice From ce786557c2d0fdfb3b8dfddd74ff29201d397397 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 11:13:14 +0800 Subject: [PATCH 14/52] style(web-ui): drop the tooltip on the device card's action button The round glyph already says what it does; the accessible name keeps the words. An incompatible device still explains why it is unavailable. --- .../components/NavPanel/components/DeviceStatusControl.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index ab4a0b53b0..4c6f28e3b6 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -586,8 +586,8 @@ const DeviceStatusControl: React.FC = ({ role="group" aria-label={[displayedDevice.name, deviceActivity(displayedDevice)].join(' · ')} data-control-state={displayedControlState}> - {/* One round action: the glyph says what it does, the tooltip and - the accessible name carry the words. */} + {/* One round action: the glyph says what it does and the accessible + name carries the words. */} = ({ : displayedDevice.local ? : } aria-label={`${displayedDevice.name} · ${displayedActionText}`} - title={switchingDevice ? displayedActionText : displayedActionLabel} + // The glyph says what it does; only an incompatible device needs words. + title={displayedIncompatible ? displayedActionLabel : undefined} // An incompatible device stays focusable so its version notice // (the tooltip) is reachable; it simply has no action. disabled={!displayedIncompatible && ((!displayedActive && !isPreviewing) || !peerContext || switchingDevice || previewMotion.isTransitioning)} From b5adf09d74a6e63df1a4406c7894bae289578fd7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 11:18:53 +0800 Subject: [PATCH 15/52] fix(web-ui): refresh a populated flat session list without a loading row After a device switch the flat list re-reads its workspaces and inserted a "loading" row under the rows it already showed, pushing "show more" down and back. A populated list now refreshes silently; failures still show the retry button, and an empty list still shows its loading state. --- .../components/NavPanel/sections/sessions/SessionsSection.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 db5f936ade..42ce391c9a 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 @@ -1989,7 +1989,8 @@ const SessionsSection: React.FC = ({ ); })} - {aggregateLoadStatus} + {/* A populated list refreshes silently: a loading row would push "show more" down and back. Failures still show. */} + {aggregateLoadState.isLoading ? null : aggregateLoadStatus} {!showAllWithoutLimit && expandToggleState.shouldRender && ( } -
} {environmentOptions}
} ; From c3fe2c36763b5fc87c84cd742b2993a68f3e167a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 12:10:16 +0800 Subject: [PATCH 19/52] fix(web-ui): keep background device lists and opens consistent - Drop sessions a background host deleted or archived from its container. - Show a device's empty or failed first read instead of the last local list. - Clear the session overlay and pending row action when an open fails. - Cap how long a cross-device open holds background requests. - Count host totals only in the unfiltered all-sessions view; read in steps. - Adopt a host order arranged on another controller. - Drop superseded session reads, share the capability ping, re-ask a host that refused a read, and poll hosts without a catalog stream. - Register the device section and switch curtain appearance contracts. - Activate the most recently used workspace after closing the current one. --- .../core/src/service/workspace/manager.rs | 46 ++++++++++++++++-- .../sections/devices/DeviceCatalogSection.tsx | 15 +++--- .../DeviceWorkspaceSections.appearance.ts | 9 ++++ .../NavPanel/sections/devices/openOnDevice.ts | 42 +++++++++------- .../sections/sessions/SessionsSection.tsx | 21 +++++--- .../sessions/useSessionNavExpansion.ts | 5 +- .../workspaces/pendingWorkspaceAction.ts | 16 +++++++ .../NewProjectDialog.appearance.ts | 2 +- .../DeviceSwitchCurtain.appearance.ts | 5 ++ .../device-switch/DeviceSwitchCurtain.tsx | 3 +- .../services/backgroundSurfaceSeeding.ts | 25 +++++++++- .../src/flow_chat/store/FlowChatStore.test.ts | 9 ++++ .../src/flow_chat/store/FlowChatStore.ts | 22 +++++++++ .../api/adapters/peer-device-adapter.ts | 30 ++++++++---- .../registry/defaultAppearanceRegistry.ts | 4 ++ .../deviceWorkspaceCatalog.test.ts | 48 ++++++++++++++++++- .../peer-device/deviceWorkspaceCatalog.ts | 44 ++++++++++++++--- .../business/workspaceDisplayOrder.test.ts | 7 +++ .../business/workspaceDisplayOrder.ts | 18 ++++++- 19 files changed, 319 insertions(+), 52 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance.ts create mode 100644 src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.appearance.ts diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index 21f1ba1f70..7321eb4500 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -1471,9 +1471,16 @@ impl WorkspaceManager { } fn find_next_workspace_id_after_close(&self, preferred_kind: &WorkspaceKind) -> Option { - let same_kind = self - .opened_workspace_ids + // The opened list keeps the user's arrangement, so recency comes from + // the recent lists: the most recently used opened workspace wins. + let by_recency = self + .recent_workspaces .iter() + .chain(self.recent_assistant_workspaces.iter()) + .filter(|id| self.opened_workspace_ids.contains(id)) + .chain(self.opened_workspace_ids.iter()); + let same_kind = by_recency + .clone() .find(|id| { self.workspaces .get(id.as_str()) @@ -1493,7 +1500,7 @@ impl WorkspaceManager { return None; } - self.opened_workspace_ids.first().cloned() + by_recency.next().cloned() } /// Ensures a workspace is in the opened list. A newly opened workspace goes @@ -1711,6 +1718,39 @@ mod tests { assert_eq!(manager.opened_workspace_ids, vec!["d", "a", "b", "c"]); } + #[tokio::test] + async fn closing_the_current_workspace_activates_the_most_recently_used_one() { + use super::{WorkspaceInfo, WorkspaceInfoRuntimeExt, WorkspaceKind, WorkspaceOpenOptions}; + + let mut manager = super::WorkspaceManager::new(super::WorkspaceManagerConfig::default()); + let options = WorkspaceOpenOptions { + workspace_kind: WorkspaceKind::Remote, + remote_connection_id: Some("ssh-recent".into()), + remote_ssh_host: Some("remote.example".into()), + auto_set_current: false, + ..Default::default() + }; + let mut ids = Vec::new(); + for root in ["/srv/a", "/srv/b", "/srv/c"] { + let workspace = WorkspaceInfo::new_without_worktree(root.into(), options.clone()) + .await + .unwrap(); + ids.push(workspace.id.clone()); + manager.workspaces.insert(workspace.id.clone(), workspace); + } + manager.opened_workspace_ids = ids.clone(); + manager.recent_workspaces = vec![ids[2].clone(), "closed".into(), ids[0].clone()]; + assert_eq!( + manager.find_next_workspace_id_after_close(&WorkspaceKind::Remote), + Some(ids[2].clone()) + ); + manager.recent_workspaces.clear(); + assert_eq!( + manager.find_next_workspace_id_after_close(&WorkspaceKind::Remote), + Some(ids[0].clone()) + ); + } + #[tokio::test] async fn port_scoped_connections_open_distinct_workspaces_beside_legacy_records() { use super::{ diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx index 9de56432b0..7301fc972b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceCatalogSection.tsx @@ -12,7 +12,7 @@ import { History, Loader2, MessageCirclePlus, Settings2 } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; -import { deviceWorkspaceCatalog } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; +import { deviceWorkspaceCatalog, type DeviceCatalogSnapshot } from '@/infrastructure/peer-device/deviceWorkspaceCatalog'; import { peerConnectionManager } from '@/infrastructure/peer-device/PeerConnectionManager'; import { useDeviceWorkspaceCatalog } from '@/infrastructure/peer-device/useDeviceWorkspaceCatalog'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; @@ -237,12 +237,15 @@ function cancelPrewarm(): void { /** * Workspaces of a background device, in the host's order: the device catalog - * when it has read the host, else the list this window last loaded for it. + * once it has answered (an empty or failed read is shown as such), and until + * then the list this window last loaded for it, so the first read draws no + * loading row where the list already stood. */ -function useBackgroundWorkspaces(slot: SidebarDeviceSlot, catalogWorkspaces: readonly WorkspaceInfo[]): readonly WorkspaceInfo[] { +function useBackgroundWorkspaces(slot: SidebarDeviceSlot, catalog: DeviceCatalogSnapshot): readonly WorkspaceInfo[] { + const answered = catalog.status === 'ready' || catalog.status === 'error'; return useMemo( - () => catalogWorkspaces.length > 0 ? catalogWorkspaces : workspaceManager.getSurfaceOpenedWorkspaces(slot.surfaceId), - [catalogWorkspaces, slot.surfaceId], + () => answered ? catalog.workspaces : workspaceManager.getSurfaceOpenedWorkspaces(slot.surfaceId), + [answered, catalog.workspaces, slot.surfaceId], ); } @@ -250,7 +253,7 @@ function useBackgroundWorkspaces(slot: SidebarDeviceSlot, catalogWorkspaces: rea const DeviceCatalogSection: React.FC<{ slot: SidebarDeviceSlot }> = ({ slot }) => { const { t } = useI18n('common'); const catalog = useDeviceWorkspaceCatalog(slot.surfaceId, slot.controllable); - const workspaces = useBackgroundWorkspaces(slot, catalog.workspaces); + const workspaces = useBackgroundWorkspaces(slot, catalog); const actions = useCatalogActions(slot.surfaceId); const grouping = useWorkspaceSessionViewStore(state => state.grouping); const groups = useMemo(() => projectWorkspaceBackedSessionGroups(workspaces, 'all'), [workspaces]); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance.ts new file mode 100644 index 0000000000..8c72bec785 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance.ts @@ -0,0 +1,9 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const navDeviceSectionAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'nav-device-section', + parts: [{ id: 'root' }, { id: 'header' }, { id: 'body' }], + states: [ + { id: 'rendered', selector: { kind: 'self', suffix: '[data-openbitfun-state~="rendered"]' } }, + { id: 'expanded', selector: { kind: 'self', suffix: '[data-openbitfun-state~="expanded"]' } }, + ], +}; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts index d74bba30e7..0efa29ad76 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts @@ -23,8 +23,8 @@ import { openLatestAssistantSession } from '../workspaces/openLatestAssistantSes import { useSceneStore } from '@/app/stores/sceneStore'; import { projectResourceSceneId } from '@/app/scenes/project-resources/projectResourceTarget'; import { + clearWorkspaceAction, requestWorkspaceAction, - usePendingWorkspaceAction, type PendingWorkspaceActionKind, } from '../workspaces/pendingWorkspaceAction'; import { sessionSceneWorkspaceKey } from '@/app/services/sessionSceneTarget'; @@ -97,21 +97,25 @@ export async function openDeviceSession( }); dispatchHistorySessionOpenIntent(sessionId, title, CROSS_DEVICE_OPEN_OVERLAY_MS); } - if (await activateDeviceSlot(peer, slot) !== 'activated') { - clearHistorySessionOpenTransition(sessionId); - return; + let settled = false; + try { + if (await activateDeviceSlot(peer, slot) !== 'activated') return; + const scope = getActiveSurfaceScope(); + if (!flowChatStore.getState().sessions.has(sessionId) && session.createdAt !== undefined) { + // Not seeded yet (opened before the catalog read finished): the row's own + // record is the host's, so no second read is needed. + await flowChatStore.adoptHostSessionMetadata(workspaceId, [session as SessionMetadata]); + scope.assertCurrent('adopt device catalog session'); + } + if (!flowChatStore.getState().sessions.has(sessionId)) { + throw new Error(i18nService.t('common:nav.devices.sessionUnavailable')); + } + settled = true; + } finally { + // A failed or superseded switch must not leave the overlay up. + if (!settled) clearHistorySessionOpenTransition(sessionId); } const scope = getActiveSurfaceScope(); - if (!flowChatStore.getState().sessions.has(sessionId) && session.createdAt !== undefined) { - // Not seeded yet (opened before the catalog read finished): the row's own - // record is the host's, so no second read is needed. - await flowChatStore.adoptHostSessionMetadata(workspaceId, [session as SessionMetadata]); - scope.assertCurrent('adopt device catalog session'); - } - if (!flowChatStore.getState().sessions.has(sessionId)) { - clearHistorySessionOpenTransition(sessionId); - throw new Error(i18nService.t('common:nav.devices.sessionUnavailable')); - } dispatchHistorySessionOpenIntent(sessionId, title, CROSS_DEVICE_OPEN_OVERLAY_MS); flowChatStore.clearSessionUnreadCompletion(sessionId); await openMainSession(sessionId, { workspaceId, activateWorkspace, isCurrent: scope.isCurrent }); @@ -135,8 +139,14 @@ export async function openDeviceWorkspaceAction( workspaceId: string, kind: PendingWorkspaceActionKind, ): Promise { - requestWorkspaceAction({ surfaceId: slot.surfaceId, workspaceId, kind }); - if (await activateDeviceSlot(peer, slot) !== 'activated') usePendingWorkspaceAction.setState({ action: null }); + const action = { surfaceId: slot.surfaceId, workspaceId, kind }; + requestWorkspaceAction(action); + let activated = false; + try { + activated = await activateDeviceSlot(peer, slot) === 'activated'; + } finally { + if (!activated) clearWorkspaceAction(action); + } } export async function openDeviceProjectResources( 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 42ce391c9a..67d5c22550 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 @@ -69,7 +69,10 @@ import ArchivedSessionMenuItems from './ArchivedSessionMenuItems'; import { captureWorkspaceSessionCatalogQuery, countPersistedWorkspaceCatalogSessions, resolveWorkspaceSessionCatalogSource, workspaceSessionCatalogQuery, workspaceSessionCatalogSources, workspaceSessionCatalogTotal } from './workspaceSessionCatalogQuery'; import { useWorkspaceSessionDiscovery } from './useWorkspaceSessionDiscovery'; import { useSessionNavExpansion } from './useSessionNavExpansion'; -import { knownWorkspaceSessionTotal, presentedSessionTotal, rememberWorkspaceSessionTotal } from '@/flow_chat/services/workspaceSessionTotals'; +import { + DEFAULT_SESSION_VIEW_KEY, knownWorkspaceSessionTotal, presentedSessionTotal, rememberWorkspaceSessionTotal, +} from '@/flow_chat/services/workspaceSessionTotals'; + import { isSessionNavRowActive } from './sessionNavSelection'; import { isSessionRowPointerTarget } from './sessionOpenPointer'; import { @@ -123,6 +126,8 @@ import { SessionStatusIndicator } from './SessionStatusIndicator'; import { createSessionsNavSelector } from './sessionsNavSelector'; import './SessionsSection.scss'; +/** A limited flat list reads each workspace's newest rows in steps of this many. */ +const AGGREGATE_READ_BUCKET = 20; const log = createLogger('SessionsSection'); const reportedDiscoveryNotificationKeys = new Set(); const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); @@ -284,10 +289,12 @@ const SessionsSection: React.FC = ({ const [aggregateReloadRequestId, setAggregateReloadRequestId] = useState(0); /** Host-reported top-level total of a limited flat list. */ const [aggregateTotal, setAggregateTotal] = useState(0); - // Rows a limited flat list keeps loaded: shown rows plus one step. It only - // grows, so collapsing never re-reads. + // Rows a limited flat list keeps loaded: shown rows plus one step, rounded + // up so a few "show more" clicks share one read. It only grows, so + // collapsing never re-reads. const aggregateTargetRef = useRef(0); - aggregateTargetRef.current = Math.max(aggregateTargetRef.current, displayCount + SESSIONS_PREFETCH_COUNT); + aggregateTargetRef.current = Math.max(aggregateTargetRef.current, + Math.ceil((displayCount + SESSIONS_PREFETCH_COUNT) / AGGREGATE_READ_BUCKET) * AGGREGATE_READ_BUCKET); const aggregateTarget = aggregateTargetRef.current; const [catalogRefreshVersion, setCatalogRefreshVersion] = useState(0); const onDiscoveryCompleted = useCallback(() => { @@ -835,15 +842,17 @@ const SessionsSection: React.FC = ({ // Only an acknowledged workspace query provides a scoped total. Include // newly-created local rows while their metadata publication is in flight. + // Host totals count every top-level session, so they only bound the default + // view; a filtered or source-scoped list counts its own rows. + const totalViewKey = workspaceSessionViewKey(sourceAgent, sessionShow, sessionFilters); const liveTopLevelTotal = Math.max( workspaceSessionCatalogTotal(topLevelSessions.length, catalog), - limitFlatRows && isAggregateList ? aggregateTotal : 0, + limitFlatRows && isAggregateList && totalViewKey === DEFAULT_SESSION_VIEW_KEY ? aggregateTotal : 0, ); // While the catalog reader restarts (device switch, refresh, new epoch) only // loaded rows are countable; keep the last settled total so the "show more" // row and its count never vanish and come back. const catalogSettled = catalog.complete && !catalog.loading; - const totalViewKey = workspaceSessionViewKey(sourceAgent, sessionShow, sessionFilters); const rememberedTotal = workspaceId && !workspaceScopes?.length ? knownWorkspaceSessionTotal(surface.surfaceId, workspaceId, totalViewKey) : undefined; const totalTopLevelSessionCount = presentedSessionTotal(catalogSettled, liveTopLevelTotal, rememberedTotal); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts index 7ca18b421b..63af6a370f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/useSessionNavExpansion.ts @@ -39,7 +39,10 @@ export function useSessionNavExpansion( useEffect(() => () => { const { scope: last, displayCount } = latest.current; if (displayCount > SESSIONS_INITIAL_COUNT && (shouldHandOff.current?.() ?? true)) { - handoffs.set(last, { displayCount, at: Date.now() }); + const now = Date.now(); + // Unclaimed hand-offs (a list that never remounted) lapse here. + for (const [key, entry] of handoffs) if (now - entry.at > HANDOFF_MS) handoffs.delete(key); + handoffs.set(last, { displayCount, at: now }); } }, []); useEffect(() => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts index 7a8f989428..c8b5074b43 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/pendingWorkspaceAction.ts @@ -15,15 +15,31 @@ interface PendingWorkspaceAction { kind: PendingWorkspaceActionKind; } +/** An action whose row never mounted (the workspace closed meanwhile) lapses. */ +const PENDING_WORKSPACE_ACTION_TTL_MS = 30_000; +let requestedAt = 0; + export const usePendingWorkspaceAction = create<{ action: PendingWorkspaceAction | null }>(() => ({ action: null })); export function requestWorkspaceAction(action: PendingWorkspaceAction): void { + requestedAt = Date.now(); usePendingWorkspaceAction.setState({ action }); } +/** Withdraw `action` if it is still the pending one. */ +export function clearWorkspaceAction(action: PendingWorkspaceAction): void { + const pending = usePendingWorkspaceAction.getState().action; + if (pending && pending.surfaceId === action.surfaceId && pending.workspaceId === action.workspaceId + && pending.kind === action.kind) usePendingWorkspaceAction.setState({ action: null }); +} + /** Take the action if it belongs to this row; it runs at most once. */ export function takeWorkspaceAction(surfaceId: DeviceSurfaceId, workspaceId: string): PendingWorkspaceActionKind | null { const { action } = usePendingWorkspaceAction.getState(); + if (action && Date.now() - requestedAt > PENDING_WORKSPACE_ACTION_TTL_MS) { + usePendingWorkspaceAction.setState({ action: null }); + return null; + } if (!action || action.surfaceId !== surfaceId || action.workspaceId !== workspaceId) return null; usePendingWorkspaceAction.setState({ action: null }); return action.kind; diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts index bde972348b..156e19674b 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.appearance.ts @@ -4,6 +4,6 @@ export const newProjectDialogAppearanceDescriptor: AppearanceSurfaceDescriptor = parts: [ { id: 'root' }, { id: 'title' }, { id: 'content' }, { id: 'field' }, { id: 'pathSelector' }, - { id: 'error' }, { id: 'footer' }, { id: 'choices' }, + { id: 'error' }, { id: 'footer' }, { id: 'choices' }, { id: 'devices' }, ], }; diff --git a/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.appearance.ts b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.appearance.ts new file mode 100644 index 0000000000..1552571f84 --- /dev/null +++ b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.appearance.ts @@ -0,0 +1,5 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; +export const deviceSwitchCurtainAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'device-switch-curtain', + parts: [{ id: 'root' }, { id: 'spinner' }], +}; diff --git a/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx index 31e24b9520..b77d070df3 100644 --- a/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx +++ b/src/web-ui/src/app/scenes/device-switch/DeviceSwitchCurtain.tsx @@ -60,8 +60,9 @@ export const DeviceSwitchCurtain: React.FC = () => { if (phase === 'hidden') return null; return (
-
+
); }; diff --git a/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts index 96c3f88e9b..6f783c6291 100644 --- a/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts +++ b/src/web-ui/src/flow_chat/services/backgroundSurfaceSeeding.ts @@ -17,6 +17,8 @@ import { DEFAULT_SESSION_VIEW_KEY, forgetSurfaceSessionTotals, rememberWorkspace let installs = 0; /** Page record arrays already projected, per surface; the catalog replaces arrays on change. */ const seededRecords = new Map>(); +/** Top-level session ids each workspace page listed last time, per surface. */ +const listedIds = new Map>>(); let teardown: (() => void) | null = null; export function seedBackgroundSurface(surfaceId: DeviceSurfaceId): void { const snapshot = deviceWorkspaceCatalog.getSnapshot(surfaceId); @@ -25,8 +27,12 @@ export function seedBackgroundSurface(surfaceId: DeviceSurfaceId): void { flowChatStore.discardSeededSessions(surfaceId); forgetSurfaceSessionTotals(surfaceId); seededRecords.delete(surfaceId); + listedIds.delete(surfaceId); return; } + let listed = listedIds.get(surfaceId); + if (!listed) listedIds.set(surfaceId, listed = new Map()); + const removed: string[] = []; const pages: BackgroundSessionPage[] = []; let seen = seededRecords.get(surfaceId); if (!seen) seededRecords.set(surfaceId, seen = new WeakSet()); @@ -37,6 +43,21 @@ export function seedBackgroundSurface(surfaceId: DeviceSurfaceId): void { // re-project rows that were already seeded. if (seen.has(page.records)) continue; seen.add(page.records); + // A session the host listed before and no longer lists was deleted or + // archived there (or fell past the loaded rows, where it is not drawn). + // With the complete list, anything absent from it is gone. + if (page.status === 'ready' && !page.error) { + const current = new Set(page.sessions.map(session => session.sessionId)); + const previous = listed.get(workspace.id); + if (previous) for (const sessionId of previous) if (!current.has(sessionId)) removed.push(sessionId); + if (!page.hasMore) { + for (const session of flowChatStore.getSurfaceSessions(surfaceId).values()) { + if (session.workspaceId === workspace.id && !session.parentSessionId && session.sessionKind !== 'subagent' + && !current.has(session.sessionId)) removed.push(session.sessionId); + } + } + listed.set(workspace.id, current); + } if (page.total !== null) rememberWorkspaceSessionTotal(surfaceId, workspace.id, DEFAULT_SESSION_VIEW_KEY, page.total); const remote = workspace.workspaceKind === 'remote'; pages.push({ @@ -47,7 +68,9 @@ export function seedBackgroundSurface(surfaceId: DeviceSurfaceId): void { sessions: page.records, }); } - if (surfaceId !== getActiveSurfaceId()) flowChatStore.seedSurfaceSessions(surfaceId, pages); + if (surfaceId === getActiveSurfaceId()) return; + flowChatStore.seedSurfaceSessions(surfaceId, pages); + flowChatStore.dropSurfaceSessions(surfaceId, removed); } /** Reference-counted; navigation installs it while it lists several devices. */ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index d895854dec..04a1073df3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -9379,6 +9379,15 @@ describe('background device session seeding', () => { expect(session?.dialogTurns).toHaveLength(1); }); + it('drops sessions its host deleted or archived, never the rendered device or drafts', () => { + flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1'), metadata('s-2')])); + flowChatStore.dropSurfaceSessions('peer-seed', ['s-2', 'missing']); + expect([...flowChatStore.getSurfaceSessions('peer-seed').keys()]).toEqual(['s-1']); + flowChatStore.seedSurfaceSessions(LOCAL_SURFACE_ID, page([metadata('local-1')])); + flowChatStore.dropSurfaceSessions(LOCAL_SURFACE_ID, ['s-1']); + expect([...flowChatStore.getSurfaceSessions('peer-seed').keys()]).toEqual(['s-1']); + }); + it('drops only seeds when the account changes', () => { flowChatStore.seedSurfaceSessions('peer-seed', page([metadata('s-1')])); flowChatStore.discardSeededSessions('peer-seed'); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index ef167cb337..0084e4ef2f 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -8121,6 +8121,28 @@ export class FlowChatStore { } } + /** + * Remove sessions a background device's host no longer lists (deleted or + * archived there). Never touches the rendered surface or a draft. + */ + public dropSurfaceSessions(surfaceId: DeviceSurfaceId, sessionIds: readonly string[]): void { + if (surfaceId === getActiveSurfaceId() || sessionIds.length === 0) return; + const container = this.surfaceContainers.get(surfaceId); + if (!container) return; + const sessions = new Map(container.state.sessions); + let changed = false; + for (const sessionId of sessionIds) { + const session = sessions.get(sessionId); + if (!session || session.isTransient || isUnmaterializedSessionDraft(session)) continue; + sessions.delete(sessionId); + container.seededSessions.delete(sessionId); + changed = true; + } + if (!changed) return; + container.state = { ...container.state, sessions }; + this.notifySurfaceSessions(surfaceId); + } + /** Drop seeds of a surface (account change); loaded sessions are untouched. */ public discardSeededSessions(surfaceId: DeviceSurfaceId): void { const container = this.surfaceContainers.get(surfaceId); diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 031be4a8a5..7f73c87b8d 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -213,7 +213,17 @@ export function isPeerRetryableIdempotentMutation( export type PeerInvokePriority = 'high' | 'normal' | 'low'; -const isDeviceOpenRunning = () => getDeviceOpenIntent() !== null; +/** + * How long a cross-device open may hold background requests. A slow host must + * not starve them: past this, they share the link again. + */ +const DEVICE_OPEN_LOW_PRIORITY_HOLD_MS = 8_000; + +/** Milliseconds the running open may still hold background requests, or 0. */ +function deviceOpenHoldRemaining(): number { + const intent = getDeviceOpenIntent(); + return intent ? Math.max(0, intent.startedAt + DEVICE_OPEN_LOW_PRIORITY_HOLD_MS - (typeof performance !== 'undefined' ? performance.now() : Date.now())) : 0; +} const LOW_PRIORITY_EXACT = new Set([ 'get_file_metadata', @@ -842,8 +852,9 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { // While a cross-device open runs, background work (git, search, file // tree, watchers, mini apps) waits for it instead of sharing the link with // the session being opened. It is released when the open settles. - if (this.queues.low.length > 0 && isDeviceOpenRunning()) { - this.resumeLowAfterOpen(); + const hold = this.queues.low.length > 0 ? deviceOpenHoldRemaining() : 0; + if (hold > 0) { + this.resumeLowAfterOpen(hold); return undefined; } return this.queues.low.shift(); @@ -851,15 +862,18 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { private lowResumeArmed = false; - private resumeLowAfterOpen(): void { + private resumeLowAfterOpen(hold: number): void { if (this.lowResumeArmed) return; this.lowResumeArmed = true; - const unsubscribe = useDeviceOpenIntent.subscribe(state => { - if (state.intent) return; - unsubscribe(); + const release: Array<() => void> = []; + const resume = () => { + for (const dispose of release.splice(0)) dispose(); this.lowResumeArmed = false; this.pump(); - }); + }; + const timer = setTimeout(resume, hold); + release.push(() => clearTimeout(timer)); + release.push(useDeviceOpenIntent.subscribe(state => { if (!state.intent) resume(); })); } private async invokePeerCommand( diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index e7da56890a..5cfef5cab1 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -181,6 +181,8 @@ import { imageViewerAppearanceDescriptor } from '@/tools/editor/components/Image import { pdfViewerAppearanceDescriptor } from '@/tools/editor/components/PdfViewer.appearance'; import { branchQuickSwitchAppearanceDescriptor } from '@/tools/git/components/BranchQuickSwitch.appearance'; import { workspaceListSectionAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceListSection.appearance'; +import { navDeviceSectionAppearanceDescriptor } from '@/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.appearance'; +import { deviceSwitchCurtainAppearanceDescriptor } from '@/app/scenes/device-switch/DeviceSwitchCurtain.appearance'; import { workspaceIconAppearanceDescriptor, workspaceIconEditorAppearanceDescriptor } from '@/app/components/WorkspaceIcon/appearance'; import { workspaceItemAppearanceDescriptor } from '@/app/components/NavPanel/sections/workspaces/WorkspaceItem.appearance'; import { newProjectDialogAppearanceDescriptor } from '@/app/components/NewProjectDialog/NewProjectDialog.appearance'; @@ -453,6 +455,8 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(pdfViewerAppearanceDescriptor) .registerComponent(branchQuickSwitchAppearanceDescriptor) .registerComponent(workspaceListSectionAppearanceDescriptor) + .registerComponent(navDeviceSectionAppearanceDescriptor) + .registerComponent(deviceSwitchCurtainAppearanceDescriptor) .registerComponent(workspaceIconAppearanceDescriptor) .registerComponent(workspaceIconEditorAppearanceDescriptor) .registerComponent(workspaceItemAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts index 8333d2297c..2361e639df 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.test.ts @@ -9,6 +9,7 @@ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConn import { DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS, + DEVICE_CATALOG_POLL_MS, DEVICE_CATALOG_RELEASE_GRACE_MS, DeviceWorkspaceCatalog, type DeviceWorkspaceCatalogIO, @@ -28,7 +29,7 @@ const session = (sessionId: string, extra: Record = {}) => ({ }); async function flush(): Promise { - for (let i = 0; i < 10; i += 1) await Promise.resolve(); + for (let i = 0; i < 20; i += 1) await Promise.resolve(); } describe('DeviceWorkspaceCatalog', () => { @@ -197,6 +198,10 @@ describe('DeviceWorkspaceCatalog', () => { await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); await flush(); expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); + invoke.mockClear(); + await vi.advanceTimersByTimeAsync(DEVICE_CATALOG_POLL_MS + DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'get_opened_workspaces', { request: {} }); }); it('re-reads open sections under a new account', async () => { @@ -334,4 +339,45 @@ describe('DeviceWorkspaceCatalog', () => { expect(io.acknowledge).toHaveBeenCalledOnce(); expect(io.acknowledge).toHaveBeenCalledWith('peer-a', { workspace_id: 'ws-1' }, expect.objectContaining({ sessionId: 'done' })); }); + + it('drops the answer of a session read a newer read replaced', async () => { + catalog.retain('peer-a'); + await flush(); + let releaseSlow: (value: unknown) => void = () => undefined; + let calls = 0; + invoke.mockImplementation(async (_device: string | null, command: string) => { + if (command === 'get_opened_workspaces') return workspaces; + if (command !== 'list_persisted_sessions_page') return { capabilities }; + calls += 1; + if (calls === 1) return new Promise(resolve => { releaseSlow = resolve; }); + return { sessions: [session('new'), session('older')], totalTopLevelCount: 2, hasMore: false }; + }); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + void catalog.loadMoreSessions('peer-a', 'ws-1'); + await flush(); + releaseSlow({ sessions: [session('stale')], totalTopLevelCount: 1, hasMore: false }); + await flush(); + expect(catalog.getSessions('peer-a', 'ws-1').sessions.map(item => item.sessionId)).toEqual(['new', 'older']); + }); + + it('asks a host that refused a session read how it addresses sessions again', async () => { + catalog.retain('peer-a'); + await flush(); + const refused = Object.assign(new Error('unknown field workspace_id'), { name: 'DeviceCatalogCommandError' }); + invoke.mockImplementation(async (_device: string | null, command: string) => { + if (command === 'get_opened_workspaces') return workspaces; + if (command === 'peer_mode_ping') return { capabilities: {} }; + throw refused; + }); + catalog.retainSessions('peer-a', 'ws-1'); + await flush(); + invoke.mockClear(); + await catalog.retry('peer-a'); + await flush(); + expect(invoke).toHaveBeenCalledWith('peer-a', 'peer_mode_ping', {}); + expect(invoke).toHaveBeenCalledWith('peer-a', 'list_persisted_sessions_page', { + request: expect.objectContaining({ workspace_path: '/home/me/ws-1' }), + }); + }); }); diff --git a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts index c78cc661f0..11f3b9df91 100644 --- a/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts +++ b/src/web-ui/src/infrastructure/peer-device/deviceWorkspaceCatalog.ts @@ -52,6 +52,8 @@ const MAX_PAGES_PER_READ = 5; /** Session reads one device runs at once; a refresh never floods its relay link. */ const SESSION_READ_CONCURRENCY = 2; export const DEVICE_CATALOG_INVALIDATE_DEBOUNCE_MS = 300; +/** How often a device whose host has no catalog stream is re-read while shown. */ +export const DEVICE_CATALOG_POLL_MS = 60_000; export const DEVICE_CATALOG_RELEASE_GRACE_MS = 30_000; /** @@ -125,6 +127,10 @@ interface Entry { readonly sync: InvalidationSync; /** `workspace_id_references_v1`; null until the host answered a ping. */ workspaceIdReferences: boolean | null; + /** The ping in flight, shared by every read that needs its answer. */ + negotiation: Promise | null; + /** Latest session read per workspace; an older read's answer is dropped. */ + readonly sessionReads: Map; streamSubscriptionId: string | null; streamPending: boolean; stopped: boolean; @@ -398,6 +404,8 @@ export class DeviceWorkspaceCatalog { activityRetain: 0, sync: new InvalidationSync(() => this.read(entry)), workspaceIdReferences: isLocalSurface(surfaceId) ? true : null, + negotiation: null, + sessionReads: new Map(), streamSubscriptionId: null, streamPending: false, stopped: false, @@ -504,12 +512,14 @@ export class DeviceWorkspaceCatalog { } /** Learn whether the host addresses sessions by workspace id. */ - private async negotiate(entry: Entry): Promise { - if (entry.workspaceIdReferences !== null) return; - const ping = await this.io.invoke<{ capabilities?: Record } | undefined>( + private negotiate(entry: Entry): Promise { + if (entry.workspaceIdReferences !== null) return Promise.resolve(); + entry.negotiation ??= this.io.invoke<{ capabilities?: Record } | undefined>( entry.deviceId, 'peer_mode_ping', {}, - ); - entry.workspaceIdReferences = ping?.capabilities?.workspace_id_references_v1 === true; + ).then(ping => { + entry.workspaceIdReferences = ping?.capabilities?.workspace_id_references_v1 === true; + }).finally(() => { entry.negotiation = null; }); + return entry.negotiation; } private workspaceRequest(entry: Entry, workspace: WorkspaceInfo): Record { @@ -540,6 +550,9 @@ export class DeviceWorkspaceCatalog { if (!this.isCurrent(entry)) return; const workspace = entry.snapshot.workspaces.find(candidate => candidate.id === workspaceId); if (!workspace) return; + const read = (entry.sessionReads.get(workspaceId) ?? 0) + 1; + entry.sessionReads.set(workspaceId, read); + const latest = () => this.isCurrent(entry) && entry.sessionReads.get(workspaceId) === read; this.updateSessions(entry, workspaceId, { status: 'loading', limit, error: undefined }); try { await this.negotiate(entry); @@ -557,7 +570,7 @@ export class DeviceWorkspaceCatalog { ...(cursor ? { cursor } : {}), }, }); - if (!this.isCurrent(entry)) return; + if (!latest()) return; for (const session of result?.sessions ?? []) { if (session.status === 'archived') continue; records.push(session); @@ -580,8 +593,13 @@ export class DeviceWorkspaceCatalog { error: undefined, }); } catch (error) { - if (!this.isCurrent(entry)) return; + if (!latest()) return; log.warn('Failed to read device sessions', { surfaceId: entry.surfaceId, workspaceId, error }); + // A host that refused the request may have been replaced by another + // build (upgrade or downgrade): ask again how it addresses sessions. + if ((error as Error | undefined)?.name === 'DeviceCatalogCommandError' && !isLocalSurface(entry.surfaceId)) { + entry.workspaceIdReferences = null; + } const current = this.getSessions(entry.surfaceId, workspaceId); this.updateSessions(entry, workspaceId, { status: current.sessions.length > 0 ? 'ready' : 'error', @@ -680,6 +698,18 @@ export class DeviceWorkspaceCatalog { document.addEventListener('visibilitychange', onVisible); disposers.push(() => document.removeEventListener('visibilitychange', onVisible)); } + // A host without the catalog stream sends no hints: re-read the devices + // shown from it now and then, only while the window is visible. + const poll = setInterval(() => { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return; + const rendered = getActiveSurfaceId(); + for (const entry of this.entries.values()) { + if (!entry.snapshot.live && entry.surfaceId !== rendered && entry.snapshot.status === 'ready') { + this.invalidate(entry.surfaceId); + } + } + }, DEVICE_CATALOG_POLL_MS); + disposers.push(() => clearInterval(poll)); this.teardownEvents = () => { for (const dispose of disposers) dispose(); }; diff --git a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts index 97e8d0747e..31649b9da0 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.test.ts @@ -25,4 +25,11 @@ describe('stableWorkspaceOrder', () => { expect(ids(stableWorkspaceOrder('local', list('a', 'b')))).toEqual(['b', 'a']); expect(ids(stableWorkspaceOrder('peer', list('a', 'b')))).toEqual(['b', 'a']); }); + + it('adopts an order the host was arranged into on purpose', () => { + stableWorkspaceOrder('peer', list('a', 'b', 'c', 'd')); + // Another controller reordered the host: more than a move to the front. + expect(ids(stableWorkspaceOrder('peer', list('b', 'a', 'd', 'c')))).toEqual(['b', 'a', 'd', 'c']); + expect(ids(stableWorkspaceOrder('peer', list('c', 'b', 'a', 'd')))).toEqual(['b', 'a', 'd', 'c']); + }); }); diff --git a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts index bb31c94c18..c2e4c3d8e7 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceDisplayOrder.ts @@ -8,7 +8,9 @@ * every host list, live or background, so both projections of a device agree * and activation never moves a row. Workspaces new to the list keep the place * the host gave them relative to their neighbours; an explicit reorder - * replaces the order. + * replaces the order. A host order that differs by more than one workspace + * moved to the front was arranged on purpose (a reorder on another + * controller), so the window adopts it. */ const orders = new Map(); @@ -24,6 +26,11 @@ export function stableWorkspaceOrder(surfaceId: string return [...hostWorkspaces]; } const byId = new Map(hostWorkspaces.map(workspace => [workspace.id, workspace])); + if (!isActivationReorder(remembered.filter(id => byId.has(id)), + hostWorkspaces.map(workspace => workspace.id).filter(id => remembered.includes(id)))) { + setWorkspaceDisplayOrder(surfaceId, hostWorkspaces.map(workspace => workspace.id)); + return [...hostWorkspaces]; + } const result: T[] = remembered.flatMap(id => (byId.has(id) ? [byId.get(id)!] : [])); const placed = new Set(result.map(workspace => workspace.id)); hostWorkspaces.forEach((workspace, hostIndex) => { @@ -39,6 +46,15 @@ export function stableWorkspaceOrder(surfaceId: string return result; } +/** `host` equals `shown`, or is `shown` with one workspace moved to the front. */ +function isActivationReorder(shown: readonly string[], host: readonly string[]): boolean { + if (shown.length !== host.length) return false; + if (shown.every((id, index) => id === host[index])) return true; + const moved = host[0]; + const rest = shown.filter(id => id !== moved); + return rest.every((id, index) => id === host[index + 1]); +} + export function resetWorkspaceDisplayOrdersForTest(): void { orders.clear(); } From 1c540aae105bf5ed63aa44e79a250cd77aa515b5 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 12:29:43 +0800 Subject: [PATCH 20/52] fix(core): declare the workspace recency iterator mutable --- src/crates/assembly/core/src/service/workspace/manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/service/workspace/manager.rs b/src/crates/assembly/core/src/service/workspace/manager.rs index 7321eb4500..0337d66793 100644 --- a/src/crates/assembly/core/src/service/workspace/manager.rs +++ b/src/crates/assembly/core/src/service/workspace/manager.rs @@ -1473,7 +1473,7 @@ impl WorkspaceManager { fn find_next_workspace_id_after_close(&self, preferred_kind: &WorkspaceKind) -> Option { // The opened list keeps the user's arrangement, so recency comes from // the recent lists: the most recently used opened workspace wins. - let by_recency = self + let mut by_recency = self .recent_workspaces .iter() .chain(self.recent_assistant_workspaces.iter()) From 06e1a12072cfff38ac138880e2be67aef69f913c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 13:23:53 +0800 Subject: [PATCH 21/52] feat(web-ui): one top tab per session, kept for every device A session tab used to stand for a workspace: opening another session of that workspace replaced it, and a device switch dropped every session tab. - Key session tabs by device, workspace and session, so several sessions of one workspace and sessions of other devices each keep their own tab; past 12 session tabs the least recently used idle one closes. - Keep session tabs of every device through a switch. A tab of another device shows its device name, renders that device when activated (the same open as its sidebar row), is never a pane fallback, and stays unselected when its open fails. Returning to a device reselects the tab of its active session. - Remove the per-workspace recent-sessions menu the tabs replace. --- src/web-ui/AGENTS.md | 4 +- .../SceneBar/SceneBar.appearance.ts | 2 +- .../src/app/components/SceneBar/SceneBar.scss | 13 +- .../app/components/SceneBar/SceneBar.test.tsx | 134 ++---------------- .../src/app/components/SceneBar/SceneBar.tsx | 81 ++++------- .../SceneBar/SessionTabHistoryMenu.scss | 88 ------------ .../SceneBar/SessionTabHistoryMenu.tsx | 103 -------------- .../src/app/components/SceneBar/types.ts | 16 ++- .../hooks/canvasPanelOwnership.test.tsx | 4 +- .../scenes/miniapps/miniAppLifecycle.test.ts | 6 +- .../workspaceResources.test.ts | 6 +- .../services/sessionSceneLifecycle.test.ts | 30 ++-- .../src/app/services/sessionSceneLifecycle.ts | 18 ++- src/web-ui/src/app/stores/sceneStore.test.ts | 39 +++-- src/web-ui/src/app/stores/sceneStore.ts | 96 ++++++++++--- .../app/workbench/WorkbenchLayout.test.tsx | 4 +- .../app/workbench/WorkbenchTabDrag.test.tsx | 4 +- .../app/workbench/canvasTabTransfer.test.ts | 4 +- .../src/app/workbench/scenePaneModel.ts | 9 +- .../app/workbench/sceneTabTransfer.test.ts | 4 +- .../app/workbench/workbenchContent.test.ts | 8 +- .../app/workbench/workbenchNavigation.test.ts | 4 +- .../btwSessionPane.navigation.test.ts | 6 +- .../tool-cards/FileOperationToolCard.test.tsx | 4 +- .../markdown/MarkdownRenderer.test.tsx | 6 +- src/web-ui/src/locales/en-US/common.json | 1 - src/web-ui/src/locales/zh-CN/common.json | 1 - src/web-ui/src/locales/zh-TW/common.json | 1 - 28 files changed, 234 insertions(+), 462 deletions(-) delete mode 100644 src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss delete mode 100644 src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index 7994df19f0..861c984a76 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -206,13 +206,13 @@ also run the focused state contracts: pnpm --dir src/web-ui run test:run src/app/services/sessionSceneLifecycle.test.ts src/flow_chat/services/sessionActivation.test.ts src/flow_chat/services/storeSync.test.ts src/app/stores/sceneStore.test.ts ``` -For top workspace tab recent-session disclosure, selection and reconciliation: +For top session tabs (one per session, kept per device), selection and reconciliation: ```bash pnpm --dir src/web-ui run test:run src/app/components/SceneBar/SceneBar.test.tsx src/app/stores/sceneStore.test.ts src/app/services/sessionSceneLifecycle.test.ts src/app/hooks/useSessionTabLabels.test.tsx src/shared/ui/MenuPopover.test.tsx src/shared/ui/MenuComposition.contract.test.ts ``` -These local DOM/store fixtures cover bounded history, legacy tabs and simulated +These local DOM/store fixtures cover the session tab limit, other-device tabs and simulated SSH/Peer identities; they do not establish native rendering or live remote behavior. For composer mode restoration, passive synchronization, or explicit mode selection: diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts b/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts index 6a93a6bcda..d92c03fcde 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.appearance.ts @@ -8,6 +8,6 @@ export const sceneBarAppearanceDescriptor: AppearanceSurfaceDescriptor = { { id: 'scrollNext' }, { id: 'controls' }, { id: 'dropHint' }, - { id: 'history' }, + { id: 'tabDevice' }, ], }; diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index 1c9f85e255..f5af0db766 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -19,9 +19,6 @@ flex: 0 0 auto; min-width: 0; - // Keep the tab's layout and hit target while its portalled surface expands. - &[data-session-history-open='true'] [role='tab'] { opacity: 0; } - &[data-canvas-drop-position]::after { content: ''; position: absolute; @@ -36,6 +33,16 @@ &[data-canvas-drop-position='after']::after { inset-inline-end: 0; } } + // The device a session tab belongs to when it is not the rendered one. + &__tab-device { + max-inline-size: 8em; + overflow: hidden; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-label-sm-font-size); + text-overflow: ellipsis; + white-space: nowrap; + } + &__tab-region { display: flex; align-items: center; diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx index 9fff98ffc4..2428a26b22 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx @@ -8,11 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { InteractionMotion } from '@/shared/utils/motionPreference'; import type { SceneTab, SceneTabId } from './types'; import SceneBar from './SceneBar'; -import { useSceneStore } from '../../stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '../../stores/sceneStore'; import { useContentResourceStore } from '../../workbench/contentResourceStore'; import { writeSessionTabDrag } from '../../workbench/canvasTabTransfer'; import { clearAgentCanvasForPeerSwitch, switchAgentCanvasScope, useAgentCanvasStore } from '../panels/content-canvas/stores'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import { useDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -98,7 +99,7 @@ describe('SceneBar overflow navigation', () => { sceneHarness.closeScene.mockReset(); clearAgentCanvasForPeerSwitch(); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); }); afterEach(() => { @@ -112,137 +113,18 @@ describe('SceneBar overflow navigation', () => { act(() => root.render()); } - function withSessionHistory() { + it('activates a session tab on click and names the device of a tab from another device', () => { const tab = sceneHarness.state.openTabs[0]; - tab.session = { surfaceId: 'local', workspaceKey: 'workspace-a', sessionId: 'six' }; - tab.sessionHistory = ['six', 'five', 'four', 'three', 'two']; - return tab; - } - - const visibleHistory = () => document.querySelector('[role="menu"]:not([aria-hidden="true"])'); - - it('switches on the first click and opens the five recent sessions only when the selected tab is clicked again', () => { - withSessionHistory(); + tab.session = { surfaceId: 'peer-tab-device', workspaceKey: 'workspace-a', sessionId: 'remote' }; + useDeviceDirectory.setState({ devices: [{ device_id: 'peer-tab-device', device_name: 'Studio' } as never], localId: 'me' }); sceneHarness.state.activeTabId = 'git'; renderSceneBar(); const trigger = container.querySelector('[role="tab"][data-openbitfun-value="session"]')!; + expect(container.querySelector('[data-openbitfun-part="tabDevice"]')?.textContent).toBe('Studio'); act(() => trigger.click()); expect(sceneHarness.activateScene).toHaveBeenCalledWith('session'); - expect(visibleHistory()).toBeNull(); - sceneHarness.state.activeTabId = 'session'; - renderSceneBar(); - act(() => trigger.click()); - expect(trigger.getAttribute('aria-expanded')).toBe('true'); - expect([...visibleHistory()!.querySelectorAll('[role="menuitemradio"]')].map(item => item.textContent)) - .toEqual(['six', 'five', 'four', 'three', 'two']); - expect(visibleHistory()!.querySelector('[aria-checked="true"]')?.textContent).toBe('six'); - act(() => trigger.click()); - expect(visibleHistory()).toBeNull(); - expect(trigger.getAttribute('aria-expanded')).toBe('false'); - }); - - it('selects a recent session through the guarded scene navigation and closes the list', () => { - const tab = withSessionHistory(); - const open = vi.spyOn(useSceneStore.getState(), 'openSessionScene').mockImplementation(() => {}); - useSceneStore.setState({ openTabs: [tab] }); - renderSceneBar(); - const trigger = container.querySelector('[role="tab"]')!; - act(() => trigger.click()); - act(() => visibleHistory()!.querySelector('[data-menu-id="four"]')!.click()); - expect(open).toHaveBeenCalledWith({ ...tab.session, sessionId: 'four' }, expect.anything()); - const guard = open.mock.calls[0][1]!.isCurrent!; - expect(guard()).toBe(true); - useSceneStore.setState({ openTabs: [] }); - expect(guard()).toBe(false); - expect(visibleHistory()).toBeNull(); - }); - - it('starts the floating surface at the capsule bounds and keeps the original tab mounted', () => { - withSessionHistory(); - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { - if (this.getAttribute('role') === 'tab') return new DOMRect(100, 14, 148, 28); - if (this.getAttribute('role') === 'menu') return new DOMRect(100, 14, 220, 210); - return new DOMRect(); - }); - renderSceneBar(); - const trigger = container.querySelector('[role="tab"]')!; - act(() => trigger.click()); - const menu = visibleHistory()!; - expect(menu.style.left).toBe('100px'); - expect(menu.style.top).toBe('14px'); - expect(menu.style.getPropertyValue('--_session-history-source-width')).toBe('148px'); - expect(menu.style.getPropertyValue('--_session-history-source-height')).toBe('28px'); - expect(container.querySelector('[role="tab"]')).toBe(trigger); - }); - - it('supports ArrowDown, Escape and outside dismissal, and closes history on device switches', () => { - withSessionHistory(); - renderSceneBar(); - const trigger = container.querySelector('[role="tab"]')!; - act(() => { - trigger.focus(); - trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })); - }); - expect(visibleHistory()).not.toBeNull(); - expect(document.activeElement?.textContent).toBe('six'); - act(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); - expect(visibleHistory()).toBeNull(); - expect(document.activeElement).toBe(trigger); - act(() => trigger.click()); - act(() => document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))); - expect(visibleHistory()).toBeNull(); - act(() => trigger.click()); - expect(visibleHistory()).not.toBeNull(); - act(() => activateSurface('peer:history')); - expect(visibleHistory()).toBeNull(); - }); - - it('does not open recent sessions while activation is pending or when the close action is clicked', () => { - withSessionHistory(); - sceneHarness.state.pendingTabId = 'session'; - renderSceneBar(); - act(() => container.querySelector('[role="tab"]')!.click()); - expect(visibleHistory()).toBeNull(); - sceneHarness.state.pendingTabId = null; - renderSceneBar(); - act(() => container.querySelector('[data-scene-bar-part="closeTab"]')!.click()); - expect(sceneHarness.closeScene).toHaveBeenCalledWith('session'); - expect(visibleHistory()).toBeNull(); - }); - - it('focuses a background pane before offering its selected workspace history', () => { - withSessionHistory(); - useSceneStore.setState({ focusedPane: 'secondary' }); - renderSceneBar(); - const trigger = container.querySelector('[role="tab"]')!; - act(() => trigger.click()); - expect(sceneHarness.activateScene).toHaveBeenCalledWith('session'); - expect(visibleHistory()).toBeNull(); - act(() => useSceneStore.setState({ focusedPane: 'primary' })); - act(() => trigger.click()); - expect(visibleHistory()).not.toBeNull(); - }); - - it.each([undefined, ['six']])('keeps a workspace with only its current session collapsed (history: %s)', history => { - const tab = withSessionHistory(); - tab.sessionHistory = history; - renderSceneBar(); - const trigger = container.querySelector('[role="tab"]')!; - act(() => trigger.click()); - act(() => trigger.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))); - expect(visibleHistory()).toBeNull(); expect(trigger.hasAttribute('aria-haspopup')).toBe(false); - expect(trigger.hasAttribute('aria-expanded')).toBe(false); - }); - - it('collapses when reconciliation leaves only the current session in history', () => { - const tab = withSessionHistory(); - renderSceneBar(); - act(() => container.querySelector('[role="tab"]')!.click()); - expect(visibleHistory()).not.toBeNull(); - tab.sessionHistory = ['six']; - renderSceneBar(); - expect(visibleHistory()).toBeNull(); + useDeviceDirectory.setState({ devices: [], localId: null }); }); function startSessionTabDrag() { diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 0aee23460e..3d44d824d3 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -5,7 +5,7 @@ * Session tabs show only the referenced session title. */ -import React, { useCallback, useState, useEffect, useRef } from 'react'; +import React, { useCallback, useState, useEffect, useMemo, useSyncExternalStore } from 'react'; import { Pin, CircleAlert } from 'lucide-react'; import { useSceneStore } from '../../stores/sceneStore'; import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; @@ -14,7 +14,6 @@ import type { MenuItem } from '@/shared/context-menu-system/types/menu.types'; import { Icon, TabGroup, type TabGroupItem } from '@openbitfun/ui'; import { useSceneTabNavigation } from './useSceneTabNavigation'; -import { SessionTabHistoryMenu } from './SessionTabHistoryMenu'; import { useSceneManager } from '../../hooks/useSceneManager'; import { useSessionTabLabels } from '../../hooks/useSessionTabLabels'; import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; @@ -22,7 +21,8 @@ import { isSceneTabClosable } from '../../scenes/registry'; import { useContentResourceStore } from '../../workbench/contentResourceStore'; import { dropSessionTabOnWorkbench, isSessionTabDrag, type WorkbenchTabDropTarget } from '../../workbench/canvasTabTransfer'; import { useAgentCanvasStore } from '../panels/content-canvas/stores'; -import { onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { getActiveSurfaceId, isLocalSurface, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { resolveDeviceNameFrom, useDeviceDirectory } from '@/infrastructure/account/deviceDirectory'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { dropSceneTab, isSceneTabDrag, writeSceneTabDrag, endSceneTabDrag } from '../../workbench/sceneTabTransfer'; import type { WorkbenchPane } from '../../workbench/scenePaneModel'; @@ -38,8 +38,23 @@ function getSceneIdFromTabTarget(target: EventTarget | null): SceneTabId | undef return tab?.dataset.openbitfunValue as SceneTabId | undefined; } -function hasSessionHistory(tab: SceneTab | undefined): boolean { - return Boolean(tab?.session && tab.sessionHistory?.some(id => id !== tab.session!.sessionId)); +/** + * Device names of session tabs on a device other than the rendered one, once + * the tabs span more than one device; a single-device window shows none. + */ +function useSessionTabDevices(tabs: readonly SceneTab[]): Record { + const directory = useDeviceDirectory(); + const activeSurfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); + return useMemo(() => { + const labels: Record = {}; + for (const tab of tabs) { + const surfaceId = tab.session?.surfaceId; + if (!surfaceId || surfaceId === activeSurfaceId) continue; + const deviceId = isLocalSurface(surfaceId) ? directory.localId : surfaceId; + labels[tab.id] = deviceId ? resolveDeviceNameFrom(directory.devices, deviceId) : surfaceId; + } + return labels; + }, [activeSurfaceId, directory.devices, directory.localId, tabs]); } interface SceneBarProps { @@ -63,18 +78,11 @@ const SceneBar: React.FC = ({ closeScene, } = useSceneManager(pane); const sessionLabels = useSessionTabLabels(openTabs); - const focusedPane = useSceneStore(state => state.focusedPane); + const deviceLabels = useSessionTabDevices(openTabs); const resources = useContentResourceStore(state => state.resources); const selectedTabId = pendingTabId ?? activeTabId; const { t } = useI18n('common'); const { t: tComponents } = useI18n('components'); - const [history, setHistory] = useState<{ tabId: SceneTabId; open: boolean } | null>(null); - const historyAnchorRef = useRef(null); - const historyTab = openTabs.find(tab => tab.id === history?.tabId); - const historyOpen = Boolean(history?.open && hasSessionHistory(historyTab) && historyTab - && historyTab.id === activeTabId && focusedPane === pane && !pendingTabId); - const closeHistory = useCallback(() => setHistory(previous => previous?.open - ? { ...previous, open: false } : previous), []); const [offeredSceneTab, setOfferedSceneTab] = useState(false); const [offeredCanvasTabId, setOfferedCanvasTabId] = useState(null); const draggingCanvasTabId = useAgentCanvasStore(state => { @@ -101,21 +109,8 @@ const SceneBar: React.FC = ({ }); const handleTabValueChange = useCallback((value: string) => { - closeHistory(); activateScene(value as SceneTabId); - }, [activateScene, closeHistory]); - - useEffect(() => { - if (history?.open && !historyOpen) closeHistory(); - }, [history?.open, historyOpen, closeHistory]); - - const toggleHistory = (tabId: SceneTabId, anchor: HTMLElement) => { - if (tabId !== activeTabId || pendingTabId) return; - if (focusedPane !== pane) { activateScene(tabId); return; } - if (!hasSessionHistory(openTabs.find(tab => tab.id === tabId))) return; - historyAnchorRef.current = anchor; - setHistory(previous => ({ tabId, open: previous?.tabId !== tabId || !previous.open })); - }; + }, [activateScene]); useEffect(() => { // The source has written its transfer format and committed its drag state @@ -132,7 +127,7 @@ const SceneBar: React.FC = ({ setCanvasDrop(null); }; const stopDragListener = observeTabDragLifecycle(offerDrop, clearDrop); - const stopSurfaceListener = onSurfaceActivated(() => { clearDrop(); setHistory(null); }); + const stopSurfaceListener = onSurfaceActivated(clearDrop); return () => { stopDragListener(); stopSurfaceListener(); @@ -178,7 +173,6 @@ const SceneBar: React.FC = ({ }, [closeScene, tabDefs]); const handleContextMenu = (event: React.MouseEvent, id: SceneTabId) => { - closeHistory(); event.preventDefault(); event.stopPropagation(); const tab = openTabs.find(candidate => candidate.id === id); @@ -225,17 +219,15 @@ const SceneBar: React.FC = ({ items.push({ value: tab.id, label: displayLabel, - labelSuffix: sessionLabel?.number || tab.pinned || (tab.contentId && (resources[tab.contentId]?.isDirty || resources[tab.contentId]?.fileMissing)) ? <> + labelSuffix: sessionLabel?.number || deviceLabels[tab.id] || tab.pinned || (tab.contentId && (resources[tab.contentId]?.isDirty || resources[tab.contentId]?.fileMissing)) ? <> {sessionLabel?.number && } + {deviceLabels[tab.id] && {deviceLabels[tab.id]}} {tab.pinned && } {tab.contentId && resources[tab.contentId]?.isDirty && •} {tab.contentId && resources[tab.contentId]?.fileMissing && } : undefined, labelTransitionKey: tab.session?.sessionId, - tabProps: hasSessionHistory(tab) ? { - 'aria-haspopup': 'menu', - 'aria-expanded': historyOpen && history?.tabId === tab.id, - } : undefined, // Keep the close hit target stationary between pointer down and up; // shrinking it can retarget the click at the button edge (issue #2210). endAction: closable ? ( @@ -325,22 +317,9 @@ const SceneBar: React.FC = ({ const title = resource?.target.kind === 'file' ? [resource.scope.surfaceId, resource.scope.remoteConnectionId, resource.target.path].filter(Boolean).join(' · ') : undefined; return
{ - const trigger = (event.target as Element).closest('[role="tab"]'); - if (trigger) toggleHistory(tabId, trigger); - }} - onKeyDown={event => { - if (event.key !== 'ArrowDown' || !hasSessionHistory(tab) || tabId !== activeTabId || pendingTabId) return; - const trigger = (event.target as Element).closest('[role="tab"]'); - if (!trigger) return; - event.preventDefault(); - event.stopPropagation(); - if (!historyOpen) toggleHistory(tabId, trigger); - }} onContextMenu={event => handleContextMenu(event, tabId)} - onDragStart={event => { closeHistory(); writeSceneTabDrag(event.dataTransfer, tabId); if (tab?.session) beginConversationTransfer(event.dataTransfer, tab.session, tabId); }} + onDragStart={event => { writeSceneTabDrag(event.dataTransfer, tabId); if (tab?.session) beginConversationTransfer(event.dataTransfer, tab.session, tabId); }} onDragEnd={() => { endConversationTransfer(); endSceneTabDrag(); }} >{node}
; }} @@ -371,12 +350,6 @@ const SceneBar: React.FC = ({ )} - {historyTab?.session && } {showDropHint && canAcceptTab && (
diff --git a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss deleted file mode 100644 index 409842bf2a..0000000000 --- a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.scss +++ /dev/null @@ -1,88 +0,0 @@ -.openbitfun-session-tab-history { - --_session-history-source-width: 0px; - --_session-history-source-height: var(--openbitfun-control-tab-group-item-height-sm); - --_session-history-source-left: 0px; - --_session-history-source-top: 0px; - - // Keep the moving clip opaque so live chat content does not need backdrop sampling. - inline-size: max(220px, var(--_session-history-source-width)); - padding: 0; - border: 0; - border-radius: var(--openbitfun-radius-lg); - outline: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - outline-offset: calc(-0.5 * var(--openbitfun-border-width-default)); - color: var(--openbitfun-color-content-primary); - background: var(--openbitfun-color-surface-raised); - box-shadow: var(--openbitfun-shadow-overlay); - opacity: 1; - translate: none; - transition: none; - - --_session-history-capsule: inset( - var(--_session-history-source-top) - calc(100% - var(--_session-history-source-left) - var(--_session-history-source-width)) - calc(100% - var(--_session-history-source-top) - var(--_session-history-source-height)) - var(--_session-history-source-left) - round calc(var(--_session-history-source-height) / 2) - ); - - &[data-state='entering'] { - clip-path: var(--_session-history-capsule); - } - - &[data-state='entered'] { - // Reveal both axes together; backwards fill releases the clip and shadow at rest. - animation: session-tab-history-expand var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-enter) backwards; - } - - &[data-state='exiting'] { - pointer-events: none; - animation: session-tab-history-collapse var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-exit) both; - } - - &__header { - box-sizing: border-box; - display: flex; - align-items: center; - gap: var(--openbitfun-control-tab-group-item-gap); - inline-size: 100%; - block-size: var(--_session-history-source-height); - padding: 0 var(--openbitfun-control-tab-group-item-padding-inline-sm); - border: 0; - border-radius: inherit; - color: inherit; - background: transparent; - font-family: var(--openbitfun-type-label-md-font-family); - font-size: var(--openbitfun-type-label-md-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); - line-height: var(--openbitfun-type-label-md-line-height); - letter-spacing: var(--openbitfun-type-label-md-letter-spacing); - text-align: start; - cursor: pointer; - } - - &__rows { - padding: var(--openbitfun-space-1); - border-block-start: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); - } - - @media (prefers-reduced-motion: reduce) { - &[data-state] { animation: none; clip-path: none; } - } - - @media (forced-colors: active) { - outline-color: ButtonText; - background: Canvas; - box-shadow: none; - } -} - -@keyframes session-tab-history-expand { - 0% { clip-path: var(--_session-history-capsule); } - 100% { clip-path: inset(0 round var(--openbitfun-radius-lg)); } -} - -@keyframes session-tab-history-collapse { - 0% { clip-path: inset(0 round var(--openbitfun-radius-lg)); } - 100% { clip-path: var(--_session-history-capsule); } -} diff --git a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx b/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx deleted file mode 100644 index 43fca3cb05..0000000000 --- a/src/web-ui/src/app/components/SceneBar/SessionTabHistoryMenu.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { createContext, forwardRef, useContext, useLayoutEffect, useMemo, useRef, type RefObject } from 'react'; -import { Menu, MenuList, MenuPopover, OverflowText, type MenuEntry } from '@openbitfun/ui'; -import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; -import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; -import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; -import { useSessionTabLabels } from '../../hooks/useSessionTabLabels'; -import { useSceneStore } from '../../stores/sceneStore'; -import type { SceneTab } from './types'; -import './SessionTabHistoryMenu.scss'; - -const HistorySurfaceContext = createContext<{ - anchorRef: RefObject; - title: string; - number?: string; - onClose: () => void; -} | null>(null); - -/** Keep one menu surface at the capsule's origin; reveal its size without scaling text. */ -const HistorySurface: typeof Menu = forwardRef(function HistorySurface(props, forwardedRef) { - const context = useContext(HistorySurfaceContext)!; - const rootRef = useRef(null); - useLayoutEffect(() => { - const anchor = context.anchorRef.current; - const root = rootRef.current; - if (!anchor || !root) return; - const rect = anchor.getBoundingClientRect(); - const left = Number(props.style?.left ?? rect.left); - const top = Number(props.style?.top ?? rect.top); - root.style.setProperty('--_session-history-source-width', `${rect.width}px`); - root.style.setProperty('--_session-history-source-height', `${rect.height}px`); - root.style.setProperty('--_session-history-source-left', `${Math.max(0, rect.left - left)}px`); - root.style.setProperty('--_session-history-source-top', `${Math.max(0, rect.top - top)}px`); - }, [context.anchorRef, props.style?.left, props.style?.top]); - - return
{ - rootRef.current = node; - if (typeof forwardedRef === 'function') forwardedRef(node); - else if (forwardedRef) forwardedRef.current = node; - }} - className={`${props.className ?? ''} openbitfun-session-tab-history`} - > - - {props.children} -
; -}); - -const historyParts = { root: HistorySurface }; - -interface SessionTabHistoryMenuProps { - tab: SceneTab; - anchorRef: RefObject; - open: boolean; - onClose: () => void; -} - -export function SessionTabHistoryMenu({ tab, anchorRef, open, onClose }: SessionTabHistoryMenuProps) { - const { t } = useI18n('common'); - const tabs = useMemo(() => tab.session - ? (tab.sessionHistory ?? [tab.session.sessionId]).slice(0, 5).map(sessionId => ({ - id: `session:${sessionId}`, lastUsed: 0, session: { ...tab.session!, sessionId }, - })) : [], [tab.session, tab.sessionHistory]); - const labels = useSessionTabLabels(tabs); - const scope = getActiveSurfaceScope(); - const items: MenuEntry[] = tabs.flatMap(entry => { - const label = labels[entry.id]; - const target = entry.session!; - if (!label || target.surfaceId !== scope.surfaceId) return []; - return [{ - id: target.sessionId, - label: label.title, - shortcut: label.number ? : undefined, - role: 'menuitemradio', - checked: target.sessionId === tab.session?.sessionId, - onSelect: () => { - const isCurrent = () => scope.isCurrent() - && useSceneStore.getState().openTabs.some(candidate => candidate.id === tab.id - && candidate.session - && candidate.session === tab.session - && (candidate.sessionHistory ?? [candidate.session.sessionId]).includes(target.sessionId)); - useSceneStore.getState().openSessionScene(target, { isCurrent }); - }, - }]; - }); - const currentLabel = labels[`session:${tab.session?.sessionId}`]; - - return ; -} diff --git a/src/web-ui/src/app/components/SceneBar/types.ts b/src/web-ui/src/app/components/SceneBar/types.ts index 93a64fd4e0..ba4b89c863 100644 --- a/src/web-ui/src/app/components/SceneBar/types.ts +++ b/src/web-ui/src/app/components/SceneBar/types.ts @@ -51,8 +51,22 @@ export function getSceneViewId(id: SceneTabId): SceneTabId { return isSessionSceneId(id) ? 'session' : id; } +/** + * One tab per session: several sessions of one workspace, and sessions of + * other devices, each keep their own tab. + */ export function getSessionSceneTabId(target: SessionSceneTarget): SceneTabId { - return `session:${encodeURIComponent(JSON.stringify([target.surfaceId, target.workspaceKey]))}`; + return `session:${encodeURIComponent(JSON.stringify([target.surfaceId, target.workspaceKey, target.sessionId]))}`; +} + +/** The workspace id a session tab's key names, when it names one. */ +export function sessionSceneWorkspaceId(target: SessionSceneTarget): string | undefined { + try { + const key = JSON.parse(target.workspaceKey) as unknown; + return Array.isArray(key) && key[0] === 'workspace' && typeof key[1] === 'string' ? key[1] : undefined; + } catch { + return undefined; + } } /** Static definition (from registry) for a scene tab type */ diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx index 4957667c48..ae61c93a8a 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/canvasPanelOwnership.test.tsx @@ -24,7 +24,7 @@ import { import { useApp } from '@/app/hooks/useApp'; import { fileTabManager } from '@/shared/services/FileTabManager'; import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; -import { useSceneStore } from '@/app/stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '@/app/stores/sceneStore'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { resolveSessionSceneTarget } from '@/app/services/sessionSceneTarget'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; @@ -99,7 +99,7 @@ describe('canvas host panel ownership', () => { clearAgentCanvasForPeerSwitch(); stores.forEach(store => store.getState().reset()); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); ['agent', 'project', 'git'].forEach(mode => drainPendingTabs(mode as 'agent' | 'project' | 'git')); appManager.updateLayout({ diff --git a/src/web-ui/src/app/scenes/miniapps/miniAppLifecycle.test.ts b/src/web-ui/src/app/scenes/miniapps/miniAppLifecycle.test.ts index 4287f7c48f..126a7db219 100644 --- a/src/web-ui/src/app/scenes/miniapps/miniAppLifecycle.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/miniAppLifecycle.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { activateSurface, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; -import { registerSessionSceneNavigation, useSceneStore } from '@/app/stores/sceneStore'; +import { registerSessionSceneNavigation, useSceneStore, resetSceneStoreForTest } from '@/app/stores/sceneStore'; import { useConversationDockStore } from '@/app/stores/conversationDockStore'; import { useMiniAppStore } from './miniAppStore'; import { beginMiniAppOperation, isMiniAppClosing, requestMiniAppClose, trackMiniAppStream } from './miniAppLifecycle'; @@ -22,7 +22,7 @@ describe('explicit MiniApp shutdown', () => { runtime.snapshot = null; runtime.detach.mockResolvedValue(false); runtime.stop.mockResolvedValue(undefined); runtime.cancel.mockResolvedValue({ cancelledRuns: 1 }); runtime.cancelStream.mockResolvedValue(undefined); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); useMiniAppStore.setState({ ...useMiniAppStore.getInitialState(), apps: [app], runningWorkerIds: [app.id] }); useMiniAppStore.getState().claimComposer(app.id, { token: entry.claimToken, sessionId: entry.sessionId }); useConversationDockStore.setState(useConversationDockStore.getInitialState()); @@ -115,7 +115,7 @@ describe('explicit MiniApp shutdown', () => { }); it('does not interpret surface teardown as closing an application', () => { - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); useMiniAppStore.getState().releaseComposer(app.id, entry.claimToken); expect(runtime.stop).not.toHaveBeenCalled(); expect(runtime.cancel).not.toHaveBeenCalled(); diff --git a/src/web-ui/src/app/scenes/workspace-resources/workspaceResources.test.ts b/src/web-ui/src/app/scenes/workspace-resources/workspaceResources.test.ts index af2a145f5a..5dd42198c1 100644 --- a/src/web-ui/src/app/scenes/workspace-resources/workspaceResources.test.ts +++ b/src/web-ui/src/app/scenes/workspace-resources/workspaceResources.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it } from 'vitest'; -import { useSceneStore } from '../../stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '../../stores/sceneStore'; import { resolveResourceWorkspace, useNavSceneStore } from '../../stores/navSceneStore'; import { normalizeResourceLayout, useWorkspaceResourceState } from './workspaceResourceState'; import { getSessionSceneTabId } from '../../components/SceneBar/types'; @@ -14,7 +14,7 @@ const sessionTarget = { surfaceId: 'local', workspaceKey: 'project', sessionId: describe('workspace resource navigation', () => { beforeEach(() => { - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); useNavSceneStore.getState().closeNavScene(); }); it('opens resources without replacing the active conversation', () => { @@ -94,7 +94,7 @@ describe('workspace resource navigation', () => { useNavSceneStore.getState().openWorkspaceResources('project'); useSceneStore.getState().openScene('settings'); expect(useNavSceneStore.getState().navSceneId).toBe('settings'); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); expect(useNavSceneStore.getState().showSceneNav).toBe(false); expect(useNavSceneStore.getState().resourceWorkspace).toBeNull(); }); diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts index 225ce020ed..cd271bbb3f 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.test.ts @@ -5,7 +5,7 @@ import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { useModernFlowChatStore } from '@/flow_chat/store/modernFlowChatStore'; import type { Session } from '@/flow_chat/types/flow-chat'; import { activateSurface, LOCAL_SURFACE_ID } from '@/infrastructure/peer-device/deviceSurface'; -import { selectActiveSceneId, useSceneStore } from '../stores/sceneStore'; +import { selectActiveSceneId, useSceneStore, resetSceneStoreForTest } from '../stores/sceneStore'; import { getSessionSceneTabId } from '../components/SceneBar/types'; import { resolveSessionSceneTarget } from './sessionSceneTarget'; import { workspaceManager, type WorkspaceEventListener } from '@/infrastructure/services/business/workspaceManager'; @@ -43,7 +43,7 @@ describe('Session scene resource lifetime with real stores', () => { activateSurface(LOCAL_SURFACE_ID); select([], null); useModernFlowChatStore.getState().clear(); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); stop = startSessionSceneLifecycle(); }); @@ -54,7 +54,7 @@ describe('Session scene resource lifetime with real stores', () => { activateSurface(LOCAL_SURFACE_ID); select([], null); useModernFlowChatStore.getState().clear(); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); }); it('removes the last session tab, projection and navigation history together', () => { @@ -72,7 +72,7 @@ describe('Session scene resource lifetime with real stores', () => { }); }); - it('retains different workspaces and replaces only the selected workspace session', () => { + it('gives every session its own tab, including several of one workspace', () => { const first = session('a1', { workspaceId: 'workspace-a', workspacePath: '/projects/a' }); const second = session('a2', { workspaceId: 'workspace-a', workspacePath: '/projects/a' }); const other = session('b1', { workspaceId: 'workspace-b', workspacePath: '/projects/b' }); @@ -85,11 +85,13 @@ describe('Session scene resource lifetime with real stores', () => { const otherTabId = useSceneStore.getState().activeTabId; flowChatStore.switchSession(second.sessionId); useSceneStore.getState().openScene('session'); + const secondTabId = useSceneStore.getState().activeTabId; + // A new tab opens next to the session tab that was shown. expect(useSceneStore.getState().openTabs.map(tab => [tab.id, tab.session?.sessionId])).toEqual([ - [firstTabId, second.sessionId], [otherTabId, other.sessionId], + [firstTabId, first.sessionId], [otherTabId, other.sessionId], [secondTabId, second.sessionId], ]); - expect(useSceneStore.getState().activeTabId).toBe(firstTabId); + expect(new Set([firstTabId, otherTabId, secondTabId]).size).toBe(3); expect(flowChatStore.getState().sessions.size).toBe(3); }); @@ -108,7 +110,7 @@ describe('Session scene resource lifetime with real stores', () => { stop?.(); activateSurface(surfaceId); select([], null); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); stop = startSessionSceneLifecycle(); const sessions = ['one', 'two', 'three'].map(id => session(id, { workspaceId: 'ssh-project', workspacePath: '/srv/project', remoteConnectionId: 'ssh-host', @@ -118,10 +120,10 @@ describe('Session scene resource lifetime with real stores', () => { flowChatStore.switchSession('two'); flowChatStore.switchSession('three'); const tabId = useSceneStore.getState().activeTabId; - expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['three', 'two', 'one']); - expect(useSceneStore.getState().openTabs[0].session?.surfaceId).toBe(surfaceId); + expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId)).toEqual(['one', 'two', 'three']); + expect(useSceneStore.getState().openTabs.every(tab => tab.session?.surfaceId === surfaceId)).toBe(true); flowChatStore.removeSession('two'); - expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['three', 'one']); + expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId)).toEqual(['one', 'three']); expect(useSceneStore.getState().activeTabId).toBe(tabId); expect(flowChatStore.getActiveSession()?.sessionId).toBe('three'); }); @@ -391,15 +393,19 @@ describe('Session scene resource lifetime with real stores', () => { select([local], local.sessionId); useSceneStore.getState().openScene('session'); + const localTabId = useSceneStore.getState().activeTabId; + + // The other device shows none of this device's sessions, but its tab stays. activateSurface('empty-peer-lifecycle-test'); expect(useSceneStore.getState().activeTabId).toBeNull(); expect(useModernFlowChatStore.getState().activeSession).toBeNull(); + expect(useSceneStore.getState().openTabs.map(tab => tab.id)).toEqual([localTabId]); + // Back on this device, its active session returns to that tab. activateSurface(LOCAL_SURFACE_ID); expect(flowChatStore.getActiveSession()).toBe(local); expect(useModernFlowChatStore.getState().activeSession).toBe(local); - expect(useSceneStore.getState().openTabs).toEqual([]); - useSceneStore.getState().openScene('session'); + expect(useSceneStore.getState().activeTabId).toBe(localTabId); expect(selectActiveSceneId(useSceneStore.getState())).toBe('session'); }); diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index 1fb87e6a14..2d76685fd6 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -5,7 +5,7 @@ import { getActiveSurfaceId, isSurfaceChangedError } from '@/infrastructure/peer import { createLogger } from '@/shared/utils/logger'; import { useDeviceOpenIntent } from '@/infrastructure/peer-device/deviceOpenIntent'; import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; -import { isSessionSceneId, type SessionSceneTarget } from '../components/SceneBar/types'; +import { getSessionSceneTabId, isSessionSceneId, sessionSceneWorkspaceId, type SessionSceneTarget } from '../components/SceneBar/types'; import { startSessionPaneLayoutSync } from '../scenes/session/sessionPaneLayoutSync'; import { resolveSessionSceneTarget, resolveSessionSceneWorkspace } from './sessionSceneTarget'; import { selectedSessionTab, tabPane } from '../workbench/scenePaneModel'; @@ -40,7 +40,15 @@ export function startSessionSceneLifecycle(): () => void { return workspace ? workspace.id === state.activeWorkspaceId : !state.currentWorkspace; }, activate: async (target, isCurrent) => { - if (target.surfaceId !== getActiveSurfaceId() || !isCurrent()) return false; + if (!isCurrent()) return false; + if (target.surfaceId !== getActiveSurfaceId()) { + // Another device's tab: render that device and open the session there, + // the same open as its sidebar row. The open holds and selects the tab. + const { openAgentCompanionSession } = await import('./openAgentCompanionSession'); + void openAgentCompanionSession(target.sessionId, target.surfaceId, sessionSceneWorkspaceId(target)) + .catch(error => log.warn('Failed to open another device session tab', { surfaceId: target.surfaceId, error })); + return false; + } try { const { activateMainSession } = await import('@/flow_chat/services/sessionActivation'); if (!isCurrent()) return false; @@ -70,6 +78,12 @@ export function startSessionSceneLifecycle(): () => void { previousSelection = current(); previousWorkspaces = new Map(workspaceManager.getState().openedWorkspaces); useSceneStore.getState().resetForPeerSwitch(true); + // The device's own active session comes back to its kept tab. + const scenes = useSceneStore.getState(); + if (previousSelection && !scenes.activeTabId?.startsWith('session:') && !scenes.pendingTabId + && scenes.openTabs.some(tab => tab.id === getSessionSceneTabId(previousSelection!))) { + scenes.openSessionScene(previousSelection); + } } const closedWorkspace = event?.type === 'workspace:closed' || event?.type === 'workspace:removed' ? previousWorkspaces.get(event.workspaceId) : undefined; diff --git a/src/web-ui/src/app/stores/sceneStore.test.ts b/src/web-ui/src/app/stores/sceneStore.test.ts index 79f9cb06d7..a05502d82b 100644 --- a/src/web-ui/src/app/stores/sceneStore.test.ts +++ b/src/web-ui/src/app/stores/sceneStore.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { recordInteractionModality } from '@/shared/utils/motionPreference'; import { beginDeviceOpen, endDeviceOpen } from '@/infrastructure/peer-device/deviceOpenIntent'; -import { registerSessionSceneNavigation, useSceneStore } from './sceneStore'; +import { registerSessionSceneNavigation, resetSceneStoreForTest, useSceneStore } from './sceneStore'; import { getSessionSceneTabId } from '../components/SceneBar/types'; import { useSettingsStore } from '../scenes/settings/settingsStore'; import { projectResourceSceneId } from '../scenes/project-resources/projectResourceTarget'; @@ -18,7 +18,7 @@ describe('sceneStore transition snapshots', () => { beforeEach(() => { recordInteractionModality('programmatic'); resetSettingsDraftRegistryForTests(); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); }); afterEach(() => { @@ -61,29 +61,36 @@ describe('sceneStore transition snapshots', () => { } finally { stop(); } }); - it('keeps the five most recently opened sessions per workspace, moving repeats to the front', () => { + it('opens one tab per session and closes the least recently used past the limit', () => { + let now = 1_000; + vi.spyOn(Date, 'now').mockImplementation(() => (now += 1)); const scenes = useSceneStore.getState(); - for (let index = 1; index <= 7; index++) { + scenes.openSessionScene({ ...sessionTarget, sessionId: '1' }); + scenes.openSessionScene({ ...sessionTarget, sessionId: '2' }); + scenes.openSessionScene({ ...sessionTarget, sessionId: '1' }); + expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId)).toEqual(['1', '2']); + for (let index = 3; index <= 14; index++) { scenes.openSessionScene({ ...sessionTarget, sessionId: String(index) }); } - scenes.openSessionScene({ ...sessionTarget, sessionId: '4' }); - expect(useSceneStore.getState().openTabs).toHaveLength(1); - expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['4', '7', '6', '5', '3']); - scenes.openSessionScene({ ...sessionTarget, workspaceKey: 'other', sessionId: 'elsewhere' }); - expect(useSceneStore.getState().openTabs.map(tab => tab.sessionHistory)) - .toEqual([['4', '7', '6', '5', '3'], ['elsewhere']]); + const open = useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId); + expect(open).toHaveLength(12); + // '2' and '1' were used least recently; the shown session always stays. + expect(open).not.toContain('1'); + expect(open).not.toContain('2'); + expect(useSceneStore.getState().activeTabId).toBe(getSessionSceneTabId({ ...sessionTarget, sessionId: '14' })); }); - it('accepts legacy tabs without history and records background session changes without stealing focus', () => { + it('keeps session tabs of every device through a switch, without selecting them', () => { const scenes = useSceneStore.getState(); scenes.openSessionScene(sessionTarget); - useSceneStore.setState(state => ({ openTabs: state.openTabs.map(({ sessionHistory: _history, ...tab }) => tab) })); scenes.openScene('git'); scenes.updateSessionScene({ ...sessionTarget, sessionId: 'next' }); expect(useSceneStore.getState().activeTabId).toBe('git'); - expect(useSceneStore.getState().openTabs[0].sessionHistory).toEqual(['next', 'current']); + expect(useSceneStore.getState().openTabs.map(tab => tab.session?.sessionId).filter(Boolean)).toEqual(['current']); + scenes.openSessionScene(sessionTarget); scenes.resetForPeerSwitch(); - expect(useSceneStore.getState().openTabs).toEqual([]); + expect(useSceneStore.getState().openTabs.map(tab => tab.id)).toEqual([sessionTabId]); + expect(useSceneStore.getState().activeTabId).toBeNull(); }); it('holds a cross-device session tab through both switch resets and reconciliation until the open settles', () => { @@ -98,9 +105,11 @@ describe('sceneStore transition snapshots', () => { useSceneStore.getState().resetForPeerSwitch(true); useSceneStore.getState().reconcileSessionScenes(new Map()); expect(useSceneStore.getState().activeTabId).toBe(heldId); + // The open ended without rendering peer-b: its tab stays, unselected. endDeviceOpen(intent); useSceneStore.getState().reconcileSessionScenes(new Map()); - expect(useSceneStore.getState().openTabs.some(tab => tab.id === heldId)).toBe(false); + expect(useSceneStore.getState().openTabs.some(tab => tab.id === heldId)).toBe(true); + expect(useSceneStore.getState().activeTabId).not.toBe(heldId); }); it('does not add a failed session activation to the recent list', async () => { diff --git a/src/web-ui/src/app/stores/sceneStore.ts b/src/web-ui/src/app/stores/sceneStore.ts index 31fbbd13a6..19058f1844 100644 --- a/src/web-ui/src/app/stores/sceneStore.ts +++ b/src/web-ui/src/app/stores/sceneStore.ts @@ -3,8 +3,11 @@ * * Tab rules: * - Every explicitly opened scene stays in openTabs until the user closes it. - * Session tabs reference one session per workspace; the shell-owned - * sessionSceneLifecycle retires their tabs when that resource disappears. + * Each session tab references one session of one device; several + * sessions of a workspace, and sessions of other devices, keep their own + * tabs. The shell-owned sessionSceneLifecycle retires a tab when its + * session disappears from the rendered device; tabs of other devices stay + * until the user closes them, and activating one renders its device. * - Pinned tabs stay ahead of regular tabs; closeability is an independent * scene-definition capability. * - The app starts with no tabs. SceneViewport owns the tabless welcome @@ -90,9 +93,21 @@ function buildSceneTab(id: SceneTabId, now: number): SceneTab { } function rememberSession(tab: SceneTab, session: SessionSceneTarget): SceneTab { - const previous = tab.sessionHistory ?? (tab.session ? [tab.session.sessionId] : []); - return { ...tab, session, sessionHistory: [session.sessionId, - ...previous.filter(id => id !== session.sessionId)].slice(0, 5) }; + return { ...tab, session, sessionHistory: [session.sessionId] }; +} + +/** Session tabs kept open; past this the least recently used idle ones close. */ +const MAX_SESSION_TABS = 12; + +/** Close the least recently used session tabs beyond the limit, never `keep` or a shown one. */ +function capSessionTabs(tabs: SceneTab[], keep: SceneTabId, shown: ReadonlySet): SceneTab[] { + const sessions = tabs.filter(tab => tab.session && !tab.pinned); + if (sessions.length <= MAX_SESSION_TABS) return tabs; + const closable = sessions.filter(tab => tab.id !== keep && !shown.has(tab.id) && !isHeldSessionTab(tab)) + .sort((a, b) => a.lastUsed - b.lastUsed) + .slice(0, sessions.length - MAX_SESSION_TABS); + const closed = new Set(closable.map(tab => tab.id)); + return tabs.filter(tab => !closed.has(tab.id)); } /** Shell adapter: the tab owner never loads history or changes runtime state. */ @@ -300,7 +315,8 @@ export const useSceneStore = create((set, get) => ({ updateSessionScene: (target) => { const id = getSessionSceneTabId(target); const state = get(); - if (!state.openTabs.some(tab => tab.id === id && tab.session?.sessionId !== target.sessionId)) return; + if (!state.openTabs.some(tab => tab.id === id && tab.session !== target + && (tab.session?.sessionId !== target.sessionId || tab.session.workspaceKey !== target.workspaceKey))) return; set({ openTabs: state.openTabs.map(tab => tab.id === id ? rememberSession(tab, target) : tab) }); }, @@ -314,7 +330,8 @@ export const useSceneStore = create((set, get) => ({ tabs.set(tab.id, tab); continue; } - if (isHeldSessionTab(tab)) { + // Another device's sessions are not in this projection; its tabs stay. + if (isHeldSessionTab(tab) || (tab.session && tab.session.surfaceId !== getActiveSurfaceId())) { tabs.set(tab.id, tab); continue; } @@ -336,9 +353,16 @@ export const useSceneStore = create((set, get) => ({ } } const nextTabs = [...tabs.values()]; - if (nextTabs.length === state.openTabs.length && nextTabs.every((tab, i) => tab === state.openTabs[i])) return; + // Another device's session is shown only while its open renders that + // device; an open that failed or was superseded leaves the tab unselected. + const strandedId = [state.paneSelections.primary, state.paneSelections.secondary].map(selectedScene) + .find(id => { + const tab = id ? tabs.get(id) : undefined; + return Boolean(tab?.session && tab.session.surfaceId !== getActiveSurfaceId() && !isHeldSessionTab(tab)); + }) ?? null; + if (!strandedId && nextTabs.length === state.openTabs.length && nextTabs.every((tab, i) => tab === state.openTabs[i])) return; const activeId = state.activeTabId && (remapped.get(state.activeTabId) ?? state.activeTabId); - const retainedActiveId = activeId && tabs.has(activeId) ? activeId : null; + const retainedActiveId = activeId && tabs.has(activeId) && activeId !== strandedId ? activeId : null; const mappedHistory = state.navHistory.map(id => remapped.get(id) ?? id); const navHistory = mappedHistory.filter(id => tabs.has(id)); const retiredPendingTab = state.pendingTabId !== null @@ -349,7 +373,7 @@ export const useSceneStore = create((set, get) => ({ const id = selectedScene(state.paneSelections[pane]); if (!id) return state.paneSelections[pane]; const nextId = remapped.get(id) ?? id; - return tabs.has(nextId) ? sceneSelection(nextId) : vacantSelection(pane, state.canvasPane); + return tabs.has(nextId) && nextId !== strandedId ? sceneSelection(nextId) : vacantSelection(pane, state.canvasPane); }; const panes = reconcilePaneState({ ...state, openTabs: nextTabs, paneSelections: { primary: selection('primary'), secondary: selection('secondary') } }); @@ -360,8 +384,10 @@ export const useSceneStore = create((set, get) => ({ ? mappedHistory.slice(0, state.navCursor + 1).filter(id => tabs.has(id)).length - 1 : -1, }); if (!retainedActiveId && state.activeTabId) { - const fallback = paneFallback(nextTabs, state.focusedPane) - ?? [...nextTabs].sort((a, b) => b.lastUsed - a.lastUsed)[0]?.id; + // Never fall back onto another device's tab: that would render the device. + const local = nextTabs.filter(tab => !tab.session || tab.session.surfaceId === getActiveSurfaceId()); + const fallback = paneFallback(local, state.focusedPane) + ?? [...local].sort((a, b) => b.lastUsed - a.lastUsed)[0]?.id; if (fallback) get().activateScene(fallback); } }, @@ -450,10 +476,12 @@ export const useSceneStore = create((set, get) => ({ const id = getSessionSceneTabId(target); const previous = state.openTabs.find(tab => tab.id === id); const tab: SceneTab = { - ...previous, id, pane: 'primary', session: target, lastUsed: Date.now(), - sessionHistory: [target.sessionId, ...(previous?.sessionHistory ?? []).filter(sessionId => sessionId !== target.sessionId)], + ...previous, id, pane: 'primary', session: target, lastUsed: Date.now(), sessionHistory: [target.sessionId], }; - const openTabs = [...state.openTabs.filter(candidate => candidate.id !== id), tab]; + // An open tab keeps its place; a new one goes after the shown session tab. + const openTabs = previous + ? state.openTabs.map(candidate => candidate.id === id ? tab : candidate) + : insertAfterShown(state, tab); const panes = reconcilePaneState({ ...state, openTabs, focusedPane: 'primary', paneSelections: { ...state.paneSelections, primary: sceneSelection(id) } }); set({ ...projectPaneState(panes), pendingTabId: null, @@ -474,13 +502,23 @@ export const useSceneStore = create((set, get) => ({ const saved = restoreContent ? contentSurfaceTabs.get(getActiveSurfaceId()) : undefined; const restored = saved?.openTabs.filter(tab => tab.contentId && useContentResourceStore.getState().resources[tab.contentId]) ?? buildDefaultTabs(); - // A cross-device open keeps its session in front through the switch. + // Session tabs of every device survive the switch; a cross-device open + // keeps its session in front. const held = state.openTabs.find(isHeldSessionTab); - const tabs = held ? [...restored, held] : restored; + const sessionTabs = state.openTabs.filter(tab => tab.session); + const tabs = [...sessionTabs, ...restored.filter(tab => !tab.session && !sessionTabs.some(kept => kept.id === tab.id))]; + // A kept session tab is shown again only through its own activation. + const base = saved ?? state; + const unselected = (pane: WorkbenchPane) => { + const id = selectedScene(base.paneSelections[pane]); + return id && tabs.some(tab => tab.id === id && tab.session) ? vacantSelection(pane, base.canvasPane) : base.paneSelections[pane]; + }; + const paneSelections = { primary: unselected('primary'), secondary: unselected('secondary') }; const panes = held - ? reconcilePaneState({ ...(saved ?? state), openTabs: tabs, focusedPane: 'primary', - paneSelections: { ...(saved ?? state).paneSelections, primary: sceneSelection(held.id) } }) - : saved ? reconcilePaneState({ ...saved, openTabs: tabs }) : createPaneState(tabs, tabs[0]?.id ?? null); + ? reconcilePaneState({ ...base, openTabs: tabs, focusedPane: 'primary', + paneSelections: { ...paneSelections, primary: sceneSelection(held.id) } }) + : saved ? reconcilePaneState({ ...saved, openTabs: tabs, paneSelections }) + : createPaneState(tabs, tabs.find(tab => !tab.session)?.id ?? null); const activeTabId = focusedScene(panes); set({ ...projectPaneState(panes), pendingTabId: null, navHistory: activeTabId ? [activeTabId] : [], navCursor: activeTabId ? 0 : -1, @@ -489,6 +527,12 @@ export const useSceneStore = create((set, get) => ({ }, })); +/** Tests only: drop every tab, including the session tabs a switch keeps. */ +export function resetSceneStoreForTest(): void { + useSceneStore.setState({ openTabs: [], paneSelections: { primary: { kind: 'empty' }, secondary: { kind: 'canvas' } } }); + useSceneStore.getState().resetForPeerSwitch(); +} + export function registerSessionSceneNavigation(adapter: SessionSceneNavigation): () => void { sessionNavigation = adapter; return () => { @@ -593,7 +637,9 @@ function openSceneTarget(id: SceneTabId, session?: SessionSceneTarget, options?: ...(session && presentation ? { pane: tabPane(presentation) } : {}) }; const source = existing ?? created; const tab = { ...(session ? rememberSession(source, session) : source), lastUsed: Date.now() }; - const tabs = existing ? state.openTabs.map(candidate => candidate.id === id ? tab : candidate) : [...state.openTabs, tab]; + const shown = new Set(workbenchPanes.map(pane => selectedScene(state.paneSelections[pane])).filter((value): value is SceneTabId => Boolean(value))); + const tabs = existing ? state.openTabs.map(candidate => candidate.id === id ? tab : candidate) + : session ? capSessionTabs(insertAfterShown(state, tab), id, shown) : [...state.openTabs, tab]; let panes = selectSceneInPane({ ...state, openTabs: orderPinnedTabsFirst(tabs) }, id); if (options?.revealCanvas) panes = selectCanvasInPane(panes, false); if (options?.focus === false) panes = { ...panes, focusedPane: state.focusedPane }; @@ -606,6 +652,14 @@ function openSceneTarget(id: SceneTabId, session?: SessionSceneTarget, options?: } }); } +/** A new session tab opens next to the session tab its pane shows, else last. */ +function insertAfterShown(state: ScenePaneState, tab: SceneTab): SceneTab[] { + const shownId = selectedScene(state.paneSelections[tab.pane ?? state.focusedPane]); + const index = shownId ? state.openTabs.findIndex(candidate => candidate.id === shownId && candidate.session) : -1; + if (index < 0) return [...state.openTabs, tab]; + return [...state.openTabs.slice(0, index + 1), tab, ...state.openTabs.slice(index + 1)]; +} + function navigateHistory(direction: -1 | 1): void { void navigateWorkbench(state => { for (let i = state.navCursor + direction; i >= 0 && i < state.navHistory.length; i += direction) { diff --git a/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx b/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx index 6076ec5c23..5e0ac4f28f 100644 --- a/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx +++ b/src/web-ui/src/app/workbench/WorkbenchLayout.test.tsx @@ -6,7 +6,7 @@ import SessionScene from '../scenes/session/SessionScene'; import WorkbenchLayout from './WorkbenchLayout'; import { sessionPaneLayoutStore } from '../scenes/session/sessionPaneLayoutStore'; import SessionPanelSettingsSection from '../scenes/settings/pages/application/SessionPanelSettingsSection'; -import { useSceneStore } from '../stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '../stores/sceneStore'; import { getSessionSceneTabId } from '../components/SceneBar/types'; import { useWindowResizeLayout } from '../layout/windowResizeScheduler'; @@ -59,7 +59,7 @@ describe('workbench pane layout integration', () => { beforeEach(() => { mocks.terminalPosition = 'right'; useWindowResizeLayout.setState(useWindowResizeLayout.getInitialState()); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); sessionPaneLayoutStore.setState(sessionPaneLayoutStore.getInitialState()); vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { diff --git a/src/web-ui/src/app/workbench/WorkbenchTabDrag.test.tsx b/src/web-ui/src/app/workbench/WorkbenchTabDrag.test.tsx index 8fd0d52d78..069d4e464b 100644 --- a/src/web-ui/src/app/workbench/WorkbenchTabDrag.test.tsx +++ b/src/web-ui/src/app/workbench/WorkbenchTabDrag.test.tsx @@ -5,7 +5,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { flushSync } from 'react-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import WorkbenchLayout from './WorkbenchLayout'; -import { useSceneStore } from '../stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '../stores/sceneStore'; import { workbenchPaneLayoutStore } from './workbenchPaneLayoutStore'; import { endSceneTabDrag } from './sceneTabTransfer'; import { CanvasToolbarContribution } from '../components/panels/content-canvas/CanvasToolbar'; @@ -58,7 +58,7 @@ describe('workbench header drag event integration', () => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 1201, left: 0 } as DOMRect); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); workbenchPaneLayoutStore.setState(workbenchPaneLayoutStore.getInitialState()); clearAgentCanvasForPeerSwitch(); useContentResourceStore.setState({ resources: {} }); diff --git a/src/web-ui/src/app/workbench/canvasTabTransfer.test.ts b/src/web-ui/src/app/workbench/canvasTabTransfer.test.ts index 3f4bbfe639..ca439c717a 100644 --- a/src/web-ui/src/app/workbench/canvasTabTransfer.test.ts +++ b/src/web-ui/src/app/workbench/canvasTabTransfer.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SESSION_TAB_DRAG_TYPE, dropSessionTabOnWorkbench, popOutCanvasTab, writeSessionTabDrag } from './canvasTabTransfer'; import { useContentResourceStore } from './contentResourceStore'; -import { useSceneStore } from '../stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '../stores/sceneStore'; import { clearAgentCanvasForPeerSwitch, switchAgentCanvasScope, useAgentCanvasStore } from '../components/panels/content-canvas/stores'; import { openCanvasContent, openWorkbenchContent } from '@/shared/services/workbenchContentService'; import { getEditorDocument, releaseEditorDocument } from '@/tools/editor/services/EditorDocument'; @@ -37,7 +37,7 @@ describe('canvas-to-workbench view transfer', () => { activateSurface('local'); clearAgentCanvasForPeerSwitch(); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); }); afterEach(() => { for (const tab of useAgentCanvasStore.getState().getAllTabs()) releaseEditorDocument(canvasTabDocumentId(tab)); diff --git a/src/web-ui/src/app/workbench/scenePaneModel.ts b/src/web-ui/src/app/workbench/scenePaneModel.ts index 66a8688533..0575248c15 100644 --- a/src/web-ui/src/app/workbench/scenePaneModel.ts +++ b/src/web-ui/src/app/workbench/scenePaneModel.ts @@ -1,3 +1,4 @@ +import { getActiveSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; import { getSceneViewId, type SceneTab, type SceneTabId } from '../components/SceneBar/types'; export type WorkbenchPane = 'primary' | 'secondary'; @@ -16,8 +17,14 @@ export interface ScenePaneState { canvasPane: WorkbenchPane; } +/** + * The tab a pane falls back to. Another device's session tab is never one: + * showing it means rendering that device, which only its own activation does. + */ export function paneFallback(tabs: SceneTab[], pane: WorkbenchPane, excludedView?: SceneTabId): SceneTabId | null { - return tabs.filter(tab => tabPane(tab) === pane && getSceneViewId(tab.id) !== excludedView) + const surfaceId = getActiveSurfaceId(); + return tabs.filter(tab => tabPane(tab) === pane && getSceneViewId(tab.id) !== excludedView + && (!tab.session || tab.session.surfaceId === surfaceId)) .sort((a, b) => b.lastUsed - a.lastUsed)[0]?.id ?? null; } diff --git a/src/web-ui/src/app/workbench/sceneTabTransfer.test.ts b/src/web-ui/src/app/workbench/sceneTabTransfer.test.ts index f46753574c..e3a5ef84d0 100644 --- a/src/web-ui/src/app/workbench/sceneTabTransfer.test.ts +++ b/src/web-ui/src/app/workbench/sceneTabTransfer.test.ts @@ -1,6 +1,6 @@ import { createPaneState, selectedScene } from './scenePaneModel'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; +import { registerSessionSceneNavigation, useSceneStore, resetSceneStoreForTest } from '../stores/sceneStore'; import { getSessionSceneTabId } from '../components/SceneBar/types'; import { sessionPaneLayoutStore, selectSessionPaneMode } from '../scenes/session/sessionPaneLayoutStore'; import { dropSceneTab, endSceneTabDrag, isSceneTabDrag, writeSceneTabDrag } from './sceneTabTransfer'; @@ -21,7 +21,7 @@ function transfer() { beforeEach(() => { endSceneTabDrag(); resetSettingsDraftRegistryForTests(); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); sessionPaneLayoutStore.setState(sessionPaneLayoutStore.getInitialState()); }); diff --git a/src/web-ui/src/app/workbench/workbenchContent.test.ts b/src/web-ui/src/app/workbench/workbenchContent.test.ts index 14e297ddb0..e7d1a5b7e1 100644 --- a/src/web-ui/src/app/workbench/workbenchContent.test.ts +++ b/src/web-ui/src/app/workbench/workbenchContent.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; +import { registerSessionSceneNavigation, useSceneStore, resetSceneStoreForTest } from '../stores/sceneStore'; import { useContentResourceStore } from './contentResourceStore'; import { registerContentCloseGuard } from './contentResourceLifecycle'; import { fileTabManager } from '@/shared/services/FileTabManager'; @@ -49,7 +49,7 @@ describe('workbench content navigation', () => { flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); clearAgentCanvasForPeerSwitch(); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); resetSettingsDraftRegistryForTests(); appManager.updateLayout({ chatCollapsed: false, rightPanelCollapsed: true }); }); @@ -328,7 +328,7 @@ describe('workbench content navigation', () => { } finally { stop(); } }); - it.each(['closed', 'replaced', 'scope-changed'])('cancels content when the destination changes during activation: %s', async reason => { + it.each(['closed', 'scope-changed'])('cancels content when the destination changes during activation: %s', async reason => { const target = openSession(); useSceneStore.getState().openScene('git'); let complete!: (activated: boolean) => void; @@ -399,7 +399,7 @@ describe('workbench content navigation', () => { } finally { draft(); } }); - it.each(['closed', 'replaced'])('does not restore a destination changed while settings exit is pending: %s', async reason => { + it.each(['closed'])('does not restore a destination changed while settings exit is pending: %s', async reason => { const target = openSession(); useSceneStore.getState().openScene('settings'); const draft = registerSettingsDraft({ id: 'settings-draft', pageId: 'page', label: 'Settings', dirty: true, diff --git a/src/web-ui/src/app/workbench/workbenchNavigation.test.ts b/src/web-ui/src/app/workbench/workbenchNavigation.test.ts index dc2db3398b..5382f3c02b 100644 --- a/src/web-ui/src/app/workbench/workbenchNavigation.test.ts +++ b/src/web-ui/src/app/workbench/workbenchNavigation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { beforeEach, afterEach, expect, it, vi } from 'vitest'; -import { registerSessionSceneNavigation, useSceneStore } from '../stores/sceneStore'; +import { registerSessionSceneNavigation, useSceneStore, resetSceneStoreForTest } from '../stores/sceneStore'; import { getSessionSceneTabId } from '../components/SceneBar/types'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { sceneSelection } from './scenePaneModel'; @@ -21,7 +21,7 @@ beforeEach(() => { activateSurface('local'); let tick = 1000; vi.spyOn(Date, 'now').mockImplementation(() => ++tick); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); sessionPaneLayoutStore.setState(sessionPaneLayoutStore.getInitialState()); resetSettingsDraftRegistryForTests(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); diff --git a/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts b/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts index 81a1645251..54d9ca5c6e 100644 --- a/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts +++ b/src/web-ui/src/flow_chat/services/btwSessionPane.navigation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useSceneStore } from '@/app/stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '@/app/stores/sceneStore'; import { resolveSessionSceneTarget } from '@/app/services/sessionSceneTarget'; import { appManager } from '@/app/services/AppManager'; import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; @@ -60,7 +60,7 @@ describe('subagent pane navigation through production stores', () => { activateSurface('local'); clearAgentCanvasForPeerSwitch(); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); appManager.updateLayout({ chatCollapsed: false, rightPanelCollapsed: true }); }); @@ -70,7 +70,7 @@ describe('subagent pane navigation through production stores', () => { clearAgentCanvasForPeerSwitch(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); }); it('resolves unknown continuation policy without reloading an already complete transcript', async () => { diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx index 43cbd49812..f386edb9e6 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx @@ -398,11 +398,11 @@ describe('FileOperationToolCard', () => { const { useContentResourceStore } = await vi.importActual< typeof import('../../app/workbench/contentResourceStore') >('../../app/workbench/contentResourceStore'); - const { useSceneStore } = await vi.importActual( + const { useSceneStore, resetSceneStoreForTest } = await vi.importActual( '../../app/stores/sceneStore', ); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); mocks.createDiffEditorTab.mockImplementation(tabUtils.createDiffEditorTab); const config = { toolName: 'Edit', displayName: 'Edit', icon: 'EDIT', diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx index 94fcc4bf24..973d6bf457 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.test.tsx @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { MenuItem } from '@/shared/context-menu-system/types'; import { MarkdownRenderer, ThinkingMarkdownRenderer } from './MarkdownRenderer'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores/canvasStore'; -import { useSceneStore } from '@/app/stores/sceneStore'; +import { useSceneStore, resetSceneStoreForTest } from '@/app/stores/sceneStore'; import { selectSessionPaneMode, sessionPaneLayoutStore } from '@/app/scenes/session/sessionPaneLayoutStore'; import { useContentResourceStore } from '@/app/workbench/contentResourceStore'; import { appManager } from '@/app/services/AppManager'; @@ -196,7 +196,7 @@ describe('Markdown file links', () => { activateSurface('local'); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); appManager.updateLayout({ chatCollapsed: false, rightPanelCollapsed: true }); container = document.createElement('div'); @@ -227,7 +227,7 @@ describe('Markdown file links', () => { container.remove(); flowChatStore.setState(state => ({ ...state, sessions: new Map(), activeSessionId: null })); useContentResourceStore.setState({ resources: {} }); - useSceneStore.getState().resetForPeerSwitch(); + resetSceneStoreForTest(); vi.clearAllMocks(); }); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index cfc02262ac..24b97b5ccb 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1766,7 +1766,6 @@ "panelView": "Panel View" }, "sceneBar": { - "recentSessions": "Recently opened sessions", "tabsLabel": "Scene tabs", "scrollPrevious": "Scroll tabs left", "scrollNext": "Scroll tabs right", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 1b964e6307..9fad03e0d7 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1766,7 +1766,6 @@ "panelView": "面板视图" }, "sceneBar": { - "recentSessions": "最近打开的会话", "tabsLabel": "场景标签页", "scrollPrevious": "向左滚动标签页", "scrollNext": "向右滚动标签页", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 6758b911b0..de8bd14b95 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1766,7 +1766,6 @@ "panelView": "面板視圖" }, "sceneBar": { - "recentSessions": "最近開啟的工作階段", "tabsLabel": "場景標籤頁", "scrollPrevious": "向左捲動標籤頁", "scrollNext": "向右捲動標籤頁", From 3b3375f001760db383b84e1d124a360f1ca62c34 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 13:23:53 +0800 Subject: [PATCH 22/52] perf(web-ui): fetch the clicked session first on a cross-device open - Subscribe the session's host stream on click, while the device switch runs, and hand it with its buffered events to the session's history owner once the device is rendered. - Do not hold the switch on the device's config during an open; it reloads in the background. - Stop polling device-control status on a host that refuses it. --- docs/architecture/peer-device-mode.md | 35 +++++++- .../ComputerUseControlCard.test.tsx | 6 ++ .../app/components/ComputerUseControlCard.tsx | 9 +- .../sections/devices/openOnDevice.test.ts | 5 ++ .../NavPanel/sections/devices/openOnDevice.ts | 4 + .../openAgentCompanionSession.test.ts | 1 + .../app/services/openAgentCompanionSession.ts | 3 + .../services/RelaySessionHistory.test.ts | 33 +++++++- .../flow_chat/services/RelaySessionHistory.ts | 84 +++++++++++++++++-- .../PeerDeviceSurfaceController.test.ts | 14 +++- .../PeerDeviceSurfaceController.ts | 14 +++- .../peer-device/peerDeviceSurfaceRuntime.ts | 2 + 12 files changed, 195 insertions(+), 15 deletions(-) diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index e788066a54..4ce2d273b9 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -235,8 +235,10 @@ machine included while a peer is rendered, is drawn from a read-only catalog refreshes are held while the window is hidden or a cross-device open runs and run once afterwards; the device that just became rendered is not re-read; catalog notifications are coalesced per task. - A host without the stream is re-read when the window becomes visible. The - stream is released 30 s after the section closes. + A host without the stream is re-read when the window becomes visible and + every 60 s while it is visible. The stream is released 30 s after the section + closes. A newer session read of a workspace replaces an older one in flight, + and a host that refuses a read is asked again how it addresses sessions. - A failed read keeps the last rendered catalog and offers a retry. An incompatible build is listed but not read. @@ -257,7 +259,18 @@ session's tab is held in front (`sceneStore.holdSessionScene`) from the click th and the main area shows the session's open overlay from the click on. Low-priority peer requests (git, search, file tree, watchers, mini apps) queued during the open wait in the adapter and run once it settles; session-status reconciliation -waits likewise, and `set_active_workspace` is high priority. +waits likewise (held for at most 8 s, so a slow host cannot starve them), and +`set_active_workspace` is high priority. + +The open is ordered by what the user waits for. On click, the session's host +stream is subscribed directly (`prewarmRelaySessionHistory`; the +`account_subscribe_session` relay command does not depend on the rendered +surface), so the host reads the session while the switch runs. The session's +history owner adopts that stream and its buffered events once the device is +rendered; an unadopted stream closes after 30 s. The switch does not wait for +the device's config while an open runs: it reloads in the background (the +config cache is already scoped to the surface epoch). A host that refuses +device-control status is not polled again on that surface. The one write the catalog sends without rendering a device is "mark all read": `save_session_metadata` limited to `unreadCompletion` and @@ -276,7 +289,21 @@ always created on this machine. The Agent companion lists background-device sessions next to the rendered device's, labelled with their device. Opening one renders its device first; replying from the companion is offered only for the rendered device. A switch -reloads the target's config and workspace catalog concurrently. +reloads the target's config and workspace catalog concurrently, unless a +cross-device open defers the config. + +### Session tabs across devices + +The top bar keeps one tab per session (`getSessionSceneTabId` keys surface, +workspace and session), so several sessions of one workspace and sessions of +other devices each have their own tab; past 12 session tabs the least recently +used idle one closes. Session tabs of every device survive a switch. +Reconciliation retires only tabs of the rendered device whose session is gone. +A tab of another device shows that device's name and is never selected without +its device: activating it runs the same cross-device open as a sidebar row, a +pane never falls back onto it, and an open that fails or is superseded leaves +it in the bar, unselected. Returning to a device reselects the kept tab of its +active session. ## Cloud account sync vs Peer Remote diff --git a/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx b/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx index 9c80924511..1023a3d84b 100644 --- a/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx +++ b/src/web-ui/src/app/components/ComputerUseControlCard.test.tsx @@ -203,6 +203,12 @@ it('still reports a failed status read as unavailable and permits Stop', async ( await act(async () => [...container.querySelectorAll('button')].find(b => b.textContent === 'computerControl.stop')!.click()); expect(mocks.stop).toHaveBeenCalledWith(4); }); +it('stops asking a host that refuses device control', async () => { + mocks.status.mockRejectedValue(new Error("command 'computer_use_control_status' is not supported on CLI peer host: no automation runtime")); + await act(async () => root.render()); + await act(async () => vi.advanceTimersByTime(5000)); + expect(mocks.status).toHaveBeenCalledOnce(); +}); it('shows the preview by default and hides the card when control ends externally', async () => { mocks.status.mockResolvedValue(active('Window A')); mocks.preview.mockResolvedValue({ generation: 4, target: 'Window A', image_base64: 'AA==', mime_type: 'image/png', diff --git a/src/web-ui/src/app/components/ComputerUseControlCard.tsx b/src/web-ui/src/app/components/ComputerUseControlCard.tsx index c78c18c685..d58846c7a3 100644 --- a/src/web-ui/src/app/components/ComputerUseControlCard.tsx +++ b/src/web-ui/src/app/components/ComputerUseControlCard.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { Button, Card, CardBody, CardFooter, CardHeader, OverlayLayer, OverflowText } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer-device/deviceSurface'; +import { classifyPeerHostRefusal } from '@/infrastructure/peer-device/peerHostRefusal'; import { computerUseControlAPI, projectControlPointer, type ComputerUseControlSnapshot, type ComputerUseControlPreview, @@ -44,6 +45,7 @@ export function ComputerUseControlCard() { useEffect(() => { let disposed = false; + let refused = false; let timer: ReturnType | undefined; setView(undefined); setStopping(false); @@ -82,14 +84,17 @@ export function ComputerUseControlCard() { }); } } - } catch { + } catch (error) { + // A host that refuses the command has no device control: asking it + // again every second only competes with real work for the link. + refused = classifyPeerHostRefusal(error) !== null; // Older peers may not expose this capability. Retain a previously // active card with an explicit unavailable state, never a local fallback. if (!disposed && scope.isCurrent() && revision === mutation.current) setView(previous => previous && { ...previous, frame: undefined, framePointer: undefined, error: true, }); } finally { - if (!disposed && scope.isCurrent()) timer = setTimeout(() => void poll(), 1000); + if (!disposed && !refused && scope.isCurrent()) timer = setTimeout(() => void poll(), 1000); } }; void poll(); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts index b8babe89ea..78aee1d4d6 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + prewarm: vi.fn(), sessions: new Map(), adopt: vi.fn(async (_workspaceId: string, sessions: Array<{ sessionId: string }>) => { for (const session of sessions) mocks.sessions.set(session.sessionId, session); @@ -35,6 +36,7 @@ vi.mock('@/infrastructure/i18n', () => ({ i18nService: { t: (key: string) => key vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn() }) })); vi.mock('@/flow_chat/services/FlowChatManager', () => ({ flowChatManager: { createChatDraft: mocks.createChatDraft } })); vi.mock('@/flow_chat/services/sessionActivation', () => ({ openMainSession: mocks.openMainSession })); +vi.mock('@/flow_chat/services/RelaySessionHistory', () => ({ prewarmRelaySessionHistory: mocks.prewarm })); vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ workspaceManager: { getState: () => ({ openedWorkspaces: mocks.workspaces }), setActiveWorkspace: vi.fn() }, })); @@ -96,6 +98,9 @@ describe('openOnDevice', () => { expect(mocks.hold.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); expect(mocks.intent.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); expect(peer.switchToDevice).toHaveBeenCalledWith('peer-a', 'Studio Mac'); + // The session's stream starts before the switch, not after it. + expect(mocks.prewarm).toHaveBeenCalledWith('peer-a', 's-1'); + expect(mocks.prewarm.mock.invocationCallOrder[0]).toBeLessThan(peer.switchToDevice.mock.invocationCallOrder[0]); expect(mocks.promote).toHaveBeenCalledOnce(); expect(mocks.adopt).not.toHaveBeenCalled(); expect(mocks.ensureMetadata).not.toHaveBeenCalled(); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts index 0efa29ad76..0272e7b614 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/openOnDevice.ts @@ -17,6 +17,7 @@ import { workspaceManager } from '@/infrastructure/services/business/workspaceMa import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; +import { prewarmRelaySessionHistory } from '@/flow_chat/services/RelaySessionHistory'; import { findReusableEmptySessionId, flowChatSessionConfigForWorkspace } from '@/app/utils/projectSessionWorkspace'; import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; import { openLatestAssistantSession } from '../workspaces/openLatestAssistantSession'; @@ -92,6 +93,9 @@ export async function openDeviceSession( // From the click on, the main area shows this session's tab and its loading // presentation; the tab is held through the switch until the open settles. if (getActiveSurfaceId() !== slot.surfaceId) { + // The session is what the user waits for: its host starts reading it now, + // while the device switch runs, instead of after it. + if (!slot.isLocal) prewarmRelaySessionHistory(slot.surfaceId, sessionId); useSceneStore.getState().holdSessionScene({ surfaceId: slot.surfaceId, workspaceKey: sessionSceneWorkspaceKey(workspaceId), sessionId, }); diff --git a/src/web-ui/src/app/services/openAgentCompanionSession.test.ts b/src/web-ui/src/app/services/openAgentCompanionSession.test.ts index 077f57fcae..b9b90564c3 100644 --- a/src/web-ui/src/app/services/openAgentCompanionSession.test.ts +++ b/src/web-ui/src/app/services/openAgentCompanionSession.test.ts @@ -22,6 +22,7 @@ function flushDoubleRequestAnimationFrame(): void { animationFrameCallbacks.shift()?.(16); } +vi.mock('@/flow_chat/services/RelaySessionHistory', () => ({ prewarmRelaySessionHistory: vi.fn() })); vi.mock('@/flow_chat/services/btwSessionPane', () => ({ openBtwSessionInAuxPane: (...args: unknown[]) => mocks.openBtwSessionInAuxPane(...args), })); diff --git a/src/web-ui/src/app/services/openAgentCompanionSession.ts b/src/web-ui/src/app/services/openAgentCompanionSession.ts index cb4c9ade9e..82eb18faf4 100644 --- a/src/web-ui/src/app/services/openAgentCompanionSession.ts +++ b/src/web-ui/src/app/services/openAgentCompanionSession.ts @@ -1,6 +1,7 @@ import { FlowChatStore } from '@/flow_chat/store/FlowChatStore'; import { openBtwSessionInAuxPane } from '@/flow_chat/services/btwSessionPane'; import { openMainSession } from '@/flow_chat/services/sessionActivation'; +import { prewarmRelaySessionHistory } from '@/flow_chat/services/RelaySessionHistory'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { resolveSessionSceneWorkspace } from './sessionSceneTarget'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; @@ -25,6 +26,8 @@ export async function openAgentCompanionSession( // Same presentation as a sidebar open: the session's tab and loading state // are shown at once and held through the device switch. const intent = beginDeviceOpen({ surfaceId, key: `session:${sessionId}`, sessionId }); + // The host starts reading the session while the device switch runs. + if (!isLocalSurface(surfaceId)) prewarmRelaySessionHistory(surfaceId, sessionId); try { if (sourceWorkspaceId) { useSceneStore.getState().holdSessionScene({ diff --git a/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts b/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts index a62043d0a7..93172932cb 100644 --- a/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts +++ b/src/web-ui/src/flow_chat/services/RelaySessionHistory.test.ts @@ -22,7 +22,7 @@ vi.mock('@/infrastructure/api/service-api/RemoteConnectAPI', () => ({ remoteConn subscribeSession: fixture.subscribe, unsubscribeSession: fixture.unsubscribe, loadOlderSession: fixture.older, } })); -import { RelaySessionHistory } from './RelaySessionHistory'; +import { prewarmRelaySessionHistory, RelaySessionHistory } from './RelaySessionHistory'; describe('RelaySessionHistory', () => { beforeEach(() => { vi.clearAllMocks(); fixture.current = true; }); @@ -54,6 +54,37 @@ describe('RelaySessionHistory', () => { expect(fixture.record).toBeNull(); }); + it('adopts a stream warmed before the device was rendered, with what it buffered', async () => { + let resolveSubscription!: (id: string) => void; + fixture.subscribe.mockImplementation(() => new Promise(resolve => { resolveSubscription = resolve; })); + prewarmRelaySessionHistory('host', 'session'); + // The host answers while the device switch is still running. + fixture.record?.({ sessionId: 'session', id: 'turn/turn' }); + fixture.record?.({ sessionId: 'other', id: 'turn/other' }); + fixture.ready?.({ sessionId: 'session', hasMore: false, oldestSeq: 1, cursor: 1 }); + const apply = vi.fn(); + const onReady = vi.fn(); + const history = new RelaySessionHistory('session', apply, onReady, vi.fn()); + const opened = history.open(); + resolveSubscription('warm-subscription'); + await opened; + expect(fixture.subscribe).toHaveBeenCalledOnce(); + expect(apply).toHaveBeenCalledOnce(); + expect(apply).toHaveBeenCalledWith({ sessionId: 'session', id: 'turn/turn' }); + expect(onReady).toHaveBeenCalledOnce(); + history.close(); + expect(fixture.unsubscribe).toHaveBeenCalledWith('warm-subscription'); + }); + + it('closes a warm stream nobody adopted', async () => { + vi.useFakeTimers(); + fixture.subscribe.mockResolvedValue('unused'); + prewarmRelaySessionHistory('host', 'abandoned'); + await vi.advanceTimersByTimeAsync(30_000); + expect(fixture.unsubscribe).toHaveBeenCalledWith('unused'); + expect(fixture.record).toBeNull(); + }); + it('renders pending records, then resyncs only for a gap the controller replays', async () => { vi.useFakeTimers(); fixture.subscribe.mockImplementation(async () => { diff --git a/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts b/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts index 4b018bf747..a8ca0ce3dd 100644 --- a/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts +++ b/src/web-ui/src/flow_chat/services/RelaySessionHistory.ts @@ -33,6 +33,69 @@ export class RelayHistoryFirstPageOverdueError extends Error { } } +/** + * A session stream opened before its device is rendered: a cross-device open + * starts it on click, so the host reads the session while the switch runs. + * Events are buffered until the session's history owner adopts the stream. + */ +interface WarmSessionStream { + subscription: Promise; + events: Array<{ kind: 'record' | 'gap' | 'syncError' | 'ready' | 'interaction'; value: unknown }>; + dispose: () => void; + timer: ReturnType; +} + +/** A warm stream nobody adopted (the open failed or moved on) closes after this. */ +const WARM_STREAM_TTL_MS = 30_000; +const warmStreams = new Map(); +const warmKey = (surfaceId: string, sessionId: string) => `${surfaceId}\u0000${sessionId}`; + +/** Start reading `sessionId` on `surfaceId` (a peer) before that device is rendered. */ +export function prewarmRelaySessionHistory(surfaceId: string, sessionId: string): void { + const key = warmKey(surfaceId, sessionId); + if (warmStreams.has(key)) return; + const events: WarmSessionStream['events'] = []; + const forThisSession = (value: unknown) => (value as { sessionId?: unknown } | null)?.sessionId === sessionId; + const disposers = [ + remoteConnectAPI.onSessionRecord(value => { if (forThisSession(value)) events.push({ kind: 'record', value }); }), + remoteConnectAPI.onSessionGap(value => { if (forThisSession(value)) events.push({ kind: 'gap', value }); }), + remoteConnectAPI.onSessionSyncError(value => { if (forThisSession(value)) events.push({ kind: 'syncError', value }); }), + remoteConnectAPI.onSessionReady(value => { if (forThisSession(value)) events.push({ kind: 'ready', value }); }), + remoteConnectAPI.onSessionInteractionChanged(value => { if (forThisSession(value)) events.push({ kind: 'interaction', value }); }), + ]; + const subscription = remoteConnectAPI.subscribeSession(surfaceId, sessionId); + const stream: WarmSessionStream = { + subscription, + events, + dispose: () => disposers.splice(0).forEach(dispose => dispose()), + timer: setTimeout(() => { + if (warmStreams.get(key) !== stream) return; + warmStreams.delete(key); + stream.dispose(); + void subscription.then(id => remoteConnectAPI.unsubscribeSession(id)).catch(() => undefined); + }, WARM_STREAM_TTL_MS), + }; + // A failed warm read is not an error: the owner opens its own stream. + void subscription.catch(error => { + if (warmStreams.get(key) !== stream) return; + warmStreams.delete(key); + clearTimeout(stream.timer); + stream.dispose(); + log.debug('Session stream prewarm failed', { sessionId, error }); + }); + warmStreams.set(key, stream); +} + +function takeWarmStream(surfaceId: string, sessionId: string): WarmSessionStream | undefined { + const key = warmKey(surfaceId, sessionId); + const stream = warmStreams.get(key); + if (!stream) return undefined; + warmStreams.delete(key); + clearTimeout(stream.timer); + stream.dispose(); + return stream; +} + export interface RelayHistoryReady { sessionId: string; hasMore: boolean; @@ -63,6 +126,8 @@ export class RelaySessionHistory { private flushTimer: ReturnType | null = null; private readonly fail: (error: unknown) => void; private readonly resumeInteraction: (() => Promise) | undefined; + private readonly warm: WarmSessionStream | undefined; + private readonly handlers: Partial void>> = {}; /** * `onRecord` must only reduce the record: the host sends each record of a page @@ -83,8 +148,10 @@ export class RelaySessionHistory { ) { this.fail = error => { this.rejectReady(error); this.rejectPage?.(error); onError(error); }; this.resumeInteraction = onResumed; + // Taken before this owner listens, so no event is both buffered and live. + this.warm = takeWarmStream(this.scope.surfaceId, sessionId); // Register before asking Rust to replay its cache or fetch the first page. - this.listeners.push(remoteConnectAPI.onSessionRecord(value => { + this.listeners.push(remoteConnectAPI.onSessionRecord(this.handlers.record = value => { if (!this.isCurrent()) return; const record = value as SessionRecord; if (record.sessionId !== this.sessionId) return; @@ -97,18 +164,18 @@ export class RelaySessionHistory { // sends no tombstones for what it dropped (a rollback, undo, or history // rewrite). Records already received are rendered first; the replay that // follows, up to its ready event, is then the whole truth. - this.listeners.push(remoteConnectAPI.onSessionGap(gap => { + this.listeners.push(remoteConnectAPI.onSessionGap(this.handlers.gap = gap => { if (!this.isCurrent() || gap.sessionId !== this.sessionId || gap.resync !== true) return; this.flush(); try { onResync?.(); } catch (error) { this.fail(error); } })); - this.listeners.push(remoteConnectAPI.onSessionSyncError(error => { + this.listeners.push(remoteConnectAPI.onSessionSyncError(this.handlers.syncError = error => { if (!this.isCurrent() || error.sessionId !== this.sessionId || error.targetDeviceId !== this.scope.surfaceId) return; // Records received before the failure stay rendered. this.flush(); this.fail(new Error(error.message)); })); - this.listeners.push(remoteConnectAPI.onSessionReady(ready => { + this.listeners.push(remoteConnectAPI.onSessionReady(this.handlers.ready = ready => { if (!this.isCurrent() || ready.sessionId !== this.sessionId) return; if (!this.flush()) return; const resumed = this.pageApplied === null; @@ -120,7 +187,7 @@ export class RelaySessionHistory { this.resolveReady(); this.pageApplied?.(); })); - this.listeners.push(remoteConnectAPI.onSessionInteractionChanged(event => { + this.listeners.push(remoteConnectAPI.onSessionInteractionChanged(this.handlers.interaction = event => { if (!this.isCurrent() || event.sessionId !== this.sessionId || !onResumed) return; void onResumed().catch(error => log.warn('Session interaction mailbox refresh failed', {sessionId:this.sessionId,error})); })); @@ -172,7 +239,12 @@ export class RelaySessionHistory { this.scope.assertCurrent('open relay session history'); if (this.closed) throw new Error('Session subscription is closed'); try { - const id = await remoteConnectAPI.subscribeSession(this.scope.surfaceId, this.sessionId); + // A stream warmed by the click: what it buffered is applied first, in order. + const warm = this.warm; + if (warm) for (const event of warm.events.splice(0)) this.handlers[event.kind]?.(event.value); + const id = warm + ? await warm.subscription.catch(() => remoteConnectAPI.subscribeSession(this.scope.surfaceId, this.sessionId)) + : await remoteConnectAPI.subscribeSession(this.scope.surfaceId, this.sessionId); if (!this.isCurrent()) { await remoteConnectAPI.unsubscribeSession(id); this.scope.assertCurrent('finish opening relay session history'); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts index acafebaf3c..2429d6363d 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts @@ -154,6 +154,8 @@ class FakeConnectionManager { function createHarness(options: { rebootstrap?: (scope: SurfaceScope) => Promise; + reloadConfig?: (scope: SurfaceScope) => Promise; + deferConfigReload?: () => boolean; } = {}) { const manager = new FakeConnectionManager(); const commits: string[] = []; @@ -169,7 +171,8 @@ function createHarness(options: { connectionManager: manager, getLocalAdapter: async () => adapter(), resetProductSurface: async assertCurrent => assertCurrent(), - reloadConfig: async scope => scope.assertCurrent('reload config'), + reloadConfig: options.reloadConfig ?? (async scope => scope.assertCurrent('reload config')), + deferConfigReload: options.deferConfigReload, rebootstrapWorkspaces: options.rebootstrap ?? (async scope => scope.assertCurrent('rebootstrap workspaces')), setPeerControllerActive: async active => { @@ -255,6 +258,15 @@ describe('PeerDeviceSurfaceController switching', () => { expect(harness.snapshot().peerMode).toMatchObject({ active: true, deviceId: 'peer-a' }); }); + it('does not hold a switch on config while a cross-device open waits for it', async () => { + const config = deferred(); + const reloadConfig = vi.fn(() => config.promise); + const harness = createHarness({ reloadConfig, deferConfigReload: () => true }); + await expect(harness.controller.switchToDevice('peer-a', 'A')).resolves.toBe('activated'); + expect(reloadConfig).toHaveBeenCalledOnce(); + config.resolve(); + }); + it('coalesces a rapid switch to the last target before the first connection completes', async () => { const harness = createHarness(); harness.manager.delay('peer-a'); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts index e4187d79cb..3804e7575a 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts @@ -69,6 +69,12 @@ export interface PeerDeviceSurfaceControllerDependencies { getLocalAdapter: () => Promise; resetProductSurface: (assertCurrent: () => void) => Promise; reloadConfig: (scope: SurfaceScope) => Promise; + /** + * True while a cross-device open waits for this switch: the device's config + * then reloads after the switch instead of holding it, since the session + * the user opened does not need it to show. + */ + deferConfigReload?: () => boolean; rebootstrapWorkspaces: (scope: SurfaceScope) => Promise; setPeerControllerActive: (active: boolean, required: boolean) => Promise; commitSurface: (target: ResolvedSurfaceTarget) => SurfaceScope; @@ -429,8 +435,14 @@ export class PeerDeviceSurfaceController { // Config and the workspace catalog are independent reads of the same host; // fetching them together halves the round trips a switch waits for. + const deferConfig = this.dependencies.deferConfigReload?.() === true; + if (deferConfig) { + void this.dependencies.reloadConfig(activationScope).catch(error => { + if (!isSurfaceChangedError(error)) log.warn('Deferred device config reload failed', { error }); + }); + } await Promise.all([ - this.awaitIntent( + deferConfig ? Promise.resolve() : this.awaitIntent( intent, this.dependencies.reloadConfig(activationScope), activationScope, diff --git a/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts b/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts index 1db9d9f1e4..a698b02df0 100644 --- a/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts +++ b/src/web-ui/src/infrastructure/peer-device/peerDeviceSurfaceRuntime.ts @@ -1,6 +1,7 @@ /** Production composition for the window-wide device-surface controller. */ import type { ITransportAdapter } from '@/infrastructure/api/adapters/base'; +import { getDeviceOpenIntent } from './deviceOpenIntent'; import { createTransportAdapter, getTransportAdapter, @@ -149,6 +150,7 @@ function createDependencies(): PeerDeviceSurfaceControllerDependencies { }, resetProductSurface, reloadConfig: reloadConfigFromCurrentTransport, + deferConfigReload: () => getDeviceOpenIntent() !== null, rebootstrapWorkspaces: async (scope) => { await workspaceManager.reinitializeForPeerModeSwitch(); scope.assertCurrent('rebootstrap device surface workspaces'); From 1942287ee5cfa4f7ccd02fcf1aeacb64eca7421f Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 13:51:26 +0800 Subject: [PATCH 23/52] style(web-ui): show a session tab's device as a small capsule --- .../src/app/components/SceneBar/SceneBar.scss | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index f5af0db766..727e6fb896 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -34,11 +34,20 @@ } // The device a session tab belongs to when it is not the rendered one. + // Same capsule as the sidebar's device tags, so a device reads the same everywhere. &__tab-device { - max-inline-size: 8em; + flex: 0 0 auto; + max-inline-size: 7em; + margin-inline-start: var(--openbitfun-space-1); + padding: 0 5px; overflow: hidden; - color: var(--openbitfun-color-content-muted); - font-size: var(--openbitfun-type-label-sm-font-size); + border-radius: 999px; + background: color-mix(in srgb, var(--openbitfun-color-content-muted) 55%, transparent); + color: var(--openbitfun-color-surface-chrome); + font-size: var(--openbitfun-type-micro-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: 14px; + letter-spacing: normal; text-overflow: ellipsis; white-space: nowrap; } From 3956b57102df4abbfec9bc307b61755c87c239bb Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 13:54:18 +0800 Subject: [PATCH 24/52] style(web-ui): quieter device label on session tabs that scrolls with the title --- .../src/app/components/SceneBar/SceneBar.scss | 28 +++++++++++-------- .../src/app/components/SceneBar/SceneBar.tsx | 10 +++++-- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index 727e6fb896..213dc23363 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -34,22 +34,28 @@ } // The device a session tab belongs to when it is not the rendered one. - // Same capsule as the sidebar's device tags, so a device reads the same everywhere. + // The device a tab belongs to: a quiet, smaller line of metadata set off by + // a hairline divider and a device glyph, never a second title. &__tab-device { + display: inline-flex; flex: 0 0 auto; - max-inline-size: 7em; + align-items: center; + gap: 3px; margin-inline-start: var(--openbitfun-space-1); - padding: 0 5px; - overflow: hidden; - border-radius: 999px; - background: color-mix(in srgb, var(--openbitfun-color-content-muted) 55%, transparent); - color: var(--openbitfun-color-surface-chrome); + padding-inline-start: var(--openbitfun-space-1); + border-inline-start: var(--openbitfun-border-width-default) solid + color-mix(in srgb, var(--openbitfun-color-content-muted) 45%, transparent); + color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-micro-font-size); - font-weight: var(--openbitfun-type-label-selected-font-weight); line-height: 14px; - letter-spacing: normal; - text-overflow: ellipsis; - white-space: nowrap; + + svg { flex: 0 0 auto; } + } + + &__tab-device-name { + display: block; + min-inline-size: 0; + max-inline-size: 6em; } &__tab-region { diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 3d44d824d3..a8fcc547d9 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -6,13 +6,13 @@ */ import React, { useCallback, useState, useEffect, useMemo, useSyncExternalStore } from 'react'; -import { Pin, CircleAlert } from 'lucide-react'; +import { Pin, CircleAlert, Monitor } from 'lucide-react'; import { useSceneStore } from '../../stores/sceneStore'; import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; import { ContextType } from '@/shared/context-menu-system/types/context.types'; import type { MenuItem } from '@/shared/context-menu-system/types/menu.types'; -import { Icon, TabGroup, type TabGroupItem } from '@openbitfun/ui'; +import { Icon, OverflowText, TabGroup, type TabGroupItem } from '@openbitfun/ui'; import { useSceneTabNavigation } from './useSceneTabNavigation'; import { useSceneManager } from '../../hooks/useSceneManager'; import { useSessionTabLabels } from '../../hooks/useSessionTabLabels'; @@ -222,7 +222,11 @@ const SceneBar: React.FC = ({ labelSuffix: sessionLabel?.number || deviceLabels[tab.id] || tab.pinned || (tab.contentId && (resources[tab.contentId]?.isDirty || resources[tab.contentId]?.fileMissing)) ? <> {sessionLabel?.number && } {deviceLabels[tab.id] && {deviceLabels[tab.id]}} + data-openbitfun-component="scene-bar" data-openbitfun-part="tabDevice"> +
+ {/* This machine has no action: the same disc marks it as home. */} + {!displayedHasAction && displayedDevice.local && ( + + + + )} {displayedHasAction && (
= ({ }; export default DeviceStatusControl; + +/** Whether the Relay renames devices (`device_alias_v1`), asked when the card opens. */ +function useRelayDeviceAliasSupport(enabled: boolean): boolean { + const [supported, setSupported] = useState(false); + useEffect(() => { + if (!enabled) return undefined; + let current = true; + void remoteConnectAPI.accountRelayCapabilities() + .then(capabilities => { if (current) setSupported(capabilities.includes('device_alias_v1')); }) + .catch(() => { if (current) setSupported(false); }); + return () => { current = false; }; + }, [enabled]); + return supported; +} From 87d029f687b40b76d5e2c8e19f8b17ced85ca5ed Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 14:05:00 +0800 Subject: [PATCH 27/52] style(web-ui): edit a device name in place in the card's own type --- .../src/app/components/NavPanel/NavPanel.scss | 50 +++++++++++++++++-- .../components/DeviceStatusControl.tsx | 22 +++++--- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 2ed868fd83..fbdd9a8e4f 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -2048,23 +2048,67 @@ $_section-header-height: 22px; color: color-mix(in srgb, var(--_device-overview-light) 72%, var(--openbitfun-color-content-primary)); } -// Renaming sits right after the name and stays quiet until hovered. +// Renaming sits right after the name: a small quiet pencil that brightens +// with the heading it edits. .openbitfun-device-overview__alias-edit { + --_icon-button-size: 20px; + --_icon-button-icon-size: 12px; + --_icon-button-content: var(--openbitfun-color-content-muted); + --_icon-button-background-hover: color-mix(in srgb, var(--_device-overview-light) 16%, transparent); flex: 0 0 auto; - color: var(--openbitfun-color-content-muted); + opacity: 0.7; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + + .openbitfun-device-overview__device-heading:hover &, + &:focus-visible { opacity: 1; } } .openbitfun-device-overview__alias-editor { display: flex; flex: 1 1 auto; align-items: center; - gap: var(--openbitfun-space-1); min-width: 0; } +// Editing keeps the heading's type and place: no box, only an underline lit +// by the card's state light. .openbitfun-device-overview__alias-input { flex: 1 1 auto; min-width: 0; + gap: var(--openbitfun-space-1); + padding-inline: 0; + border: 0; + border-block-end: var(--openbitfun-border-width-default) solid + color-mix(in srgb, var(--_device-overview-light) 35%, transparent); + border-radius: 0; + + &, + &:hover, + &:focus-within { background: transparent; } + + &:focus-within { + border-block-end-color: color-mix(in srgb, var(--_device-overview-light) 80%, transparent); + } + + input { + font-family: var(--openbitfun-type-heading-card-font-family); + font-size: var(--openbitfun-type-heading-card-font-size); + font-weight: var(--openbitfun-type-heading-card-font-weight); + letter-spacing: var(--openbitfun-type-heading-card-letter-spacing); + caret-color: var(--_device-overview-light); + } + + input::placeholder { color: var(--openbitfun-color-content-muted); } +} + +.openbitfun-device-overview__alias-action { + --_icon-button-size: 20px; + --_icon-button-icon-size: 12px; + --_icon-button-content: var(--openbitfun-color-content-secondary); + --_icon-button-background-hover: color-mix(in srgb, var(--_device-overview-light) 18%, transparent); + border-radius: var(--openbitfun-radius-pill); + + &:first-child { --_icon-button-content: color-mix(in srgb, var(--_device-overview-light) 72%, var(--openbitfun-color-content-primary)); } } .openbitfun-device-overview__device-name { diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index 02dbc7e83a..8f6c45ad22 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -578,6 +578,8 @@ const DeviceStatusControl: React.FC = ({ {editingAlias ? ( + {/* The name itself becomes editable: same type, an underline + in the card's light, actions inside the same line. */} = ({ if (event.key === 'Enter') { event.preventDefault(); void saveAlias(); } if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); setAliasEdit(null); } }} - placeholder={t('accountLogin.deviceAliasPlaceholder')} + placeholder={displayedAccountDevice?.device_name ?? displayedDevice.name} aria-label={t('accountLogin.deviceAlias')} + title={t('accountLogin.deviceAliasPlaceholder')} autoFocus + onFocus={event => event.currentTarget.select()} disabled={editingAlias.saving} data-testid="nav-device-alias-input" + trailing={<> + } + aria-label={t('accountLogin.saveDeviceAlias')} loading={editingAlias.saving} + onClick={() => { void saveAlias(); }} data-testid="nav-device-alias-save" /> + } + aria-label={t('accountLogin.cancel')} disabled={editingAlias.saving} + onClick={() => setAliasEdit(null)} /> + } /> - } - aria-label={t('accountLogin.saveDeviceAlias')} loading={editingAlias.saving} - onClick={() => { void saveAlias(); }} data-testid="nav-device-alias-save" /> - } - aria-label={t('accountLogin.cancel')} disabled={editingAlias.saving} - onClick={() => setAliasEdit(null)} /> ) : ( From 693525de6e1b6722fb518147c86cb06f72d82981 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 14:17:30 +0800 Subject: [PATCH 28/52] feat(web-ui): devices dialog in the device card's language, grown from the card - Every Devices & Connections surface carries a state light: icons sit in tinted glass discs, connected cards glow faintly, and states are a dot and a word (a live link breathes) instead of filled pills. - My devices: lit device discs, this machine's home disc in the action slot, quieter row actions, and renaming in place with an underline. - Opening the dialog from the device card grows it out of the card instead of closing one surface and fading in another; the dialog code loads when the card opens. --- .../components/DeviceStatusControl.tsx | 4 + .../components/PersistentFooterActions.tsx | 2 + .../RemoteConnectDialog/AccountPanel.scss | 58 +++++++++++++-- .../RemoteConnectDialog/AccountPanel.test.tsx | 8 +- .../RemoteConnectDialog/AccountPanel.tsx | 63 ++++++++++------ .../ConnectionStateMark.tsx | 26 +++++++ .../RemoteConnectDialog.scss | 74 +++++++++++++++++-- .../RemoteConnectDialog.status.test.tsx | 2 +- .../RemoteConnectDialog.tsx | 17 ++++- .../RemoteNetworkConnections.tsx | 8 +- .../RemotePairingCard.test.tsx | 2 +- .../RemoteConnectDialog/RemotePairingCard.tsx | 8 +- .../_remote-connect-surface.scss | 45 +++++++++++ .../RemoteConnectDialog/appearance.ts | 1 + .../RemoteConnectDialog/morphKey.ts | 2 + .../src/app/services/surfaceMorph.test.ts | 30 ++++++++ src/web-ui/src/app/services/surfaceMorph.ts | 72 ++++++++++++++++++ 17 files changed, 367 insertions(+), 55 deletions(-) create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/ConnectionStateMark.tsx create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/morphKey.ts create mode 100644 src/web-ui/src/app/services/surfaceMorph.test.ts create mode 100644 src/web-ui/src/app/services/surfaceMorph.ts diff --git a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx index 8f6c45ad22..eb3245b12b 100644 --- a/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/DeviceStatusControl.tsx @@ -25,6 +25,8 @@ import { type DeviceOverviewHostKind, } from '../deviceInterconnectionOverview'; import { useDeviceInterconnectionOverview } from './useDeviceInterconnectionOverview'; +import { DEVICES_DIALOG_MORPH_KEY } from '@/app/components/RemoteConnectDialog/morphKey'; +import { rememberMorphOrigin } from '@/app/services/surfaceMorph'; import { DeviceArtwork } from './DeviceArtwork'; import { DeviceSystemGlyph } from './DeviceSystemGlyph'; import { useDevicePreviewMotion } from './useDevicePreviewMotion'; @@ -311,6 +313,8 @@ const DeviceStatusControl: React.FC = ({ }, [activePeerId, peerContext, warning]); const handleManageDevices = useCallback(() => { + // The dialog grows out of this card instead of replacing it. + rememberMorphOrigin(DEVICES_DIALOG_MORPH_KEY, popoverRef.current); onOpenChange(false); onManageDevices(); }, [onManageDevices, onOpenChange]); diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 1ed105acd5..b9858876eb 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -104,6 +104,8 @@ const PersistentFooterActions: React.FC = () => { if (nextOpen && menuOpen) { closeMenu(); } + // The dialog grows out of the card: have its code ready before it is asked for. + if (nextOpen) void import('../../RemoteConnectDialog'); setDeviceOverviewOpen(nextOpen); }, [closeMenu, menuOpen]); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss index ccf6767665..32c3f33380 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss @@ -130,6 +130,8 @@ } &__device-card { + @include surface.state-light(--_account-light); + --_account-light: var(--openbitfun-color-status-success-content); position: relative; display: flex; min-width: 0; @@ -139,14 +141,22 @@ padding: var(--openbitfun-space-3) surface.$row-inline; transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + // Lit like the device card: this machine in its home light, another + // online device in the live light, an offline one neutral. + &.current { --_account-light: var(--openbitfun-color-accent-secondary); } + &.offline { --_account-light: var(--openbitfun-color-content-muted); } + & + & { @include surface.card-divider; } &.selectable:has(> .account-panel__device-select:hover) { - background: var(--openbitfun-color-action-neutral-surface); + background: color-mix(in srgb, var(--_account-light) 8%, var(--openbitfun-color-action-neutral-surface)); } + // Row actions stay quiet until the row is in hand. + &:not(:hover, :focus-within) .account-panel__device-action { opacity: 0.6; } + &.selectable:has(> .account-panel__device-select:active) { background: var(--openbitfun-color-action-neutral-surface-pressed); } @@ -192,7 +202,17 @@ } &__device-glyph { - @include surface.icon-slot; + @include surface.icon-disc(--_account-light); + } + + &__device-home { + @include surface.icon-disc(--_account-light, 28px); + } + + &__device-action { + --_icon-button-size: 24px; + --_icon-button-icon-size: 12px; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } &__device-info { @@ -238,6 +258,7 @@ &[data-openbitfun-state='online']::before { background: var(--openbitfun-color-status-success-content); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--openbitfun-color-status-success-content) 22%, transparent); } } @@ -271,16 +292,39 @@ display: flex; min-width: 0; align-items: center; - gap: var(--openbitfun-space-2); - - > [data-openbitfun-component='icon-button'] { - flex-shrink: 0; - } } + // Editing keeps the name's type and place: an underline in the row's light. &__device-alias-input { flex: 1 1 auto; min-inline-size: 0; + gap: var(--openbitfun-space-1); + padding-inline: 0; + border: 0; + border-block-end: var(--openbitfun-border-width-default) solid color-mix(in srgb, var(--_account-light) 35%, transparent); + border-radius: 0; + + &, + &:hover, + &:focus-within { background: transparent; } + + &:focus-within { border-block-end-color: color-mix(in srgb, var(--_account-light) 80%, transparent); } + + input { + @include surface.row-title; + caret-color: var(--_account-light); + } + + input::placeholder { color: var(--openbitfun-color-content-muted); } + } + + &__device-alias-action { + --_icon-button-size: 20px; + --_icon-button-icon-size: 12px; + --_icon-button-background-hover: color-mix(in srgb, var(--_account-light) 18%, transparent); + border-radius: var(--openbitfun-radius-pill); + + &:first-child { --_icon-button-content: color-mix(in srgb, var(--_account-light) 72%, var(--openbitfun-color-content-primary)); } } &__loading-overlay { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx index f208f47741..a70a358098 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.test.tsx @@ -210,9 +210,9 @@ it('adopts the account-bound local device ID without reconnecting', async () => mocks.getDeviceInfo.mockReset().mockResolvedValueOnce({ device_id: 'before-auth' }) .mockReturnValueOnce(new Promise(resolve => { finishInfo = resolve; })); await act(async () => { root.render( {}} />); }); - expect(container.textContent).not.toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).toBeNull(); await act(async () => { finishInfo({ device_id: 'local' }); }); - expect(container.textContent).toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).not.toBeNull(); expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(1); expect(mocks.accountStatus).toHaveBeenCalledTimes(1); }); @@ -251,9 +251,9 @@ it('ignores pre-auth device information that arrives after the adopted ID', asyn .mockReturnValueOnce(new Promise(resolve => { finishOldInfo = resolve; })) .mockResolvedValueOnce({ device_id: 'local' }); await act(async () => { root.render( {}} />); }); - expect(container.textContent).toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).not.toBeNull(); await act(async () => { finishOldInfo({ device_id: 'before-auth' }); }); - expect(container.textContent).toContain('accountLogin.thisDevice'); + expect(container.querySelector('[aria-label="accountLogin.thisDevice"]')).not.toBeNull(); expect(mocks.accountConnectDevices).toHaveBeenCalledTimes(1); }); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx index 649bdc2c5c..57c51b840a 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx @@ -1,13 +1,13 @@ import { refreshDeviceDirectory, isDeviceControllable, deviceClientVersion } from '@/infrastructure/account/deviceDirectory'; /** Account login and authenticated device connections. */ -import { OverflowText, Alert, Avatar, Button, Icon, IconButton, Input, ScrollArea, StatusPill } from '@openbitfun/ui'; +import { OverflowText, Alert, Avatar, Button, Icon, IconButton, Input, ScrollArea } from '@openbitfun/ui'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { confirmDanger, } from '@/infrastructure/confirm-dialog'; -import { LogOut, Pencil, Check, X } from 'lucide-react'; +import { LogOut, Pencil, Check, X, House } from 'lucide-react'; import { DeviceSystemGlyph } from '../NavPanel/components/DeviceSystemGlyph'; import { reportedHostKind } from '../NavPanel/deviceInterconnectionOverview'; import { remoteConnectAPI, deviceDisplayName, deviceMetadataLabel } from '@/infrastructure/api/service-api/RemoteConnectAPI'; @@ -959,40 +959,47 @@ export const AccountPanel: React.FC = ({ name next to an editor reads as two device names. */} {editingDeviceId === d.device_id ? ( + {/* The name itself becomes editable, as on the device card. */} setAliasDraft(e.target.value)} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); void handleUpdateAlias(d); } if (event.key === 'Escape') { event.preventDefault(); setEditingDeviceId(null); } }} - placeholder={t('accountLogin.deviceAliasPlaceholder')} + onFocus={event => event.currentTarget.select()} + placeholder={d.device_name ?? t('accountLogin.deviceAliasPlaceholder')} + title={t('accountLogin.deviceAliasPlaceholder')} aria-label={t('accountLogin.deviceAlias')} autoFocus disabled={savingAliasId === d.device_id} - /> - } - loading={savingAliasId === d.device_id} - onClick={() => void handleUpdateAlias(d)} - size="sm" - variant="primary" - /> - } - onClick={() => setEditingDeviceId(null)} - size="sm" - variant="quiet" + trailing={<> + } + loading={savingAliasId === d.device_id} + onClick={() => void handleUpdateAlias(d)} + size="xs" + variant="quiet" + /> + } + onClick={() => setEditingDeviceId(null)} + size="xs" + variant="quiet" + /> + } /> ) : ( {displayName} - {isLocal && {t('accountLogin.thisDevice')}} )} @@ -1017,21 +1024,29 @@ export const AccountPanel: React.FC = ({ delete beside it rendered two competing icon clusters. */} {editingDeviceId !== d.device_id && ( + {/* This machine: the device card's home disc. */} + {isLocal && ( + + + + )} } + icon={} onClick={(e) => { e.stopPropagation(); handleStartAliasEdit(d); }} - size="sm" + size="xs" title={t('accountLogin.editDeviceAlias')} variant="quiet" /> } + icon={} onClick={(e) => { e.stopPropagation(); handleDeleteDevice(d.device_id, displayName); }} - size="sm" + size="xs" title={removeLabel} variant="quiet" /> diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionStateMark.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionStateMark.tsx new file mode 100644 index 0000000000..5aae3407e5 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/ConnectionStateMark.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +export type ConnectionStateTone = 'success' | 'warning' | 'neutral'; + +/** + * A connection state as the device card draws it: a coloured dot and a quiet + * label. A live link breathes, like the card's in-use signal. + */ +export function ConnectionStateMark({ tone, live = tone === 'success', children }: { + tone: ConnectionStateTone; + live?: boolean; + children: React.ReactNode; +}) { + return ( + + + ); +} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index ca036200ab..a07e210a1d 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -284,10 +284,11 @@ } .openbitfun-remote-connect__overview-action { + @include surface.state-light(--_rc-light); display: grid; width: 100%; min-height: 72px; - grid-template-columns: surface.$icon-slot minmax(0, 1fr) auto 16px; + grid-template-columns: 36px minmax(0, 1fr) auto 16px; align-items: center; gap: surface.$row-gap; padding: surface.$row-block surface.$row-inline; @@ -315,7 +316,15 @@ } .openbitfun-remote-connect__overview-action-icon { - @include surface.icon-slot; + @include surface.icon-disc(--_rc-light); +} + +.openbitfun-remote-connect__overview-action:hover:not(:disabled) { + background: color-mix(in srgb, var(--_rc-light) 8%, var(--openbitfun-color-action-neutral-surface)); +} + +.openbitfun-remote-connect__overview-action:hover:not(:disabled) .openbitfun-remote-connect__overview-action-icon { + background: color-mix(in srgb, var(--_rc-light) 22%, transparent); } .openbitfun-remote-connect__overview-action-copy { @@ -353,6 +362,8 @@ .openbitfun-remote-connect__network-card, .openbitfun-remote-connect__bot-card { @include surface.card; + @include surface.state-light(--_rc-light); + @include surface.lit-card(--_rc-light); } .openbitfun-remote-connect__card-header, @@ -360,12 +371,13 @@ @include surface.card-row; } -.openbitfun-remote-connect__card-header-icon { - @include surface.icon-slot; +.openbitfun-remote-connect__card-header-icon, +.openbitfun-remote-connect__bot-identity-icon { + @include surface.icon-disc(--_rc-light); } +// A brand mark keeps its own colours inside the disc. .openbitfun-remote-connect__bot-identity-icon { - @include surface.icon-slot; color: var(--openbitfun-color-content-primary); } @@ -391,6 +403,50 @@ align-items: center; } +// State as the device card draws it: a dot and a quiet word; a live link breathes. +.openbitfun-remote-connect__state-mark { + --_state-tone: var(--openbitfun-color-content-muted); + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: var(--openbitfun-space-2); + color: var(--openbitfun-color-content-secondary); + font-family: var(--openbitfun-type-label-md-font-family); + font-size: var(--openbitfun-type-label-md-font-size); + line-height: var(--openbitfun-type-label-md-line-height); + white-space: nowrap; + + &[data-tone='success'] { --_state-tone: var(--openbitfun-color-status-success-content); } + &[data-tone='warning'] { --_state-tone: var(--openbitfun-color-status-warning-content); } +} + +.openbitfun-remote-connect__state-dot { + position: relative; + flex: 0 0 auto; + inline-size: var(--openbitfun-space-2); + block-size: var(--openbitfun-space-2); + border-radius: var(--openbitfun-radius-pill); + background: var(--_state-tone); + + [data-live] > &::after { + position: absolute; + inset: 0; + border-radius: inherit; + background: inherit; + content: ''; + animation: openbitfun-remote-connect-live 1.8s var(--openbitfun-motion-easing-decelerate) infinite; + } +} + +@keyframes openbitfun-remote-connect-live { + from { opacity: 0.6; transform: scale(1); } + to { opacity: 0; transform: scale(2.6); } +} + +@media (prefers-reduced-motion: reduce) { + .openbitfun-remote-connect__state-dot::after { animation: none; } +} + .openbitfun-remote-connect__card-section { display: flex; min-width: 0; @@ -518,8 +574,9 @@ align-items: center; justify-content: center; border-radius: var(--openbitfun-radius-pill); - background: var(--openbitfun-color-action-neutral-surface); - color: var(--openbitfun-color-content-primary); + background: color-mix(in srgb, var(--openbitfun-color-accent-default) 14%, transparent); + box-shadow: inset 0 0 0 var(--openbitfun-border-width-default) color-mix(in srgb, var(--openbitfun-color-accent-default) 26%, transparent); + color: color-mix(in srgb, var(--openbitfun-color-accent-default) 60%, var(--openbitfun-color-content-primary)); font-family: var(--openbitfun-type-micro-font-family); font-size: var(--openbitfun-type-micro-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); @@ -776,7 +833,8 @@ } .openbitfun-remote-connect__client-icon { - @include surface.icon-slot; + --_rc-light: var(--openbitfun-color-status-success-content); + @include surface.icon-disc(--_rc-light, 28px); } .openbitfun-remote-connect__client-name { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx index 602f9a02b9..69513ee7bd 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.status.test.tsx @@ -272,7 +272,7 @@ describe('Remote Connect shared status through the real dialog and sidebar', () await tick(); const connections = element('[data-openbitfun-part="connections"]'); expect(connections.querySelector('.openbitfun-remote-connect__card-header [role="status"]')).toBeNull(); - expect(connections.querySelectorAll('[data-openbitfun-component="status-pill"]')).toHaveLength(1); + expect(connections.querySelectorAll('[data-openbitfun-part="stateMark"]')).toHaveLength(1); expect(cardStatus()).toBe('remoteConnect.stateWaiting'); await clickText('remoteConnect.cancelInvitation'); expect(element('[data-openbitfun-part="connections"] .openbitfun-remote-connect__card-header [role="status"]').textContent) diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 492da664ea..4826558a9e 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -18,7 +18,6 @@ import { OverflowText, PageHeader, ScrollArea, Select, - StatusPill, Switch, TabGroup, type TabGroupItem, @@ -29,6 +28,9 @@ import { OverflowText, DialogHeading, DialogTitle, } from '@openbitfun/ui'; +import { morphFromOrigin } from '@/app/services/surfaceMorph'; +import { DEVICES_DIALOG_MORPH_KEY } from './morphKey'; +import { ConnectionStateMark } from './ConnectionStateMark'; import React, { useState, useEffect, useCallback, useRef } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { @@ -926,9 +928,9 @@ export const RemoteConnectDialog: React.FC = ({ data-openbitfun-part="status" data-openbitfun-state={connected ? 'connected' : undefined} > - + {connectionStatusLabel(connected)} - + ); @@ -954,6 +956,7 @@ export const RemoteConnectDialog: React.FC = ({ >
= ({ {statusDetail} )} - {statusLabel} + {statusLabel}
-
diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx index 5eb19ed3dc..4288c67e83 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteNetworkConnections.tsx @@ -108,7 +108,7 @@ export function RemoteNetworkConnections({ {error &&
{error}
}
{connected && ( - )} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss b/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss index 151c75e5d2..0bc3a672d6 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss @@ -98,24 +98,15 @@ $divider: var(--openbitfun-border-width-default) solid var(--openbitfun-color-bo color: var(--openbitfun-color-content-secondary); } -// The device card's language: every surface carries a state light -// (`--_rc-light`). Neutral until a link is live, then it lights its disc, -// dot and card. +// One neutral material for every surface. Colour is kept for meaning only +// (a state dot, a destructive word), never for decoration, so a page full of +// rows still reads calm. `$light` names the private property a surface may +// use for its own neutral accents (underlines, carets). @mixin state-light($light) { - #{$light}: var(--openbitfun-color-content-muted); - #{$light}-strength: 10%; - - :where([data-color-scheme='dark']) & { - #{$light}-strength: 16%; - } - - &[data-openbitfun-state~='connected'], - &[data-openbitfun-state~='authenticated'] { - #{$light}: var(--openbitfun-color-status-success-content); - } + #{$light}: var(--openbitfun-color-content-secondary); } -// A row's icon in a tinted glass disc, lit by its state light. +// A row's icon in a quiet neutral disc. @mixin icon-disc($light, $size: 36px) { display: inline-flex; flex-shrink: 0; @@ -124,20 +115,13 @@ $divider: var(--openbitfun-border-width-default) solid var(--openbitfun-color-bo inline-size: $size; block-size: $size; border-radius: var(--openbitfun-radius-pill); - background: color-mix(in srgb, var(#{$light}) 18%, transparent); - color: color-mix(in srgb, var(#{$light}) 60%, var(--openbitfun-color-content-primary)); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 7%, transparent); + color: var(--openbitfun-color-content-primary); transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } -// The card glows faintly from its top edge in its state light. @mixin lit-card($light) { - background: - radial-gradient( - 110% 90% at 0% 0%, - color-mix(in srgb, var(#{$light}) var(#{$light}-strength), transparent), - transparent 70% - ), - color-mix(in srgb, var(--openbitfun-color-content-primary) 4%, transparent); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 4%, transparent); } @mixin row-copy { From 87bd0cb99ca833004d9f933bc35cd6a340cce5a6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 15:05:17 +0800 Subject: [PATCH 31/52] perf: open a device session from a small first page; smoother waits - A cross-device open reads a 48-event first page instead of a full 200-event / 768 KiB page over the relay; older pages follow in the background. Every host honours the limit. - The host dialog queue is polled every 2 s only while something is queued or running (10 s otherwise) and not while the window is hidden. - A session open's transition ends when the session shows, not at its timeout, so the work it defers resumes at once. - Activating a workspace no longer swaps the sidebar's live list for the background projection and back; creating a session ignores repeat clicks. - A selected scene tab breathes while its scene is prepared; quick waits no longer flash a spinner (scene fallback, history paging, sidebar loading); the mini-app gallery shows its skeleton while its view loads. --- .../src/remote_connect/host_stream_subscriber.rs | 14 ++++++++++++++ src/shared/dialog-queue/HostDialogQueue.ts | 11 +++++++++-- .../devices/DeviceWorkspaceSections.test.tsx | 14 ++++++++++++-- .../sections/devices/DeviceWorkspaceSections.tsx | 7 +++++-- .../sections/sessions/SessionsSection.scss | 8 ++++++++ .../sections/workspaces/WorkspaceItem.tsx | 7 +++++++ .../src/app/components/SceneBar/SceneBar.scss | 15 +++++++++++++++ .../src/app/components/SceneBar/SceneBar.tsx | 1 + src/web-ui/src/app/scenes/SceneViewport.scss | 12 ++++++++++++ .../app/scenes/miniapps/MiniAppGalleryScene.tsx | 3 ++- .../components/modern/ModernFlowChatContainer.tsx | 6 +++--- .../components/modern/VirtualMessageList.scss | 12 ++++++++++++ 12 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs b/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs index 07dbd6e479..8d77d9aaa3 100644 --- a/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs +++ b/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs @@ -11,6 +11,11 @@ use std::{sync::Arc, time::Duration}; use tokio::sync::{broadcast, mpsc, oneshot}; const KEEPALIVE: Duration = Duration::from_secs(4 * 60); +/// Events in the first page of an open: enough to fill a transcript view, so +/// the session shows after one small read instead of a full 200-event / +/// 768 KiB page over the relay. Older pages follow in the background. Every +/// host honours `limit`, so no capability is needed. +const FIRST_PAGE_EVENTS: usize = 48; const UNSUPPORTED_HOST_MESSAGE: &str = "The controlled device runs an older OpenBitFun version that does not support on-demand session streaming. Update OpenBitFun on that device to continue."; #[derive(Deserialize)] @@ -148,6 +153,7 @@ impl Reader { .read(StreamReadRequest { stream_id: self.session_id.clone(), subscribe: true, + limit: Some(FIRST_PAGE_EVENTS), ..Default::default() }) .await?; @@ -582,6 +588,14 @@ mod tests { assert_eq!(lab.transport.reads.lock().unwrap().len(), reads_before); } + #[tokio::test] + async fn opening_reads_a_small_latest_page_first() { + let lab = lab(); + let _subscriber = lab.subscribe("s").await.unwrap(); + let reads = lab.transport.reads.lock().unwrap().clone(); + assert_eq!(reads[0].limit, Some(super::FIRST_PAGE_EVENTS)); + } + #[tokio::test] async fn host_restart_is_announced_as_a_gap_and_replayed_from_the_latest_page() { let lab = lab(); diff --git a/src/shared/dialog-queue/HostDialogQueue.ts b/src/shared/dialog-queue/HostDialogQueue.ts index ed37f73b52..bbef59ecdd 100644 --- a/src/shared/dialog-queue/HostDialogQueue.ts +++ b/src/shared/dialog-queue/HostDialogQueue.ts @@ -254,9 +254,16 @@ export function observeHostQueue(queue: HostDialogQueue): () => void { const refresh = async () => { if (closed || running) return; if (timer) clearTimeout(timer); + // A hidden window is refreshed when it is shown again. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; running = true; - let delay = 2_000; - try { await queue.refresh(); } catch { delay = 10_000; } + let delay = 10_000; + try { + // Watch closely only while something is queued; an empty queue changes + // through this window's own sends (which refresh it) or rarely elsewhere. + const snapshot = await queue.refresh(); + if ((snapshot?.items?.length ?? 0) > 0 || snapshot?.activeTurnId) delay = 2_000; + } catch { delay = 10_000; } finally { running = false; if (!closed) timer = setTimeout(() => void refresh(), delay); } }; const wake = () => { if (document.visibilityState !== 'hidden') void refresh(); }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx index 84a46918ea..5ef8bb2ee8 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.test.tsx @@ -4,13 +4,13 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const slots = vi.hoisted(() => ({ value: [] as unknown[] })); -const live = vi.hoisted(() => ({ loading: false })); +const live = vi.hoisted(() => ({ loading: false, opened: [] as unknown[] })); vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); vi.mock('./useSidebarDevices', async importOriginal => ({ ...await importOriginal(), useSidebarDevices: () => slots.value, })); -vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useWorkspaceContext: () => ({ loading: live.loading }) })); +vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useWorkspaceContext: () => ({ loading: live.loading, openedWorkspacesList: live.opened }) })); vi.mock('../workspaces/WorkspaceListSection', () => ({ default: () =>
})); vi.mock('./DeviceCatalogSection', () => ({ default: ({ slot }: { slot: { surfaceId: string } }) =>
, @@ -68,6 +68,16 @@ describe('DeviceWorkspaceSections hand-over', () => { expect(body('local')).toBe('live-list'); }); + it('keeps the live rows while a workspace is activated in them', () => { + live.loading = true; + live.opened = [{ id: 'ws' }]; + slots.value = [slot('local', true), slot('peer-b', false)]; + render(); + expect(body('local')).toBe('live-list'); + live.loading = false; + live.opened = []; + }); + it('keeps device sections in a fixed order whichever device is rendered', () => { slots.value = [slot('local', true), slot('peer-b', false)]; render(); diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx index 12d4dde803..265b15fdc6 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/DeviceWorkspaceSections.tsx @@ -106,13 +106,16 @@ const DeviceSection: React.FC<{ const loading = useCatalogLoading(slot.surfaceId, slot.rendered) || (opening && !slot.rendered); // While the rendered device's live catalog hydrates after a switch, keep // drawing its warm device catalog so the list neither empties nor jumps. - const { loading: liveLoading } = useWorkspaceContext(); + // Only a live list that has nothing yet is hydrating: activating a workspace + // also sets `loading`, and must not swap the list it is clicked in. + const { loading: liveLoading, openedWorkspacesList } = useWorkspaceContext(); + const liveHydrating = liveLoading && openedWorkspacesList.length === 0; const hasCatalog = useCatalogHasWorkspaces(slot.surfaceId); // During a cross-device open every device draws its background projection, // which uses the live list's data and rules; the rendered device hands over // to its live list once, after the open, so its reads never delay the open. const switching = useDeviceOpenIntent(state => state.intent !== null); - const showLive = slot.rendered && ((!switching && !liveLoading) || !hasCatalog); + const showLive = slot.rendered && ((!switching && !liveHydrating) || !hasCatalog); // CLI hosts say so; client hosts name their platform. const platform = devicePlatform(slot.os); const hostTag = slot.hostKind === 'cli' ? t('nav.devices.hostCli') 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 14f40e380a..23a762068f 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 @@ -74,6 +74,7 @@ } &__inline-loading { + animation: openbitfun-nav-inline-loading-reveal 160ms ease-out 200ms both; display: flex; align-items: center; gap: 5px; @@ -941,3 +942,10 @@ @include nav-font.nav-panel-text-icon; } } + +// A wait that ends quickly shows nothing: the indicator fades in only once +// the wait has lasted long enough to be noticed. +@keyframes openbitfun-nav-inline-loading-reveal { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 01be39365e..828fa1ed63 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -726,8 +726,13 @@ const WorkspaceItem: React.FC = ({ } }, [t, workspace.rootPath]); + const creatingSessionRef = useRef(false); const handleCreateSession = useCallback(async (mode?: 'Standard' | 'Cowork' | 'Claw') => { setMenuOpen(false); + // One new session per gesture: a second click while the first is + // being created would open a second draft. + if (creatingSessionRef.current) return; + creatingSessionRef.current = true; const resolvedMode = mode ?? (workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined); try { const reusableId = findReusableEmptySessionId(workspace, resolvedMode); @@ -751,6 +756,8 @@ const WorkspaceItem: React.FC = ({ error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'), { duration: 4000 } ); + } finally { + creatingSessionRef.current = false; } }, [ setActiveWorkspace, diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index db38e6e6d5..f1007ed5fd 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -19,6 +19,12 @@ flex: 0 0 auto; min-width: 0; + // The tab is chosen while its scene is still being prepared: it breathes + // until the scene shows, starting only once the wait is noticeable. + &[data-pending] [role='tab'] { + animation: openbitfun-scene-tab-pending 1.1s ease-in-out 160ms infinite; + } + &[data-canvas-drop-position]::after { content: ''; position: absolute; @@ -224,3 +230,12 @@ transform: none; } } + +@keyframes openbitfun-scene-tab-pending { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +@media (prefers-reduced-motion: reduce) { + .openbitfun-scene-bar__item[data-pending] [role='tab'] { animation: none; } +} diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 1cd430f2b7..098e212b88 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -320,6 +320,7 @@ const SceneBar: React.FC = ({ const title = resource?.target.kind === 'file' ? [resource.scope.surfaceId, resource.scope.remoteConnectionId, resource.target.path].filter(Boolean).join(' · ') : undefined; return
handleContextMenu(event, tabId)} onDragStart={event => { writeSceneTabDrag(event.dataTransfer, tabId); if (tab?.session) beginConversationTransfer(event.dataTransfer, tab.session, tabId); }} diff --git a/src/web-ui/src/app/scenes/SceneViewport.scss b/src/web-ui/src/app/scenes/SceneViewport.scss index 016c899411..55d0807056 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.scss +++ b/src/web-ui/src/app/scenes/SceneViewport.scss @@ -37,6 +37,7 @@ justify-content: center; pointer-events: none; z-index: 1; + animation: openbitfun-scene-viewport-wait-reveal 180ms var(--openbitfun-motion-easing-standard) 220ms both; } // ── Scene slot ──────────────────────────────────────── @@ -99,6 +100,10 @@ } @media (prefers-reduced-motion: reduce) { + .openbitfun-scene-viewport__lazy-fallback { + animation-duration: 1ms; + } + .openbitfun-scene-viewport__scene--incoming { transition: none; } @@ -134,3 +139,10 @@ position: absolute; inset: 0; } + +// A wait that ends quickly shows nothing: the indicator fades in only once +// the wait has lasted long enough to be noticed. +@keyframes openbitfun-scene-viewport-wait-reveal { + from { opacity: 0; } + to { opacity: 1; } +} diff --git a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx index 6596d28721..754760bc4f 100644 --- a/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx +++ b/src/web-ui/src/app/scenes/miniapps/MiniAppGalleryScene.tsx @@ -8,6 +8,7 @@ import React, { Suspense, useState } from 'react'; import { TabGroup } from '@openbitfun/ui'; import { useI18n } from '@/infrastructure/i18n'; import type { MiniAppMeta } from '@/infrastructure/api/service-api/MiniAppAPI'; +import GallerySkeleton from '@/app/components/GalleryLayout/GallerySkeleton'; import './MiniAppGalleryScene.scss'; const MiniAppLibraryView = lazyWithRecovery(() => import('./views/MiniAppLibraryView')); @@ -57,7 +58,7 @@ const MiniAppGalleryScene: React.FC = ({ initialDetail return (
- + }> {activeTab === 'submissions' ? : renderLibrary()} diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index f906cd9135..ad25101c97 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -938,9 +938,9 @@ export const ModernFlowChatContainer: React.FC = ( return; } - if (historyState === 'failed') { - clearHistorySessionOpenTransition(pendingHistoryOpenSession.sessionId); - } + // Settled either way: the open's transition ends now, not at its timeout + // (a cross-device open allows up to 20 s, and its readers defer work). + clearHistorySessionOpenTransition(pendingHistoryOpenSession.sessionId); setPendingHistoryOpenSession(null); }, [ historyState, 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 404329d4c0..845f03ed96 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -197,6 +197,11 @@ &[data-history-paging-sentinel='error'] { visibility: visible; } + + // A page that arrives quickly never flashes its spinner. + &[data-history-paging-sentinel='loading'] > * { + animation: history-paging-reveal 160ms ease-out 200ms both; + } } &__history-paging-spinner { @@ -249,3 +254,10 @@ mask-image: none; } } + +// A wait that ends quickly shows nothing: the indicator fades in only once +// the wait has lasted long enough to be noticed. +@keyframes history-paging-reveal { + from { opacity: 0; } + to { opacity: 1; } +} From ced2a86319feb9b08d5e5eb8af61c81c2208bde5 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 15:11:30 +0800 Subject: [PATCH 32/52] feat(web-ui): crossfade scene switches The scene being left stays drawn, inert, above the incoming one and fades out over it once the incoming scene is ready, instead of vanishing while the new scene faded in from half opacity. Reduced motion switches at once. --- src/web-ui/src/app/scenes/SceneViewport.scss | 21 +++++++++++++------ .../src/app/scenes/SceneViewport.test.tsx | 10 +++++++-- src/web-ui/src/app/scenes/SceneViewport.tsx | 7 +++++-- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/web-ui/src/app/scenes/SceneViewport.scss b/src/web-ui/src/app/scenes/SceneViewport.scss index 55d0807056..5050f9a8fc 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.scss +++ b/src/web-ui/src/app/scenes/SceneViewport.scss @@ -78,9 +78,12 @@ transition: opacity 480ms cubic-bezier(0.23, 1, 0.32, 1); } + // The scene being left covers the incoming one and fades away above it, + // so the switch is one crossfade with no dip to the background. &--outgoing { z-index: 2; pointer-events: none; + background: var(--openbitfun-color-surface-scene); } &:not(.openbitfun-scene-viewport__scene--visible) { @@ -92,11 +95,18 @@ } &:not(.openbitfun-scene-viewport--paned) &__clip[data-scene-motion-phase='preparing'] { - .openbitfun-scene-viewport__scene--incoming.openbitfun-scene-viewport__scene--visible { - opacity: 0.5; + .openbitfun-scene-viewport__scene--outgoing.openbitfun-scene-viewport__scene--visible { + opacity: 1; transition: none; } } + + &:not(.openbitfun-scene-viewport--paned) &__clip[data-scene-motion-phase='running'] { + .openbitfun-scene-viewport__scene--outgoing.openbitfun-scene-viewport__scene--visible { + opacity: 0; + transition: opacity 220ms cubic-bezier(0.4, 0, 0.2, 1); + } + } } @media (prefers-reduced-motion: reduce) { @@ -108,10 +118,9 @@ transition: none; } - .openbitfun-scene-viewport:not(.openbitfun-scene-viewport--paned) .openbitfun-scene-viewport__clip[data-scene-motion-phase='preparing'] { - .openbitfun-scene-viewport__scene--incoming.openbitfun-scene-viewport__scene--visible { - opacity: 1; - } + .openbitfun-scene-viewport__scene--outgoing.openbitfun-scene-viewport__scene--visible { + opacity: 0 !important; + transition: none !important; } } diff --git a/src/web-ui/src/app/scenes/SceneViewport.test.tsx b/src/web-ui/src/app/scenes/SceneViewport.test.tsx index 47d78b6762..3dd52ca32d 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.test.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.test.tsx @@ -203,7 +203,10 @@ describe('SceneViewport transitions', () => { await Promise.resolve(); }); - expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['agents']); + // Crossfade: the scene being left stays drawn, inert, above the new one + // until it has faded out. + expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id')).sort()).toEqual(['agents', 'session']); + expect(container.querySelector('[data-scene-id="session"]')?.classList.contains('openbitfun-scene-viewport__scene--outgoing')).toBe(true); expect(container.querySelector('[data-scene-id="session"]')?.getAttribute('aria-hidden')).toBe('true'); expect(container.querySelector('[data-scene-id="session"]')?.hasAttribute('inert')).toBe(true); expect(container.querySelector('[data-scene-id="agents"]')?.classList.contains( @@ -224,6 +227,7 @@ describe('SceneViewport transitions', () => { expect(container.querySelector('[data-scene-id="agents"]')?.classList.contains( 'openbitfun-scene-viewport__scene--incoming', )).toBe(false); + expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['agents']); sceneHarness.state = { ...sceneHarness.state, @@ -232,7 +236,9 @@ describe('SceneViewport transitions', () => { }; act(() => root.render()); - expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id'))).toEqual(['session']); + // A ready target crossfades at once: the left scene fades above it. + expect(visibleScenes().map(scene => scene.getAttribute('data-scene-id')).sort()).toEqual(['agents', 'session']); + expect(container.querySelector('[data-scene-id="session"]')?.getAttribute('aria-hidden')).toBe('false'); expect(container.querySelector('[data-scene-id="agents"]')?.getAttribute('aria-hidden')).toBe('true'); expect(container.querySelector('[data-scene-id="agents"]')?.hasAttribute('inert')).toBe(true); expect(container.querySelector('[data-scene-id="agents"]')?.hasAttribute('hidden')).toBe(false); diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index 27ff3ace3b..de5d6315c4 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -148,8 +148,11 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering const isActive = tabId === selectedId && paneVisible; const isOutgoing = !isActive && tabId === transition.outgoingTabId; const isIncoming = isActive && transition.pendingTransition?.incomingTabId === tabId; - const isVisible = paneVisible && (transition.pendingTransition?.phase === 'holding' && transition.outgoingTabId - ? isOutgoing : isActive); + const phase = transition.pendingTransition?.phase; + // Holding shows only the outgoing scene; once the incoming one is + // ready both are shown and the outgoing one fades out above it. + const isVisible = paneVisible && (phase === 'holding' && transition.outgoingTabId + ? isOutgoing : isActive || (!paned && isOutgoing && (phase === 'preparing' || phase === 'running'))); const Host: React.ElementType = paned ? SplitViewSurface : 'div'; const focusScene = () => { if (isActive) useSceneStore.getState().focusPane(location); From 7ed1762c4a21714786e17146da4533d086ef0ff4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 15:19:08 +0800 Subject: [PATCH 33/52] perf(web-ui): one host round trip to switch workspace; warm scenes at idle - Switching workspace (and so opening a session in another workspace) shows the switch as soon as the host has switched; the recent and opened lists refresh afterwards instead of a second round trip first. - Settings' default page and the common scenes load their code while the app is idle, so the first open renders at once. - Switching between loaded sessions settles the transcript into place. --- src/web-ui/src/app/scenes/SceneViewport.tsx | 44 +++++++++++++++++++ .../modern/ModernFlowChatContainer.tsx | 20 +++++++++ .../business/workspaceManager.test.ts | 19 ++++++++ .../services/business/workspaceManager.ts | 36 ++++++++++----- 4 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index de5d6315c4..33b287e717 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -29,6 +29,8 @@ import { selectedScene, tabPane } from '../workbench/scenePaneModel'; import { useSceneStore } from '../stores/sceneStore'; import { useSessionPaneLayout } from './session/sessionPaneLayoutStore'; import SettingsScene from './settings/SettingsScene'; +import { DEFAULT_SETTINGS_PAGE_ID, preloadSettingsPage } from './settings/settingsRegistry'; +import { backgroundTaskScheduler } from '@/shared/utils/backgroundTaskScheduler'; import AssistantScene from './assistant/AssistantScene'; import SessionScene from './session/SessionScene'; import WelcomeScene from './welcome/WelcomeScene'; @@ -54,6 +56,47 @@ const ShellScene = lazyWithRecovery(() => import('./shell/ShellScene')); const MiniAppScene = lazyWithRecovery(() => import('./miniapps/MiniAppScene')); const SCENE_ENTRY_DURATION_MS = 480; + +/** + * Scenes a user opens first get their code ready while the app is idle, so + * opening one renders at once instead of fetching and parsing its chunk on the + * click. One at a time, at idle priority, after startup has settled. + */ +const WARM_SCENES: ReadonlyArray<() => Promise> = [ + () => preloadSettingsPage(DEFAULT_SETTINGS_PAGE_ID), + () => AgentsScene.preload(), + () => SkillsScene.preload(), + () => MiniAppGalleryScene.preload(), + () => PersonalOverviewScene.preload(), + () => ProjectResourcesScene.preload(), + () => TerminalScene.preload(), + () => GitScene.preload(), +]; +const SCENE_WARMUP_DELAY_MS = 2_500; + +function useSceneWarmup(): void { + useEffect(() => { + if (import.meta.env.MODE === 'test') return undefined; + let cancelled = false; + const handles: Array<{ cancel: () => void }> = []; + const timer = window.setTimeout(() => { + let chain: Promise = Promise.resolve(); + for (const warm of WARM_SCENES) { + chain = chain.then(() => { + if (cancelled) return undefined; + const handle = backgroundTaskScheduler.schedule(() => warm(), { priority: 'low', idle: true }); + handles.push(handle); + return handle.promise.catch(() => undefined); + }); + } + }, SCENE_WARMUP_DELAY_MS); + return () => { + cancelled = true; + window.clearTimeout(timer); + handles.forEach(handle => handle.cancel()); + }; + }, []); +} const EMPTY_SCENE_ID = '__empty-scene__' as const; type RenderedSceneId = SceneTabId | typeof EMPTY_SCENE_ID; @@ -112,6 +155,7 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering const secondaryTransition = useSceneTransition(secondarySceneId, navigationMotion, navigationSequence); const pendingTransition = primaryTransition.pendingTransition; useDialogCompletionNotify(); + useSceneWarmup(); const renderedTabIds: RenderedSceneId[] = [...new Set(openTabs.map(tab => getSceneViewId(tab.id)))]; for (const resource of Object.values(resources)) { const id: SceneTabId = `content:${resource.id}`; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index ad25101c97..f23546d6c3 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -1248,6 +1248,25 @@ export const ModernFlowChatContainer: React.FC = ( const showHistoryLoadingLayer = !showHistoryOpenIntentOverlay && !showFailedHistoryPlaceholder && (showHistoryPlaceholder || activeHistoryRestore?.status === 'loading'); + // Switching between sessions whose transcripts are already loaded settles + // the new one into place instead of cutting to it; a session still loading + // gets the loading boundary's own hand-over. + const messagesRef = useRef(null); + const shownSessionIdRef = useRef(null); + useLayoutEffect(() => { + const sessionId = activeSession?.sessionId ?? null; + const previous = shownSessionIdRef.current; + shownSessionIdRef.current = sessionId; + const element = messagesRef.current; + if (!element || !previous || !sessionId || previous === sessionId) return; + if (showHistoryLoadingLayer || showHistoryOpenIntentOverlay || virtualItems.length === 0) return; + if (typeof element.animate !== 'function' + || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return; + element.animate( + [{ opacity: 0.35, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], + { duration: 220, easing: 'cubic-bezier(0.2, 0, 0, 1)' }, + ); + }, [activeSession?.sessionId, showHistoryLoadingLayer, showHistoryOpenIntentOverlay, virtualItems.length]); const historyLoadingIdentity = surfaceScope.key('history-loading', surfaceScope.epoch, showHistoryOpenIntentOverlay ? pendingHistoryOpenSession?.sessionId : activeSession?.sessionId); const isHistoryContentPresented = historyLoadingPresentation?.identity === historyLoadingIdentity && historyLoadingPresentation.ready; @@ -2779,6 +2798,7 @@ export const ModernFlowChatContainer: React.FC = ( />
{ expect(manager.getState().assistantWorkspaces).toHaveLength(1); }); + it('shows a switched workspace after the host switch alone, refreshing lists afterwards', async () => { + const first = { id: 'first', name: 'First', rootPath: '/test/first', workspaceKind: 'normal' }; + const second = { id: 'second', name: 'Second', rootPath: '/test/second', workspaceKind: 'normal' }; + globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue({ + cleanupRemovedCount: 0, currentWorkspace: first, openedWorkspaces: [first, second], + recentWorkspaces: [], assistantWorkspaces: [], primaryAssistantWorkspaceId: null, + }); + const manager = await getFreshWorkspaceManager(); + await manager.initialize(); + let releaseLists!: (value: unknown) => void; + globalStateMocks.getOpenedWorkspaces.mockReturnValue(new Promise(resolve => { releaseLists = resolve; })); + globalStateMocks.setActiveWorkspace.mockResolvedValue(second); + await manager.setActiveWorkspace(second.id); + // Resolved while the list refresh is still in flight. + expect(manager.getState().activeWorkspaceId).toBe(second.id); + expect([...manager.getState().openedWorkspaces.keys()]).toEqual(['first', 'second']); + releaseLists([first, second]); + }); + it('waits for running sessions to be cancelled before closing and preserves the catalog', async () => { globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue({ cleanupRemovedCount: 0, currentWorkspace: assistant, openedWorkspaces: [assistant], diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index 350239ffdb..b4e6b83c66 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -1419,6 +1419,8 @@ class WorkspaceManager { } } + private activeWorkspaceSwitchSequence = 0; + public async setActiveWorkspace(workspaceId: string): Promise { const surface = this.captureSurface(); try { @@ -1439,23 +1441,37 @@ class WorkspaceManager { ? await globalStateAPI.openWorkspaceById(workspaceId) : await globalStateAPI.setActiveWorkspace(workspaceId); this.assertSurfaceUnchanged(surface, 'set active workspace'); - const [recentWorkspaces, openedWorkspaces] = await Promise.all([ - globalStateAPI.getRecentWorkspaces(), - globalStateAPI.getOpenedWorkspaces(), - ]); - this.assertSurfaceUnchanged(surface, 'set active workspace'); - const orderedOpenedWorkspaces = this.preserveOpenedWorkspaceOrder(openedWorkspaces); - + // The host has switched: show it now, from the lists this window holds. + // The recent and opened lists are refreshed afterwards, off the path the + // user waits on (opening a session in another workspace waits on this). + const knownOpened = this.getOpenedWorkspacesList(); + const opened = knownOpened.some(candidate => candidate.id === workspace.id) + ? knownOpened : [workspace, ...knownOpened]; + const switchSequence = ++this.activeWorkspaceSwitchSequence; this.updateWorkspaceState( workspace, - recentWorkspaces, - orderedOpenedWorkspaces, + this.state.recentWorkspaces, + this.preserveOpenedWorkspaceOrder(opened), false, null, { type: 'workspace:switched', workspace } ); - this.emit({ type: 'workspace:active-changed', workspace }); + void Promise.all([ + globalStateAPI.getRecentWorkspaces(), + globalStateAPI.getOpenedWorkspaces(), + ]).then(([recentWorkspaces, openedWorkspaces]) => { + // A newer switch (or another device) owns the state by now. + if (!this.isSurfaceUnchanged(surface) || switchSequence !== this.activeWorkspaceSwitchSequence) return; + this.updateWorkspaceState( + this.state.currentWorkspace, + recentWorkspaces, + this.preserveOpenedWorkspaceOrder(openedWorkspaces), + false, + null, + { type: 'workspace:recent-updated' }, + ); + }).catch(error => log.warn('Failed to refresh workspace lists after a switch', { workspaceId, error })); return workspace; } catch (error) { if (!this.isSurfaceUnchanged(surface)) { From 0de96a06a3a69482b6e3f5ac23f5e6e647eb1dd8 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 15:29:27 +0800 Subject: [PATCH 34/52] fix(web-ui): keep the composer's layout while a session loads or opens A persisted session whose history was still loading (and a session being opened) was treated as new, so the composer showed its expanded new-session layout and then collapsed when the session arrived. --- .../src/flow_chat/components/ChatInput.tsx | 23 +++++++++++++------ .../ChatInputWorkspaceStripLayout.test.ts | 7 +++--- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 79816340a8..66e2ab1971 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -723,8 +723,22 @@ export const ChatInput: React.FC = ({ // A submission keeps the session started even if every surviving Turn is // later rolled back. Before that first submission, the composer intentionally // stays expanded instead of collapsing as the empty draft is measured. - const effectiveTargetSessionStarted = effectiveTargetSession ? hasSessionStarted(effectiveTargetSession) : false; - const isNewSessionComposer = !effectiveTargetSessionStarted; + const historySessionOpenTransition = useSyncExternalStore( + subscribeHistorySessionOpenTransition, + getHistorySessionOpenTransitionSnapshot, + getHistorySessionOpenTransitionSnapshot, + ); + // A persisted session whose history is still loading has no turns yet, but + // it is not a new session: treating it as one drew the expanded new-session + // composer for the length of the load and then collapsed it (a height jump). + const effectiveTargetSessionStarted = effectiveTargetSession + ? hasSessionStarted(effectiveTargetSession) || effectiveTargetSession.isHistorical === true + || effectiveTargetSession.historyState === 'metadata-only' || effectiveTargetSession.historyState === 'hydrating' + : false; + // While a session is being opened the composer already belongs to it: keep + // the conversation layout instead of the new-session one, so its height and + // controls do not change when the session arrives. + const isNewSessionComposer = !effectiveTargetSessionStarted && historySessionOpenTransition === null; const dispatchObserverJob = dispatchJobStore(state => { const jobId = effectiveTargetSession?.config.dispatchJobId; return jobId ? state.jobs[jobId] : undefined; @@ -738,11 +752,6 @@ export const ChatInput: React.FC = ({ hostMasksDispatch: !!registration, displayAsChild: isBtwSession, }); - const historySessionOpenTransition = useSyncExternalStore( - subscribeHistorySessionOpenTransition, - getHistorySessionOpenTransitionSnapshot, - getHistorySessionOpenTransitionSnapshot, - ); const acpSessionForInput = useMemo( () => acpSessionRef(effectiveTargetSession), [effectiveTargetSession], 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 9cda78f1df..d655c5a4a1 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStripLayout.test.ts @@ -426,11 +426,12 @@ describe('composer context track layout', () => { it('keeps new workbench sessions expanded while compact conversations start collapsed', () => { const component = readLocalFile('ChatInput.tsx'); - expect(component).toContain( - 'const effectiveTargetSessionStarted = effectiveTargetSession ? hasSessionStarted(effectiveTargetSession) : false;', + // A loading persisted session, or one being opened, is not a new session. + expect(component).toMatch( + /const effectiveTargetSessionStarted = effectiveTargetSession\s*\?\s*hasSessionStarted\(effectiveTargetSession\) \|\| effectiveTargetSession\.isHistorical === true/, ); expect(component).toContain( - 'const isNewSessionComposer = !effectiveTargetSessionStarted;', + 'const isNewSessionComposer = !effectiveTargetSessionStarted && historySessionOpenTransition === null;', ); expect(component).toContain( "const compactComposer = conversationScope?.presentation === 'compact';", From c5cd860832fe8f1ca029d664ea6cfdf2ffb3d59d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 15:31:15 +0800 Subject: [PATCH 35/52] style(web-ui): devices dialog in the device card's atmosphere One soft light from above in a single hue, the sidebar on the same lit surface, frosted glass cards with larger radii and icon discs, and round glass row actions that nudge forward on hover. --- .../RemoteConnectDialog/AccountPanel.scss | 2 +- .../RemoteConnectDialog.scss | 42 +++++++++++++++---- .../_remote-connect-surface.scss | 9 ++-- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss index 71af00b8d5..b97f0a0d70 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.scss @@ -150,7 +150,7 @@ &.offline .account-panel__device-glyph { color: var(--openbitfun-color-content-muted); } & + & { - @include surface.card-divider(calc(36px + #{surface.$row-gap})); + @include surface.card-divider(calc(40px + #{surface.$row-gap})); } &.selectable:has(> .account-panel__device-select:hover) { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 6fe596de28..e94b2f5abb 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -1,8 +1,18 @@ @use './remote-connect-surface' as surface; .openbitfun-remote-connect-dialog { - // One quiet surface; the sidebar and cards are tints of it. - background: var(--openbitfun-color-surface-raised); + // The device card's atmosphere: one soft light from above in a single + // hue, glass panes on it, round glass actions. No second colour. + --_rc-ambient: var(--openbitfun-color-accent-secondary); + --_rc-ambient-strength: 12%; + background: + radial-gradient(80% 55% at 50% 0%, color-mix(in srgb, var(--_rc-ambient) var(--_rc-ambient-strength), transparent), transparent 75%), + radial-gradient(60% 45% at 100% 100%, color-mix(in srgb, var(--_rc-ambient) calc(var(--_rc-ambient-strength) / 3), transparent), transparent 70%), + var(--openbitfun-color-surface-raised); + + :where([data-color-scheme='dark']) & { + --_rc-ambient-strength: 22%; + } block-size: min(620px, calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter))); min-block-size: min(620px, calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter))); @@ -56,8 +66,8 @@ flex-direction: column; gap: var(--openbitfun-space-5); padding: surface.$page-block-start var(--openbitfun-space-3) var(--openbitfun-space-4); - // A quieter band of the same glass instead of a ruled column. - background: color-mix(in srgb, var(--openbitfun-color-content-primary) 3%, transparent); + // The same lit surface; the column is set apart by space, not a band. + background: transparent; } .openbitfun-remote-connect__sidebar-brand { @@ -121,7 +131,8 @@ } &[aria-current] { - background: color-mix(in srgb, var(--openbitfun-color-content-primary) 9%, transparent); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 10%, transparent); + backdrop-filter: var(--openbitfun-effect-blur-subtle); color: var(--openbitfun-color-content-primary); font-weight: var(--openbitfun-type-label-selected-font-weight); } @@ -291,7 +302,7 @@ display: grid; width: 100%; min-height: 72px; - grid-template-columns: 36px minmax(0, 1fr) auto 16px; + grid-template-columns: 40px minmax(0, 1fr) auto 32px; align-items: center; gap: surface.$row-gap; padding: surface.$row-block surface.$row-inline; @@ -303,7 +314,7 @@ cursor: pointer; & + & { - @include surface.card-divider(calc(36px + #{surface.$row-gap})); + @include surface.card-divider(calc(40px + #{surface.$row-gap})); } &:hover:not(:disabled) .openbitfun-remote-connect__overview-action-chevron { @@ -357,7 +368,22 @@ .openbitfun-remote-connect__overview-action-chevron { @include surface.chevron; - transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + // A round glass control, like the card's arrows. + box-sizing: content-box; + inline-size: 16px; + block-size: 16px; + padding: 8px; + border-radius: var(--openbitfun-radius-pill); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 6%, transparent); + transition: + color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); +} + +.openbitfun-remote-connect__overview-action:hover:not(:disabled) .openbitfun-remote-connect__overview-action-chevron { + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 13%, transparent); + transform: translateX(2px); } // ==================== Cards ==================== diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss b/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss index 0bc3a672d6..193bd643d1 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/_remote-connect-surface.scss @@ -67,8 +67,9 @@ $divider: var(--openbitfun-border-width-default) solid var(--openbitfun-color-bo min-width: 0; flex-direction: column; overflow: hidden; - border-radius: var(--openbitfun-layout-field-group-radius); - background: color-mix(in srgb, var(--openbitfun-color-content-primary) 4%, transparent); + border-radius: var(--openbitfun-radius-xl); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 5%, transparent); + backdrop-filter: var(--openbitfun-effect-blur-subtle); } @mixin card-row { @@ -107,7 +108,7 @@ $divider: var(--openbitfun-border-width-default) solid var(--openbitfun-color-bo } // A row's icon in a quiet neutral disc. -@mixin icon-disc($light, $size: 36px) { +@mixin icon-disc($light, $size: 40px) { display: inline-flex; flex-shrink: 0; align-items: center; @@ -115,7 +116,7 @@ $divider: var(--openbitfun-border-width-default) solid var(--openbitfun-color-bo inline-size: $size; block-size: $size; border-radius: var(--openbitfun-radius-pill); - background: color-mix(in srgb, var(--openbitfun-color-content-primary) 7%, transparent); + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 9%, transparent); color: var(--openbitfun-color-content-primary); transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } From e200eb23bada3ed2a4e94cb4ca2b55aa5be13385 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 15:40:15 +0800 Subject: [PATCH 36/52] perf(web-ui): open a local session without waiting on choreography - A load that ends before the skeleton would show (it now fades in after 140 ms) reveals the transcript with one 140 ms fade instead of the full skeleton hand-over, which held a ready transcript back for 760 ms. - Slow loads hand over in 440 ms instead of 760 ms. - Resting the pointer on a local session row for 120 ms starts reading its tail, so the history is usually in memory when the click lands. --- .../sections/sessions/SessionsSection.tsx | 19 ++++++++++++++ .../modern/HistorySessionLoadingBoundary.scss | 15 +++++++++++ .../HistorySessionLoadingBoundary.test.tsx | 15 +++++++++++ .../modern/HistorySessionLoadingBoundary.tsx | 26 +++++++++++++++++-- .../src/flow_chat/services/FlowChatManager.ts | 5 ++++ .../flow-chat-manager/SessionModule.ts | 22 ++++++++++++++++ .../services/flow-chat-manager/index.ts | 1 + 7 files changed, 101 insertions(+), 2 deletions(-) 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 67d5c22550..99a6b32c5c 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 @@ -129,6 +129,23 @@ import './SessionsSection.scss'; /** A limited flat list reads each workspace's newest rows in steps of this many. */ const AGGREGATE_READ_BUCKET = 20; const log = createLogger('SessionsSection'); +/** How long the pointer rests on a session row before its history is read. */ +const SESSION_HOVER_PREWARM_MS = 120; +let sessionHoverPrewarmTimer: ReturnType | null = null; + +function prewarmSessionOnHover(sessionId: string): void { + if (sessionHoverPrewarmTimer !== null) clearTimeout(sessionHoverPrewarmTimer); + sessionHoverPrewarmTimer = setTimeout(() => { + sessionHoverPrewarmTimer = null; + if (flowChatStore.getState().activeSessionId !== sessionId) flowChatManager.prewarmHistoricalSession(sessionId); + }, SESSION_HOVER_PREWARM_MS); +} + +function cancelSessionHoverPrewarm(): void { + if (sessionHoverPrewarmTimer !== null) clearTimeout(sessionHoverPrewarmTimer); + sessionHoverPrewarmTimer = null; +} + const reportedDiscoveryNotificationKeys = new Set(); const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); @@ -1631,6 +1648,8 @@ const SessionsSection: React.FC = ({ data-session-level={String(level)} data-session-active={isRowActive ? 'true' : 'false'} onPointerDown={event => handleSessionOpenPointerDown(event, session)} + onPointerEnter={() => prewarmSessionOnHover(session.sessionId)} + onPointerLeave={cancelSessionHoverPrewarm} onClick={() => handleSwitch(session.sessionId)} onContextMenu={event => handleContextMenu(event, session.sessionId)} > diff --git a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.scss b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.scss index aa15a93447..f746bee7c2 100644 --- a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.scss +++ b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.scss @@ -117,3 +117,18 @@ 0%, 12% { mask-position: 100% 0; } 85%, 100% { mask-position: 0 0; } } + +// The skeleton appears only once a load has lasted long enough to notice; a +// fast open goes straight to its transcript. +.history-session-loading__overlay .history-session-skeleton { + animation: history-session-skeleton-reveal 180ms ease-out 140ms both; +} + +@keyframes history-session-skeleton-reveal { + from { opacity: 0; } + to { opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .history-session-loading__overlay .history-session-skeleton { animation: none; } +} diff --git a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.test.tsx index 7e3c213568..d3c4099d75 100644 --- a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.test.tsx @@ -52,6 +52,9 @@ describe('history loading presentation handoff', () => { reducedMotion = false; hidden = false; action.mockReset(); + // These loads last long enough to show the skeleton; see the quick-load case. + let clock = 0; + vi.spyOn(performance, 'now').mockImplementation(() => (clock += 1_000)); media = Object.assign(new EventTarget(), { media: '(prefers-reduced-motion: reduce)', }) as MediaQueryList; @@ -96,6 +99,18 @@ describe('history loading presentation handoff', () => { expect(animations.length).toBeGreaterThan(0); }); + it('goes straight to the transcript when the load ends before the skeleton shows', async () => { + vi.spyOn(performance, 'now').mockReturnValue(5_000); + render(); + render({ loading: false }); + // No skeleton hand-over: one short fade of the content. + expect(phase()).toBe('revealing'); + expect(animations).toHaveLength(1); + expect(animations[0].element.matches('.history-session-loading__content')).toBe(true); + await finish(); + expect(phase()).toBe('idle'); + }); + it('preserves legacy appearance packages while keeping the old spinner invisible', () => { const legacyPackage = JSON.parse(JSON.stringify({ schema: 'openbitfun.appearance', schemaVersion: 2, id: 'test.legacy-loading', diff --git a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx index baea7e427d..fb253cbc08 100644 --- a/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx +++ b/src/web-ui/src/flow_chat/components/modern/HistorySessionLoadingBoundary.tsx @@ -14,14 +14,25 @@ interface Props { } type Phase = 'idle' | 'loading' | 'exiting' | 'revealing'; -const EXIT_DURATION = 400; -const REVEAL_DURATION = 360; +const EXIT_DURATION = 240; +const REVEAL_DURATION = 200; +/** + * A load shorter than this never showed its skeleton (it fades in after a + * delay, see the stylesheet): the transcript just fades in, without the + * skeleton hand-over, so a fast open is not made to wait on choreography. + */ +const QUICK_LOAD_MS = 260; +const QUICK_REVEAL_DURATION = 140; /** Presentation only. The existing viewport owner decides when the transcript is settled. */ export function HistorySessionLoadingBoundary({ identity, loading, hasTranscript, disabled, intent, label, onPresentationChange, children, }: Props) { const rootRef = useRef(null); + const coverStartedAt = useRef<{ identity: string; at: number } | null>(null); + if (loading && coverStartedAt.current?.identity !== identity) { + coverStartedAt.current = { identity, at: performance.now() }; + } const [state, setState] = useState<{ identity: string; phase: Phase }>({ identity, phase: loading ? 'loading' : 'idle', }); @@ -98,6 +109,17 @@ export function HistorySessionLoadingBoundary({ if (!root || !content || !view) return; const skeleton = root.querySelector('.history-session-skeleton'); if (!skeleton) { settle(); return; } + const shownFor = coverStartedAt.current?.identity === identity + ? performance.now() - coverStartedAt.current.at : Infinity; + if (shownFor < QUICK_LOAD_MS) { + setPhase('revealing'); + const quick = animate(content, [{ opacity: 0 }, { opacity: 1 }], { + duration: QUICK_REVEAL_DURATION, easing: 'ease-out', fill: 'forwards', + }); + await quick.finished; + if (!cancelled) settle(); + return; + } const viewport = content.getBoundingClientRect(); // Read only the bounded rendered window, once, after its opening placement. // Animate the decorative overlay, never the measured rows or their geometry. diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 39c1019560..59a00f0bdd 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -45,6 +45,7 @@ import { createChatSession as createChatSessionModule, hydrateSessionHistoryForDetail as hydrateSessionHistoryForDetailModule, preloadHistoricalSessionForOpen as preloadHistoricalSessionForOpenModule, + prewarmHistoricalSession as prewarmHistoricalSessionModule, switchChatSession as switchChatSessionModule, deleteChatSession as deleteChatSessionModule, archiveChatSession as archiveChatSessionModule, @@ -683,6 +684,10 @@ export class FlowChatManager { return switchChatSessionModule(this.context, sessionId, isStillRelevant); } + prewarmHistoricalSession(sessionId: string): void { + prewarmHistoricalSessionModule(this.context, sessionId); + } + preloadHistoricalSessionForOpen(sessionId: string): void { preloadHistoricalSessionForOpenModule(this.context, sessionId); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 28d20e29b8..63c0db6968 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -18,6 +18,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceScope, + isLocalSurface, isSurfaceChangedError, type SurfaceScope, } from '@/infrastructure/peer-device/deviceSurface'; @@ -373,6 +374,27 @@ export function preloadHistoricalSessionForOpen( }); } +/** + * Start reading a session the pointer rests on, before it is clicked: its tail + * (a few turns) is usually in memory by the time the click lands, so the open + * shows the transcript at once instead of a loading state. The same bounded + * hydrate the open would run; nothing is selected or activated. + */ +export function prewarmHistoricalSession(context: FlowChatContext, sessionId: string): void { + const scope = getActiveSurfaceScope(); + // Another device's history is a live relay subscription, not a cheap read: + // hovering across rows must not open one per row. + if (!isLocalSurface(scope.surfaceId)) return; + if (Array.from(context.pendingHistoryLoads.keys()).some(key => pendingHistoryLoadSessionId(key, scope) === sessionId)) return; + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session || session.externalHistory?.mode === 'follow') return; + if (!shouldHydrateHistoricalSessionBeforeSwitch(session)) return; + void hydrateHistoricalSession(context, sessionId, false).catch(error => { + if (isSurfaceChangedError(error)) return; + log.debug('Historical session prewarm failed', { sessionId, error }); + }); +} + const isAssistantWorkspace = (workspace?: WorkspaceInfo | null): boolean => { return workspace?.workspaceKind === WorkspaceKind.Assistant; }; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index 945815ef53..92cb4a0b9f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -34,6 +34,7 @@ export { createChatSession, hydrateSessionHistoryForDetail, preloadHistoricalSessionForOpen, + prewarmHistoricalSession, switchChatSession, deleteChatSession, archiveChatSession, From 268931739c1aee50104dc0f0be884bd2cde8dfac Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 15:59:05 +0800 Subject: [PATCH 37/52] perf(web-ui): read history alongside the workspace switch; keep evicted tails - Opening a session in another workspace starts its history read before activating that workspace, so the two round trips overlap; the switch adopts the read already in flight. - When the history budget first evicts a long session, its latest three turns stay in memory with their catalog range, so reopening it shows them at once; older turns page in as before. Only further pressure drops it to metadata. --- .../services/sessionActivation.test.ts | 6 +++- .../flow_chat/services/sessionActivation.ts | 6 ++++ .../src/flow_chat/store/FlowChatStore.test.ts | 19 +++++++++++ .../src/flow_chat/store/FlowChatStore.ts | 32 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts index bc728173df..510d123899 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ + prewarm: vi.fn(), flowChatState: { activeSessionId: null as string | null, sessions: new Map(), @@ -39,7 +40,7 @@ vi.mock('../store/FlowChatStore', () => ({ })); vi.mock('./FlowChatManager', () => ({ - flowChatManager: { switchChatSession: mocks.switchChatSession }, + flowChatManager: { switchChatSession: mocks.switchChatSession, prewarmHistoricalSession: mocks.prewarm }, })); vi.mock('./storeSync', () => ({ @@ -228,6 +229,9 @@ describe('openMainSession resource activation', () => { await Promise.all([first, second]); expect(mocks.setActiveWorkspace.mock.calls.map(([id]) => id)).toEqual(['a', 'b']); + // The history read starts with the workspace switch, not after it. + expect(mocks.prewarm).toHaveBeenCalledWith('a'); + expect(mocks.prewarm.mock.invocationCallOrder[0]).toBeLessThan(mocks.setActiveWorkspace.mock.invocationCallOrder[0]); expect(mocks.switchChatSession.mock.calls.map(([id]) => id)).toEqual(['b']); expect(mocks.workspaceState.activeWorkspaceId).toBe('b'); expect(mocks.sceneState.openScene).toHaveBeenCalledTimes(1); diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts index 6a09d48171..1784af102b 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -50,6 +50,12 @@ export async function activateMainSession(sessionId: string, options?: SessionAc && workspaceManager.getState().currentWorkspace) { throw new Error(i18nService.t('common:sceneBar.workspaceUnavailable')); } + if (workspaceId && workspaceManager.getState().activeWorkspaceId !== workspaceId) { + // Read the session's history while its workspace is being activated + // instead of after: the read addresses the session's own workspace, and + // the switch below adopts the read already in flight. + flowChatManager.prewarmHistoricalSession(sessionId); + } if (workspaceId) { // Workspace activation mutates the host as well as the frontend. Serialize // it so a slow A request cannot finish after a newer B request on the host. diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 04a1073df3..715ef7ed6f 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -9292,6 +9292,25 @@ describe('FlowChatStore reconstructable history residency', () => { expect(flowChatStore.getSessionHistoryViewState(session.sessionId)).toBeUndefined(); }); + it('keeps the latest turns of a long session when first evicted, so it reopens without a read', () => { + const turns = (flowChatStore as any).convertToDialogTurns(Array.from({ length: 10 }, (_, index) => createPersistedTurn(index))); + const session = createSession({ sessionId: 'history-1', dialogTurns: turns, + historyState: 'ready', turnCatalog: createTurnCatalog(10), totalTurnCount: 10 }); + flowChatStore.setState(() => ({ sessions: new Map([[session.sessionId, session]]), activeSessionId: null })); + (flowChatStore as any).seedSessionHistoryLoadedRanges(session.sessionId); + (flowChatStore as any).updateHistoryResidency(); + withHistoryBudgetPressure(() => resourceBudget.trim()); + const warm = flowChatStore.getState().sessions.get(session.sessionId)!; + expect(warm.dialogTurns.map(turn => turn.id)).toEqual(turns.slice(-3).map((turn: DialogTurn) => turn.id)); + expect(warm.historyState).toBe('ready'); + expect(flowChatStore.getSessionHistoryViewState(session.sessionId)?.loadedRanges[0]) + .toMatchObject({ startOrdinal: 7, endOrdinalExclusive: 10 }); + // Under continued pressure the tail goes too. + (flowChatStore as any).updateHistoryResidency(); + withHistoryBudgetPressure(() => resourceBudget.trim()); + expect(flowChatStore.getState().sessions.get(session.sessionId)?.historyState).toBe('metadata-only'); + }); + it('protects a visible child and never evicts a newly edited or streaming projection', () => { const turns = (flowChatStore as any).convertToDialogTurns([createPersistedTurn(0)]); const session = createSession({ sessionId: 'history-1', dialogTurns: turns, historyState: 'ready' }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 0084e4ef2f..db1bf50479 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -350,6 +350,8 @@ function sameDispatchTargetIdentity( const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; const HISTORICAL_SESSION_INITIAL_REMOTE_TAIL_TURN_COUNT = 3; const HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT = 3; +/** Turns a session keeps in memory when the history budget evicts it. */ +const RESIDENT_TAIL_TURN_COUNT = HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT; const HISTORICAL_SESSION_FULL_HISTORY_IDLE_TIMEOUT_MS = 1500; const HISTORICAL_SESSION_PREVIOUS_WINDOW_TURN_COUNT = 12; const PEER_SESSION_REFRESH_TAIL_TURN_COUNT = 3; @@ -2477,6 +2479,36 @@ export class FlowChatStore { this.historyResidency.delete(id); const current = surface.state.sessions.get(sessionId); if (!current) return; + // First eviction keeps the session's latest turns: reopening it + // then shows them at once, exactly as a fresh tail restore would, + // and older turns page in through its catalog. Only a session + // already down to that tail (or without a catalog to page from) + // drops to metadata. + const view = surface.sessionHistoryViews.get(sessionId); + const total = projectedSessionTurnCount(current); + const tail = current.dialogTurns.filter(turn => !isProvisionalUsageReportTurn(turn)) + .slice(-RESIDENT_TAIL_TURN_COUNT); + const hasMoreThanTail = current.dialogTurns.length > tail.length + || (view?.loadedRanges.some(range => range.turns.length > 0 && range.startOrdinal < total - tail.length) ?? false); + const catalog = view?.catalog ?? current.turnCatalog ?? null; + if (view && catalog && tail.length > 0 && total >= tail.length && hasMoreThanTail) { + surface.sessionHistoryViews.set(sessionId, { + catalog, + loadedRanges: [{ + startOrdinal: total - tail.length, endOrdinalExclusive: total, turns: tail, + lastAccessedAt: Date.now(), source: 'initial-tail', + }], + activeRange: null, + pendingTargetOrdinal: null, + navigationGeneration: view.navigationGeneration, + }); + surface.sessionHistoryTurnAccessTimes.delete(sessionId); + const sessions = new Map(surface.state.sessions); + sessions.set(sessionId, { ...current, dialogTurns: tail, isPartial: true, loadedTurnCount: tail.length }); + surface.state = { ...surface.state, sessions }; + if (surface.surfaceId === getActiveSurfaceId()) this.notifyListeners(); + return; + } surface.sessionHistoryViews.delete(sessionId); surface.sessionHistoryTurnAccessTimes.delete(sessionId); const sessions = new Map(surface.state.sessions); From 7527e2e6b3c81791ada93f93ac43d1ee1d15ae3d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 16:00:11 +0800 Subject: [PATCH 38/52] style(web-ui): plain chevrons on the devices overview rows The glass disc around each row's chevron rendered without its glyph, as an empty circle. --- .../RemoteConnectDialog/RemoteConnectDialog.scss | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index e94b2f5abb..5f075d8b88 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -302,7 +302,7 @@ display: grid; width: 100%; min-height: 72px; - grid-template-columns: 40px minmax(0, 1fr) auto 32px; + grid-template-columns: 40px minmax(0, 1fr) auto 16px; align-items: center; gap: surface.$row-gap; padding: surface.$row-block surface.$row-inline; @@ -368,21 +368,12 @@ .openbitfun-remote-connect__overview-action-chevron { @include surface.chevron; - // A round glass control, like the card's arrows. - box-sizing: content-box; - inline-size: 16px; - block-size: 16px; - padding: 8px; - border-radius: var(--openbitfun-radius-pill); - background: color-mix(in srgb, var(--openbitfun-color-content-primary) 6%, transparent); transition: color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), - background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } .openbitfun-remote-connect__overview-action:hover:not(:disabled) .openbitfun-remote-connect__overview-action-chevron { - background: color-mix(in srgb, var(--openbitfun-color-content-primary) 13%, transparent); transform: translateX(2px); } From 1b3f3e9cf8d426a574c62ce9ee19c343d5042daf Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 16:12:54 +0800 Subject: [PATCH 39/52] style(web-ui): group scene tabs by device and name each device once When the top bar holds tabs of several devices, each device's tabs sit together behind a single quiet device label instead of every tab carrying its own device suffix. The rendered device's label reads a step brighter, groups after the first are set off by a short hairline, and each tab is described by its group's label for assistive tech. --- docs/architecture/peer-device-mode.md | 6 +- .../src/app/components/SceneBar/SceneBar.scss | 56 ++++++++++--- .../app/components/SceneBar/SceneBar.test.tsx | 38 +++++++-- .../src/app/components/SceneBar/SceneBar.tsx | 80 ++++++++++++++----- 4 files changed, 141 insertions(+), 39 deletions(-) diff --git a/docs/architecture/peer-device-mode.md b/docs/architecture/peer-device-mode.md index 4ce2d273b9..a4106b4fd0 100644 --- a/docs/architecture/peer-device-mode.md +++ b/docs/architecture/peer-device-mode.md @@ -299,8 +299,10 @@ workspace and session), so several sessions of one workspace and sessions of other devices each have their own tab; past 12 session tabs the least recently used idle one closes. Session tabs of every device survive a switch. Reconciliation retires only tabs of the rendered device whose session is gone. -A tab of another device shows that device's name and is never selected without -its device: activating it runs the same cross-device open as a sidebar row, a +When the bar holds tabs of more than one device, it shows each device's tabs +side by side and names the device once, ahead of its group (tabs without a +session belong to the rendered device); a bar of one device names none. A tab +of another device is never selected without its device: activating it runs the same cross-device open as a sidebar row, a pane never falls back onto it, and an open that fails or is superseded leaves it in the bar, unselected. Returning to a device reselects the kept tab of its active session. diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index f1007ed5fd..a94afbe1e3 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -39,27 +39,55 @@ &[data-canvas-drop-position='after']::after { inset-inline-end: 0; } } - // The device a session tab belongs to when it is not the rendered one. - // The device a tab belongs to: a quiet, smaller line of metadata set off by - // a hairline divider, never a second title. - &__tab-device { + // When the tabs span several devices, each device's tabs sit together and + // the device is named once, ahead of them: quiet metadata in the bar's own + // line, never a tab or a chip. Groups after the first are set off by a short + // hairline, so the row reads as a few runs of tabs, not one long strip. + &__device-group { + position: relative; display: inline-flex; flex: 0 0 auto; align-items: center; - margin-inline-start: var(--openbitfun-space-1); - padding-inline-start: var(--openbitfun-space-1); - border-inline-start: var(--openbitfun-border-width-default) solid - color-mix(in srgb, var(--openbitfun-color-content-muted) 45%, transparent); + max-inline-size: 9em; + block-size: var(--openbitfun-control-tab-group-item-height-sm); + padding-inline: var(--openbitfun-space-1); color: var(--openbitfun-color-content-muted); + font-family: var(--openbitfun-type-label-selected-font-family); font-size: var(--openbitfun-type-micro-font-size); + font-weight: var(--openbitfun-type-label-selected-font-weight); line-height: 14px; + letter-spacing: 0.02em; + white-space: nowrap; + pointer-events: none; + animation: openbitfun-scene-device-group-in var(--openbitfun-motion-duration-normal) + var(--openbitfun-motion-easing-standard) both; + + // The rendered device's group reads a step brighter than the others. + &[data-current] { + color: var(--openbitfun-color-content-secondary); + } + + &:not([data-first]) { + margin-inline-start: var(--openbitfun-space-2); + padding-inline-start: calc(var(--openbitfun-space-2) + var(--openbitfun-border-width-default)); + &::before { + content: ''; + position: absolute; + inset-inline-start: 0; + inset-block: 50% auto; + block-size: 14px; + inline-size: var(--openbitfun-border-width-default); + border-radius: var(--openbitfun-radius-pill); + background: color-mix(in srgb, var(--openbitfun-color-content-muted) 35%, transparent); + transform: translateY(-50%); + } + } } - &__tab-device-name { + &__device-group-name { display: block; min-inline-size: 0; - max-inline-size: 6em; } &__tab-region { @@ -236,6 +264,12 @@ 50% { opacity: 0.55; } } +@keyframes openbitfun-scene-device-group-in { + from { opacity: 0; transform: translateX(-4px); } + to { opacity: 1; transform: none; } +} + @media (prefers-reduced-motion: reduce) { - .openbitfun-scene-bar__item[data-pending] [role='tab'] { animation: none; } + .openbitfun-scene-bar__item[data-pending] [role='tab'], + .openbitfun-scene-bar__device-group { animation: none; } } diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx index 2428a26b22..99e9962b99 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx @@ -113,18 +113,44 @@ describe('SceneBar overflow navigation', () => { act(() => root.render()); } - it('activates a session tab on click and names the device of a tab from another device', () => { - const tab = sceneHarness.state.openTabs[0]; - tab.session = { surfaceId: 'peer-tab-device', workspaceKey: 'workspace-a', sessionId: 'remote' }; - useDeviceDirectory.setState({ devices: [{ device_id: 'peer-tab-device', device_name: 'Studio' } as never], localId: 'me' }); + it('activates a session tab on click', () => { sceneHarness.state.activeTabId = 'git'; renderSceneBar(); const trigger = container.querySelector('[role="tab"][data-openbitfun-value="session"]')!; - expect(container.querySelector('[data-openbitfun-part="tabDevice"]')?.textContent).toBe('Studio'); act(() => trigger.click()); expect(sceneHarness.activateScene).toHaveBeenCalledWith('session'); expect(trigger.hasAttribute('aria-haspopup')).toBe(false); - useDeviceDirectory.setState({ devices: [], localId: null }); + expect(container.querySelector('[data-openbitfun-part="tabDevice"]')).toBeNull(); + }); + + it('groups tabs by device and names each device once', () => { + const [session, settings, terminal] = sceneHarness.state.openTabs; + session.session = { surfaceId: 'peer-tab-device', workspaceKey: 'workspace-a', sessionId: 'remote' }; + terminal.session = { surfaceId: 'peer-tab-device', workspaceKey: 'workspace-a', sessionId: 'remote-2' }; + settings.session = { surfaceId: 'local', workspaceKey: 'workspace-b', sessionId: 'here' }; + useDeviceDirectory.setState({ + devices: [ + { device_id: 'peer-tab-device', device_name: 'Studio' } as never, + { device_id: 'me', device_name: 'MacBook' } as never, + ], + localId: 'me', + }); + try { + renderSceneBar(); + const labels = Array.from(container.querySelectorAll('[data-openbitfun-part="tabDevice"]')); + expect(labels.map(label => label.textContent)).toEqual(['Studio', 'MacBook']); + expect(labels[1].hasAttribute('data-current')).toBe(true); + // Studio's tabs are adjacent; the scene tab joins the rendered device. + const order = Array.from(container.querySelectorAll('[role="tab"]')) + .map(tab => tab.getAttribute('data-openbitfun-value')); + expect(order).toEqual(['session', 'terminal', 'settings', 'git']); + const describedBy = (value: string) => container + .querySelector(`[role="tab"][data-openbitfun-value="${value}"]`)?.getAttribute('aria-describedby'); + expect(describedBy('terminal')).toBe(labels[0].id); + expect(describedBy('git')).toBe(labels[1].id); + } finally { + useDeviceDirectory.setState({ devices: [], localId: null }); + } }); function startSessionTabDrag() { diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 098e212b88..95494a5566 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -5,7 +5,7 @@ * Session tabs show only the referenced session title. */ -import React, { useCallback, useState, useEffect, useMemo, useSyncExternalStore } from 'react'; +import React, { useCallback, useState, useEffect, useId, useMemo, useSyncExternalStore } from 'react'; import { Pin, CircleAlert } from 'lucide-react'; import { useSceneStore } from '../../stores/sceneStore'; import { useContextMenuStore } from '@/shared/context-menu-system/store/ContextMenuStore'; @@ -38,22 +38,47 @@ function getSceneIdFromTabTarget(target: EventTarget | null): SceneTabId | undef return tab?.dataset.openbitfunValue as SceneTabId | undefined; } +interface TabDeviceGroup { + /** Position among the groups; keys the group's label element. */ + order: number; + name: string; + /** The rendered device: its scenes, not only its sessions, sit in this group. */ + current: boolean; +} + /** - * Device names of session tabs on a device other than the rendered one, once - * the tabs span more than one device; a single-device window shows none. + * Tabs ordered so each device's tabs sit together, with the group each first + * tab opens. A tab without a session belongs to the rendered device. Groups + * keep the order their device first appears in, tabs keep theirs within it, + * and a window whose tabs share one device has no groups at all. */ -function useSessionTabDevices(tabs: readonly SceneTab[]): Record { +function useTabDeviceGroups(tabs: readonly SceneTab[]): { + tabs: readonly SceneTab[]; + groupStarts: ReadonlyMap; +} { const directory = useDeviceDirectory(); const activeSurfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); return useMemo(() => { - const labels: Record = {}; + const bySurface = new Map(); for (const tab of tabs) { - const surfaceId = tab.session?.surfaceId; - if (!surfaceId || surfaceId === activeSurfaceId) continue; + const surfaceId = tab.session?.surfaceId ?? activeSurfaceId; + const group = bySurface.get(surfaceId); + if (group) group.push(tab); + else bySurface.set(surfaceId, [tab]); + } + const groupStarts = new Map(); + if (bySurface.size < 2) return { tabs, groupStarts }; + const ordered: SceneTab[] = []; + for (const [surfaceId, group] of bySurface) { const deviceId = isLocalSurface(surfaceId) ? directory.localId : surfaceId; - labels[tab.id] = deviceId ? resolveDeviceNameFrom(directory.devices, deviceId) : surfaceId; + groupStarts.set(group[0].id, { + order: groupStarts.size, + name: deviceId ? resolveDeviceNameFrom(directory.devices, deviceId) : surfaceId, + current: surfaceId === activeSurfaceId, + }); + ordered.push(...group); } - return labels; + return { tabs: ordered, groupStarts }; }, [activeSurfaceId, directory.devices, directory.localId, tabs]); } @@ -78,7 +103,8 @@ const SceneBar: React.FC = ({ closeScene, } = useSceneManager(pane); const sessionLabels = useSessionTabLabels(openTabs); - const deviceLabels = useSessionTabDevices(openTabs); + const { tabs: shownTabs, groupStarts } = useTabDeviceGroups(openTabs); + const groupLabelId = useId(); const resources = useContentResourceStore(state => state.resources); const selectedTabId = pendingTabId ?? activeTabId; const { t } = useI18n('common'); @@ -105,7 +131,7 @@ const SceneBar: React.FC = ({ } = useSceneTabNavigation({ activeTabId: selectedTabId, navigationMotion, - openTabIds: openTabs.map(tab => tab.id), + openTabIds: shownTabs.map(tab => tab.id), }); const handleTabValueChange = useCallback((value: string) => { @@ -141,7 +167,7 @@ const SceneBar: React.FC = ({ const rect = item.getBoundingClientRect(); return { tabId, placement: event.clientX < rect.left + rect.width / 2 ? 'before' : 'after' }; } - const last = openTabs[openTabs.length - 1]; + const last = shownTabs[shownTabs.length - 1]; return last ? { tabId: last.id, placement: 'after' } : undefined; }; @@ -206,7 +232,11 @@ const SceneBar: React.FC = ({ }); }; - const tabItems = openTabs.reduce((items, tab) => { + // Each tab names its device through the label that opens its group. + let groupLabel: string | undefined; + const tabItems = shownTabs.reduce((items, tab) => { + const group = groupStarts.get(tab.id); + if (group) groupLabel = `${groupLabelId}-${group.order}`; const def = tabDefs.find(candidate => candidate.id === tab.id); if (!def) return items; @@ -219,13 +249,9 @@ const SceneBar: React.FC = ({ items.push({ value: tab.id, label: displayLabel, - labelSuffix: sessionLabel?.number || deviceLabels[tab.id] || tab.pinned || (tab.contentId && (resources[tab.contentId]?.isDirty || resources[tab.contentId]?.fileMissing)) ? <> + tabProps: groupLabel ? { 'aria-describedby': groupLabel } : undefined, + labelSuffix: sessionLabel?.number || tab.pinned || (tab.contentId && (resources[tab.contentId]?.isDirty || resources[tab.contentId]?.fileMissing)) ? <> {sessionLabel?.number && } - {deviceLabels[tab.id] && - {/* Scrolls with the title: the tab is the overflow trigger of both. */} - {deviceLabels[tab.id]} - } {tab.pinned && } {tab.contentId && resources[tab.contentId]?.isDirty && •} {tab.contentId && resources[tab.contentId]?.fileMissing && } @@ -318,7 +344,8 @@ const SceneBar: React.FC = ({ const tab = openTabs.find(candidate => candidate.id === tabId); const resource = tab?.contentId ? resources[tab.contentId] : undefined; const title = resource?.target.kind === 'file' ? [resource.scope.surfaceId, resource.scope.remoteConnectionId, resource.target.path].filter(Boolean).join(' · ') : undefined; - return
= ({ onDragStart={event => { writeSceneTabDrag(event.dataTransfer, tabId); if (tab?.session) beginConversationTransfer(event.dataTransfer, tab.session, tabId); }} onDragEnd={() => { endConversationTransfer(); endSceneTabDrag(); }} >{node}
; + if (!group) return itemNode; + // Named once, ahead of the device's tabs; the tabs carry the name + // to assistive tech through aria-describedby. + return <> + + {group.name} + + {itemNode} + ; }} size="sm" value={selectedTabId ?? undefined} From 619fd3e94673a9843f3080a1ba6f83c36e9e2ef7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 16:23:01 +0800 Subject: [PATCH 40/52] style(web-ui): quieter create dialog; reachable close on the last scene tab Create dialog: drop the step subtitles, the folders-locked helper and the 'works as it does here' wording; choice cards get soft icon discs and a hairline lift instead of an outline, devices are compact rows with an inline host tag and chevron, back buttons are quiet, and the primary action no longer repeats the device name from the title. Scene tabs: the close button no longer carries a hover tooltip, and a hovered tab that grows past the scroller's edge is scrolled fully into view, so the last tab's close action stays reachable when the bar overflows. --- .../NewProjectDialog/NewProjectDialog.scss | 99 +++++++++++++++++-- .../NewProjectDialog.test.tsx | 4 +- .../NewProjectDialog/NewProjectDialog.tsx | 18 ++-- .../app/components/SceneBar/SceneBar.test.tsx | 22 +++++ .../src/app/components/SceneBar/SceneBar.tsx | 22 ++++- src/web-ui/src/locales/en-US/common.json | 7 +- src/web-ui/src/locales/zh-CN/common.json | 7 +- src/web-ui/src/locales/zh-TW/common.json | 7 +- 8 files changed, 150 insertions(+), 36 deletions(-) diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss index 81fd3b2bf2..87e041a3c4 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.scss @@ -51,30 +51,115 @@ overflow-y: auto; } + // Devices read as one list of quiet rows: the icon in a soft disc, the + // name, its host tag, and a chevron that leans in on hover. + &__device-card { + min-block-size: 0; + border-radius: var(--openbitfun-radius-lg); + + &:hover { + outline-color: transparent; + } + + [data-openbitfun-part='leading'] { + flex-basis: 32px; + inline-size: 32px; + block-size: 32px; + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 8%, transparent); + color: var(--openbitfun-color-content-primary); + } + } + &__device-trigger { justify-content: flex-start; - padding: var(--openbitfun-space-3) var(--openbitfun-space-4); + padding: var(--openbitfun-space-3) var(--openbitfun-space-4) var(--openbitfun-space-3) var(--openbitfun-space-3); gap: var(--openbitfun-space-3); } + &__device-content { + display: flex; + flex: 1 1 auto; + align-items: baseline; + gap: var(--openbitfun-space-2); + min-inline-size: 0; + } + &__device-name { + min-inline-size: 0; color: var(--openbitfun-color-content-primary); - font-family: var(--openbitfun-type-body-sm-font-family); - font-size: var(--openbitfun-type-body-sm-font-size); + font-family: var(--openbitfun-type-body-md-font-family); + font-size: var(--openbitfun-type-body-md-font-size); font-weight: var(--openbitfun-type-label-selected-font-weight); + line-height: var(--openbitfun-type-body-md-line-height); + } + + &__device-meta { + flex: 0 0 auto; + color: var(--openbitfun-color-content-muted); + font-size: var(--openbitfun-type-body-sm-font-size); line-height: var(--openbitfun-type-body-sm-line-height); } + &__device-chevron { + align-self: center; + flex: 0 0 auto; + margin-inline-start: auto; + color: var(--openbitfun-color-content-muted); + transition: + color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + &__device-card:hover:not([data-disabled='true']) &__device-chevron { + color: var(--openbitfun-color-content-secondary); + transform: translateX(2px); + } + &__device-spinner { animation: new-project-dialog-spin 1s linear infinite; } + // Each choice is a pane with its icon in a soft disc; hover brightens the + // pane and its disc together and lifts the card a hair, never an outline. &__choice-card { - min-block-size: 160px; + min-block-size: 148px; + border-radius: var(--openbitfun-radius-xl); + transition: + background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); &:hover { outline-color: transparent; } + + &:hover:not([data-disabled='true']) { + transform: translateY(-1px); + } + + &:active:not([data-disabled='true']) { + transform: none; + } + + [data-openbitfun-part='leading'] { + flex-basis: 40px; + inline-size: 40px; + block-size: 40px; + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 8%, transparent); + color: var(--openbitfun-color-content-primary); + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + &:hover:not([data-disabled='true']) [data-openbitfun-part='leading'] { + background: color-mix(in srgb, var(--openbitfun-color-content-primary) 12%, transparent); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + + &:hover:not([data-disabled='true']) { + transform: none; + } + } } &__choice-trigger { @@ -82,7 +167,7 @@ align-items: flex-start; justify-content: flex-start; padding: var(--openbitfun-space-5); - gap: var(--openbitfun-space-3); + gap: var(--openbitfun-space-4); } &__choice-content { @@ -102,7 +187,7 @@ } &__choice-description { - color: var(--openbitfun-color-content-secondary); + color: var(--openbitfun-color-content-muted); font-family: var(--openbitfun-type-body-sm-font-family); font-size: var(--openbitfun-type-body-sm-font-size); font-weight: var(--openbitfun-type-body-sm-font-weight); @@ -120,7 +205,7 @@ &__folder { min-width: 0; - min-block-size: 160px; + min-block-size: 112px; justify-content: center; gap: var(--openbitfun-space-3); } diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx index 82e20f918f..81792952fc 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx @@ -394,12 +394,10 @@ describe('NewProjectDialog composition', () => { await click('actions.add'); expect(sourceOption(0, 'other').disabled).toBe(true); expect(sourceOption(1, 'container').disabled).toBe(true); - expect(document.body.textContent).toContain('workspaceSource.foldersLockedHint'); act(() => document.querySelector('button[aria-label="newProject.removeFolder"]')!.click()); expect(sourceOption(0, 'other').disabled).toBe(false); expect(sourceOption(1, 'container').disabled).toBe(false); expect(sourceOption(1, 'container').getAttribute('aria-checked')).toBe('true'); - expect(document.body.textContent).not.toContain('workspaceSource.foldersLockedHint'); }); it('names the executing peer host without changing folder routing to the controller', async () => { @@ -549,7 +547,7 @@ describe('NewProjectDialog composition', () => { expect(document.body.textContent).toContain('newWorkspace.createProjectOn'); enterName('Remote app'); await add('/Users/me/app'); - await click('newWorkspace.createProjectOn'); + await click('newWorkspace.createProject'); expect(confirm).toHaveBeenCalledWith({ name: 'Remote app', folders: [{ path: '/Users/me/app' }] }); }); diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx index fcdc07b45d..dddc7fa831 100644 --- a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.tsx @@ -1,6 +1,6 @@ /** A named project groups one primary folder and its associated folders. */ import { - ActionCard, Button, Card, CardBody, Dialog, DialogBody, DialogClose, DialogDescription, DialogFooter, + ActionCard, Button, Card, CardBody, Dialog, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeading, DialogTitle, Icon, IconButton, Input, OverflowText, Tooltip, } from '@openbitfun/ui'; import { FolderPlus, Loader2, Monitor, MonitorSmartphone, Server, SquareTerminal } from 'lucide-react'; @@ -385,8 +385,6 @@ export const NewProjectDialog: React.FC = ({ {dialogTitle} - {step === 'choice' && {t('newWorkspace.description')}} - {step === 'device' && {t('newWorkspace.chooseDeviceDescription')}} @@ -451,15 +449,16 @@ export const NewProjectDialog: React.FC = ({
} aria-label={t('newWorkspace.createProjectOn', { name: device.name })} aria-busy={connecting} disabled={busy || !device.controllable} onClick={() => { void handleChooseDevice(device); }} data-testid="new-project-device-option" data-device-surface={device.surfaceId} body={( - + {device.name} - {meta && {meta}} + {meta && {meta}} + )} />
@@ -506,7 +505,6 @@ export const NewProjectDialog: React.FC = ({ if (workspaceSourceCoordinates(value).location !== location) setSelectedHostOption(undefined); setSource(value); setSelectedOption(undefined); setConnectedRemote(undefined); setError(''); }} /> - {hasFolders && {t('workspaceSource.foldersLockedHint')}}
)}
@@ -551,7 +549,7 @@ export const NewProjectDialog: React.FC = ({ {step === 'device' && ( - @@ -559,7 +557,7 @@ export const NewProjectDialog: React.FC = ({ {step === 'project' && ( {onCreateAssistant && ( - @@ -567,7 +565,7 @@ export const NewProjectDialog: React.FC = ({
{!onCreateAssistant && }
diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx index 99e9962b99..be734ae524 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.test.tsx @@ -123,6 +123,28 @@ describe('SceneBar overflow navigation', () => { expect(container.querySelector('[data-openbitfun-part="tabDevice"]')).toBeNull(); }); + it('brings a grown last tab fully into view so its close action stays reachable', () => { + renderSceneBar(); + const scroller = container.querySelector('[data-scene-bar-part="tabs"]')!; + const item = container.querySelector('[data-scene-tab-id="git"]')!; + const close = item.querySelector('[data-scene-bar-part="closeTab"]'); + expect(close?.hasAttribute('title')).toBe(false); + scroller.scrollBy = vi.fn(); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ right: 400 } as DOMRect); + vi.spyOn(item, 'getBoundingClientRect').mockReturnValue({ right: 430 } as DOMRect); + const hovered = vi.spyOn(item, 'matches').mockReturnValue(false); + const grown = () => { + const event = new Event('transitionend', { bubbles: true }); + Object.defineProperty(event, 'propertyName', { value: 'padding-inline-end' }); + act(() => { item.firstElementChild!.dispatchEvent(event); }); + }; + grown(); + expect(scroller.scrollBy).not.toHaveBeenCalled(); + hovered.mockReturnValue(true); + grown(); + expect(scroller.scrollBy).toHaveBeenCalledWith(expect.objectContaining({ left: 32 })); + }); + it('groups tabs by device and names each device once', () => { const [session, settings, terminal] = sceneHarness.state.openTabs; session.session = { surfaceId: 'peer-tab-device', workspaceKey: 'workspace-a', sessionId: 'remote' }; diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 95494a5566..060d504def 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -134,6 +134,20 @@ const SceneBar: React.FC = ({ openTabIds: shownTabs.map(tab => tab.id), }); + /** + * A hovered tab grows to reveal its close action. Near the scroll end that + * growth lands past the visible edge, so bring the grown tab fully in view + * once it has grown (or at once when the growth is not animated). + */ + const revealGrownTab = useCallback((item: HTMLElement) => { + const scroller = tabsRef.current; + if (!scroller || !item.matches(':hover')) return; + const overflow = item.getBoundingClientRect().right - scroller.getBoundingClientRect().right; + if (overflow <= 0) return; + const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; + scroller.scrollBy({ left: Math.ceil(overflow) + 2, behavior: reduced ? 'auto' : 'smooth' }); + }, [tabsRef]); + const handleTabValueChange = useCallback((value: string) => { activateScene(value as SceneTabId); }, [activateScene]); @@ -263,7 +277,6 @@ const SceneBar: React.FC = ({
; diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 24b97b5ccb..1128760bcc 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -848,7 +848,6 @@ "currentHostHint": "Files and commands stay on the current work host.", "otherHostHint": "Connect to a remote host or container over SSH. Native WSL is only available on the current Windows work host.", "wslHint": "Next, select a Linux distribution on the current Windows work host.", - "foldersLockedHint": "Remove folders to switch.", "connectionChoice": "Connection setup", "availableOptions": "Available connections and environments", "refreshOptions": "Refresh available options", @@ -2344,12 +2343,11 @@ }, "newWorkspace": { "localProjectTitle": "Project on this device", - "localProjectDescription": "Use existing folders on this device, or start a new project.", + "localProjectDescription": "Link existing folders or start a new project.", "deviceProjectTitle": "Project on another device", - "deviceProjectDescription": "Create it on another online device signed in to your account. Everything works as it does here.", + "deviceProjectDescription": "Create on another of your online devices.", "createDeviceProject": "Create a project on another device", "chooseDevice": "Choose a device", - "chooseDeviceDescription": "The project is created and stored on the selected device.", "noOtherDevices": "No other devices are online. Sign in to the same account in OpenBitFun on another device to choose it here.", "signInForDevices": "Sign in to create projects on your other online devices.", "createProjectOn": "Create a project on {{name}}", @@ -2357,7 +2355,6 @@ "connecting": "Connecting…", "deviceError": "Couldn't connect to this device. Try again later.", "title": "Create", - "description": "Choose what you want to create.", "assistantTitle": "Personal assistant", "assistantDescription": "An ongoing collaborator with its own memory.", "projectTitle": "Project", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 9fad03e0d7..3ac0b7337f 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -848,7 +848,6 @@ "currentHostHint": "文件和命令均位于当前工作主机。", "otherHostHint": "通过 SSH 连接远程主机或容器。原生 WSL 仅支持当前 Windows 工作主机。", "wslHint": "下一步选择当前 Windows 工作主机上的 Linux 发行版。", - "foldersLockedHint": "移除文件夹后可切换。", "connectionChoice": "连接配置", "availableOptions": "可用连接与环境", "refreshOptions": "刷新可用选项", @@ -2344,12 +2343,11 @@ }, "newWorkspace": { "localProjectTitle": "本机工程", - "localProjectDescription": "在这台设备上关联已有文件夹,或创建全新工程。", + "localProjectDescription": "关联已有文件夹,或新建工程。", "deviceProjectTitle": "其他设备上的工程", - "deviceProjectDescription": "在同账号的其他在线设备上创建,操作与本机一致。", + "deviceProjectDescription": "在你的其他在线设备上创建。", "createDeviceProject": "在其他设备上创建工程", "chooseDevice": "选择设备", - "chooseDeviceDescription": "工程会创建并保存在所选设备上。", "noOtherDevices": "暂无其他在线设备。在其他设备上登录同一账号并打开 OpenBitFun 后,即可在这里选择。", "signInForDevices": "登录账号后,可在同账号的其他在线设备上创建工程。", "createProjectOn": "在 {{name}} 上创建工程", @@ -2357,7 +2355,6 @@ "connecting": "正在连接…", "deviceError": "无法连接该设备,请稍后重试。", "title": "创建", - "description": "选择要创建的内容。", "assistantTitle": "个人助理", "assistantDescription": "与你长期协作,拥有独立记忆。", "projectTitle": "工程", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index de8bd14b95..fbdfa93d1e 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -848,7 +848,6 @@ "currentHostHint": "檔案和命令均位於目前工作主機。", "otherHostHint": "透過 SSH 連線遠端主機或容器。原生 WSL 僅支援目前 Windows 工作主機。", "wslHint": "下一步選擇目前 Windows 工作主機上的 Linux 發行版。", - "foldersLockedHint": "移除資料夾後可切換。", "connectionChoice": "連線設定", "availableOptions": "可用連線與環境", "refreshOptions": "重新整理可用選項", @@ -2344,12 +2343,11 @@ }, "newWorkspace": { "localProjectTitle": "本機專案", - "localProjectDescription": "在這台裝置上關聯現有資料夾,或建立全新專案。", + "localProjectDescription": "關聯既有資料夾,或新建工程。", "deviceProjectTitle": "其他裝置上的專案", - "deviceProjectDescription": "在同帳號的其他線上裝置上建立,操作與本機一致。", + "deviceProjectDescription": "在你的其他線上裝置上建立。", "createDeviceProject": "在其他裝置上建立專案", "chooseDevice": "選擇裝置", - "chooseDeviceDescription": "專案會建立並儲存在所選裝置上。", "noOtherDevices": "暫無其他線上裝置。在其他裝置上登入同一帳號並開啟 OpenBitFun 後,即可在這裡選擇。", "signInForDevices": "登入帳號後,可在同帳號的其他線上裝置上建立專案。", "createProjectOn": "在 {{name}} 上建立專案", @@ -2357,7 +2355,6 @@ "connecting": "正在連線…", "deviceError": "無法連線到該裝置,請稍後重試。", "title": "建立", - "description": "選擇要建立的內容。", "assistantTitle": "個人助理", "assistantDescription": "與你長期協作,擁有獨立記憶。", "projectTitle": "專案", From 3d5ed57f337f04da317410efc7ae6c24062de14d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 16:29:08 +0800 Subject: [PATCH 41/52] style(web-ui): silver light for this device and idle devices The device card's light was purple on this device and blue on a device not yet connected. Both now share one neutral silver drawn from the secondary content colour, so it follows light and dark themes; only a live connection keeps its green, and an incompatible device its amber. The Devices & Connections dialog's ambient light uses the same silver. --- src/web-ui/src/app/components/NavPanel/NavPanel.scss | 8 +++----- .../RemoteConnectDialog/RemoteConnectDialog.scss | 8 ++++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 260009bdfe..03471b0891 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1577,7 +1577,9 @@ $_section-header-height: 22px; --_device-overview-inline-size: min(312px, calc(100vw - 16px)); --_device-overview-stage-size: 120px; --_device-overview-inset: var(--openbitfun-space-4); - --_device-overview-light: var(--openbitfun-color-accent-default); + // Silver until a device is under control: this device and a device not yet + // connected share one neutral light; only a live connection turns green. + --_device-overview-light: var(--openbitfun-color-content-secondary); --_device-overview-light-strength: 16%; position: fixed; z-index: var(--openbitfun-layer-popover); @@ -1606,10 +1608,6 @@ $_section-header-height: 22px; --_device-overview-light-strength: 26%; } - &[data-control-state='local'] { - --_device-overview-light: var(--openbitfun-color-accent-secondary); - } - &[data-control-state='active'] { --_device-overview-light: var(--openbitfun-color-status-success-content); } diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 5f075d8b88..a2e52d7650 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -2,16 +2,16 @@ .openbitfun-remote-connect-dialog { // The device card's atmosphere: one soft light from above in a single - // hue, glass panes on it, round glass actions. No second colour. - --_rc-ambient: var(--openbitfun-color-accent-secondary); - --_rc-ambient-strength: 12%; + // silver, glass panes on it, round glass actions. No second colour. + --_rc-ambient: var(--openbitfun-color-content-secondary); + --_rc-ambient-strength: 9%; background: radial-gradient(80% 55% at 50% 0%, color-mix(in srgb, var(--_rc-ambient) var(--_rc-ambient-strength), transparent), transparent 75%), radial-gradient(60% 45% at 100% 100%, color-mix(in srgb, var(--_rc-ambient) calc(var(--_rc-ambient-strength) / 3), transparent), transparent 70%), var(--openbitfun-color-surface-raised); :where([data-color-scheme='dark']) & { - --_rc-ambient-strength: 22%; + --_rc-ambient-strength: 18%; } block-size: min(620px, calc(100vh - 2 * var(--openbitfun-overlay-dialog-viewport-gutter))); From 7d354028644280efd7aba963beb0b0d6d365339d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 16:52:25 +0800 Subject: [PATCH 42/52] fix(web-ui): keep the last scene tab's close action clear of the scroll arrow Hovering a tab grows it by its close action. On the last tab that growth widened the strip, so the forward scroll arrow reappeared on top of the close button. Reserve the growth once at the strip's end and let the last tab grow into it, so the strip's width never changes on hover; tabs that grow past the visible edge measure against the arrow when it shows. --- .../src/app/components/SceneBar/SceneBar.scss | 27 +++++++++++++++++++ .../src/app/components/SceneBar/SceneBar.tsx | 5 +++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.scss b/src/web-ui/src/app/components/SceneBar/SceneBar.scss index a94afbe1e3..1fa194f6d0 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.scss +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.scss @@ -242,6 +242,33 @@ } @media (hover: hover) and (pointer: fine) { + // A hovered tab grows by its close action. Reserve that growth once at the + // strip's end and let the last tab grow into it, so hovering the last tab + // never widens the strip: the scroll arrow stays hidden at the end instead + // of reappearing over the close button. + .openbitfun-scene-bar__tabs { + --_scene-bar-close-growth: calc( + max( + var(--openbitfun-control-tab-group-item-padding-inline-sm), + (var(--openbitfun-control-tab-group-item-height-sm) + var(--openbitfun-control-tab-group-item-action-size)) / 2 + + var(--openbitfun-control-tab-group-item-gap-sm) + ) + - var(--openbitfun-control-tab-group-item-padding-inline-sm) + ); + + padding-inline-end: calc(var(--openbitfun-border-width-default) + var(--_scene-bar-close-growth)); + } + + .openbitfun-scene-bar__item:last-child > [data-openbitfun-part='item'][data-has-end-action='true'] { + transition: + padding-inline-end var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), + margin-inline-end var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + + &:is(:hover, :focus-visible, :has(:focus-visible)) { + margin-inline-end: calc(0px - var(--_scene-bar-close-growth)); + } + } + .openbitfun-scene-bar [data-scene-bar-part='closeTab']:hover:not(:disabled) { color: var(--openbitfun-color-status-danger-emphasis); } diff --git a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx index 060d504def..19a188f8f3 100644 --- a/src/web-ui/src/app/components/SceneBar/SceneBar.tsx +++ b/src/web-ui/src/app/components/SceneBar/SceneBar.tsx @@ -142,7 +142,10 @@ const SceneBar: React.FC = ({ const revealGrownTab = useCallback((item: HTMLElement) => { const scroller = tabsRef.current; if (!scroller || !item.matches(':hover')) return; - const overflow = item.getBoundingClientRect().right - scroller.getBoundingClientRect().right; + // The visible edge is the scroller's, or the forward arrow's when it shows. + const arrow = scroller.parentElement?.querySelector('[data-openbitfun-part="scrollNext"]:not(:disabled)'); + const edge = Math.min(scroller.getBoundingClientRect().right, arrow?.getBoundingClientRect().left ?? Infinity); + const overflow = item.getBoundingClientRect().right - edge; if (overflow <= 0) return; const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; scroller.scrollBy({ left: Math.ceil(overflow) + 2, behavior: reduced ? 'auto' : 'smooth' }); From 9a055084712a420b045855356b8d38e4b9007363 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 16:52:25 +0800 Subject: [PATCH 43/52] fix(web-ui): keep the session goal action steady when the + menu opens Opening the composer's + menu refreshes the mode catalog, which publishes new mode objects. The target-mode tool effect keyed on that object, so it cleared the resolved tools and refetched them, hiding 'Set session goal' until the profile config returned. Key the effect on the mode's id, source and default tools, refetch on explicit config events, and keep the last known tools while the same mode re-resolves. --- .../src/flow_chat/components/ChatInput.tsx | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 66e2ab1971..3997ea2bda 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1555,42 +1555,71 @@ export const ChatInput: React.FC = ({ targetWorkspacePath, ]); + // Catalog refreshes (e.g. opening the "+" menu) publish new mode objects with + // identical content. Key tool resolution on stable values so a refresh does + // not hide tool-gated actions; explicit config events bump the revision. + const targetModeId = targetModeInfo?.id ?? null; + const targetModeSource = targetModeInfo?.source ?? null; + const targetModeDefaultToolsKey = targetModeInfo?.defaultTools + ? targetModeInfo.defaultTools.join('\n') + : null; + const targetModeInfoRef = useRef(targetModeInfo); + targetModeInfoRef.current = targetModeInfo; + const resolvedTargetModeKeyRef = useRef(null); + const [targetModeConfigRevision, setTargetModeConfigRevision] = useState(0); + useEffect(() => { - if (isSubagentInputTarget) { - setTargetModeEnabledTools(null); - setTargetModeToolsResolved(false); - return; - } + const bumpRevision = () => setTargetModeConfigRevision(revision => revision + 1); + globalEventBus.on('mode:config:updated', bumpRevision); + globalEventBus.on('custom-agent:updated', bumpRevision); + return () => { + globalEventBus.off('mode:config:updated', bumpRevision); + globalEventBus.off('custom-agent:updated', bumpRevision); + }; + }, []); - if (!targetModeInfo) { + useEffect(() => { + const clearTargetModeTools = () => { + resolvedTargetModeKeyRef.current = null; setTargetModeEnabledTools(null); setTargetModeToolsResolved(false); + }; + + if (isSubagentInputTarget || !targetModeId) { + clearTargetModeTools(); return; } - if (targetModeInfo.source === 'external') { - setTargetModeEnabledTools(targetModeInfo.defaultTools ?? null); + + const modeKey = `${targetModeSource ?? ''}:${targetModeId}`; + if (targetModeSource === 'external') { + resolvedTargetModeKeyRef.current = modeKey; + setTargetModeEnabledTools(targetModeInfoRef.current?.defaultTools ?? null); setTargetModeToolsResolved(true); return; } + // Keep the last known tools while re-resolving the same mode so gated + // actions do not flicker; only a different mode starts unresolved. + if (resolvedTargetModeKeyRef.current !== modeKey) { + clearTargetModeTools(); + } + let cancelled = false; - setTargetModeEnabledTools(null); - setTargetModeToolsResolved(false); (async () => { try { - const config = await configAPI.getAgentProfileConfig(targetModeInfo.id); + const config = await configAPI.getAgentProfileConfig(targetModeId); if (!cancelled) { + resolvedTargetModeKeyRef.current = modeKey; setTargetModeEnabledTools(config.enabled_tools ?? null); setTargetModeToolsResolved(true); } } catch (err) { log.error('Failed to load mode tool config for chat input', { err, - targetAgentType: targetModeInfo.id, + targetAgentType: targetModeId, }); if (!cancelled) { - setTargetModeEnabledTools(null); - setTargetModeToolsResolved(false); + clearTargetModeTools(); } } })(); @@ -1598,7 +1627,13 @@ export const ChatInput: React.FC = ({ return () => { cancelled = true; }; - }, [isSubagentInputTarget, targetModeInfo]); + }, [ + isSubagentInputTarget, + targetModeId, + targetModeSource, + targetModeDefaultToolsKey, + targetModeConfigRevision, + ]); const canUseThreadGoal = caps.threadGoal && From 76cfec46956ce5490762738ff64ad64174c741bf Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 17:06:37 +0800 Subject: [PATCH 44/52] fix(remote-connect): report a lost relay reply instead of 'failed to receive ack' The Relay always answers an acknowledged event, so a dropped ack means the controller's own socket closed mid-call, which repeated device connect and disconnect can do. The socket library surfaced that as 'failed to receive ack', which reached the notification center verbatim. Map it to a stable RELAY_REPLY_LOST message, recognise it (and the legacy text) in the Web UI, and show a localized 'connection interrupted, try again' instead. --- .../src/remote_connect/realtime_client.rs | 33 +++++++++++++++++-- .../src/app/services/sessionSceneLifecycle.ts | 10 ++++-- .../peer-device/peerReplyLost.test.ts | 18 ++++++++++ .../peer-device/peerReplyLost.ts | 22 +++++++++++++ src/web-ui/src/locales/en-US/common.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 1 + src/web-ui/src/locales/zh-TW/common.json | 1 + 7 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 src/web-ui/src/infrastructure/peer-device/peerReplyLost.test.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/peerReplyLost.ts diff --git a/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs index 3f6697d168..ea112430a2 100644 --- a/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/realtime_client.rs @@ -3,6 +3,7 @@ use anyhow::{anyhow, bail, Result}; use futures::{stream::FuturesUnordered, StreamExt}; use serde_json::{json, Value}; +use sioc::error::AckError; use sioc::prelude::*; use std::time::Duration; @@ -31,6 +32,21 @@ impl RpcCallDeadlines { } } +/// Stable text for an acknowledgement the socket lost because its connection +/// closed (a reconnect, a device switch, a revoked socket) before the Relay +/// answered. The frontend matches this prefix; keep it byte-stable. +pub const RELAY_REPLY_LOST: &str = + "Relay connection closed before the reply arrived; delivery outcome is unknown"; + +/// The Relay always answers an acknowledged event, so a dropped acknowledgement +/// means this socket went away. Say so instead of the transport's own text. +fn ack_error(error: AckError) -> anyhow::Error { + match error { + AckError::Recv(_) => anyhow!(RELAY_REPLY_LOST), + other => anyhow::Error::from(other), + } +} + pub(crate) fn rpc_call_payload(target: &str, params: Value, timeout: Duration) -> Value { let timeout_ms = u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX); json!({"method":format!("{target}:invoke"),"params":params,"timeoutMs":timeout_ms}) @@ -169,7 +185,8 @@ impl RealtimeConnection { )) .await? .timeout(Duration::from_secs(10)) - .await?; + .await + .map_err(ack_error)?; if ack.payload.0["ok"] != true { bail!("Relay RPC registration rejected"); } @@ -242,7 +259,8 @@ impl RealtimeSender { )) .await? .timeout(Duration::from_secs(10)) - .await?; + .await + .map_err(ack_error)?; if response.payload.0["ok"] != true { bail!( "Relay event rejected: {}", @@ -274,7 +292,7 @@ impl RealtimeSender { .await? .timeout(deadlines.ack) .await - .map_err(anyhow::Error::from) + .map_err(ack_error) }) .await .map_err(|_| anyhow!("Relay RPC timed out; delivery outcome is unknown"))??; @@ -307,6 +325,15 @@ impl RealtimeSender { mod tests { use super::*; + #[tokio::test] + async fn a_dropped_acknowledgement_reads_as_a_lost_reply() { + let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + drop(sender); + let dropped = receiver.await.unwrap_err(); + let error = ack_error(AckError::Recv(dropped)); + assert_eq!(error.to_string(), RELAY_REPLY_LOST); + } + #[test] fn default_rpc_payload_keeps_the_legacy_deadlines() { let deadlines = RpcCallDeadlines::for_timeout(DEFAULT_RPC_TIMEOUT); diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index 2d76685fd6..281b811b85 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -56,8 +56,14 @@ export function startSessionSceneLifecycle(): () => void { } catch (error) { if (isCurrent() && !isSurfaceChangedError(error)) { log.error('Failed to activate session tab', { sessionId: target.sessionId, error }); - const { notificationService } = await import('@/shared/notification-system'); - if (isCurrent()) notificationService.error(error instanceof Error ? error.message : String(error)); + const [{ notificationService }, { peerReplyLostMessage }, { i18nService }] = await Promise.all([ + import('@/shared/notification-system'), + import('@/infrastructure/peer-device/peerReplyLost'), + import('@/infrastructure/i18n'), + ]); + const message = peerReplyLostMessage(error, (key, options) => i18nService.t(key, options)) + ?? (error instanceof Error ? error.message : String(error)); + if (isCurrent()) notificationService.error(message); } return false; } diff --git a/src/web-ui/src/infrastructure/peer-device/peerReplyLost.test.ts b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.test.ts new file mode 100644 index 0000000000..dc4a5ba62e --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { isPeerReplyLost, peerReplyLostMessage } from './peerReplyLost'; + +describe('peerReplyLost', () => { + it('recognises current and legacy lost-reply errors only', () => { + expect(isPeerReplyLost(new Error('Relay connection closed before the reply arrived; delivery outcome is unknown'))).toBe(true); + expect(isPeerReplyLost('failed to receive ack')).toBe(true); + expect(isPeerReplyLost(new Error('Relay is disconnected; request was not submitted'))).toBe(true); + expect(isPeerReplyLost(new Error('RPC target unavailable'))).toBe(false); + expect(isPeerReplyLost(undefined)).toBe(false); + }); + + it('localizes a lost reply and leaves other errors to the caller', () => { + const t = (key: string) => key; + expect(peerReplyLostMessage(new Error('failed to receive ack'), t)).toBe('common:peerHost.connectionInterrupted'); + expect(peerReplyLostMessage(new Error('boom'), t)).toBeNull(); + }); +}); diff --git a/src/web-ui/src/infrastructure/peer-device/peerReplyLost.ts b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.ts new file mode 100644 index 0000000000..fa6c4049fa --- /dev/null +++ b/src/web-ui/src/infrastructure/peer-device/peerReplyLost.ts @@ -0,0 +1,22 @@ +/** + * A device request whose reply was lost because this window's Relay + * connection closed mid-flight (a reconnect or device switch). The request + * may or may not have run, so callers show a calm "try again" instead of the + * transport's own text. + */ + +// `realtime_client::RELAY_REPLY_LOST` keeps this prefix byte-stable; the +// second pattern is the socket library's text from builds before it existed. +const REPLY_LOST = /Relay connection closed before the reply arrived|failed to receive ack|Relay is disconnected/; + +export function isPeerReplyLost(error: unknown): boolean { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + return REPLY_LOST.test(message); +} + +type Translate = (key: string, options?: Record) => string; + +/** Localized copy for a lost reply, or `null` when `error` is something else. */ +export function peerReplyLostMessage(error: unknown, t: Translate): string | null { + return isPeerReplyLost(error) ? t('common:peerHost.connectionInterrupted') : null; +} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 1128760bcc..591b19c712 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -2365,6 +2365,7 @@ "back": "Back to selection" }, "peerHost": { + "connectionInterrupted": "The connection to the device was interrupted. Try again.", "unavailable": "This feature is not available on that device.", "updateRequired": "The OpenBitFun version on that device does not support this feature. Try updating OpenBitFun on that device.", "withDetail": "{{message}} ({{detail}})" diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 3ac0b7337f..1fac50f4f8 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -2365,6 +2365,7 @@ "back": "返回选择" }, "peerHost": { + "connectionInterrupted": "与设备的连接中断了,请重试。", "unavailable": "此功能在该设备上不可用。", "updateRequired": "该设备上的 OpenBitFun 版本不支持此功能。请尝试在该设备上更新 OpenBitFun。", "withDetail": "{{message}}({{detail}})" diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index fbdfa93d1e..2dac9d868b 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -2365,6 +2365,7 @@ "back": "返回選擇" }, "peerHost": { + "connectionInterrupted": "與裝置的連線中斷了,請重試。", "unavailable": "此功能在該裝置上無法使用。", "updateRequired": "該裝置上的 OpenBitFun 版本不支援此功能。請嘗試在該裝置上更新 OpenBitFun。", "withDetail": "{{message}}({{detail}})" From f2219756ddcdd88edc7d3fa08b0b2c159e571965 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 17:06:37 +0800 Subject: [PATCH 45/52] fix(remote-connect): start history pages at a turn boundary A peer session's first page held the newest 48 records, which could cut the oldest turn in half. The automatic older page then rebuilt that turn in place a few seconds after open, so the visible list jolted. The limit is now a budget: a page grows back to its oldest turn's header within the existing event and byte bounds, so no turn splits across pages and older pages continue exactly before it. Older controllers accept larger pages. --- .../services/services-integrations/AGENTS.md | 3 + .../src/remote_connect/host_stream.rs | 292 ++++++++++++++++-- .../remote_connect/host_stream_subscriber.rs | 56 +++- 3 files changed, 315 insertions(+), 36 deletions(-) diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 7a227b1373..1ce4f32bb0 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -42,6 +42,9 @@ slices that are outside pure product logic but still platform-neutral. Only the requested page's bodies enter the bounded log; the pending source batch is at most one persisted turn, which can itself be large. No wire or persisted-format migration is required; all controllers retain `read_stream`. + `limit` is a budget for history reads: a page grows to the first record of its + oldest turn so no turn splits across pages, within the hard event and byte + bounds. Controllers must not assume a page holds at most `limit` events. - The `remote-persistence` feature is the lightweight persisted-shape owner shared by Remote Connect, remote SSH, and offline migration. Keep it free of network, SSH transport, and runtime orchestration dependencies so owner readers and diff --git a/src/crates/services/services-integrations/src/remote_connect/host_stream.rs b/src/crates/services/services-integrations/src/remote_connect/host_stream.rs index e2f500078b..acf19362e8 100644 --- a/src/crates/services/services-integrations/src/remote_connect/host_stream.rs +++ b/src/crates/services/services-integrations/src/remote_connect/host_stream.rs @@ -44,6 +44,8 @@ pub struct StreamEvent { /// One `read_stream` answer. `cursor` is the newest sequence the host holds; /// forward reads continue after it, history reads continue before `events[0]`. +/// A history page starts on a turn boundary whenever its bounds allow, so it can +/// hold more than the requested `limit` events; see `StreamLog::backward_page`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct StreamPage { pub stream_id: String, @@ -108,6 +110,28 @@ struct HistoryReader { newer_evicted: bool, } +/// The extent of one history page, see `StreamLog::backward_page`. +struct BackwardPage { + /// Oldest sequence of the page; `None` when nothing lies before the request. + start: Option, + /// Older events could not change this page, so none need materializing. + settled: bool, +} + +/// The turn a session record belongs to, and whether it is that turn's header. +/// Control events and tombstones name no turn. +fn record_turn(event: &StreamEvent) -> Option<(&str, bool)> { + if event.event != "session-record" { + return None; + } + let turn = event.payload["turn"]["turnId"].as_str()?; + let is_header = event.payload["id"] + .as_str() + .and_then(|id| id.strip_prefix("turn/")) + == Some(turn); + Some((turn, is_header)) +} + struct StreamLog { history: Option, epoch: u64, @@ -221,6 +245,71 @@ impl StreamLog { self.events.keys().next().copied().unwrap_or(self.next_seq) } + /// Measure a history page ending just before `before`. `limit` is a budget: + /// once spent, the page keeps growing to the first record of the turn it + /// stopped in, because a turn split across pages is rebuilt in place when the + /// older page arrives, which moves the transcript under its reader. + /// + /// `MAX_PAGE_EVENTS` and `PAGE_BYTES` stay hard bounds. A turn that cannot fit + /// gives way to the newer whole turns of the page; only a page holding no turn + /// header at all (one enormous turn) stays partial, and always makes progress. + /// + /// Headers lead their turn in backfill. A live status change republishes only + /// the header, above its unchanged items; when the budget runs out on such a + /// header the page ends there and those items arrive with the older page. + /// Sizes are inspected by reference: `read_history` calls this once per + /// materialized record, and cloning here would make a read quadratic in bytes. + fn backward_page(&self, before: u64, limit: usize) -> BackwardPage { + let mut start = None; + let mut count = 0; + let mut bytes = 0; + let mut oldest_turn = None; + let mut oldest_is_header = false; + let mut oldest_header = None; + let mut bounded = false; + let mut other_turn = false; + for (&seq, (event, size)) in self.events.range(..before).rev() { + if count > 0 && (count >= MAX_PAGE_EVENTS || bytes + size > PAGE_BYTES) { + bounded = true; + break; + } + let record = record_turn(event); + if count >= limit { + other_turn = match record { + Some((turn, _)) => oldest_turn != Some(turn), + None => oldest_turn.is_none(), + }; + if other_turn { + break; + } + } + count += 1; + bytes += size; + if let Some((turn, is_header)) = record { + oldest_turn = Some(turn); + oldest_is_header = is_header; + if is_header { + oldest_header = Some(seq); + } + } + // Past the budget, control events join only between records of the + // turn; trailing ones wait for the next page. + if count <= limit || record.is_some() { + start = Some(seq); + } + } + bounded = bounded || bytes >= PAGE_BYTES || count >= MAX_PAGE_EVENTS; + if bounded && !oldest_is_header && oldest_header.is_some() { + start = oldest_header; + } + BackwardPage { + start, + settled: bounded + || other_turn + || (count >= limit && (oldest_is_header || oldest_turn.is_none())), + } + } + fn read(&self, stream_id: &str, request: &StreamReadRequest) -> Result { if request.after.is_some() && request.before.is_some() { bail!("read_stream accepts either after or before, not both"); @@ -244,17 +333,17 @@ impl StreamLog { iter.peek().is_some() } else { let before = request.before.unwrap_or(u64::MAX); - let mut iter = self.events.range(..before).rev().peekable(); - while let Some((_, (event, size))) = iter.peek() { - if !events.is_empty() && (events.len() >= limit || bytes + size > PAGE_BYTES) { - break; + match self.backward_page(before, limit).start { + Some(start) => { + events.extend( + self.events + .range(start..before) + .map(|(_, (event, _))| event.clone()), + ); + self.events.range(..start).next().is_some() } - events.push((*event).clone()); - bytes += size; - iter.next(); + None => false, } - events.reverse(); - iter.peek().is_some() }; Ok(StreamPage { stream_id: stream_id.to_owned(), @@ -566,23 +655,11 @@ impl HostStreamHub { .get_mut(&request.stream_id) .context("History stream expired")?; let before = request.before.unwrap_or(u64::MAX); - let mut count = 0; - let mut bytes = 0; - let mut page_full = false; - // Inspect sizes by reference. Cloning the growing page once per - // inserted record turns a bounded read into quadratic copying. - for (_, (_, size)) in log.events.range(..before).rev() { - if count > 0 && (count >= limit || bytes + size > PAGE_BYTES) { - page_full = true; - break; - } - count += 1; - bytes += size; - } - if count >= limit || bytes >= PAGE_BYTES || page_full { + let page = log.backward_page(before, limit); + if page.settled { break; } - if log.truncated && count == 0 { + if log.truncated && page.start.is_none() { bail!("Older history exceeded the host stream memory budget; reopen the session to reload its latest page"); } let Some(history) = log.history.as_mut() else { @@ -898,10 +975,17 @@ mod tests { "item":{"type":"text","data":{"id":format!("{turn}-{item}"),"content":"original"}}}) } + fn history_header(turn: usize) -> Value { + serde_json::json!({"sessionId":"s", "id":format!("turn/t{turn}"), + "turn":{"turnId":format!("t{turn}"), "turnIndex":turn}}) + } + async fn history_fixture(before: Option) -> Result { let turn = before.unwrap_or(100).saturating_sub(1); + let mut records = vec![history_header(turn)]; + records.extend((0..3).map(|item| history_record(turn, item))); Ok(HistoryBatch { - records: (0..3).map(|item| history_record(turn, item)).collect(), + records, before: (turn > 0).then_some(turn), }) } @@ -920,9 +1004,9 @@ mod tests { }; let first = hub.read_history("phone", &request, load).await.unwrap(); assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1); - assert_eq!(first.events.len(), 2); + assert_eq!(first.events.len(), 4, "the budget grows to the turn header"); assert!(first.has_more); - assert_eq!(first.events[0].payload["id"], "item/99-1"); + assert_eq!(first.events[0].payload["id"], "turn/t99"); assert!(first.cursor < JS_MAX_SAFE_INTEGER); let wire = serde_json::to_string(&first).unwrap(); assert_eq!(serde_json::from_str::(&wire).unwrap(), first); @@ -965,16 +1049,158 @@ mod tests { } #[tokio::test] - async fn paged_history_live_updates_remove_stale_pending_records() { + async fn history_pages_hold_whole_turns_and_continue_exactly_before_them() { let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + let reads = std::sync::atomic::AtomicUsize::new(0); + let load = |before| { + reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + history_fixture(before) + }; + // The budget runs out in the second turn; the third is never read. let request = StreamReadRequest { - limit: Some(1), + limit: Some(5), ..request("s") }; - let first = hub - .read_history("phone", &request, history_fixture) + let first = hub.read_history("phone", &request, load).await.unwrap(); + assert_eq!(first.events.len(), 8); + assert_eq!(first.events[0].payload["id"], "turn/t98"); + assert!(first.has_more); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 2); + let older = hub + .read_history( + "phone", + &StreamReadRequest { + before: Some(first.events[0].seq), + epoch: Some(first.epoch), + ..request + }, + load, + ) .await .unwrap(); + assert_eq!(older.cursor, first.cursor); + assert_eq!(older.events[0].payload["id"], "turn/t96"); + assert_eq!( + older.events.last().unwrap().seq + 1, + first.events[0].seq, + "the older page continues exactly before the first" + ); + assert!(older + .events + .iter() + .all(|event| !["t98", "t99"] + .contains(&event.payload["turn"]["turnId"].as_str().unwrap()))); + } + + #[tokio::test] + async fn history_pages_give_way_to_a_turn_that_cannot_fit() { + let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + let load = |before: Option| async move { + let (turn, items, older) = match before { + None => (1, 2, Some(1)), + Some(_) => (0, 1000, None), + }; + let mut records = vec![history_header(turn)]; + records.extend((0..items).map(|item| history_record(turn, item))); + Ok(HistoryBatch { + records, + before: older, + }) + }; + let mut request = StreamReadRequest { + limit: Some(5), + ..request("s") + }; + let first = hub.read_history("phone", &request, load).await.unwrap(); + assert_eq!(first.events.len(), 3, "only the whole newer turn travels"); + assert_eq!(first.events[0].payload["id"], "turn/t1"); + assert!(first.has_more); + // The enormous turn pages through its hard bound with nothing skipped. + let mut seen = HashSet::new(); + request.before = Some(first.events[0].seq); + request.epoch = Some(first.epoch); + loop { + let page = hub.read_history("phone", &request, load).await.unwrap(); + assert!(!page.events.is_empty() && page.events.len() <= MAX_PAGE_EVENTS); + for event in &page.events { + assert!(seen.insert(event.payload["id"].as_str().unwrap().to_owned())); + } + request.before = Some(page.events[0].seq); + if !page.has_more { + assert_eq!(page.events[0].payload["id"], "turn/t0"); + break; + } + } + assert_eq!(seen.len(), 1001); + } + + #[tokio::test] + async fn latest_pages_find_turn_boundaries_among_live_records() { + let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + hub.activate("s"); + let turn = |id: &str, status: &str| serde_json::json!({"sessionId":"s","id":format!("turn/{id}"),"turn":{"turnId":id,"status":status}}); + let item = |turn: &str, id: &str| serde_json::json!({"sessionId":"s","id":format!("item/{id}"),"turn":{"turnId":turn},"item":{"type":"text","data":{"id":id}}}); + let (a, b, c) = (item("one", "a"), item("one", "b"), item("two", "c")); + let records = vec![ + turn("one", "inprogress"), + a.clone(), + b.clone(), + turn("two", "inprogress"), + c.clone(), + ]; + hub.synchronize_records("s".into(), true, || async { Ok(records) }) + .await + .unwrap(); + hub.append("s".into(), "note".into(), serde_json::json!({})) + .await + .unwrap(); + let ids = |page: &StreamPage| -> Vec { + page.events + .iter() + .map(|event| event.payload["id"].as_str().unwrap_or("note").to_owned()) + .collect() + }; + let small = |limit| StreamReadRequest { + limit: Some(limit), + ..request("s") + }; + // Control events are looked through while the turn is still open... + let latest = hub.read("d", &small(2)).unwrap(); + assert_eq!(ids(&latest), vec!["turn/two", "item/c", "note"]); + assert!(latest.has_more); + // ...and a status change moves only the header above the items it heads: + // a budget spent on it ends the page there, the items follow older. + let (a2, b2) = (a.clone(), b.clone()); + hub.synchronize_records("s".into(), false, || async { + Ok(vec![turn("one", "completed"), a2, b2]) + }) + .await + .unwrap(); + let latest = hub.read("d", &small(1)).unwrap(); + assert_eq!(ids(&latest), vec!["turn/one"]); + assert!(latest.has_more); + let latest = hub.read("d", &small(3)).unwrap(); + assert_eq!(ids(&latest), vec!["turn/two", "item/c", "note", "turn/one"]); + } + + #[tokio::test] + async fn paged_history_live_updates_remove_stale_pending_records() { + let hub = HostStreamHub::start(Arc::new(Recorder(Default::default()))); + let request = StreamReadRequest { + limit: Some(1), + ..request("s") + }; + // An item filling the byte bound travels alone, whatever its turn holds. + let load = |before| async move { + let mut batch = history_fixture(before).await?; + if before.is_none() { + batch.records.last_mut().unwrap()["item"]["data"]["content"] = + Value::from("x".repeat(PAGE_BYTES)); + } + Ok(batch) + }; + let first = hub.read_history("phone", &request, load).await.unwrap(); + assert_eq!(first.events.len(), 1); // Two old items still wait in the pending batch. The live turn deletes // them, and changes the item already delivered in the first page. let mut updated = history_record(99, 2); @@ -989,7 +1215,7 @@ mod tests { after: Some(first.cursor), ..request.clone() }, - history_fixture, + load, ) .await .unwrap(); @@ -1006,7 +1232,7 @@ mod tests { before: Some(first.events[0].seq), ..request }, - history_fixture, + load, ) .await .unwrap(); diff --git a/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs b/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs index 8d77d9aaa3..9a704f3e3e 100644 --- a/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs +++ b/src/crates/services/services-integrations/src/remote_connect/host_stream_subscriber.rs @@ -11,10 +11,12 @@ use std::{sync::Arc, time::Duration}; use tokio::sync::{broadcast, mpsc, oneshot}; const KEEPALIVE: Duration = Duration::from_secs(4 * 60); -/// Events in the first page of an open: enough to fill a transcript view, so -/// the session shows after one small read instead of a full 200-event / +/// Event budget of the first page of an open: enough to fill a transcript view, +/// so the session shows after one small read instead of a full 200-event / /// 768 KiB page over the relay. Older pages follow in the background. Every -/// host honours `limit`, so no capability is needed. +/// host honours `limit`, so no capability is needed; current hosts extend the +/// page to the first record of the turn the budget ran out in, older ones cut it +/// mid-turn and their boundary turn fills in with the next page. const FIRST_PAGE_EVENTS: usize = 48; const UNSUPPORTED_HOST_MESSAGE: &str = "The controlled device runs an older OpenBitFun version that does not support on-demand session streaming. Update OpenBitFun on that device to continue."; @@ -596,6 +598,54 @@ mod tests { assert_eq!(reads[0].limit, Some(super::FIRST_PAGE_EVENTS)); } + #[tokio::test] + async fn opening_a_long_session_shows_whole_turns_and_pages_older_ones_after_them() { + let lab = lab(); + lab.hub.activate("s"); + let mut records = Vec::new(); + for turn in 0..5 { + records.push(record("s", &format!("t{turn}"), "completed")); + for item in 0..19 { + let mut body = record("s", &format!("t{turn}"), "completed"); + let id = format!("{turn}-{item}"); + body["id"] = serde_json::json!(format!("item/{id}")); + body["round"] = + serde_json::json!({"id":format!("r{turn}"),"turnId":format!("t{turn}")}); + body["item"] = serde_json::json!({"type":"text","data":{"id":id}}); + records.push(body); + } + } + lab.hub + .synchronize_records("s".into(), true, || async { Ok(records) }) + .await + .unwrap(); + let subscriber = lab.subscribe("s").await.unwrap(); + let ids = |lab: &Lab| -> Vec { + lab.events + .lock() + .unwrap() + .iter() + .filter(|(event, _)| event == "session-record") + .map(|(_, payload)| payload["id"].as_str().unwrap().to_owned()) + .collect() + }; + // 48 events run out inside t2; the page grows back to its header. + let first = ids(&lab); + assert_eq!(first.len(), 60); + assert_eq!(first[0], "turn/t2"); + let ready = lab.events.lock().unwrap().last().unwrap().1.clone(); + assert_eq!(ready["hasMore"], true); + // The older page brings only turns the first page does not touch. + subscriber.load_older().await.unwrap(); + let all = ids(&lab); + assert_eq!(all.len(), 100); + assert_eq!(all[60], "turn/t0"); + let unique: std::collections::HashSet<_> = all.iter().collect(); + assert_eq!(unique.len(), 100); + let ready = lab.events.lock().unwrap().last().unwrap().1.clone(); + assert_eq!(ready["hasMore"], false); + } + #[tokio::test] async fn host_restart_is_announced_as_a_gap_and_replayed_from_the_latest_page() { let lab = lab(); From dcf70973ee7c0b0b08451bb0869c4a5bfc1e9d18 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 17:11:11 +0800 Subject: [PATCH 46/52] perf(web-ui): show progress at once when creating a session, with fewer round trips The sidebar's new-session button swallowed repeat clicks behind a hidden ref and gave no feedback while the session was created. It now turns busy and disabled, and shows a spinner once the wait passes 140 ms. Creation reads the project defaults once and shares them with the driver, sends the mode-resolution reads together, resolves the draft's reasoning preset and token limit together, and syncs an assistant session's title after it is shown. On another device each of these was a relay round trip. --- .../sections/workspaces/WorkspaceItem.tsx | 53 +++++++++++---- .../NavPanel/unifiedSessionCreation.test.ts | 17 +++++ .../flow-chat-manager/SessionModule.test.ts | 15 +++++ .../flow-chat-manager/SessionModule.ts | 50 +++++++++++--- .../local/LocalSessionDriver.test.ts | 34 ++++++++++ .../local/LocalSessionDriver.ts | 67 ++++++++++++++++--- .../src/flow_chat/session-drivers/types.ts | 6 ++ 7 files changed, 208 insertions(+), 34 deletions(-) diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 828fa1ed63..39f3bdb733 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -16,6 +16,7 @@ import { DialogHeading, DialogTitle, OverflowText, + Spinner, StatusPill, } from '@openbitfun/ui'; import React, { Suspense, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react'; @@ -84,6 +85,22 @@ const AssistantSessionHistory = lazyWithRecovery(() => import('./AssistantSessio const WorkspaceSessionBatchModal = lazyWithRecovery(() => import('./WorkspaceSessionBatchModal')); const ScheduledJobsModal = lazyWithRecovery(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); +/** Fast creates finish inside this window and never flash a spinner. */ +const NEW_SESSION_PENDING_VISIBLE_DELAY_MS = 140; + +function useDelayedPendingIndicator(pending: boolean): boolean { + const [visible, setVisible] = useState(false); + useEffect(() => { + if (!pending) { + setVisible(false); + return; + } + const timer = window.setTimeout(() => setVisible(true), NEW_SESSION_PENDING_VISIBLE_DELAY_MS); + return () => window.clearTimeout(timer); + }, [pending]); + return pending && visible; +} + const MAX_WORKSPACE_NAME_CHARS = 80; function containsWorkspaceNameControlCharacter(value: string): boolean { @@ -726,13 +743,16 @@ const WorkspaceItem: React.FC = ({ } }, [t, workspace.rootPath]); - const creatingSessionRef = useRef(false); + // One new session per gesture: a second click while the first is being + // created would open a second draft. The pending state is rendered on the + // button (busy + disabled) so a slow create, e.g. over a peer relay, is + // visibly in progress instead of silently swallowing clicks. + const [creatingSession, setCreatingSession] = useState(false); + const showCreatingSessionIndicator = useDelayedPendingIndicator(creatingSession); const handleCreateSession = useCallback(async (mode?: 'Standard' | 'Cowork' | 'Claw') => { setMenuOpen(false); - // One new session per gesture: a second click while the first is - // being created would open a second draft. - if (creatingSessionRef.current) return; - creatingSessionRef.current = true; + if (creatingSession) return; + setCreatingSession(true); const resolvedMode = mode ?? (workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined); try { const reusableId = findReusableEmptySessionId(workspace, resolvedMode); @@ -757,9 +777,10 @@ const WorkspaceItem: React.FC = ({ { duration: 4000 } ); } finally { - creatingSessionRef.current = false; + setCreatingSession(false); } }, [ + creatingSession, setActiveWorkspace, t, workspace, @@ -839,7 +860,7 @@ const WorkspaceItem: React.FC = ({ 'openbitfun-nav-panel__assistant-item', isSelected && 'is-active', isDragging && 'is-dragging', - menuOpen && 'is-menu-open', + (menuOpen || creatingSession) && 'is-menu-open', isSingle && 'is-single', ].filter(Boolean).join(' ')} data-openbitfun-product-component="workspace-item" @@ -935,13 +956,17 @@ const WorkspaceItem: React.FC = ({ data-openbitfun-product-component="workspace-item" data-openbitfun-product-part="action" type="button" - className="openbitfun-nav-panel__assistant-item-menu-trigger" + className={`openbitfun-nav-panel__assistant-item-menu-trigger${showCreatingSessionIndicator ? ' is-pending' : ''}`} onClick={() => { void handleCreateSession(); }} + disabled={creatingSession} + aria-busy={creatingSession || undefined} aria-label={t('nav.workspaces.actions.newSession')} data-testid="nav-workspace-new-session-btn" data-workspace-id={workspace.id} > - + {showCreatingSessionIndicator + ? + : } @@ -1102,7 +1127,7 @@ const WorkspaceItem: React.FC = ({ 'openbitfun-nav-panel__workspace-item', isSelected && 'is-active', isDragging && 'is-dragging', - menuOpen && 'is-menu-open', + (menuOpen || creatingSession) && 'is-menu-open', sessionsCollapsed && 'is-sessions-collapsed', isSingle && 'is-single', ].filter(Boolean).join(' ')} @@ -1383,13 +1408,17 @@ const WorkspaceItem: React.FC = ({ data-openbitfun-product-component="workspace-item" data-openbitfun-product-part="action" type="button" - className="openbitfun-nav-panel__workspace-item-menu-trigger" + className={`openbitfun-nav-panel__workspace-item-menu-trigger${showCreatingSessionIndicator ? ' is-pending' : ''}`} onClick={handleCreateProjectSession} + disabled={creatingSession} + aria-busy={creatingSession || undefined} aria-label={t('nav.sessions.newSession')} data-testid="nav-workspace-new-session-btn" data-workspace-id={workspace.id} > - + {showCreatingSessionIndicator + ? + : } diff --git a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts index 3d6857e74d..3aaf6988f4 100644 --- a/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts +++ b/src/web-ui/src/app/components/NavPanel/unifiedSessionCreation.test.ts @@ -7,6 +7,23 @@ function source(relativePath: string): string { } describe('unified project session creation', () => { + it('shows a pending new-session button instead of silently swallowing clicks', () => { + const workspaceItem = source('./sections/workspaces/WorkspaceItem.tsx'); + expect(workspaceItem).not.toContain('creatingSessionRef'); + expect(workspaceItem).toContain('const [creatingSession, setCreatingSession] = useState(false);'); + const buttons = workspaceItem.split('data-testid="nav-workspace-new-session-btn"'); + expect(buttons).toHaveLength(3); + for (const before of buttons.slice(0, 2)) { + const button = before.slice(before.lastIndexOf(''))).toContain(''); + } + }); + it('keeps New Session in workspace actions and opens the conversation dock before voice', () => { const mainNav = source('./MainNav.tsx'); const workspaceBody = source('../../layout/WorkspaceBody.tsx'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 594f19d4f6..6a0d0c5b88 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -324,6 +324,21 @@ describe('resolveAgentTypeForSessionCreation', () => { await expect(resolveAgentTypeForSessionCreation('Standard', workspace)).resolves.toBe('Standard'); }); + it('issues the default-mode reads together and keeps the project mode when the global read fails', async () => { + const project = createDeferred<{ supported: boolean; defaults: { mode?: string } }>(); + const reader = vi.fn(() => project.promise); + configApiMocks.getConfig.mockRejectedValue(new Error('preference unavailable')); + agentApiMocks.getAvailableModes.mockResolvedValue([{ id: 'Standard' }, { id: 'Ultimate' }]); + const resolved = resolveAgentTypeForSessionCreation(undefined, { id: 'project' } as any, reader); + await Promise.resolve(); + expect(reader).toHaveBeenCalledTimes(1); + expect(configApiMocks.getConfig).toHaveBeenCalledTimes(1); + expect(agentApiMocks.getAvailableModes).toHaveBeenCalledTimes(1); + expect(projectDefaults.read).not.toHaveBeenCalled(); + project.resolve({ supported: true, defaults: { mode: 'Ultimate' } }); + await expect(resolved).resolves.toBe('Ultimate'); + }); + it('does not override any explicit mode, including the Standard Harness id', async () => { await expect(resolveAgentTypeForSessionCreation('Cowork', null)).resolves.toBe('Cowork'); await expect(resolveAgentTypeForSessionCreation('Standard', null)).resolves.toBe('Standard'); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 63c0db6968..e4c7b0a6a2 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -410,9 +410,28 @@ const resolveSessionWorkspace = (config: SessionConfig): WorkspaceInfo => { return workspace; }; +export type ProjectDefaultsReader = () => ReturnType; + +/** + * One creation reads the owning project's defaults once: the mode resolver and + * the session driver share the same in-flight read instead of issuing two + * host round trips (each one a relay round trip in Peer Device Mode). + */ +export const createProjectDefaultsReader = (projectId: string | undefined): ProjectDefaultsReader | undefined => { + if (!projectId) return undefined; + let read: ReturnType | undefined; + return () => (read ??= readProjectDefaults(projectId)); +}; + +const settledValue = (result: PromiseSettledResult): T => { + if (result.status === 'rejected') throw result.reason; + return result.value; +}; + export const resolveAgentTypeForSessionCreation = async ( requestedMode: string | undefined, - workspace: WorkspaceInfo | null + workspace: WorkspaceInfo | null, + readOwningProjectDefaults?: ProjectDefaultsReader, ): Promise => { if (isAssistantWorkspace(workspace)) { return 'Claw'; @@ -429,21 +448,28 @@ export const resolveAgentTypeForSessionCreation = async ( try { const projectId = workspace?.worktree && !workspace.worktree.isMain && workspace.workspaceKind !== WorkspaceKind.Remote ? workspace.worktree.mainWorkspaceId : workspace?.id; - const projectMode = projectId ? (await readProjectDefaults(projectId)).defaults.mode : undefined; + const readDefaults = readOwningProjectDefaults ?? createProjectDefaultsReader(projectId); + // The three reads are independent host requests. Issue them together so a + // peer-hosted creation pays one relay round trip instead of three; each + // result is still consulted only where the serial order would have read it. + const [projectResult, preferenceResult, modesResult] = await Promise.allSettled([ + readDefaults ? readDefaults() : Promise.resolve(undefined), + configAPI.getConfig(CHAT_INPUT_MODE_PREFERENCE_CONFIG_PATH, { + skipRetryOnNotFound: true, + }) as Promise, + agentAPI.getAvailableModes({ + workspaceId: workspace?.id, + }), + ]); + const projectMode = settledValue(projectResult)?.defaults.mode; const configuredDefaultMode = projectMode || resolveConfiguredChatInputDefaultModeId( - normalizeChatInputModePreference( - await configAPI.getConfig(CHAT_INPUT_MODE_PREFERENCE_CONFIG_PATH, { - skipRetryOnNotFound: true, - }) as AppFlowChatConfig | undefined, - ), + normalizeChatInputModePreference(settledValue(preferenceResult)), ); if (!configuredDefaultMode) { return 'Standard'; } - const availableModes = await agentAPI.getAvailableModes({ - workspaceId: workspace?.id, - }); + const availableModes = settledValue(modesResult); if (availableModes.some(mode => mode.id === configuredDefaultMode)) { return configuredDefaultMode; } @@ -504,7 +530,8 @@ export async function createChatSession( workspace?.workspaceKind === WorkspaceKind.Remote ? workspace.sshHost?.trim() || undefined : undefined; - const agentType = await resolveAgentTypeForSessionCreation(mode, workspace); + const readOwningProjectDefaults = createProjectDefaultsReader(projectId); + const agentType = await resolveAgentTypeForSessionCreation(mode, workspace, readOwningProjectDefaults); surfaceScope.assertCurrent('resolve session creation mode'); const workspaceCreationKey = workspace.id; const draftId = draft ? crypto.randomUUID() : undefined; @@ -544,6 +571,7 @@ export async function createChatSession( workspaceId: workspace?.id, remoteConnectionId, remoteSshHost, + readProjectDefaults: readOwningProjectDefaults, }); surfaceScope.assertCurrent('finish session creation'); return sessionId; diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts index 77ee8df312..fc854b259a 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts @@ -131,6 +131,40 @@ describe('localSessionDriver conversation creation', () => { expect(poolMocks.read).not.toHaveBeenCalled(); }); + it('reuses the creation-scoped project defaults read instead of reading again', async () => { + const shared = vi.fn(async () => ({ supported: true, defaults: { primary_model: 'shared-model' } })); + const createSession = vi.fn(); + await localSessionDriver.createSession({ flowChatStore: { createSession } } as any, { + ...seed(), config: {}, readProjectDefaults: shared, + }); + expect(shared).toHaveBeenCalledTimes(1); + expect(projectDefaults.read).not.toHaveBeenCalled(); + expect(createSession.mock.calls[0][1].modelName).toBe('shared-model'); + }); + + it('opens an assistant session before its title metadata is synchronized', async () => { + const { initializeSessionTitleMetadata } = await import('../../services/sessionTitleMetadata'); + let finishTitle!: (descriptor: unknown) => void; + vi.mocked(initializeSessionTitleMetadata).mockImplementationOnce(() => new Promise(resolve => { + finishTitle = resolve as (descriptor: unknown) => void; + })); + const descriptor = { text: 'New chat', source: 'i18n' as const, key: 'flow-chat:session.new' }; + let state = { sessions: new Map() }; + const createSession = vi.fn((id: string) => { + state = { sessions: new Map(state.sessions).set(id, { sessionId: id, titleSource: 'i18n' }) }; + }); + const setState = vi.fn((update: (current: typeof state) => typeof state) => { state = update(state); }); + await expect(localSessionDriver.createSession( + { flowChatStore: { createSession, setState } } as any, + { ...seed('Claw'), titleDescriptor: descriptor }, + )).resolves.toBe('host-session'); + expect(createSession).toHaveBeenCalledTimes(1); + expect(createSession.mock.calls[0].at(-1)).toBe(descriptor); + + finishTitle({ ...descriptor, workspaceSessionNumber: 3 }); + await vi.waitFor(() => expect(state.sessions.get('host-session')?.workspaceSessionNumber).toBe(3)); + }); + it('preserves an explicit single-model choice even when a default pool exists', async () => { poolMocks.supported.mockReturnValue(true); const createSession = vi.fn(); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 8e5bba1d69..7714b1589a 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -56,6 +56,31 @@ import { inheritReviewPermissionMode } from '../../services/inheritReviewPermiss const log = createLogger('LocalSessionDriver'); +async function syncCreatedSessionTitle( + context: FlowChatContext, + sessionId: string, + descriptor: SessionCreationSeed['titleDescriptor'], + workspaceId: string, + surfaceScope: SessionCreationSeed['surfaceScope'], +): Promise { + try { + const title = await initializeSessionTitleMetadata(sessionId, descriptor, workspaceId, surfaceScope); + if (title.workspaceSessionNumber === undefined) return; + context.flowChatStore.setState(state => { + const current = state.sessions.get(sessionId); + // A title generated or edited meanwhile owns the display; keep it. + if (!current || current.titleSource !== 'i18n') return state; + return { ...state, sessions: new Map(state.sessions).set(sessionId, { + ...current, workspaceSessionNumber: title.workspaceSessionNumber, + }) }; + }); + } catch (error) { + // A surface change abandons this projection; the session itself was + // created and the next catalog read carries the host-owned number. + log.debug('Skipped created session title synchronization', { sessionId, error }); + } +} + export const localSessionDriver: SessionDriver = { id: 'local', @@ -80,11 +105,17 @@ export const localSessionDriver: SessionDriver = { ?? catalog.recentWorkspaces.find(item => item.id === workspaceId); const projectId = workspace?.worktree && !workspace.worktree.isMain && workspace.workspaceKind !== 'remote' ? workspace.worktree.mainWorkspaceId : workspaceId; - const projectPrimary = projectId ? (await readProjectDefaults(projectId)).defaults.primary_model : undefined; + // Reuse the creation's project defaults read: the mode resolver usually + // issued it already, so this costs no second host round trip. + const readDefaults = seed.readProjectDefaults + ?? (projectId ? () => readProjectDefaults(projectId) : undefined); + const projectPrimary = readDefaults ? (await readDefaults()).defaults.primary_model : undefined; surfaceScope.assertCurrent('resolve project default model'); if (projectPrimary) config = { ...config, modelName: projectPrimary }; } if (!config.modelName && !config.modelPoolId && !acpClientIdFromAgentType(agentType)) { + // Served from the config manager cache when warm; read only when no + // project model applies. const { mode: selection } = await readModelSelections(); surfaceScope.assertCurrent('resolve default model target'); if (selection.kind === 'pool') { @@ -99,13 +130,20 @@ export const localSessionDriver: SessionDriver = { const explicitModelName = config.modelName?.trim() || undefined; - const reasoningPreset = config.modelPoolId ? undefined : config.reasoningPreset - ?? await resolveReasoningPresetForSessionCreation(explicitModelName); - surfaceScope.assertCurrent('resolve session creation reasoning preset'); + const requestedReasoningPreset = config.modelPoolId ? undefined : config.reasoningPreset; + const readReasoningPreset = (): Promise => ( + config.modelPoolId || requestedReasoningPreset != null + ? Promise.resolve(requestedReasoningPreset ?? undefined) + : resolveReasoningPresetForSessionCreation(explicitModelName) + ); if (seed.draftId && agentType !== 'Claw' && !config.executionTargetRequest) { if (!workspaceId) throw new Error('Draft workspace ID is unavailable'); - const maxContextTokens = await getModelMaxTokens(explicitModelName, agentType); + // Both reads only depend on the model already resolved above. + const [reasoningPreset, maxContextTokens] = await Promise.all([ + readReasoningPreset(), + getModelMaxTokens(explicitModelName, agentType), + ]); surfaceScope.assertCurrent('prepare conversation draft'); context.flowChatStore.createSession(seed.draftId, { ...config, workspaceId, workspacePath, projectWorkspacePath, reasoningPreset, @@ -116,6 +154,9 @@ export const localSessionDriver: SessionDriver = { return seed.draftId; } + const reasoningPreset = await readReasoningPreset(); + surfaceScope.assertCurrent('resolve session creation reasoning preset'); + const response = await agentAPI.createSession({ sessionName, agentType, @@ -161,11 +202,11 @@ export const localSessionDriver: SessionDriver = { executionTarget: response.executionTarget, }; - const createdTitleDescriptor = await initializeSessionTitleMetadata( - response.sessionId, titleDescriptor, requireSessionOwningWorkspaceId({ config: resolvedConfig }), - surfaceScope, - ); - + // The session exists on the host already; its default title renders from + // the descriptor at once. Synchronize the host-owned title identity and + // workspace session number off the creation critical path (three metadata + // round trips, each a relay round trip in Peer Device Mode), the same way + // a materialized draft does. context.flowChatStore.createSession( response.sessionId, resolvedConfig, @@ -176,7 +217,11 @@ export const localSessionDriver: SessionDriver = { effectiveWorkspacePath, remoteConnectionId, remoteSshHost, - createdTitleDescriptor, + titleDescriptor, + ); + void syncCreatedSessionTitle( + context, response.sessionId, titleDescriptor, + requireSessionOwningWorkspaceId({ config: resolvedConfig }), surfaceScope, ); return response.sessionId; diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts index 5756e6eb99..6e3abddc66 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -21,6 +21,7 @@ import type { SessionDriverId } from './resolve'; import type { SurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { LineRange } from '@/shared/editor/LineRange'; import type { SessionUsageReport } from '@/infrastructure/api/service-api/SessionAPI'; +import type { ProjectDefaultsConfig } from '@/infrastructure/config/types'; export type { SessionDriverId } from './resolve'; @@ -203,6 +204,11 @@ export interface SessionCreationSeed { workspaceId?: string; remoteConnectionId?: string; remoteSshHost?: string; + /** + * Shared read of the owning project's defaults for this creation. The mode + * resolver may already have issued it; reuse it instead of reading again. + */ + readProjectDefaults?: () => Promise<{ supported: boolean; defaults: ProjectDefaultsConfig }>; } /** Cascade facts computed once by the shared caller before removal. */ From fffdf13730e2f8a8aa2ba3ef529cec73934f9dc0 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 4 Nov 2026 17:11:33 +0800 Subject: [PATCH 47/52] feat(web-ui): session menu on other devices' sidebar rows Session rows listed under a device the window is not rendering had no more-actions button. They now carry the same button and menu as live rows (rename, copy, export, open worktree, archive; restore and delete when archived). Each action renders the owning device first, then runs the shared handler, now extracted to sessionRowActions; a superseded switch runs nothing, a missing session fails loudly, and actions the device cannot serve stay disabled with their reason. A lost relay reply on these rows reads as a connection interruption. --- .../devices/BackgroundSessionList.tsx | 115 +++++++- .../devices/CatalogSessionMenu.test.tsx | 127 ++++++++ .../sections/devices/CatalogSessionMenu.tsx | 279 ++++++++++++++++++ .../sections/devices/DeviceCatalogSection.tsx | 8 +- .../sections/devices/openOnDevice.test.ts | 87 +++++- .../NavPanel/sections/devices/openOnDevice.ts | 95 ++++++ .../sessions/ArchivedSessionMenuItems.tsx | 17 +- .../sections/sessions/SessionsSection.scss | 2 +- .../sections/sessions/SessionsSection.tsx | 80 +---- .../sessionActionConfirmation.test.ts | 6 +- .../sections/sessions/sessionRowActions.ts | 103 +++++++ .../sessions/sessionRowContextMenu.test.ts | 17 +- .../sessions/sessionWorktreeRow.test.ts | 14 +- src/web-ui/src/locales/en-US/common.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 1 + src/web-ui/src/locales/zh-TW/common.json | 1 + 16 files changed, 849 insertions(+), 104 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.test.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/devices/CatalogSessionMenu.tsx create mode 100644 src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowActions.ts diff --git a/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx index 94a32aa20c..1792fa12ac 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/devices/BackgroundSessionList.tsx @@ -8,8 +8,8 @@ * after a switch, and the hand-over to the live list moves nothing. */ -import React, { useCallback, useMemo, useSyncExternalStore } from 'react'; -import { Icon, OverflowText, StatusPill } from '@openbitfun/ui'; +import React, { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { Icon, IconButton, Input, OverflowText, StatusPill, Tooltip } from '@openbitfun/ui'; import { Loader2 } from 'lucide-react'; import { useI18n, i18nService } from '@/infrastructure/i18n'; import { surfaceScopedKey, type DeviceSurfaceId } from '@/infrastructure/peer-device/deviceSurface'; @@ -47,6 +47,7 @@ import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { sessionBelongsToWorkspaceNavRow } from '@/flow_chat/utils/sessionOrdering'; import type { SidebarDeviceSlot } from './useSidebarDevices'; +import { CatalogSessionMenu, catalogSessionActionKey, runCatalogSessionAction } from './CatalogSessionMenu'; export interface BackgroundActions { pendingKey: string | null; @@ -88,16 +89,45 @@ const BackgroundSessionRow: React.FC<{ /** Owning workspace name, shown in the flat view like the live row. */ workspaceName?: string; titleNumber?: string; -}> = ({ slot, session, level, activity, actions, flat, workspaceName, titleNumber }) => { + /** Owning workspace name for menu copy (archived delete confirmation). */ + ownerWorkspaceName: string; +}> = ({ slot, session, level, activity, actions, flat, workspaceName, titleNumber, ownerWorkspaceName }) => { + const { t } = useI18n('common'); const peer = usePeerDeviceModeOptional(); + const [menuOpen, setMenuOpen] = useState(false); + const [editing, setEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(''); + const editInputRef = useRef(null); const status = useCatalogSessionStatus(slot.surfaceId, session.sessionId, activity); const title = getTitle(session); const relationship = resolveSessionRelationship(session); const isChildSession = level === 1 && relationship.displayAsChild; const key = `session:${session.sessionId}`; const opening = actions.pendingKey === key; + // A menu action renders the device too; the row shows the same progress. + const acting = actions.pendingKey?.startsWith(catalogSessionActionKey(session.sessionId, '')) ?? false; const workspaceId = session.workspaceId ?? session.config.workspaceId ?? ''; - const open = () => actions.run(key, () => openDeviceSession(peer, slot, workspaceId, { + useEffect(() => { + if (!editing) return; + editInputRef.current?.focus(); + editInputRef.current?.select(); + }, [editing]); + const startRename = () => { + setEditingTitle(title); + setEditing(true); + }; + const cancelRename = () => { + setEditing(false); + setEditingTitle(''); + }; + const confirmRename = () => { + if (!editing) return; + const trimmed = editingTitle.trim(); + cancelRename(); + if (!trimmed || trimmed === title) return; + runCatalogSessionAction(actions, peer, slot, session.sessionId, { kind: 'rename', title: trimmed }, t); + }; + const open = () => editing ? undefined : actions.run(key, () => openDeviceSession(peer, slot, workspaceId, { sessionId: session.sessionId, } as SessionMetadata, title), 'nav.sessions.openFailed', { sessionId: session.sessionId, title }); return ( @@ -109,24 +139,69 @@ const BackgroundSessionRow: React.FC<{ level === 1 && 'is-child', isChildSession && 'is-btw-child', flat && workspaceName && !isChildSession && 'has-workspace-name', - opening && 'is-active', + (opening || acting) && 'is-active', + editing && 'is-editing', + menuOpen && 'is-menu-open', ].filter(Boolean).join(' ')} data-openbitfun-component="sessions-section" data-openbitfun-part="row" - data-openbitfun-state={opening ? 'active' : undefined} + data-openbitfun-state={[ + (opening || acting) && 'active', + editing && 'editing', + menuOpen && 'menuOpen', + ].filter(Boolean).join(' ') || undefined} data-overflow-trigger data-testid="nav-device-session-item" data-session-id={session.sessionId} data-session-level={String(level)} aria-label={title} - aria-busy={opening || undefined} + aria-busy={opening || acting || undefined} onClick={open} onKeyDown={event => { + // Keys typed in the row's own controls (menu button, rename field) stay theirs. + if (event.target !== event.currentTarget) return; if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); open(); }} > + {editing ? ( +
event.stopPropagation()}> + setEditingTitle(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { event.preventDefault(); confirmRename(); } + else if (event.key === 'Escape') { event.preventDefault(); cancelRename(); } + }} + onBlur={confirmRename} + size="sm" + data-testid="nav-device-session-rename-input" + /> + + { event.stopPropagation(); confirmRename(); }} + icon={} + /> + + + { event.preventDefault(); event.stopPropagation(); cancelRename(); }} + icon={} + /> + +
+ ) : <> @@ -151,10 +226,15 @@ const BackgroundSessionRow: React.FC<{
- {opening + {opening || acting ? : } +
+ }
); }; @@ -168,7 +248,7 @@ export const BackgroundSessionList: React.FC<{ workspaceIds: readonly string[]; actions: BackgroundActions; flat?: boolean; - /** Workspace id to name, for the flat view's workspace labels. */ + /** Workspace id to name: the flat view's workspace labels and menu copy. */ workspaceNames?: ReadonlyMap; }> = ({ slot, workspaceIds, actions, flat = false, workspaceNames }) => { const { t } = useI18n('common'); @@ -236,12 +316,17 @@ export const BackgroundSessionList: React.FC<{ return (
- {visibleItems.map(({ session, level }) => ( - sessionBelongsToWorkspaceNavRow(session, id)) ?? '') : undefined} /> - ))} + {visibleItems.map(({ session, level }) => { + const ownerId = workspaceIds.find(id => sessionBelongsToWorkspaceNavRow(session, id)) ?? ''; + const ownerName = workspaceNames?.get(ownerId); + return ( + + ); + })} {visibleItems.length === 0 && (loading ? (