From 1066f2d563d0b43b68e9c3213bc3f0a3afc248bc Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 7 Sep 2026 14:58:34 +0800 Subject: [PATCH 1/2] fix(web-ui): clarify workspace navigation and Claw initialization --- .../sections/sessions/SessionsSection.tsx | 39 ++- .../sections/workspaces/WorkspaceItem.tsx | 20 +- .../WorkspaceSessionBatchModal.test.tsx | 87 +++++++ .../app/hooks/useAssistantBootstrap.test.tsx | 139 ++++++++++ .../src/app/hooks/useAssistantBootstrap.ts | 241 ++++-------------- src/web-ui/src/app/layout/AppLayout.tsx | 8 - .../src/flow_chat/components/ChatInput.tsx | 6 + .../services/FlowChatManager.test.ts | 122 +++++++++ .../src/flow_chat/services/FlowChatManager.ts | 64 +++-- .../EventHandlerModule.test.ts | 38 +++ 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 + 13 files changed, 511 insertions(+), 256 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx create mode 100644 src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx 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 0d2bf74454..370a64f895 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 @@ -8,7 +8,7 @@ import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Button, Icon, IconButton, Input, Menu, MenuItem, Tooltip } from '@openbitfun/ui'; import { createPortal } from 'react-dom'; -import { Bot, Loader2, Archive } from 'lucide-react'; +import { Bot, Loader2, Archive, ListChecks } from 'lucide-react'; import { RetainedMountBoundary } from '@/shared/presence'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; @@ -95,6 +95,7 @@ import './SessionsSection.scss'; const log = createLogger('SessionsSection'); const ScheduledJobsModal = lazy(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal')); +const WorkspaceSessionBatchModal = lazy(() => import('../workspaces/WorkspaceSessionBatchModal')); type SessionMode = 'code' | 'cowork' | 'claw'; type HistoryOpenIntentDispatchResult = 'none' | 'dispatched' | 'already-pending'; @@ -290,6 +291,7 @@ const SessionsSection: React.FC = ({ const [exportingSessionId, setExportingSessionId] = useState(null); const [runningSessionIds, setRunningSessionIds] = useState>(new Set()); const [scheduledJobsSessionId, setScheduledJobsSessionId] = useState(null); + const [batchWorkspace, setBatchWorkspace] = useState(null); const editInputRef = useRef(null); const sessionMenuPopoverRef = useRef(null); const sessionMenuAnchorRef = useRef(null); @@ -1968,6 +1970,29 @@ const SessionsSection: React.FC = ({ > {t('nav.sessions.archive')} + } + disabled={!workspacePath && !session.workspacePath} + onClick={e => { + e.stopPropagation(); + closeSessionMenu(); + const path = workspacePath || session.projectWorkspacePath || session.workspacePath; + if (!path) return; + setBatchWorkspace({ + workspaceId: workspaceId || session.workspaceId || '', + workspaceName: presentation?.assistant.name + || (currentWorkspace?.rootPath === path && currentWorkspace.name) + || path, + workspacePath: path, + remoteConnectionId: remoteConnectionId ?? session.remoteConnectionId, + remoteSshHost: remoteSshHost ?? session.remoteSshHost, + }); + }} + data-testid="nav-session-menu-manage-sessions" + > + {t('nav.sessions.manage')} + = ({ )} + {batchWorkspace && ( + + setBatchWorkspace(null)} + workspacePath={batchWorkspace.workspacePath} + workspaceLabel={batchWorkspace.workspaceName} + remoteConnectionId={batchWorkspace.remoteConnectionId} + remoteSshHost={batchWorkspace.remoteSshHost} + /> + + )} ); }; 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 fa3c919c0c..334dae6c10 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 @@ -545,15 +545,6 @@ const WorkspaceItem: React.FC = ({ setSessionsCollapsed(prev => !prev); }, []); - const handleCardNameClick = useCallback(async () => { - if (!isActive) { - await setActiveWorkspace(workspace.id); - setSessionsCollapsed(false); - } else { - setSessionsCollapsed(prev => !prev); - } - }, [isActive, setActiveWorkspace, workspace.id]); - const handleCloseWorkspace = useCallback(async () => { setMenuOpen(false); try { @@ -664,13 +655,12 @@ const WorkspaceItem: React.FC = ({ } setIsResettingWorkspace(true); + setResetDialogOpen(false); try { await resetAssistantWorkspace(workspace.id); await flowChatManager.resetWorkspaceSessions(workspace, { reinitialize: isActive, preferredMode: 'Claw', - ensureAssistantBootstrap: - isActive && workspace.workspaceKind === WorkspaceKind.Assistant, }); notificationService.success(t('nav.workspaces.workspaceReset'), { duration: 2500 }); } catch (error) { @@ -866,7 +856,7 @@ const WorkspaceItem: React.FC = ({ draggable={draggable} onDragStart={onDragStart} onDragEnd={onDragEnd} - onClick={() => { void handleCardNameClick(); }} + onClick={handleCollapseToggle} style={{ cursor: 'pointer' }} data-testid="nav-workspace-card" data-workspace-id={workspace.id} @@ -897,7 +887,7 @@ const WorkspaceItem: React.FC = ({ data-openbitfun-part="name" type="button" className="openbitfun-nav-panel__assistant-item-name-btn" - onClick={e => { e.stopPropagation(); void handleCardNameClick(); }} + onClick={e => { e.stopPropagation(); handleCollapseToggle(); }} data-testid="nav-workspace-name-btn" data-workspace-id={workspace.id} > @@ -1150,7 +1140,7 @@ const WorkspaceItem: React.FC = ({ draggable={draggable} onDragStart={onDragStart} onDragEnd={onDragEnd} - onClick={() => { void handleCardNameClick(); }} + onClick={handleCollapseToggle} style={{ cursor: 'pointer' }} data-testid="nav-workspace-card" data-workspace-id={workspace.id} @@ -1188,7 +1178,7 @@ const WorkspaceItem: React.FC = ({ data-openbitfun-part="name" type="button" className="openbitfun-nav-panel__workspace-item-name-btn" - onClick={e => { e.stopPropagation(); void handleCardNameClick(); }} + onClick={e => { e.stopPropagation(); handleCollapseToggle(); }} data-testid="nav-workspace-name-btn" data-workspace-id={workspace.id} > diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx new file mode 100644 index 0000000000..ae6e3447e3 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceSessionBatchModal.test.tsx @@ -0,0 +1,87 @@ +// @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'; +import WorkspaceSessionBatchModal from './WorkspaceSessionBatchModal'; + +const mocks = vi.hoisted(() => ({ + listSessions: vi.fn(), archiveChatSession: vi.fn(), deleteChatSession: vi.fn(), + refreshWorkspaceSessions: vi.fn(), confirmWarning: vi.fn(), confirmDanger: vi.fn(), +})); +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ sessionAPI: mocks })); +vi.mock('@/flow_chat/services/FlowChatManager', () => ({ flowChatManager: mocks })); +vi.mock('@/infrastructure/confirm-dialog', () => mocks); +vi.mock('@/shared/notification-system', () => ({ + notificationService: { success: vi.fn(), error: vi.fn() }, +})); +vi.mock('@/infrastructure/i18n', () => ({ + useI18n: () => ({ t: (key: string) => key, formatDate: () => '', formatRelativeTime: () => '' }), +})); +vi.mock('@openbitfun/ui', () => { + const Box = ({ children }: { children?: React.ReactNode }) =>
{children}
; + return { + Dialog: Box, DialogBody: Box, DialogHeader: Box, DialogHeading: Box, + DialogTitle: Box, DialogDescription: Box, DialogFooter: Box, ScrollArea: Box, + DialogClose: () => null, Icon: () => null, Spinner: () => null, + Button: ({ children, onClick, disabled }: React.ComponentProps<'button'>) => ( + + ), + Checkbox: ({ label, checked, onChange, disabled }: React.ComponentProps<'input'> & { label: React.ReactNode }) => ( + + ), + }; +}); + +describe('Claw session batch management', () => { + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + vi.clearAllMocks(); + mocks.confirmDanger.mockResolvedValue(true); + mocks.listSessions.mockResolvedValue([ + { sessionId: 'claw-1', sessionName: 'First assistant chat', agentType: 'Claw', workspacePath: '/assistant', remoteConnectionId: 'ssh-one', status: 'idle', createdAt: 1, lastActiveAt: 1 }, + { sessionId: 'claw-2', sessionName: 'Second assistant chat', agentType: 'Claw', workspacePath: '/assistant', remoteConnectionId: 'ssh-one', status: 'idle', createdAt: 2, lastActiveAt: 2 }, + { sessionId: 'other-host', sessionName: 'Other host', agentType: 'Claw', workspacePath: '/assistant', remoteConnectionId: 'ssh-two', status: 'idle' }, + { sessionId: 'archived', sessionName: 'Archived chat', agentType: 'Claw', workspacePath: '/assistant', remoteConnectionId: 'ssh-one', status: 'archived' }, + ]); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + async function renderAndSelectAll() { + await act(async () => root.render( + {}} workspacePath="/assistant" workspaceLabel="Claw" remoteConnectionId="ssh-one" />, + )); + expect(mocks.listSessions).toHaveBeenCalledWith('/assistant', 'ssh-one', undefined); + expect(container.textContent).toContain('First assistant chat'); + expect(container.textContent).toContain('Second assistant chat'); + expect(container.textContent).not.toContain('Other host'); + expect(container.textContent).not.toContain('Archived chat'); + await act(async () => container.querySelector('input[type="checkbox"]')!.click()); + } + async function clickAction(key: string) { + const button = [...container.querySelectorAll('button')].find(candidate => candidate.textContent === key); + expect(button?.disabled).toBe(false); + await act(async () => button!.click()); + } + + it('archives selected Claw sessions immediately within the chosen host', async () => { + await renderAndSelectAll(); + await clickAction('nav.sessions.archiveSelected'); + expect(mocks.confirmWarning).not.toHaveBeenCalled(); + expect(mocks.confirmDanger).not.toHaveBeenCalled(); + expect(mocks.archiveChatSession.mock.calls).toEqual([['claw-2'], ['claw-1']]); + expect(mocks.refreshWorkspaceSessions).toHaveBeenCalledWith({ rootPath: '/assistant', connectionId: 'ssh-one', sshHost: undefined }); + }); + + it('deletes selected Claw sessions after explicit confirmation', async () => { + await renderAndSelectAll(); + await clickAction('nav.sessions.deleteSelected'); + expect(mocks.confirmDanger).toHaveBeenCalledTimes(1); + expect(mocks.deleteChatSession.mock.calls).toEqual([['claw-2'], ['claw-1']]); + }); +}); diff --git a/src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx b/src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx new file mode 100644 index 0000000000..a454983a99 --- /dev/null +++ b/src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import React, { act, useCallback } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAssistantBootstrap } from './useAssistantBootstrap'; +import { sessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; +import type { Session } from '@/flow_chat/types/flow-chat'; +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; + +const { readFileContent, submit, t } = vi.hoisted(() => ({ + readFileContent: vi.fn(), + submit: vi.fn(), + t: (key: string) => key, +})); +vi.mock('@/infrastructure/api/service-api/WorkspaceAPI', () => ({ + workspaceAPI: { readFileContent }, +})); +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: { ensureAssistantBootstrap: submit, startDialogTurn: submit }, +})); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t }) })); + +function session(overrides: Partial = {}): Session { + return { + sessionId: 'claw-1', mode: 'Claw', workspacePath: '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/assistants/default', + config: {}, dialogTurns: [], status: 'idle', title: 'Claw', + createdAt: 1, lastActiveAt: 1, error: null, + ...overrides, + } as Session; +} + +function Composer({ target }: { target: Session }) { + const onDraftReady = useCallback((value: string) => { + sessionComposerStore.getState().setValue(target.sessionId, value); + }, [target.sessionId]); + useAssistantBootstrap(target, onDraftReady); + return null; +} + +describe('assistant bootstrap draft', () => { + let container: HTMLDivElement; + let root: Root; + beforeEach(() => { + resetDeviceSurfaceForTest(); + sessionComposerStore.setState({ drafts: {} }); + vi.clearAllMocks(); + readFileContent.mockResolvedValue('# Bootstrap'); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + resetDeviceSurfaceForTest(); + }); + const render = async (target = session()) => { + await act(async () => root.render()); + }; + + it('prefills an empty Claw composer without starting a turn', async () => { + await render(); + expect(readFileContent).toHaveBeenCalledWith('/assistants/default/BOOTSTRAP.md', undefined, undefined); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe('nav.sessions.assistantBootstrapDraft'); + expect(submit).not.toHaveBeenCalled(); + }); + + it('leaves initialized assistants and existing conversations alone', async () => { + readFileContent.mockRejectedValueOnce(new Error('No such file')); + await render(); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); + readFileContent.mockClear(); + await render(session({ sessionId: 'existing', totalTurnCount: 3 })); + expect(readFileContent).not.toHaveBeenCalled(); + await render(session({ sessionId: 'code', mode: 'agentic' })); + expect(readFileContent).not.toHaveBeenCalled(); + }); + + it('does not replace a user draft or restore an explicitly cleared template', async () => { + sessionComposerStore.getState().setValue('claw-1', 'My own request'); + await render(); + expect(readFileContent).not.toHaveBeenCalled(); + sessionComposerStore.getState().clearDraft('claw-1'); + await render(session({ sessionId: 'other', mode: 'agentic' })); + await render(); + expect(readFileContent).not.toHaveBeenCalled(); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); + }); + + it('preserves typing while the bootstrap check is in flight', async () => { + let finish!: (content: string) => void; + readFileContent.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + await render(); + sessionComposerStore.getState().setValue('claw-1', 'Already typing'); + await act(async () => finish('# Bootstrap')); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe('Already typing'); + }); + + it('does not refill or restart initialization after the user stops the submitted turn', async () => { + await render(); + sessionComposerStore.getState().clearDraft('claw-1'); + await render(session({ + lastSubmittedMode: 'Claw', + dialogTurns: [{ + id: 'bootstrap-turn', sessionId: 'claw-1', status: 'cancelled', startTime: 1, + userMessage: { id: 'user-1', content: 'Initialize', timestamp: 1 }, modelRounds: [], + }], + })); + expect(readFileContent).toHaveBeenCalledTimes(1); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); + expect(submit).not.toHaveBeenCalled(); + }); + + it('discards a late result after changing sessions', async () => { + let finish!: (content: string) => void; + readFileContent.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + await render(); + await render(session({ sessionId: 'other', mode: 'agentic' })); + await act(async () => finish('# Bootstrap')); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); + expect(sessionComposerStore.getState().getDraft('other').value).toBe(''); + }); + + it('routes remote workspace reads and never falls back to a local file', async () => { + readFileContent.mockRejectedValueOnce(new Error('Remote host is offline')); + await render(session({ remoteConnectionId: 'ssh-host', workspacePath: '/home/user/assistant/' })); + expect(readFileContent).toHaveBeenCalledExactlyOnceWith('/home/user/assistant/BOOTSTRAP.md', undefined, 'ssh-host'); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); + }); + + it('rejects a late peer result even when the new surface has the same session id', async () => { + let finish!: (content: string) => void; + readFileContent.mockReturnValueOnce(new Promise(resolve => { finish = resolve; })); + await render(); + activateSurface('peer:other'); + await act(async () => finish('# Bootstrap')); + expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe(''); + }); +}); diff --git a/src/web-ui/src/app/hooks/useAssistantBootstrap.ts b/src/web-ui/src/app/hooks/useAssistantBootstrap.ts index ba49eee34a..295da9b54d 100644 --- a/src/web-ui/src/app/hooks/useAssistantBootstrap.ts +++ b/src/web-ui/src/app/hooks/useAssistantBootstrap.ts @@ -1,206 +1,53 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { - agentAPI, - type EnsureAssistantBootstrapResponse, -} from '@/infrastructure/api/service-api/AgentAPI'; +import { useEffect } from 'react'; +import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { useI18n } from '@/infrastructure/i18n'; -import { notificationService } from '@/shared/notification-system'; +import { sessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; +import { isProjectedSessionEmpty } from '@/flow_chat/utils/flowChatTurnIdentity'; +import type { Session } from '@/flow_chat/types/flow-chat'; import { createLogger } from '@/shared/utils/logger'; -import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; const log = createLogger('AssistantBootstrap'); -interface BootstrapRequest { - workspacePath: string; - sessionId: string; -} - -interface ActiveBootstrapAttempt extends BootstrapRequest { - turnId: string; -} - -export function useAssistantBootstrap() { - const { t } = useI18n('notifications'); - const activeAttemptRef = useRef(null); - const pendingRequestRef = useRef(null); - const latestWorkspacePathRef = useRef(null); - const inFlightWorkspacePathRef = useRef(null); - const blockedNoticeShownRef = useRef>(new Set()); - const requestBootstrapRef = useRef<(request: BootstrapRequest) => void>(() => {}); - - const drainPendingRequest = useCallback(() => { - const pending = pendingRequestRef.current; - if (!pending) { - return; - } - - if (latestWorkspacePathRef.current !== pending.workspacePath) { - pendingRequestRef.current = null; - return; - } - - pendingRequestRef.current = null; - requestBootstrapRef.current(pending); - }, []); - - const clearActiveAttempt = useCallback( - (event: { sessionId?: string; turnId?: string }) => { - const activeAttempt = activeAttemptRef.current; - if (!activeAttempt) { - return; - } - - if (event.sessionId !== activeAttempt.sessionId || event.turnId !== activeAttempt.turnId) { - return; - } - - activeAttemptRef.current = null; - drainPendingRequest(); - }, - [drainPendingRequest] - ); +/** Offer bootstrap as an editable draft. Only the composer may submit it. */ +export function useAssistantBootstrap( + session: Session | undefined, + onDraftReady: (value: string) => void, +): void { + const { t } = useI18n('common'); + const scope = getActiveSurfaceScope(); + const sessionId = session?.sessionId; + const workspacePath = session?.workspacePath; + const remoteConnectionId = session?.remoteConnectionId; + const isEmptyClaw = session?.mode?.toLowerCase() === 'claw' + && isProjectedSessionEmpty(session) + && !session.lastSubmittedMode + && !session.config.dispatchTarget + && !session.config.dispatchJobId; useEffect(() => { - const unlistenCompleted = agentAPI.onDialogTurnCompleted(clearActiveAttempt); - const unlistenFailed = agentAPI.onDialogTurnFailed(clearActiveAttempt); - const unlistenCancelled = agentAPI.onDialogTurnCancelled(clearActiveAttempt); - - return () => { - unlistenCompleted(); - unlistenFailed(); - unlistenCancelled(); - }; - }, [clearActiveAttempt]); - - const handleEnsureResponse = useCallback( - (request: BootstrapRequest, response: EnsureAssistantBootstrapResponse): void => { - switch (response.status) { - case 'started': - if (!response.turnId) { - log.warn('Assistant bootstrap started without turnId', { request, response }); - return; - } - activeAttemptRef.current = { - ...request, - sessionId: response.sessionId, - turnId: response.turnId, - }; - blockedNoticeShownRef.current.delete(request.workspacePath); - log.info('Assistant bootstrap started', { - workspacePath: request.workspacePath, - sessionId: response.sessionId, - turnId: response.turnId, - }); - return; - case 'blocked': - if ( - response.reason === 'model_unavailable' && - !blockedNoticeShownRef.current.has(request.workspacePath) - ) { - blockedNoticeShownRef.current.add(request.workspacePath); - notificationService.info(t('info.assistantBootstrapWaitingForModelConfiguration'), { - duration: 5000, - }); - } - log.info('Assistant bootstrap blocked', { - workspacePath: request.workspacePath, - sessionId: request.sessionId, - reason: response.reason, - detail: response.detail, - }); - return; - case 'skipped': - if (response.reason === 'bootstrap_not_required') { - blockedNoticeShownRef.current.delete(request.workspacePath); - } - log.debug('Assistant bootstrap skipped', { - workspacePath: request.workspacePath, - sessionId: request.sessionId, - reason: response.reason, - }); - return; - default: - return; + if (!isEmptyClaw || !sessionId || !workspacePath) return; + const composer = sessionComposerStore.getState(); + const draft = composer.getDraft(sessionId); + // An edited or explicitly cleared draft belongs to the user, including on reopen. + if (draft.updatedAt || draft.value || draft.contexts.length) return; + + let cancelled = false; + const requestScope = getActiveSurfaceScope(); + const bootstrapPath = `${workspacePath.replace(/[\\/]+$/, '')}/BOOTSTRAP.md`; + void workspaceAPI.readFileContent(bootstrapPath, undefined, remoteConnectionId).then(() => { + if (cancelled || !requestScope.isCurrent()) return; + const latest = sessionComposerStore.getState().getDraft(sessionId); + if (latest.updatedAt !== draft.updatedAt || latest.value || latest.contexts.length) return; + onDraftReady(t('nav.sessions.assistantBootstrapDraft')); + }).catch(error => { + if (cancelled || !requestScope.isCurrent()) return; + // Completed assistants no longer have BOOTSTRAP.md. Other failures remain diagnosable. + const message = error instanceof Error ? error.message : String(error); + if (!/does not exist|no such file|not found/i.test(message)) { + log.warn('Failed to prepare assistant bootstrap draft', { sessionId, workspacePath, error }); } - }, - [t] - ); - - const requestBootstrap = useCallback( - async (request: BootstrapRequest): Promise => { - const activeAttempt = activeAttemptRef.current; - if (activeAttempt) { - if (activeAttempt.workspacePath === request.workspacePath) { - return; - } - pendingRequestRef.current = request; - return; - } - - const inFlightWorkspacePath = inFlightWorkspacePathRef.current; - if (inFlightWorkspacePath) { - if (inFlightWorkspacePath === request.workspacePath) { - return; - } - pendingRequestRef.current = request; - return; - } - - inFlightWorkspacePathRef.current = request.workspacePath; - - try { - const response = await agentAPI.ensureAssistantBootstrap({ - sessionId: request.sessionId, - workspacePath: request.workspacePath, - }); - handleEnsureResponse(request, response); - } catch (error) { - log.error('Failed to ensure assistant bootstrap', { - workspacePath: request.workspacePath, - sessionId: request.sessionId, - error, - }); - } finally { - if (inFlightWorkspacePathRef.current === request.workspacePath) { - inFlightWorkspacePathRef.current = null; - } - - if (!activeAttemptRef.current) { - drainPendingRequest(); - } - } - }, - [drainPendingRequest, handleEnsureResponse] - ); - - useEffect(() => { - requestBootstrapRef.current = (request: BootstrapRequest) => { - void requestBootstrap(request); - }; - }, [requestBootstrap]); - - const ensureForWorkspace = useCallback( - (workspace: WorkspaceInfo | null | undefined, sessionId?: string | null): void => { - latestWorkspacePathRef.current = workspace?.rootPath ?? null; - - if ( - !workspace || - workspace.workspaceKind !== WorkspaceKind.Assistant || - !sessionId - ) { - pendingRequestRef.current = null; - return; - } - - void requestBootstrap({ - workspacePath: workspace.rootPath, - sessionId, - }); - }, - [requestBootstrap] - ); - - return { - ensureForWorkspace, - }; + }); + return () => { cancelled = true; }; + }, [isEmptyClaw, onDraftReady, remoteConnectionId, scope.epoch, sessionId, t, workspacePath]); } diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index d4a06c2ade..e493c3054d 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -12,7 +12,6 @@ import React, { useState, useCallback, useEffect, useLayoutEffect, useMemo, useR import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceContext'; import { useWindowControls } from '../hooks/useWindowControls'; import { isWindowFullscreenShortcut } from '../hooks/windowFullscreenShortcut'; -import { useAssistantBootstrap } from '../hooks/useAssistantBootstrap'; import { usePermissionRequestNotify } from '../hooks/usePermissionRequestNotify'; import { useApp } from '../hooks/useApp'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; @@ -102,7 +101,6 @@ const AppLayout: React.FC = ({ className = '' }) => { : 'local'; const { isToolbarMode } = useToolbarModeContext(); - const { ensureForWorkspace: ensureAssistantBootstrapForWorkspace } = useAssistantBootstrap(); const isMacOS = useMemo(() => { return isMacOSDesktopRuntime(); }, []); @@ -344,11 +342,6 @@ const AppLayout: React.FC = ({ className = '' }) => { } } - const activeSessionId = sessionId || flowChatStore.getState().activeSessionId; - if (currentWorkspace.workspaceKind === WorkspaceKind.Assistant && activeSessionId) { - ensureAssistantBootstrapForWorkspace(currentWorkspace, activeSessionId); - } - const pendingDescription = sessionStorage.getItem('pendingProjectDescription'); if (pendingDescription && pendingDescription.trim()) { sessionStorage.removeItem('pendingProjectDescription'); @@ -426,7 +419,6 @@ const AppLayout: React.FC = ({ className = '' }) => { currentWorkspace?.connectionId, currentWorkspace?.sshHost, remoteSshFlowChatKey, - ensureAssistantBootstrapForWorkspace, t, ]); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index bb08f61d58..5b48ec0f99 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -69,6 +69,7 @@ import { type PendingLargePasteMap, } from '../store/sessionComposerStore'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { useAssistantBootstrap } from '@/app/hooks/useAssistantBootstrap'; import { clearComposerForSubmission, failedSubmissionRecoveryTarget, @@ -1838,6 +1839,11 @@ export const ChatInput: React.FC = ({ }); }, [deviceSurfaceScope.epoch, effectiveTargetSessionId, replaceContexts]); + const applyAssistantBootstrapDraft = useCallback((value: string) => { + dispatchInput({ type: 'SET_VALUE', payload: value }); + }, [dispatchInput]); + useAssistantBootstrap(effectiveTargetSession, applyAssistantBootstrapDraft); + useEffect(() => { let previousContexts = useContextStore.getState().contexts; const unsubscribe = useContextStore.subscribe((state) => { diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts index 08d7a0014d..c91d8b1d41 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts @@ -164,6 +164,128 @@ describe('FlowChatManager initialization', () => { ); }); + it('creates one empty Claw session when reinitializing a reset workspace', async () => { + storeMocks.store = { + removeSessionsForWorkspace: vi.fn(() => []), + getState: () => ({ activeSessionId: null, sessions: new Map() }), + }; + const manager = FlowChatManager.getInstance(); + const initialize = vi.spyOn(manager, 'initialize').mockResolvedValue(false); + const create = vi.spyOn(manager, 'createChatSession').mockResolvedValue('claw-new'); + const send = vi.spyOn(manager, 'sendMessage'); + await manager.resetWorkspaceSessions({ id: 'assistant', rootPath: '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/assistants/default' }, { + reinitialize: true, preferredMode: 'Claw', + }); + expect(initialize).toHaveBeenCalledWith('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/assistants/default', 'Claw', undefined, undefined); + expect(create).toHaveBeenCalledExactlyOnceWith({ workspacePath: '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/assistants/default', workspaceId: 'assistant' }, 'Claw'); + expect(send).not.toHaveBeenCalled(); + manager.destroy(); + }); + + it.each(['empty', 'archived', 'unscoped', 'other-remote-host'])( + 'clears the previous workspace selection when the target has only %s history', + async (kind) => { + const previous = createHistoricalSession({ sessionId: 'previous', workspacePath: '/previous' }); + const cached = createHistoricalSession({ + workspacePath: kind === 'unscoped' ? undefined : '/target', + ...(kind === 'archived' ? { persistedStatus: 'archived' } : {}), + ...(kind === 'other-remote-host' ? { remoteConnectionId: 'ssh-user@other', remoteSshHost: 'other' } : {}), + }); + let state = { + activeSessionId: previous.sessionId as string | null, + sessions: new Map([['previous', previous], ...(kind === 'empty' ? [] : [['history-1', cached]] as Array<[string, typeof cached]>)]), + }; + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), + loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 0, hasMore: false })), + getState: () => state, + setState: vi.fn((update) => { state = update(state); }), + loadSessionHistory: vi.fn(), + switchSession: vi.fn((sessionId) => { state = { ...state, activeSessionId: sessionId }; }), + }; + const manager = FlowChatManager.getInstance(); + + await expect(manager.initialize('/target', 'Claw', + kind === 'other-remote-host' ? 'ssh-user@target' : undefined, + kind === 'other-remote-host' ? 'target' : undefined, + )).resolves.toBe(false); + + expect(state.activeSessionId).toBeNull(); + expect(state.sessions.get('previous')).toBe(previous); + expect(storeMocks.store.loadSessionHistory).not.toHaveBeenCalled(); + expect(storeMocks.switchChatSession).not.toHaveBeenCalled(); + manager.destroy(); + }, + ); + + it('does not retain an already active archived session on workspace initialization', async () => { + const archived = createHistoricalSession({ persistedStatus: 'archived' }); + let state = { activeSessionId: archived.sessionId as string | null, sessions: new Map([[archived.sessionId, archived]]) }; + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), + loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 0, hasMore: false })), + getState: () => state, + setState: vi.fn((update) => { state = update(state); }), + loadSessionHistory: vi.fn(), + }; + const manager = FlowChatManager.getInstance(); + + await expect(manager.initialize(archived.workspacePath)).resolves.toBe(false); + + expect(state.activeSessionId).toBeNull(); + expect(state.sessions.get(archived.sessionId)).toBe(archived); + expect(storeMocks.store.loadSessionHistory).not.toHaveBeenCalled(); + manager.destroy(); + }); + + it.each(['deleted', 'archived'])('does not select a session %s while its history loads', async (mutation) => { + const session = createHistoricalSession(); + const history = createDeferred(); + const state = { activeSessionId: null, sessions: new Map([[session.sessionId, session]]) }; + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), + loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 1, hasMore: false })), + getState: () => state, + loadSessionHistory: vi.fn(() => history.promise), + }; + const manager = FlowChatManager.getInstance(); + const initialization = manager.initialize(session.workspacePath); + await vi.waitFor(() => expect(storeMocks.store.loadSessionHistory).toHaveBeenCalled()); + if (mutation === 'deleted') { + state.sessions.delete(session.sessionId); + } else { + state.sessions.set(session.sessionId, createHistoricalSession({ persistedStatus: 'archived' })); + } + history.resolve(); + + await expect(initialization).resolves.toBe(false); + expect(storeMocks.switchChatSession).not.toHaveBeenCalled(); + manager.destroy(); + }); + + it('restores a worktree session through its project workspace scope', async () => { + const session = createHistoricalSession({ + workspacePath: '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/worktrees/task', projectWorkspacePath: '/project', + workspaceHostname: 'server', + }); + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), + loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 1, hasMore: false })), + getState: () => ({ activeSessionId: null, sessions: new Map([[session.sessionId, session]]) }), + loadSessionHistory: vi.fn(), + switchSession: vi.fn(), + }; + const manager = FlowChatManager.getInstance(); + + await expect(manager.initialize('/project', undefined, 'ssh-user@server', 'server')).resolves.toBe(true); + expect(storeMocks.store.switchSession).toHaveBeenCalledWith(session.sessionId); + manager.destroy(); + }); + it('runs listener cleanup if disposal wins the initialization race', async () => { storeMocks.store = {}; const listenerInitialization = createDeferred<() => void>(); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index b468c46bd8..80f57606fc 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -264,27 +264,18 @@ export class FlowChatManager { // runs its own bootstrap. scope.assertCurrent('initializeWorkspace'); - const sessionMatchesWorkspace = (session: { - workspacePath?: string; - remoteConnectionId?: string; - remoteSshHost?: string; - }) => { - const sp = session.workspacePath || workspacePath; + const sessionMatchesWorkspace = (session: Session) => { return sessionBelongsToWorkspaceNavRow( - { - workspacePath: sp, - remoteConnectionId: session.remoteConnectionId, - remoteSshHost: session.remoteSshHost, - }, + session, workspacePath, remoteConnectionId, remoteSshHost ); }; const isAutoSelectableWorkspaceSession = ( - session: Pick + session: Pick ) => { - if (session.isTransient) { + if (session.isTransient || session.persistedStatus === 'archived') { return false; } return !resolveSessionRelationship(session).displayAsChild; @@ -333,8 +324,18 @@ export class FlowChatManager { ? state.sessions.get(state.activeSessionId) ?? null : null; const activeSessionBelongsToWorkspace = - !!activeSession && sessionMatchesWorkspace(activeSession); + !!activeSession && activeSession.persistedStatus !== 'archived' && sessionMatchesWorkspace(activeSession); const activeSessionIdAtAutoSelectStart = state.activeSessionId; + const clearUnavailableSelection = () => { + if (!isCurrentInitializationRequest() || activeSessionIdAtAutoSelectStart === null) { + return; + } + this.context.flowChatStore.setState(current => + current.activeSessionId === activeSessionIdAtAutoSelectStart + ? { ...current, activeSessionId: null } + : current + ); + }; // History is only restored on the auto-select path below. A session that // is already active — restored by the nav list after a Peer Device @@ -375,6 +376,7 @@ export class FlowChatManager { // surface with no active session and no new one, so report no history // and let the caller create against the live workspace instead. this.context.currentWorkspacePath = workspacePath; + clearUnavailableSelection(); log.warn('Session metadata reported history with nothing selectable for this workspace', { workspacePath, metadataSessionCount: initialMetadataPage.sessions.length, @@ -406,7 +408,7 @@ export class FlowChatManager { ? currentState.sessions.get(currentState.activeSessionId) ?? null : null; const currentActiveSessionBelongsToWorkspace = - !!currentActiveSession && sessionMatchesWorkspace(currentActiveSession); + !!currentActiveSession && currentActiveSession.persistedStatus !== 'archived' && sessionMatchesWorkspace(currentActiveSession); const activeSessionChangedDuringAutoSelect = currentState.activeSessionId !== activeSessionIdAtAutoSelectStart && currentState.activeSessionId !== null; @@ -418,11 +420,23 @@ export class FlowChatManager { return hasHistoricalSessions; } + // Archive/delete can finish while history is loading. Do not activate + // the old catalog entry after that mutation removed it. + const candidate = currentState.sessions.get(latestSession.sessionId); + if (!candidate || !sessionMatchesWorkspace(candidate) || !isAutoSelectableWorkspaceSession(candidate)) { + this.context.currentWorkspacePath = workspacePath; + clearUnavailableSelection(); + return false; + } + await switchChatSessionModule(this.context, latestSession.sessionId); } if (isCurrentInitializationRequest()) { this.context.currentWorkspacePath = workspacePath; + if (!hasHistoricalSessions && !activeSessionBelongsToWorkspace) { + clearUnavailableSelection(); + } } return hasHistoricalSessions; @@ -756,8 +770,6 @@ export class FlowChatManager { options?: { reinitialize?: boolean; preferredMode?: string; - /** After reinit, ask core to run assistant bootstrap if BOOTSTRAP.md is present (e.g. workspace reset). */ - ensureAssistantBootstrap?: boolean; } ): Promise { const workspacePath = workspace.rootPath; @@ -810,24 +822,6 @@ export class FlowChatManager { options.preferredMode ); } - - if (options?.ensureAssistantBootstrap) { - const sid = this.context.flowChatStore.getState().activeSessionId; - if (sid) { - try { - const { agentAPI } = await import('@/infrastructure/api/service-api/AgentAPI'); - await agentAPI.ensureAssistantBootstrap({ - sessionId: sid, - workspacePath, - }); - } catch (error) { - log.warn('ensureAssistantBootstrap after resetWorkspaceSessions failed', { - workspacePath, - error, - }); - } - } - } } async sendMessage( diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 2a824a5823..1acab63295 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -21,6 +21,8 @@ import type { DialogTurn, FlowToolItem, FlowUserSteeringItem, ModelRound, Sessio import type { FlowChatContext } from './types'; import { markOptimisticDispatchTurnMetadata } from '@/features/dispatch/optimisticDispatchTurn'; import { interruptedTurnRecoveryGate } from '../interruptedTurnRecoveryGate'; +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { localSessionDriver } from '../../session-drivers/local/LocalSessionDriver'; const { buildBuiltInBrowserTabOptions, @@ -42,6 +44,42 @@ describe('isAppWindowFocused', () => { }); }); +describe('Claw bootstrap cancellation', () => { + beforeEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + afterEach(() => { + vi.restoreAllMocks(); + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + it('stops a backend-started bootstrap using its actual session and turn identities', async () => { + const interrupt = vi.spyOn(agentAPI, 'interruptDialogTurn').mockResolvedValue(undefined); + const store = FlowChatStore.getInstance(); + store.setState(() => ({ + sessions: new Map([['claw-1', { + sessionId: 'claw-1', title: 'Claw', mode: 'Claw', sessionKind: 'normal', + workspacePath: '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/assistants/default', config: { agentType: 'Claw' }, + dialogTurns: [], status: 'idle', createdAt: 1, lastActiveAt: 1, error: null, + } as Session]]), + activeSessionId: 'claw-1', + })); + const context = createFlowChatContext(); + __test_only__.handleDialogTurnStarted(context, { + sessionId: 'claw-1', turnId: 'assistant-bootstrap-1', turnIndex: 0, + userInput: 'Please start bootstrap', + userMessageMetadata: { assistant_bootstrap: { system_generated: true } }, + }); + + expect(await localSessionDriver.cancel(context, 'claw-1')).toBe(true); + expect(interrupt).toHaveBeenCalledExactlyOnceWith('claw-1', 'assistant-bootstrap-1'); + expect(context.userCancelledSessionIds.has('claw-1')).toBe(true); + expect(stateMachineManager.getCurrentState('claw-1')).toBe(SessionExecutionState.FINISHING); + }); +}); + describe('built-in browser open request projection', () => { it('correlates replacement and new-tab requests with distinct stable keys', () => { expect(buildBuiltInBrowserTabOptions({ diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index d52b2413eb..5f4960093f 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -378,6 +378,7 @@ "batchChildSession": "Child session", "batchTurnsLabel": "{{count}} turns", "archive": "Archive", + "assistantBootstrapDraft": "Please follow BOOTSTRAP.md and help me set up this assistant.", "archiveAll": "Archive All Sessions", "archiveSelected": "Archive Selected", "unarchive": "Restore", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 36c9eda037..d6ea038f3a 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -378,6 +378,7 @@ "batchChildSession": "子会话", "batchTurnsLabel": "{{count}} 轮", "archive": "归档", + "assistantBootstrapDraft": "请按照 BOOTSTRAP.md 的说明,和我一起完成助理初始化。", "archiveAll": "归档所有会话", "archiveSelected": "归档所选", "unarchive": "恢复", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 4abd2424df..dcc17fc6f3 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -378,6 +378,7 @@ "batchChildSession": "子會話", "batchTurnsLabel": "{{count}} 輪", "archive": "歸檔", + "assistantBootstrapDraft": "請按照 BOOTSTRAP.md 的說明,和我一起完成助理初始化。", "archiveAll": "歸檔所有會話", "archiveSelected": "歸檔所選", "unarchive": "恢復", From ad9bce23712200b1706023503da38ccf5882f15d Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Mon, 7 Sep 2026 15:45:51 +0800 Subject: [PATCH 2/2] test(web-ui): align remote connection assertions with simplified sidebar --- .../RemoteConnectDialog/RemoteConnectDialog.status.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 f5a8794f1e..502b15ce41 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 @@ -298,14 +298,13 @@ describe('Remote Connect shared status through the real dialog and sidebar', () const devices = element('[data-testid="nav-device-status-connected-devices"]'); expect(devices.querySelector('[data-openbitfun-device-kind="mobile"] strong')?.textContent).toBe('remoteConnect.mobileBrowserTitle'); expect(devices.querySelector('[data-openbitfun-device-kind="message-app"] strong')?.textContent).toBe('remoteConnect.weixin'); - const service = element('[data-testid="nav-device-connection-service"]'); - expect(service.getAttribute('data-openbitfun-service-kind')).toBe('self-hosted'); - expect(service.textContent).toContain('relay.example.test'); + expect(document.querySelector('[data-testid="nav-device-connection-service"]')).toBeNull(); await click(element('[data-testid="nav-footer-device-status"]')); await click(element('[data-testid="reopen-remote-connect"]')); expect(overviewNetwork().textContent).toContain('remoteConnect.stateConnected'); await openNetwork(); expect(cardStatus()).toBe('remoteConnect.stateConnected'); + expect((element('input[type="url"]') as HTMLInputElement).value).toBe(relayA); expect(dialog().textContent).not.toContain('remoteConnect.disconnect'); await clickText('remoteConnect.showConnectionCode'); expect(boundary.startConnection).toHaveBeenCalledTimes(2);