Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -290,6 +291,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
const [exportingSessionId, setExportingSessionId] = useState<string | null>(null);
const [runningSessionIds, setRunningSessionIds] = useState<Set<string>>(new Set());
const [scheduledJobsSessionId, setScheduledJobsSessionId] = useState<string | null>(null);
const [batchWorkspace, setBatchWorkspace] = useState<WorkspaceSessionScope | null>(null);
const editInputRef = useRef<HTMLInputElement>(null);
const sessionMenuPopoverRef = useRef<HTMLDivElement>(null);
const sessionMenuAnchorRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -1968,6 +1970,29 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
>
<span>{t('nav.sessions.archive')}</span>
</MenuItem>
<MenuItem
type="button"
leading={<Icon glyph={ListChecks} />}
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"
>
<span>{t('nav.sessions.manage')}</span>
</MenuItem>
<MenuItem
type="button"
tone="danger"
Expand Down Expand Up @@ -2075,6 +2100,18 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
</Suspense>
)}
</RetainedMountBoundary>
{batchWorkspace && (
<Suspense fallback={null}>
<WorkspaceSessionBatchModal
isOpen
onClose={() => setBatchWorkspace(null)}
workspacePath={batchWorkspace.workspacePath}
workspaceLabel={batchWorkspace.workspaceName}
remoteConnectionId={batchWorkspace.remoteConnectionId}
remoteSshHost={batchWorkspace.remoteSshHost}
/>
</Suspense>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,15 +545,6 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
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 {
Expand Down Expand Up @@ -664,13 +655,12 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
}

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) {
Expand Down Expand Up @@ -866,7 +856,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={() => { void handleCardNameClick(); }}
onClick={handleCollapseToggle}
style={{ cursor: 'pointer' }}
data-testid="nav-workspace-card"
data-workspace-id={workspace.id}
Expand Down Expand Up @@ -897,7 +887,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
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}
>
Expand Down Expand Up @@ -1150,7 +1140,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={() => { void handleCardNameClick(); }}
onClick={handleCollapseToggle}
style={{ cursor: 'pointer' }}
data-testid="nav-workspace-card"
data-workspace-id={workspace.id}
Expand Down Expand Up @@ -1188,7 +1178,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
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}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => <div>{children}</div>;
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'>) => (
<button onClick={onClick} disabled={disabled}>{children}</button>
),
Checkbox: ({ label, checked, onChange, disabled }: React.ComponentProps<'input'> & { label: React.ReactNode }) => (
<label><input type="checkbox" checked={checked} onChange={onChange} disabled={disabled} />{label}</label>
),
};
});

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(
<WorkspaceSessionBatchModal isOpen onClose={() => {}} 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<HTMLInputElement>('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']]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
139 changes: 139 additions & 0 deletions src/web-ui/src/app/hooks/useAssistantBootstrap.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): Session {
return {
sessionId: 'claw-1', mode: 'Claw', workspacePath: '/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(<Composer target={target} />));
};

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<string>(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<string>(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<string>(resolve => { finish = resolve; }));
await render();
activateSurface('peer:other');
await act(async () => finish('# Bootstrap'));
expect(sessionComposerStore.getState().getDraft('claw-1').value).toBe('');
});
});
Loading
Loading