diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index a38dbc115e..b71a6179d2 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -860,6 +860,10 @@ const FlexiblePanel: React.FC = memo(({ viewKind={content.data?.viewKind} displayTitle={content.data?.displayTitle} resizeSuspended={resizeSuspended} + sessionKind={content.data?.sessionKind} + sessionTitle={content.data?.sessionTitle} + parentToolCallId={content.data?.parentToolCallId} + subagentType={content.data?.subagentType} /> ); diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts index 76694a6688..e7e10e7c03 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.appearance.ts @@ -16,7 +16,7 @@ export const btwSessionPanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'actionBar' }, ], facets: [ - { id: 'view', attribute: 'data-openbitfun-view', values: ['empty', 'session'] }, + { id: 'view', attribute: 'data-openbitfun-view', values: ['empty', 'loading', 'unavailable', 'session'] }, ], states: [ { id: 'hasActionBar', selector: { kind: 'self', suffix: '[data-openbitfun-state~="hasActionBar"]' } }, diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.missingSession.test.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.missingSession.test.tsx new file mode 100644 index 0000000000..2cc689fdbc --- /dev/null +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.missingSession.test.tsx @@ -0,0 +1,562 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { BtwSessionPanel } from './BtwSessionPanel'; +import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; +import type { FlowChatState, Session } from '../../types/flow-chat'; + +const panelMocks = vi.hoisted(() => ({ + addExternalSession: vi.fn(), + ensurePersistedSessionMetadata: vi.fn(async () => true), + updateSessionRelationship: vi.fn(), + clearSessionUnreadCompletion: vi.fn(), + hydrateSessionHistoryForDetail: vi.fn(async () => undefined), + findTabByMetadata: vi.fn(), + closeTab: vi.fn(), + cancelSessionTask: vi.fn(async () => true), +})); + +let flowChatState: FlowChatState; +const storeListeners = new Set<(state: FlowChatState) => void>(); + +const translate = (_key: string, options?: Record & { defaultValue?: string }) => + options?.defaultValue ?? _key; + +vi.mock('react-i18next', () => ({ + // The real i18n service is loaded by the auxiliary-pane service under test. + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, + useTranslation: () => ({ t: translate }), +})); + +vi.mock('./BtwVirtualSessionList', () => ({ + BtwVirtualSessionList: () => React.createElement('div', { 'data-testid': 'btw-virtual-session-list' }), +})); + +vi.mock('../modern/useFlowGroupState', () => ({ + useFlowGroupState: () => ({ + groupStates: new Map(), + groupReceiveFeedback: new Map(), + expandedToolCapsules: new Set(), + onToolCapsuleExpandedChange: vi.fn(), + onGroupToggle: vi.fn(), + onExpandGroup: vi.fn(), + onExpandAllInTurn: vi.fn(), + onCollapseGroup: vi.fn(), + }), +})); + +vi.mock('../modern/usePermissionRequests', () => ({ + usePermissionRequests: () => ({ + requests: [], + activeBatch: undefined, + ownedRequests: [], + ownedActiveBatch: undefined, + respond: vi.fn(), + respondBatch: vi.fn(), + }), +})); + +vi.mock('../ChatInputApprovalBand', () => ({ ChatInputApprovalBand: () => null })); +vi.mock('@/flow_chat', () => ({ ScrollToBottomButton: () => null })); +vi.mock('./DeepReviewActionBar', () => ({ ReviewActionBar: () => null })); +vi.mock('@/shared/services/FileTabManager', () => ({ fileTabManager: { openFile: vi.fn() } })); +vi.mock('@/shared/utils/tabUtils', () => ({ createTab: vi.fn() })); +vi.mock('@/infrastructure/api', () => ({ agentAPI: { cancelSession: vi.fn() } })); +vi.mock('@/infrastructure/event-bus', () => ({ + globalEventBus: { emit: vi.fn(), on: vi.fn(() => () => {}) }, +})); +vi.mock('@/shared/notification-system', () => ({ notificationService: { error: vi.fn() } })); +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => ({ debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }), +})); +vi.mock('@openbitfun/ui', async importOriginal => ({ + ...await importOriginal(), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +// The auxiliary-pane service is exercised for real: this suite is about the pane +// healing its own tab, so only the canvas transport underneath it is stubbed. +vi.mock('@/app/components/panels/content-canvas/stores', () => ({ + switchAgentCanvasScope: vi.fn(), + useAgentCanvasStore: { + getState: () => ({ + findTabByMetadata: (...args: unknown[]) => panelMocks.findTabByMetadata(...args), + closeTab: (...args: unknown[]) => panelMocks.closeTab(...args), + }), + }, +})); + +vi.mock('../../services/FlowChatManager', () => ({ + FlowChatManager: { + getInstance: () => ({ + cancelSessionTask: (...args: unknown[]) => panelMocks.cancelSessionTask(...args), + }), + }, + flowChatManager: { + hydrateSessionHistoryForDetail: (...args: unknown[]) => + panelMocks.hydrateSessionHistoryForDetail(...args), + }, +})); + +vi.mock('../../store/modernFlowChatStore', () => ({ + sessionToVirtualItems: () => [], +})); + +vi.mock('../../services/ReviewActionBarPersistenceService', () => ({ + loadPersistedReviewState: vi.fn(() => Promise.resolve(null)), + persistReviewActionState: vi.fn(() => Promise.resolve()), +})); + +vi.mock('../../store/FlowChatStore', () => ({ + FlowChatStore: { + getInstance: () => ({ getState: () => flowChatState, subscribe: () => () => {} }), + }, + flowChatStore: { + getState: () => flowChatState, + retainSessionHistory: () => () => {}, + clearSessionUnreadCompletion: (...args: unknown[]) => panelMocks.clearSessionUnreadCompletion(...args), + addExternalSession: (...args: unknown[]) => panelMocks.addExternalSession(...args), + updateSessionRelationship: (...args: unknown[]) => panelMocks.updateSessionRelationship(...args), + ensurePersistedSessionMetadata: (...args: unknown[]) => + panelMocks.ensurePersistedSessionMetadata(...args), + subscribeSelector: (select: (state: FlowChatState) => T, notify: (selected: T) => void) => { + let previous = select(flowChatState); + const listener = (state: FlowChatState) => { + const next = select(state); + if (Object.is(previous, next)) return; + previous = next; + notify(next); + }; + storeListeners.add(listener); + return () => { storeListeners.delete(listener); }; + }, + subscribe: (listener: (state: FlowChatState) => void) => { + storeListeners.add(listener); + return () => { storeListeners.delete(listener); }; + }, + }, +})); + +function buildParentSession(): Session { + return { + sessionId: 'parent-session', + title: 'Parent', + dialogTurns: [], + status: 'idle', + workspaceId: 'workspace-id', + config: { workspaceId: 'workspace-id' }, + createdAt: 1, + lastActiveAt: 1, + error: null, + } as Session; +} + +function buildChildSession(overrides: Partial = {}): Session { + return { + sessionId: 'missing-child', + title: 'Child', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'missing-child', + userMessage: { id: 'user-1', content: 'work', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }], + status: 'idle', + workspaceId: 'workspace-id', + config: { workspaceId: 'workspace-id' }, + createdAt: 1, + lastActiveAt: 1, + error: null, + sessionKind: 'subagent', + parentSessionId: 'parent-session', + ...overrides, + } as Session; +} + +// The pane recovers from a passive effect, so its state updates land after the +// render that starts them. These tests await the rendered outcome instead of +// wrapping the whole load chain in act(), which that chain outlives. +describe('BtwSessionPanel child-session recovery', () => { + let container: HTMLDivElement; + let root: Root; + + const sessions = () => flowChatState.sessions; + + const publishSessions = (next: Map) => { + flowChatState = { ...flowChatState, sessions: next } as FlowChatState; + Array.from(storeListeners).forEach(listener => listener(flowChatState)); + }; + + const view = () => container + .querySelector('[data-openbitfun-component="btw-session-panel"][data-openbitfun-part="root"]') + ?.getAttribute('data-openbitfun-view'); + + const button = (testId: string) => container.querySelector(`[data-testid="${testId}"]`); + + const flush = async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + await new Promise(resolve => setTimeout(resolve, 0)); + }; + + const waitForView = async (expected: string) => { + await vi.waitFor(() => expect(view()).toBe(expected)); + }; + + const renderPanel = async (props: Partial> = {}) => { + root.render( + , + ); + await flush(); + }; + + beforeEach(() => { + activateSurface('local'); + storeListeners.clear(); + panelMocks.addExternalSession.mockReset(); + panelMocks.addExternalSession.mockImplementation( + (sessionId: string, title: string, _mode: string, workspacePath: string, meta?: Record) => { + // Mirrors FlowChatStore.addExternalSession: the shell is created before + // its history is read, and it is a record with no turns. + publishSessions(new Map(sessions()).set(sessionId, buildChildSession({ + sessionId, + title, + workspacePath, + workspaceId: meta?.workspaceId as string | undefined, + sessionKind: (meta?.sessionKind as Session['sessionKind']) ?? 'btw', + parentSessionId: meta?.parentSessionId as string | undefined, + dialogTurns: [], + historyState: 'new', + isHistorical: false, + }))); + }, + ); + panelMocks.ensurePersistedSessionMetadata.mockReset(); + panelMocks.ensurePersistedSessionMetadata.mockImplementation(async () => true); + panelMocks.updateSessionRelationship.mockClear(); + panelMocks.clearSessionUnreadCompletion.mockReset(); + panelMocks.hydrateSessionHistoryForDetail.mockReset(); + panelMocks.hydrateSessionHistoryForDetail.mockResolvedValue(undefined); + panelMocks.findTabByMetadata.mockReset(); + panelMocks.findTabByMetadata.mockReturnValue({ + tab: { id: 'child-tab' }, + groupId: 'primary', + }); + panelMocks.closeTab.mockReset(); + panelMocks.cancelSessionTask.mockClear(); + + flowChatState = { + sessions: new Map([['parent-session', buildParentSession()]]), + activeSessionId: 'parent-session', + } as FlowChatState; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + } as unknown as typeof ResizeObserver; + }); + + afterEach(async () => { + root.unmount(); + container.remove(); + await flush(); + vi.restoreAllMocks(); + }); + + it('offers a retry and a close action instead of the generic empty copy', async () => { + panelMocks.hydrateSessionHistoryForDetail.mockRejectedValue(new Error('child session is gone')); + + await renderPanel(); + await waitForView('unavailable'); + + expect(container.textContent).toContain('btw.unavailableThreadLabel'); + expect(container.textContent).not.toContain('btw.emptyThreadLabel'); + expect(button('btw-session-panel-retry')).not.toBeNull(); + expect(button('btw-session-panel-close-tab')).not.toBeNull(); + expect(panelMocks.addExternalSession).toHaveBeenCalledTimes(1); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledWith('missing-child'); + }); + + it('shows a loading state while the missing record is being reloaded', async () => { + let failLoad!: (error: Error) => void; + panelMocks.hydrateSessionHistoryForDetail.mockImplementationOnce( + () => new Promise((_resolve, reject) => { failLoad = reject; }), + ); + + await renderPanel(); + + expect(view()).toBe('loading'); + expect(container.textContent).toContain('btw.loadingThreadLabel'); + expect(button('btw-session-panel-retry')).toBeNull(); + + failLoad(new Error('child session is gone')); + await waitForView('unavailable'); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + }); + + it('recreates the record and reads its history when the tab has none', async () => { + let failLoad!: (error: Error) => void; + panelMocks.hydrateSessionHistoryForDetail.mockImplementationOnce( + () => new Promise((_resolve, reject) => { failLoad = reject; }), + ); + + await renderPanel(); + await vi.waitFor(() => expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1)); + + expect(panelMocks.addExternalSession).toHaveBeenCalledTimes(1); + expect(panelMocks.addExternalSession).toHaveBeenCalledWith( + 'missing-child', + expect.any(String), + expect.any(String), + 'D:/workspace/project', + expect.objectContaining({ + parentSessionId: 'parent-session', + workspaceId: 'workspace-id', + }), + undefined, + undefined, + ); + + failLoad(new Error('child session is gone')); + await waitForView('unavailable'); + }); + + it('recreates a missing subagent child with the identity its tab recorded', async () => { + panelMocks.hydrateSessionHistoryForDetail.mockRejectedValue(new Error('child session is gone')); + + await renderPanel({ + sessionKind: 'subagent', + sessionTitle: 'Subagent: verify the pane', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + }); + await waitForView('unavailable'); + + // Without the identity the heal rebuilds a plain side thread, and the history + // read loses the internal turns a subagent restores. + expect(panelMocks.addExternalSession).toHaveBeenCalledWith( + 'missing-child', + 'Subagent: verify the pane', + expect.any(String), + 'D:/workspace/project', + expect.objectContaining({ + parentSessionId: 'parent-session', + sessionKind: 'subagent', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + }), + undefined, + undefined, + ); + expect(sessions().get('missing-child')?.sessionKind).toBe('subagent'); + }); + + it('explains the state without dead controls on a pane with no parent and no tab', async () => { + // What the MiniApp customize chat renders: a child id, no parent, and no + // canvas tab holding it. Nothing there can be reloaded, and there is no tab + // to close, so the pane must not offer controls that do nothing. + panelMocks.findTabByMetadata.mockReturnValue(null); + + await renderPanel({ parentSessionId: undefined }); + + expect(view()).toBe('unavailable'); + expect(container.textContent).toContain('btw.unavailableThreadLabel'); + expect(container.textContent).not.toContain('btw.loadingThreadLabel'); + expect(button('btw-session-panel-retry')).toBeNull(); + expect(button('btw-session-panel-close-tab')).toBeNull(); + expect(panelMocks.addExternalSession).not.toHaveBeenCalled(); + expect(panelMocks.hydrateSessionHistoryForDetail).not.toHaveBeenCalled(); + }); + + it('never arms a load at settle time when the pane lost the parent it needed', async () => { + let failLoad!: (error: Error) => void; + panelMocks.hydrateSessionHistoryForDetail.mockImplementationOnce( + () => new Promise((_resolve, reject) => { failLoad = reject; }), + ); + + // The heal is armed and its load is in flight. + await renderPanel(); + await vi.waitFor(() => expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1)); + expect(view()).toBe('loading'); + + // The record is removed while the load runs, and the parent id goes away, so + // no new attempt could be started at all. No production path drops the parent + // of a mounted pane today; the host simply stops passing it. + const withoutChild = new Map(sessions()); + withoutChild.delete('missing-child'); + publishSessions(withoutChild); + await renderPanel({ parentSessionId: undefined }); + expect(view()).toBe('unavailable'); + + // The in-flight load settles late: it may not arm an attempt nothing starts, + // or the pane would sit on 「loading」 with no way back. + failLoad(new Error('child session is gone')); + await flush(); + await flush(); + publishSessions(new Map(sessions())); + await flush(); + + expect(view()).toBe('unavailable'); + expect(container.textContent).toContain('btw.unavailableThreadLabel'); + expect(container.textContent).not.toContain('btw.loadingThreadLabel'); + // The tab it is mounted in can still be closed; reloading needs a parent. + expect(button('btw-session-panel-close-tab')).not.toBeNull(); + expect(button('btw-session-panel-retry')).toBeNull(); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + }); + + it('never leaves the settle-time clamp armed when the record comes back as a shell', async () => { + let failLoad!: (error: Error) => void; + panelMocks.hydrateSessionHistoryForDetail.mockImplementationOnce( + () => new Promise((_resolve, reject) => { failLoad = reject; }), + ); + + await renderPanel(); + await vi.waitFor(() => expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1)); + + // The record goes away and so does the parent id: the effect releases the + // attempt and the pane explains the state rather than loading. + const withoutChild = new Map(sessions()); + withoutChild.delete('missing-child'); + publishSessions(withoutChild); + await renderPanel({ parentSessionId: undefined }); + expect(view()).toBe('unavailable'); + + // The parent id returns while the pane is not showing: the effect returns + // before it can start anything, but the host has already told the pane which + // parent it has. That is the only shape in which a settle can face a pane + // that could start an attempt — the clamp branch. + await renderPanel({ parentSessionId: 'parent-session', isActive: false }); + failLoad(new Error('child session is gone')); + await flush(); + + // A failed heal leaves a contentless shell behind. The clamped arm must have + // been released: claiming a load here would strand the pane on the loading + // copy with no retry, which is the dead end this suite exists for. + publishSessions(new Map(sessions()).set( + 'missing-child', + buildChildSession({ dialogTurns: [], historyState: 'failed' }), + )); + await renderPanel({ parentSessionId: 'parent-session', isActive: true }); + + await waitForView('unavailable'); + expect(container.textContent).not.toContain('btw.loadingThreadLabel'); + expect(button('btw-session-panel-retry')).not.toBeNull(); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + + // Retrying is what runs a load again. + button('btw-session-panel-retry')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(2); + }); + + it('does not start a load for an inactive tab and heals when it is shown', async () => { + panelMocks.hydrateSessionHistoryForDetail.mockRejectedValue(new Error('child session is gone')); + + await renderPanel({ isActive: false }); + expect(panelMocks.hydrateSessionHistoryForDetail).not.toHaveBeenCalled(); + expect(panelMocks.addExternalSession).not.toHaveBeenCalled(); + expect(storeListeners.size).toBe(0); + + await renderPanel({ isActive: true }); + await waitForView('unavailable'); + expect(panelMocks.addExternalSession).toHaveBeenCalledTimes(1); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + }); + + it('attempts the load once and never loops on re-renders or store updates', async () => { + panelMocks.hydrateSessionHistoryForDetail.mockRejectedValue(new Error('child session is gone')); + + await renderPanel(); + await waitForView('unavailable'); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + + await renderPanel(); + await renderPanel(); + publishSessions(new Map(sessions()).set('parent-session', { + ...buildParentSession(), + title: 'Parent renamed', + })); + await flush(); + + expect(view()).toBe('unavailable'); + expect(panelMocks.addExternalSession).toHaveBeenCalledTimes(1); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + }); + + it('reloads exactly once more when the operator retries', async () => { + panelMocks.hydrateSessionHistoryForDetail.mockRejectedValue(new Error('child session is gone')); + + await renderPanel(); + await waitForView('unavailable'); + panelMocks.addExternalSession.mockClear(); + panelMocks.hydrateSessionHistoryForDetail.mockClear(); + + button('btw-session-panel-retry')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await vi.waitFor(() => expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1)); + await waitForView('unavailable'); + + // The failed heal left a record with no turns, so the retry reads its + // history instead of recreating the record. + expect(panelMocks.addExternalSession).not.toHaveBeenCalled(); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledWith('missing-child'); + expect(panelMocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + }); + + it('closes the tab through the auxiliary pane service', async () => { + panelMocks.hydrateSessionHistoryForDetail.mockRejectedValue(new Error('child session is gone')); + + await renderPanel(); + await waitForView('unavailable'); + + button('btw-session-panel-close-tab')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flush(); + + expect(panelMocks.findTabByMetadata).toHaveBeenCalledWith({ + duplicateCheckKey: 'btw-session-missing-child', + }); + expect(panelMocks.closeTab).toHaveBeenCalledWith('child-tab', 'primary', { forceRemove: true }); + }); + + it('renders the restored child session and drops the recovery actions', async () => { + panelMocks.hydrateSessionHistoryForDetail.mockRejectedValue(new Error('child session is gone')); + + await renderPanel(); + await waitForView('unavailable'); + + publishSessions(new Map(sessions()).set('missing-child', buildChildSession())); + await waitForView('session'); + + expect(button('btw-session-panel-retry')).toBeNull(); + expect(container.textContent).not.toContain('btw.unavailableThreadLabel'); + }); + + it('leaves the ordinary path alone when the record is already loaded', async () => { + publishSessions(new Map(sessions()).set('missing-child', buildChildSession())); + panelMocks.hydrateSessionHistoryForDetail.mockClear(); + + await renderPanel(); + + expect(view()).toBe('session'); + expect(panelMocks.addExternalSession).not.toHaveBeenCalled(); + expect(panelMocks.hydrateSessionHistoryForDetail).not.toHaveBeenCalled(); + expect(container.textContent).not.toContain('btw.unavailableThreadLabel'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index 98a1e5119e..62f61cbb31 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -37,9 +37,19 @@ import { type ReviewDetailExecutionState, } from '../../utils/reviewDetailState'; import { + closeBtwSessionInAuxPane, + ensureBtwSessionAvailableAndSettle, + hasBtwSessionAuxPaneTab, loadBtwSessionHistory, + type BtwSessionPanelChildIdentity, type BtwSessionViewKind, } from '../../services/btwSessionPane'; +import { + createBtwSessionHydrationTracker, + type BtwSessionHydrationStatus, + type BtwSessionHydrationTracker, +} from '../../services/btwSessionHydration'; +import { hasRenderableSessionContent } from '../../services/sessionOpenIntent'; import {findLatestCodeReviewResult, findLatestCodeReviewResultState} from '../../utils/reviewSessionSummary'; import { deriveDeepReviewInterruption, @@ -94,7 +104,7 @@ function findReviewChildByRequestId( } import './BtwSessionPanel.scss'; -export interface BtwSessionPanelProps { +export interface BtwSessionPanelProps extends BtwSessionPanelChildIdentity { isActive?: boolean; childSessionId?: string; parentSessionId?: string; @@ -149,7 +159,169 @@ const isSameReviewResult = (left: unknown, right: unknown): boolean => { } }; -const BtwSessionPanelContent: React.FC = ({ +interface BtwSessionHydrationView { + status: BtwSessionHydrationStatus; + /** A load can be attempted for this child: the pane knows the parent to rebuild it from. */ + canRetry: boolean; + retry: () => void; + /** An auxiliary-pane tab holds this child, so closing it is an action that works. */ + canCloseTab: boolean; + closeTab: () => void; +} + +/** + * Whether the flow-chat store still holds the child session record. Selected as + * a boolean so unrelated session updates never re-render a mounted tab. + */ +function useChildSessionRecordPresence(childSessionId?: string): boolean { + const subscribe = useCallback((notify: () => void) => ( + childSessionId + ? flowChatStore.subscribeSelector(state => state.sessions.has(childSessionId), notify) + : () => {} + ), [childSessionId]); + const getSnapshot = useCallback( + () => Boolean(childSessionId && flowChatStore.getState().sessions.has(childSessionId)), + [childSessionId], + ); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +/** + * A canvas tab outlives its child session whenever the record is removed while + * the tab stays (delete, rollback, workspace reset). Nothing else re-hydrates + * it, so the pane owns the recovery: one load attempt per absence, and an + * actionable state when it fails. + * + * The recovery lives in the wrapper so a tab that is activated later still + * heals, but an inactive tab keeps the contract of this folder: no session + * subscription, no work — it hydrates when it becomes the visible view. + */ +function useBtwSessionHydration(params: { + childSessionId?: string; + parentSessionId?: string; + workspaceId?: string; + workspacePath?: string; + identity?: BtwSessionPanelChildIdentity; + isActive: boolean; +}): BtwSessionHydrationView { + const { + childSessionId, + parentSessionId, + workspaceId, + workspacePath, + identity, + isActive, + } = params; + const hasRecord = useChildSessionRecordPresence(isActive ? childSessionId : undefined); + // Recreating a missing record needs both ends of the relationship: a pane that + // only knows the child id explains itself instead of loading forever. + const canStartAttempt = Boolean(childSessionId && parentSessionId); + const trackerRef = useRef(null); + if (trackerRef.current === null) { + trackerRef.current = createBtwSessionHydrationTracker(); + } + const tracker = trackerRef.current; + // A tab with no record starts in the state its recovery is in: loading when an + // attempt can be armed below, and the actionable state when this pane cannot + // load at all. Either way it must not claim "nothing is open" first. + const [status, setStatus] = useState( + () => (childSessionId && !hasRecord ? (canStartAttempt ? 'loading' : 'unavailable') : 'idle'), + ); + const [retryToken, setRetryToken] = useState(0); + // The attempt outlives the effect run that started it: hydrating a missing + // record publishes the record it recreates, which re-runs this effect. + const childSessionIdRef = useRef(childSessionId); + childSessionIdRef.current = childSessionId; + const parentSessionIdRef = useRef(parentSessionId); + parentSessionIdRef.current = parentSessionId; + const identityRef = useRef(identity); + identityRef.current = identity; + + useEffect(() => { + if (!isActive) return; + const decision = tracker.observe({ childSessionId, hasRecord, canStartAttempt }); + setStatus(previous => (previous === decision.status ? previous : decision.status)); + // The tracker only arms a pane that can start, so `shouldAttempt` implies both + // ids; they are repeated here to keep them narrowed for the call below. + if (!decision.shouldAttempt || !childSessionId || !parentSessionId) return; + + void ensureBtwSessionAvailableAndSettle({ + childSessionId, + parentSessionId, + workspaceId, + workspacePath, + ...identityRef.current, + }) + .then( + () => 'loaded' as const, + // The failure is rendered, not retried: `settle` keeps the once-only guard. + () => 'failed' as const, + ) + .then(outcome => { + // A superseded tab must not settle this pane; the tracker ignores a + // settle for another id as well. + if (childSessionIdRef.current !== childSessionId) return; + tracker.settle(childSessionId, outcome); + const settled = tracker.observe({ + childSessionId, + hasRecord: flowChatStore.getState().sessions.has(childSessionId), + // Read now, not captured when the attempt was armed: a pane that lost + // its parent while the load ran cannot be told to start another one. + canStartAttempt: Boolean(childSessionIdRef.current && parentSessionIdRef.current), + }); + // This callback can only settle, never start. A decision to attempt here + // cannot be run, so release the arm it made — a load nobody runs must not + // keep the pane in flight — and keep the state actionable instead of + // claiming a load: a retry re-arms and runs it for real. + if (settled.shouldAttempt) { + tracker.settle(childSessionId, 'failed'); + setStatus('unavailable'); + return; + } + setStatus(settled.status); + }); + }, [ + childSessionId, + parentSessionId, + workspaceId, + workspacePath, + hasRecord, + isActive, + canStartAttempt, + retryToken, + tracker, + ]); + + const retry = useCallback(() => { + // Without both ends of the relationship there is nothing to load, so the + // retry must not pretend to start one. + if (!childSessionId || !canStartAttempt) return; + tracker.rearm(childSessionId); + setStatus('loading'); + setRetryToken(token => token + 1); + }, [childSessionId, canStartAttempt, tracker]); + + const closeTab = useCallback(() => { + if (!childSessionId) return; + closeBtwSessionInAuxPane(childSessionId); + }, [childSessionId]); + + // The pane is not always a tab — the MiniApp customize chat embeds it — and a + // control that silently does nothing reads as broken. Report what actually + // works; an inactive tab has nothing rendered to act on. + const canCloseTab = Boolean(isActive && childSessionId) + && hasBtwSessionAuxPaneTab(childSessionId!); + + return useMemo( + () => ({ status, canRetry: canStartAttempt, retry, canCloseTab, closeTab }), + [status, canStartAttempt, retry, canCloseTab, closeTab], + ); +} + +const BtwSessionPanelContent: React.FC = ({ childSessionId, parentSessionId, workspaceId, @@ -158,6 +330,7 @@ const BtwSessionPanelContent: React.FC { const { t } = useTranslation('flow-chat'); useEffect(() => childSessionId ? flowChatStore.retainSessionHistory(childSessionId) : undefined, [childSessionId]); @@ -1051,11 +1224,48 @@ const BtwSessionPanelContent: React.FC +
- {t('btw.emptyThreadLabel', { label: t('btw.threadLabel') })} + {unavailable ? ( + <> + {t('btw.unavailableThreadLabel', { label: t('btw.threadLabel') })} + {/* Only the actions this pane can actually perform: an embedded + pane has no tab to close, and one without a parent cannot + reload. A button that silently does nothing would be worse. */} + {hydration?.canRetry ? ( + + ) : null} + {hydration?.canCloseTab ? ( + + ) : null} + + ) : hasChildTab + ? t('btw.loadingThreadLabel', { label: t('btw.threadLabel') }) + : t('btw.emptyThreadLabel', { label: t('btw.threadLabel') })}
); @@ -1280,10 +1490,26 @@ const BtwSessionPanelContent: React.FC = (props) => { const surfaceId = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceId, getActiveSurfaceId); const viewState = useMemo(createBtwPanelViewState, [surfaceId, props.childSessionId, props.viewKind]); + // The wrapper owns the recovery so an inactive tab heals as soon as it is + // shown: it still runs its hooks while it renders null. + const hydration = useBtwSessionHydration({ + childSessionId: props.childSessionId, + parentSessionId: props.parentSessionId, + workspaceId: props.workspaceId, + workspacePath: props.workspacePath, + identity: { + sessionKind: props.sessionKind, + sessionTitle: props.sessionTitle, + parentToolCallId: props.parentToolCallId, + subagentType: props.subagentType, + }, + isActive: props.isActive !== false, + }); return props.isActive === false ? null : ( ); diff --git a/src/web-ui/src/flow_chat/services/btwSessionHydration.test.ts b/src/web-ui/src/flow_chat/services/btwSessionHydration.test.ts new file mode 100644 index 0000000000..341c3ff036 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/btwSessionHydration.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest'; +import { createBtwSessionHydrationTracker } from './btwSessionHydration'; + +/** A pane that can start loads, i.e. it knows the child and its parent. */ +const canLoad = { canStartAttempt: true } as const; + +describe('createBtwSessionHydrationTracker', () => { + it('does nothing for a pane without a child session tab', () => { + const tracker = createBtwSessionHydrationTracker(); + + expect(tracker.observe({ hasRecord: false, ...canLoad })) + .toEqual({ status: 'idle', shouldAttempt: false }); + }); + + it('leaves a recorded child session alone', () => { + const tracker = createBtwSessionHydrationTracker(); + + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad })) + .toEqual({ status: 'idle', shouldAttempt: false }); + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad })) + .toEqual({ status: 'idle', shouldAttempt: false }); + }); + + it('attempts exactly one load for a mounted child tab whose record is missing', () => { + const tracker = createBtwSessionHydrationTracker(); + + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: true }); + // Re-renders, store notifications, and tab activations keep observing. + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: false }); + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: false }); + }); + + it('reports unavailable once the attempt settled instead of looping', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + + tracker.settle('child', 'failed'); + + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + }); + + it('keeps the recovery state for a shell the failed attempt left behind', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + tracker.settle('child', 'failed'); + + // `ensureBtwSessionAvailable` recreates a record before it hydrates it, so a + // failure leaves a record with no turns. That is not a healed pane. + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + }); + + it('stays in flight while the heal creates its record mid-attempt', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + + // The record appears before the history finishes loading: keep the loading + // state instead of flashing the shell's empty transcript. + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: false }); + tracker.settle('child', 'loaded'); + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad })) + .toEqual({ status: 'idle', shouldAttempt: false }); + }); + + it('keeps the recovery offered when the record is gone after a successful load', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + + // The record was removed again while its history was loading: no attempt may + // be armed here, or the pane would show a load that nothing started. + tracker.settle('child', 'loaded'); + + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + }); + + it('re-arms when the record came back through a successful load', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + tracker.settle('child', 'loaded'); + + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad })) + .toEqual({ status: 'idle', shouldAttempt: false }); + // The record disappeared again: that is a new absence, so it may load once more. + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: true }); + }); + + it('starts a fresh attempt when the tab switches to another child session', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + tracker.settle('child', 'failed'); + + expect(tracker.observe({ childSessionId: 'other-child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: true }); + }); + + it('retries a shell record once for an explicit retry request', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + tracker.settle('child', 'failed'); + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad }).shouldAttempt).toBe(false); + + tracker.rearm('child'); + + // A failed heal leaves a shell, so a retry must load even though a record exists. + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: true }); + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, ...canLoad }).shouldAttempt).toBe(false); + }); + + it('ignores a settle that belongs to a superseded child session', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + + tracker.settle('other-child', 'failed'); + + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }).status).toBe('loading'); + tracker.settle('child', 'failed'); + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }).status).toBe('unavailable'); + }); + + it('never arms a load for a tab that cannot start one', () => { + const tracker = createBtwSessionHydrationTracker(); + + // The MiniApp customize chat renders the pane with a child id and no parent, + // so no record can be recreated. The pane must explain that, not load forever. + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, canStartAttempt: false })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, canStartAttempt: false })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + }); + + it('keeps a record it can show when the tab cannot start loads', () => { + const tracker = createBtwSessionHydrationTracker(); + + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, canStartAttempt: false })) + .toEqual({ status: 'idle', shouldAttempt: false }); + // A retry request cannot load anything either: the pane stays usable. + tracker.rearm('child'); + expect(tracker.observe({ childSessionId: 'child', hasRecord: true, canStartAttempt: false })) + .toEqual({ status: 'idle', shouldAttempt: false }); + }); + + it('clears an armed attempt when the pane loses the parent it needs', () => { + const tracker = createBtwSessionHydrationTracker(); + tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad }); + + // The parent went away mid-flight: nothing the caller can settle remains, so + // the pane must not stay on a load forever. + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, canStartAttempt: false })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + + tracker.settle('child', 'loaded'); + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, canStartAttempt: false })) + .toEqual({ status: 'unavailable', shouldAttempt: false }); + // Nothing was left in flight, so regaining a parent arms exactly one attempt. + expect(tracker.observe({ childSessionId: 'child', hasRecord: false, ...canLoad })) + .toEqual({ status: 'loading', shouldAttempt: true }); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/btwSessionHydration.ts b/src/web-ui/src/flow_chat/services/btwSessionHydration.ts new file mode 100644 index 0000000000..d3dd5c5176 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/btwSessionHydration.ts @@ -0,0 +1,130 @@ +/** + * Child-session hydration bookkeeping for the auxiliary pane. + * + * A canvas tab can outlive the session record it points at: session removal + * (delete, archive, rollback, workspace reset) drops the record from the + * flow-chat store while the tab stays in the agent canvas. The pane used to + * render its generic "nothing is open" copy forever, with no retry and no close + * action, so the tab became a dead end. + * + * This module owns the pure part of the recovery: a mounted child tab with no + * usable record attempts exactly one load, and a failing backend must not turn + * that into a retry loop. The tracker is keyed by the child session id, re-arms + * when the id changes or the record comes back, and re-arms deliberately for an + * explicit user retry. + * + * Not every pane can load: the MiniApp customize chat renders the panel with a + * child id only, and a record cannot be recreated without its parent. Such a + * pane reports that it cannot start an attempt, and the tracker explains the + * absence instead of arming a load nothing would run. + */ + +/** + * What the pane may render for a child tab: `idle` once a record is present and + * nothing has to be recovered, `loading` while an attempt is in flight, and + * `unavailable` once an attempt has finished without making the child usable — + * either it failed, or it left the record absent. + */ +export type BtwSessionHydrationStatus = 'idle' | 'loading' | 'unavailable'; + +/** How an attempt ended; `loaded` still has to make the record usable. */ +export type BtwSessionHydrationOutcome = 'loaded' | 'failed'; + +export interface BtwSessionHydrationObservation { + childSessionId?: string; + /** The flow-chat store currently holds a record for the child session. */ + hasRecord: boolean; + /** + * Whether this pane can start a load at all. A pane that only knows the child + * id cannot recreate its record, so it must never arm an attempt: that would + * leave the pane on a load nothing runs. + */ + canStartAttempt: boolean; +} + +export interface BtwSessionHydrationDecision { + /** Status the pane renders for the observed child tab. */ + status: BtwSessionHydrationStatus; + /** True exactly once per absence: the caller starts one load attempt now. */ + shouldAttempt: boolean; +} + +export interface BtwSessionHydrationTracker { + observe(observation: BtwSessionHydrationObservation): BtwSessionHydrationDecision; + /** The attempt started by `observe` finished, successfully or not. */ + settle(childSessionId: string, outcome: BtwSessionHydrationOutcome): void; + /** Allow one new attempt for the child tab, after an explicit user retry. */ + rearm(childSessionId: string): void; +} + +export function createBtwSessionHydrationTracker(): BtwSessionHydrationTracker { + let trackedSessionId: string | undefined; + let attempted = false; + let inFlight = false; + let failed = false; + let retryRequested = false; + + const reset = (childSessionId: string | undefined) => { + trackedSessionId = childSessionId; + attempted = false; + inFlight = false; + failed = false; + retryRequested = false; + }; + + const arm = (): BtwSessionHydrationDecision => { + attempted = true; + inFlight = true; + failed = false; + retryRequested = false; + return { status: 'loading', shouldAttempt: true }; + }; + + return { + observe({ childSessionId, hasRecord, canStartAttempt }) { + if (!childSessionId) { + reset(undefined); + return { status: 'idle', shouldAttempt: false }; + } + if (childSessionId !== trackedSessionId) { + reset(childSessionId); + } + if (!canStartAttempt) { + // Nothing may be armed here, and nothing may stay in flight either: the + // caller could never settle an attempt it was asked to start, which would + // leave the pane on a load forever. + reset(childSessionId); + return hasRecord + ? { status: 'idle', shouldAttempt: false } + : { status: 'unavailable', shouldAttempt: false }; + } + if (attempted) { + if (inFlight) return { status: 'loading', shouldAttempt: false }; + // A failed attempt keeps the recovery offered even when it left a shell + // record behind: a record with no turns is not a healed pane. So does an + // attempt that finished with the record still absent, including one that + // resolved — nothing observable came back, and only a retry can change it. + if (failed || !hasRecord) return { status: 'unavailable', shouldAttempt: false }; + } + // No record needs a load, and so does an explicit retry of a record that a + // failed attempt left behind. Anything else with a record is the ordinary + // path: nothing to recover, nothing to load. + if (retryRequested || !hasRecord) { + return arm(); + } + reset(childSessionId); + return { status: 'idle', shouldAttempt: false }; + }, + + settle(childSessionId, outcome) { + if (childSessionId !== trackedSessionId) return; + inFlight = false; + failed = outcome === 'failed'; + }, + + rearm(childSessionId) { + reset(childSessionId); + retryRequested = true; + }, + }; +} 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 700fbcd735..5897489880 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 @@ -10,7 +10,7 @@ import { activateSurface, getActiveSurfaceId } from '@/infrastructure/peer-devic import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { flowChatStore } from '../store/FlowChatStore'; import type { Session } from '../types/flow-chat'; -import { openBtwSessionInAuxPane } from './btwSessionPane'; +import { openBtwSessionInAuxPane, ensureBtwSessionAvailableAndSettle, hasBtwSessionAuxPaneTab } from './btwSessionPane'; import { flowChatManager } from './FlowChatManager'; vi.mock('@/infrastructure/services/business/workspaceManager', () => ({ @@ -116,4 +116,101 @@ describe('subagent pane navigation through production stores', () => { switchAgentCanvasScope('other'); expect(useAgentCanvasStore.getState().primaryGroup.tabs[0].content.data).toBe('retained'); }); + + it('recreates and reloads a child record that a session removal left behind', async () => { + openParent(); + vi.mocked(flowChatManager.hydrateSessionHistoryForDetail).mockClear(); + const metadata = vi.spyOn(flowChatStore, 'ensurePersistedSessionMetadata').mockResolvedValue(true); + // What delete / archive / rollback do to the tab's child while the tab stays. + flowChatStore.setState(state => { + const sessions = new Map(state.sessions); + sessions.delete('child'); + return { ...state, sessions }; + }); + expect(flowChatStore.getState().sessions.has('child')).toBe(false); + + await ensureBtwSessionAvailableAndSettle({ + childSessionId: 'child', + parentSessionId: 'parent', + sessionKind: 'subagent', + workspaceId: 'project', + }); + + expect(metadata).toHaveBeenCalledWith('child', 'project'); + expect(flowChatStore.getState().sessions.get('child')?.sessionKind).toBe('subagent'); + expect(flowChatManager.hydrateSessionHistoryForDetail).toHaveBeenCalledWith('child'); + }); + + it('recreates a missing subagent child from the identity its tab recorded', async () => { + openParent(); + vi.spyOn(flowChatStore, 'ensurePersistedSessionMetadata').mockResolvedValue(true); + vi.mocked(flowChatManager.hydrateSessionHistoryForDetail).mockClear(); + openBtwSessionInAuxPane({ + childSessionId: 'hidden-child', + parentSessionId: 'parent', + sessionKind: 'subagent', + sessionTitle: 'Subagent: verify the pane', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + workspaceId: 'project', + }); + + // The tab keeps the child identity: it is the only thing left that knows this + // child after its record is gone. + const data = useAgentCanvasStore.getState().primaryGroup.tabs[0].content.data; + expect(data).toMatchObject({ + childSessionId: 'hidden-child', + sessionKind: 'subagent', + sessionTitle: 'Subagent: verify the pane', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + }); + + flowChatStore.setState(state => { + const sessions = new Map(state.sessions); + sessions.delete('hidden-child'); + return { ...state, sessions }; + }); + expect(flowChatStore.getState().sessions.has('hidden-child')).toBe(false); + + // What the pane does for a mounted tab whose record is missing. + await ensureBtwSessionAvailableAndSettle({ + childSessionId: data.childSessionId, + parentSessionId: data.parentSessionId, + sessionKind: data.sessionKind, + sessionTitle: data.sessionTitle, + parentToolCallId: data.parentToolCallId, + subagentType: data.subagentType, + workspaceId: 'project', + }); + + const healed = flowChatStore.getState().sessions.get('hidden-child'); + expect(healed?.sessionKind).toBe('subagent'); + expect(healed?.title).toBe('Subagent: verify the pane'); + expect(healed?.parentToolCallId).toBe('task-call-1'); + expect(healed?.subagentType).toBe('Explore'); + expect(flowChatManager.hydrateSessionHistoryForDetail).toHaveBeenCalledWith('hidden-child'); + }); + + it('propagates a failed child reload so the pane can render it', async () => { + openParent(); + vi.mocked(flowChatManager.hydrateSessionHistoryForDetail) + .mockRejectedValueOnce(new Error('child session is gone')); + + await expect(ensureBtwSessionAvailableAndSettle({ + childSessionId: 'child', + parentSessionId: 'parent', + sessionKind: 'subagent', + workspaceId: 'project', + })).rejects.toThrow('child session is gone'); + }); + + it('reports tab ownership so an embedded pane knows it cannot close one', async () => { + openParent(); + expect(hasBtwSessionAuxPaneTab('child')).toBe(false); + + openBtwSessionInAuxPane({ childSessionId: 'child', parentSessionId: 'parent', sessionKind: 'subagent' }); + await vi.waitFor(() => expect(hasBtwSessionAuxPaneTab('child')).toBe(true)); + expect(hasBtwSessionAuxPaneTab('unknown-child')).toBe(false); + }); }); diff --git a/src/web-ui/src/flow_chat/services/btwSessionPane.ts b/src/web-ui/src/flow_chat/services/btwSessionPane.ts index 0c736b5b75..00939ac037 100644 --- a/src/web-ui/src/flow_chat/services/btwSessionPane.ts +++ b/src/web-ui/src/flow_chat/services/btwSessionPane.ts @@ -4,6 +4,7 @@ import { switchAgentCanvasScope, useAgentCanvasStore } from '@/app/components/pa import type { CanvasTab } from '@/app/components/panels/content-canvas/types'; import { expandSessionAuxPane } from '@/app/scenes/session/sessionPanelLayout'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import type { SessionRelationshipKind } from '@/shared/types/session-history'; import { createLogger } from '@/shared/utils/logger'; import { flowChatStore } from '../store/FlowChatStore'; import type { Session } from '../types/flow-chat'; @@ -16,7 +17,7 @@ export const BTW_SESSION_PANEL_TYPE = 'btw-session' as const; export type BtwSessionViewKind = 'review-check'; -export interface BtwSessionPanelData { +export interface BtwSessionPanelData extends BtwSessionPanelChildIdentity { childSessionId: string; parentSessionId: string; /** Workspace that owns the parent session; the identity used for routing. */ @@ -29,6 +30,22 @@ export interface BtwSessionPanelData { displayTitle?: string; } +/** + * What a tab knows about the child session it was opened for. + * + * The record can be gone later — session removal (delete, archive, rollback, + * workspace reset) drops it while the tab stays — and only the tab still knows + * which child this was. A re-hydration rebuilds the record from these fields, so + * a healed subagent tab keeps its kind, launch tool call and agent type instead + * of becoming a plain side thread. + */ +export interface BtwSessionPanelChildIdentity { + sessionKind?: SessionRelationshipKind; + sessionTitle?: string; + parentToolCallId?: string; + subagentType?: string; +} + export interface BtwSessionPanelMetadata { duplicateCheckKey: string; childSessionId: string; @@ -37,16 +54,12 @@ export interface BtwSessionPanelMetadata { discardSessionOnClose: boolean; } -export interface EnsureBtwSessionAvailableParams { +export interface EnsureBtwSessionAvailableParams extends BtwSessionPanelChildIdentity { childSessionId: string; parentSessionId: string; workspaceId?: string; workspacePath?: string; - sessionKind?: 'btw' | 'review' | 'deep_review' | 'miniapp' | 'subagent'; - sessionTitle?: string; agentType?: string; - parentToolCallId?: string; - subagentType?: string; remoteConnectionId?: string; remoteSshHost?: string; includeInternal?: boolean; @@ -107,29 +120,39 @@ export const buildBtwSessionPanelContent = ( workspace: BtwSessionPanelWorkspace, viewKind?: BtwSessionViewKind, displayTitle?: string, -): PanelContent => ({ - type: BTW_SESSION_PANEL_TYPE, - title: displayTitle?.trim() || resolveBtwSessionTitle(childSessionId), - data: { - childSessionId, - parentSessionId, - ...(workspace.workspaceId ? { workspaceId: workspace.workspaceId } : {}), - ...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}), - workspacePath: workspace.workspacePath, - ...(viewKind ? { viewKind } : {}), - ...(displayTitle?.trim() ? { displayTitle: displayTitle.trim() } : {}), - } satisfies BtwSessionPanelData, - metadata: { - duplicateCheckKey: getBtwSessionDuplicateKey(childSessionId), - childSessionId, - parentSessionId, - contentRole: 'btw-session', - discardSessionOnClose: (() => { - const session = flowChatStore.getState().sessions.get(childSessionId); - return session?.sessionKind === 'btw' && session.isTransient === true; - })(), - } satisfies BtwSessionPanelMetadata, -}); + identity?: BtwSessionPanelChildIdentity, +): PanelContent => { + const trimmedTitle = displayTitle?.trim(); + // The title the tab already shows is the best record title the tab knows. + const sessionTitle = identity?.sessionTitle?.trim() || trimmedTitle; + return { + type: BTW_SESSION_PANEL_TYPE, + title: trimmedTitle || resolveBtwSessionTitle(childSessionId), + data: { + childSessionId, + parentSessionId, + ...(workspace.workspaceId ? { workspaceId: workspace.workspaceId } : {}), + ...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}), + workspacePath: workspace.workspacePath, + ...(viewKind ? { viewKind } : {}), + ...(trimmedTitle ? { displayTitle: trimmedTitle } : {}), + ...(identity?.sessionKind ? { sessionKind: identity.sessionKind } : {}), + ...(sessionTitle ? { sessionTitle } : {}), + ...(identity?.parentToolCallId ? { parentToolCallId: identity.parentToolCallId } : {}), + ...(identity?.subagentType ? { subagentType: identity.subagentType } : {}), + } satisfies BtwSessionPanelData, + metadata: { + duplicateCheckKey: getBtwSessionDuplicateKey(childSessionId), + childSessionId, + parentSessionId, + contentRole: 'btw-session', + discardSessionOnClose: (() => { + const session = flowChatStore.getState().sessions.get(childSessionId); + return session?.sessionKind === 'btw' && session.isTransient === true; + })(), + } satisfies BtwSessionPanelMetadata, + }; +}; export const selectActiveAgentTab = (state: AgentCanvasState) => { const activeGroup = state.activeGroupId === 'primary' @@ -203,6 +226,11 @@ export async function loadBtwSessionHistory(params: LoadBtwSessionHistoryParams) interface EnsureBtwSessionAvailableResult { historyLoadRequested: boolean; + /** + * The history load this call started. Every caller settles it: awaiting it is + * how the pane renders a failure, and the fire-and-forget callers swallow it. + */ + historyLoad?: Promise; } const isSessionHistoryComplete = (session: Session | undefined): boolean => @@ -279,14 +307,46 @@ function ensureBtwSessionAvailableInternal( void ensureBtwSessionMetadata(params).catch(() => undefined); return { historyLoadRequested: false }; } - void loadBtwSessionHistory({ + const historyLoad = loadBtwSessionHistory({ childSessionId: params.childSessionId, parentSessionId: params.parentSessionId, - }).catch(() => undefined); - return { historyLoadRequested: true }; + }); + return { historyLoadRequested: true, historyLoad }; } +/** + * Fire-and-forget entry point for callers that only need the child record to + * exist and be hydrated. The load outcome stays unobserved; a caller that has + * to render the failure awaits `ensureBtwSessionAvailableAndSettle` instead. + */ export function ensureBtwSessionAvailable(params: EnsureBtwSessionAvailableParams): void { - ensureBtwSessionAvailableInternal(params); + void ensureBtwSessionAvailableInternal(params).historyLoad?.catch(() => undefined); +} + +/** + * The same entry point as `ensureBtwSessionAvailable`, settled. + * + * An auxiliary-pane tab can outlive its child session record. Only `ensure` + * recreates the record (`addExternalSession`) and reads its history, so a panel + * that heals a tab awaits it: the rejection is what tells the pane to show an + * actionable failure instead of its generic empty copy. + * + * A previous failed heal leaves an empty shell record, which `ensure` treats as + * a complete session. Reading that record's history is the panel's own load + * path, so it is the second branch here. + */ +export async function ensureBtwSessionAvailableAndSettle( + params: EnsureBtwSessionAvailableParams, +): Promise { + const recordPresent = flowChatStore.getState().sessions.has(params.childSessionId); + const { historyLoad } = ensureBtwSessionAvailableInternal(params); + if (historyLoad) { + await historyLoad; + return; + } + if (!recordPresent) return; + await loadBtwSessionHistory({ + childSessionId: params.childSessionId, parentSessionId: params.parentSessionId, + }); } export function openBtwSessionInAuxPane(params: { @@ -295,7 +355,7 @@ export function openBtwSessionInAuxPane(params: { workspaceId?: string; workspacePath?: string; expand?: boolean; - sessionKind?: 'btw' | 'review' | 'deep_review' | 'miniapp' | 'subagent'; + sessionKind?: SessionRelationshipKind; sessionTitle?: string; agentType?: string; parentToolCallId?: string; @@ -306,6 +366,9 @@ export function openBtwSessionInAuxPane(params: { viewKind?: BtwSessionViewKind; }): void { const ensureResult = ensureBtwSessionAvailableInternal(params); + // Opening a tab renders nothing per load: this path stays fire-and-forget, + // and only the pane that has to explain a failure awaits the outcome. + void ensureResult.historyLoad?.catch(() => undefined); const childSession = flowChatStore.getState().sessions.get(params.childSessionId); const isSubagentSession = params.sessionKind === 'subagent' || childSession?.sessionKind === 'subagent'; @@ -325,6 +388,14 @@ export function openBtwSessionInAuxPane(params: { resolveBtwSessionPanelWorkspace(params.parentSessionId, params), params.viewKind, params.sessionTitle, + // The tab records which child it holds, so a later heal can rebuild it as + // the same kind of session instead of a plain side thread. + { + sessionKind: params.sessionKind, + sessionTitle: params.sessionTitle, + parentToolCallId: params.parentToolCallId, + subagentType: params.subagentType, + }, ); const surface = getActiveSurfaceScope(); @@ -361,6 +432,18 @@ export function openBtwSessionInAuxPane(params: { } } +/** + * Whether this child currently owns an auxiliary-pane tab. + * + * A pane is not always a tab: the MiniApp customize chat embeds the panel + * directly, so closing it is not something the pane can offer there. + */ +export function hasBtwSessionAuxPaneTab(childSessionId: string): boolean { + return Boolean(useAgentCanvasStore.getState().findTabByMetadata({ + duplicateCheckKey: getBtwSessionDuplicateKey(childSessionId), + })); +} + export function closeBtwSessionInAuxPane(childSessionId: string): boolean { const duplicateCheckKey = getBtwSessionDuplicateKey(childSessionId); const canvasStore = useAgentCanvasStore.getState(); 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..bdc0d8cd79 100644 --- a/src/web-ui/src/flow_chat/services/openBtwSession.test.ts +++ b/src/web-ui/src/flow_chat/services/openBtwSession.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ensureBtwSessionAvailable, openBtwSessionInAuxPane } from './btwSessionPane'; +import { + ensureBtwSessionAvailable, + ensureBtwSessionAvailableAndSettle, + openBtwSessionInAuxPane, +} from './btwSessionPane'; import { openMainSession } from './sessionActivation'; const mocks = vi.hoisted(() => ({ @@ -214,6 +218,55 @@ describe('openBtwSessionInAuxPane', () => { ); }); + it('records the child identity on the tab so a later heal can rebuild it', () => { + sessions.set('parent-session', { + sessionId: 'parent-session', + workspaceId: 'parent-workspace-id', + projectWorkspaceId: 'project-id', + workspacePath: 'D:\\workspace\\repo', + mode: 'Standard', + }); + + openBtwSessionInAuxPane({ + childSessionId: 'subagent-child', + parentSessionId: 'parent-session', + workspaceId: 'child-worktree-id', + sessionKind: 'subagent', + sessionTitle: 'Subagent: verify the pane', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + expand: false, + }); + + expect(mocks.addTab).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + childSessionId: 'subagent-child', + sessionKind: 'subagent', + sessionTitle: 'Subagent: verify the pane', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + }), + }), + 'active', + ); + expect(mocks.addExternalSession).toHaveBeenCalledWith( + 'subagent-child', + 'Subagent: verify the pane', + 'Standard', + 'D:\\workspace\\repo', + expect.objectContaining({ + parentSessionId: 'parent-session', + sessionKind: 'subagent', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + workspaceId: 'child-worktree-id', + }), + undefined, + undefined, + ); + }); + it('switches to an existing aux pane tab and asks its layout owner to reveal it', () => { stubWindowForPanelExpansion(false); mocks.findTabByMetadata.mockReturnValue({ @@ -529,6 +582,122 @@ describe('openBtwSessionInAuxPane', () => { }); }); +describe('ensureBtwSessionAvailableAndSettle', () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + mocks.addExternalSession.mockReset(); + mocks.addExternalSession.mockImplementation( + (sessionId: string, title: string, mode: string, workspacePath: string, meta?: Record) => { + // Mirrors FlowChatStore.addExternalSession: a shell record with no turns. + sessions.set(sessionId, { + sessionId, + title, + mode, + workspacePath, + workspaceId: meta?.workspaceId, + sessionKind: meta?.sessionKind ?? 'btw', + parentSessionId: meta?.parentSessionId, + dialogTurns: [], + isHistorical: false, + historyState: 'new', + config: { workspaceId: meta?.workspaceId }, + }); + }, + ); + mocks.ensurePersistedSessionMetadata.mockReset(); + mocks.ensurePersistedSessionMetadata.mockImplementation(async (sessionId: string) => { + sessions.set(sessionId, { ...sessions.get(sessionId), sessionId, workspaceId: 'child-worktree-id', config: {} }); + return true; + }); + mocks.hydrateSessionHistoryForDetail.mockReset(); + mocks.hydrateSessionHistoryForDetail.mockResolvedValue(undefined); + sessions = new Map(); + activeSessionId = 'parent-session'; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('rejects for a missing child session instead of swallowing the load failure', async () => { + sessions.set('parent-session', { + sessionId: 'parent-session', + workspaceId: 'parent-workspace-id', + workspacePath: 'D:\\workspace\\repo', + mode: 'Standard', + }); + mocks.hydrateSessionHistoryForDetail.mockRejectedValueOnce(new Error('child session is gone')); + + await expect(ensureBtwSessionAvailableAndSettle({ + childSessionId: 'subagent-child', + parentSessionId: 'parent-session', + sessionKind: 'subagent', + workspaceId: 'child-worktree-id', + })).rejects.toThrow('child session is gone'); + + expect(sessions.get('subagent-child')).toBeDefined(); + expect(mocks.hydrateSessionHistoryForDetail).toHaveBeenCalledWith('subagent-child'); + }); + + it('reads the history of a record a failed heal left behind', async () => { + sessions.set('parent-session', { + sessionId: 'parent-session', + workspaceId: 'parent-workspace-id', + workspacePath: 'D:\\workspace\\repo', + mode: 'Standard', + }); + // What `ensureBtwSessionAvailable` leaves after a failed first attempt. + sessions.set('subagent-child', { + sessionId: 'subagent-child', + workspaceId: 'child-worktree-id', + sessionKind: 'subagent', + isHistorical: false, + historyState: 'new', + dialogTurns: [], + config: { workspaceId: 'child-worktree-id' }, + }); + + await ensureBtwSessionAvailableAndSettle({ + childSessionId: 'subagent-child', + parentSessionId: 'parent-session', + sessionKind: 'subagent', + }); + + expect(mocks.addExternalSession).not.toHaveBeenCalled(); + expect(mocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1); + expect(mocks.hydrateSessionHistoryForDetail).toHaveBeenCalledWith('subagent-child'); + }); + + it('keeps the fire-and-forget entry point unobserved', async () => { + sessions.set('parent-session', { + sessionId: 'parent-session', + workspaceId: 'parent-workspace-id', + workspacePath: 'D:\\workspace\\repo', + mode: 'Standard', + }); + mocks.hydrateSessionHistoryForDetail.mockRejectedValueOnce(new Error('child session is gone')); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { unhandled.push(reason); }; + process.on('unhandledRejection', onUnhandled); + try { + expect(ensureBtwSessionAvailable({ + childSessionId: 'subagent-child', + parentSessionId: 'parent-session', + sessionKind: 'subagent', + workspaceId: 'child-worktree-id', + })).toBeUndefined(); + await vi.waitFor(() => expect(mocks.hydrateSessionHistoryForDetail).toHaveBeenCalledTimes(1)); + await new Promise(resolve => setTimeout(resolve, 0)); + } finally { + process.off('unhandledRejection', onUnhandled); + } + + expect(unhandled).toEqual([]); + }); +}); + describe('openMainSession', () => { beforeEach(() => { vi.restoreAllMocks(); diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 0007130b2e..11ef505db6 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1194,6 +1194,10 @@ "openThread": "Open thread", "threadLabel": "Side thread", "emptyThreadLabel": "No {{label}} open", + "loadingThreadLabel": "Loading {{label}}…", + "unavailableThreadLabel": "{{label}} could not be loaded", + "retryThread": "Reload", + "closeThreadTab": "Close tab", "origin": "Asked from", "parent": "parent session", "turnLabel": "turn {{index}}", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 60098e3480..4a06334f03 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1194,6 +1194,10 @@ "openThread": "打开子会话", "threadLabel": "子会话", "emptyThreadLabel": "暂未打开{{label}}", + "loadingThreadLabel": "正在加载{{label}}…", + "unavailableThreadLabel": "{{label}}加载失败", + "retryThread": "重新加载", + "closeThreadTab": "关闭标签", "origin": "来自", "parent": "父会话", "turnLabel": "第 {{index}} 轮", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 392b0489a8..d056c1ce1e 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1194,6 +1194,10 @@ "openThread": "開啟子會話", "threadLabel": "子會話", "emptyThreadLabel": "暫未開啟{{label}}", + "loadingThreadLabel": "正在載入{{label}}…", + "unavailableThreadLabel": "{{label}}載入失敗", + "retryThread": "重新載入", + "closeThreadTab": "關閉分頁", "origin": "來自", "parent": "父會話", "turnLabel": "第 {{index}} 輪",