diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 3e81ef6b78..ba020c53e0 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -638,6 +638,189 @@ display: none; } +.sessionActionButtonActive, +.sessionActionButtonActive:hover { + background: var(--accent); + color: var(--accent-foreground); +} + +.menuActive .sessionActions { + opacity: 1; +} + +.actionMenu { + position: fixed; + z-index: 1100; + min-width: 168px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 1px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--popover); + color: var(--popover-foreground); + box-shadow: + 0 2px 4px -2px rgb(0 0 0 / 10%), + 0 8px 16px -4px rgb(0 0 0 / 18%); +} + +.actionMenuItem { + appearance: none; + width: 100%; + height: 32px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--popover-foreground); + font: inherit; + font-size: 13px; + line-height: 20px; + text-align: left; + cursor: pointer; +} + +.actionMenuItem:hover:not(:disabled), +.actionMenuItem:focus-visible:not(:disabled) { + background: var(--accent); + color: var(--accent-foreground); + outline: none; +} + +.actionMenuItem:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.actionMenuItemDanger:not(:disabled) { + color: var(--error-color); +} + +.actionMenuItemDanger:hover:not(:disabled), +.actionMenuItemDanger:focus-visible:not(:disabled) { + background: var(--error-bg); + color: var(--error-color); +} + +.actionMenuIcon { + width: 16px; + height: 16px; + flex: 0 0 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.actionMenuIcon svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.actionMenuLabel { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.archivedSection { + display: flex; + flex-direction: column; + margin-top: 6px; + padding-top: 4px; + border-top: 1px solid var(--sidebar-border); +} + +.archivedHeader { + appearance: none; + width: 100%; + height: 32px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--muted-foreground); + font: inherit; + font-size: 13px; + font-weight: 500; + line-height: 20px; + cursor: pointer; +} + +.archivedHeader:hover, +.archivedHeader:focus-visible { + background: var(--sidebar-accent); + color: var(--sidebar-accent-foreground); + outline: none; +} + +.archivedChevron { + width: 18px; + height: 18px; + flex: 0 0 18px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.archivedChevron svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.archivedTitle { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; +} + +.archivedCount { + flex: 0 0 auto; + min-width: 20px; + height: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 6px; + border-radius: 999px; + background: var(--secondary); + color: var(--secondary-foreground); + font-size: 11px; + font-weight: 500; +} + +.archivedList { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px 0; +} + +.archivedRow { + cursor: default; +} + @media (max-width: 760px) { .sidebar { display: none; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx index 9ecc20c139..5552f39738 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx @@ -3,29 +3,66 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -const { mockConnection } = vi.hoisted(() => ({ - mockConnection: { - status: 'connected', - sessionId: null as string | null, - workspaceCwd: '/tmp/project', - capabilities: { qwenCodeVersion: '1.2.3' } as - | { qwenCodeVersion?: string } - | undefined, - }, -})); +type MockSession = { + sessionId: string; + workspaceCwd: string; + displayName?: string; + createdAt?: string; + updatedAt?: string; + clientCount?: number; + hasActivePrompt?: boolean; + isArchived?: boolean; +}; + +const { mockConnection, mockActive, mockArchived, renameSessionSpy } = + vi.hoisted(() => { + const makeStore = () => ({ + sessions: [] as MockSession[], + loading: false, + error: null as unknown, + reload: vi.fn(), + deleteSession: vi.fn().mockResolvedValue(true), + archiveSession: vi.fn().mockResolvedValue(true), + unarchiveSession: vi.fn().mockResolvedValue(true), + }); + return { + mockConnection: { + status: 'connected', + sessionId: null as string | null, + workspaceCwd: '/tmp/project', + capabilities: { qwenCodeVersion: '1.2.3' } as + | { qwenCodeVersion?: string } + | undefined, + }, + mockActive: makeStore(), + mockArchived: makeStore(), + renameSessionSpy: vi.fn(), + }; + }); vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useConnection: () => mockConnection, - useActions: () => ({ renameSession: vi.fn() }), - useSessions: () => ({ - sessions: [], - loading: false, - error: null, - reload: vi.fn(), - deleteSession: vi.fn(), - }), + useActions: () => ({ renameSession: renameSessionSpy }), + useSessions: (options?: { archiveState?: 'active' | 'archived' }) => + options?.archiveState === 'archived' ? mockArchived : mockActive, })); +function makeSession( + sessionId: string, + over: Partial = {}, +): MockSession { + return { + sessionId, + workspaceCwd: '/tmp/project', + displayName: `Session ${sessionId}`, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + clientCount: 0, + hasActivePrompt: false, + ...over, + }; +} + const { I18nProvider } = await import('../../i18n'); const { WebShellSidebar } = await import('./WebShellSidebar'); @@ -69,6 +106,17 @@ function renderSidebar( beforeEach(() => { mockConnection.capabilities = { qwenCodeVersion: '1.2.3' }; + mockConnection.sessionId = null; + for (const store of [mockActive, mockArchived]) { + store.sessions = []; + store.loading = false; + store.error = null; + store.reload.mockClear(); + store.deleteSession.mockClear(); + store.archiveSession.mockClear(); + store.unarchiveSession.mockClear(); + } + renameSessionSpy.mockClear(); }); afterEach(() => { @@ -135,3 +183,88 @@ describe('WebShellSidebar — daemon status entry', () => { expect(onOpenDaemonStatus).toHaveBeenCalledTimes(1); }); }); + +function click(el: Element | null): void { + expect(el).not.toBeNull(); + act(() => { + el!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +// Clicks that kick off an async action (archive/unarchive) settle a trailing +// `setBusySessionId` in a `.finally()`; flush those microtasks inside act(). +async function clickAsync(el: Element | null): Promise { + expect(el).not.toBeNull(); + await act(async () => { + el!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +describe('WebShellSidebar — archive actions', () => { + it('archives an active session from the quick action button', async () => { + mockActive.sessions = [makeSession('aaaaaaaa')]; + const container = renderSidebar(false); + const archiveBtn = container.querySelector( + '[aria-label="Archive"]', + ); + expect(archiveBtn).not.toBeNull(); + expect(archiveBtn!.disabled).toBe(false); + await clickAsync(archiveBtn); + expect(mockActive.archiveSession).toHaveBeenCalledWith('aaaaaaaa'); + expect(mockArchived.unarchiveSession).not.toHaveBeenCalled(); + }); + + it('disables archiving the current session', () => { + mockActive.sessions = [makeSession('current1')]; + mockConnection.sessionId = 'current1'; + const container = renderSidebar(false); + const archiveBtn = container.querySelector( + '[aria-label="Archive"]', + ); + expect(archiveBtn).not.toBeNull(); + expect(archiveBtn!.disabled).toBe(true); + click(archiveBtn); + expect(mockActive.archiveSession).not.toHaveBeenCalled(); + }); + + it('opens the overflow menu with rename, archive, and delete', () => { + mockActive.sessions = [makeSession('aaaaaaaa')]; + const container = renderSidebar(false); + click(container.querySelector('[aria-label="More actions"]')); + const menu = container.querySelector('[role="menu"]'); + expect(menu).not.toBeNull(); + const labels = Array.from(menu!.querySelectorAll('[role="menuitem"]')).map( + (el) => el.textContent, + ); + expect(labels).toEqual(['Rename', 'Archive', 'Delete']); + }); + + it('archives a non-current session from the overflow menu', async () => { + mockActive.sessions = [makeSession('aaaaaaaa')]; + const container = renderSidebar(false); + click(container.querySelector('[aria-label="More actions"]')); + const archiveItem = Array.from( + container.querySelectorAll('[role="menuitem"]'), + ).find((el) => el.textContent === 'Archive'); + await clickAsync(archiveItem ?? null); + expect(mockActive.archiveSession).toHaveBeenCalledWith('aaaaaaaa'); + }); + + it('reveals archived sessions on demand and restores them', async () => { + mockArchived.sessions = [makeSession('bbbbbbbb', { isArchived: true })]; + const container = renderSidebar(false); + // Collapsed by default: the archived rows (and their Restore button) are + // not rendered until the section is expanded. + expect(container.querySelector('[aria-label="Restore"]')).toBeNull(); + const header = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('Archived'), + ); + click(header ?? null); + const restoreBtn = container.querySelector( + '[aria-label="Restore"]', + ); + expect(restoreBtn).not.toBeNull(); + await clickAsync(restoreBtn); + expect(mockArchived.unarchiveSession).toHaveBeenCalledWith('bbbbbbbb'); + }); +}); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index c8ff38a31f..dfc5bc012d 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -162,6 +162,36 @@ function IconTrash() { ); } +function IconArchive() { + return ( + + ); +} + +function IconUnarchive() { + return ( + + ); +} + +function IconMore() { + return ( + + ); +} + function IconCollapse({ collapsed }: { collapsed: boolean }) { return (
+ {items.map((item) => ( + + ))} +
+ ); +} + export function WebShellSidebar({ collapsed, onCollapsedChange, @@ -191,10 +314,31 @@ export function WebShellSidebar({ const { t } = useI18n(); const connection = useConnection(); const actions = useActions(); - const { sessions, loading, error, reload, deleteSession } = useSessions({ + const { sessions, loading, error, reload, deleteSession, archiveSession } = + useSessions({ + autoLoad: true, + pageSize: SIDEBAR_SESSION_PAGE_SIZE, + archiveState: 'active', + }); + const [archivedExpanded, setArchivedExpanded] = useState(false); + const { + sessions: archivedSessions, + loading: archivedLoading, + error: archivedError, + reload: reloadArchived, + deleteSession: deleteArchivedSession, + unarchiveSession, + } = useSessions({ autoLoad: true, + enabled: archivedExpanded, pageSize: SIDEBAR_SESSION_PAGE_SIZE, + archiveState: 'archived', }); + const [menuState, setMenuState] = useState<{ + session: DaemonSessionSummary; + isArchived: boolean; + anchorEl: HTMLElement; + } | null>(null); const [editingSessionId, setEditingSessionId] = useState(null); const [editingName, setEditingName] = useState(''); const [busySessionId, setBusySessionId] = useState(null); @@ -496,12 +640,17 @@ export function WebShellSidebar({ setDeleteCandidate(null); return; } + const isArchived = Boolean(deleteCandidate.isArchived); setDeleteCandidate(null); busySessionIdRef.current = sessionId; setBusySessionId(sessionId); - deleteSession(sessionId) - .then((removed) => { - if (!removed) reload(); + const removeSession = isArchived ? deleteArchivedSession : deleteSession; + removeSession(sessionId) + .then(() => { + // A hard delete unlinks the transcript from BOTH the active and + // archived directories, so resync both lists regardless of origin. + void reload(); + void reloadArchived(); }) .catch((err: unknown) => onError(err, t('sidebar.deleteFailed'))) .finally(() => { @@ -510,7 +659,16 @@ export function WebShellSidebar({ } setBusySessionId((current) => (current === sessionId ? null : current)); }); - }, [currentSessionId, deleteCandidate, deleteSession, onError, reload, t]); + }, [ + currentSessionId, + deleteArchivedSession, + deleteCandidate, + deleteSession, + onError, + reload, + reloadArchived, + t, + ]); const handleRenameFromMenu = useCallback( (session: DaemonSessionSummary) => { @@ -520,6 +678,76 @@ export function WebShellSidebar({ [currentSessionId, startRename], ); + const handleArchive = useCallback( + (session: DaemonSessionSummary) => { + const sessionId = session.sessionId; + // The daemon force-ends a live turn on archive; keep the current + // session off-limits, mirroring the delete guard. + if (sessionId === currentSessionId) return; + if (busySessionIdRef.current !== null) return; + busySessionIdRef.current = sessionId; + setBusySessionId(sessionId); + archiveSession(sessionId) + .then(() => { + void reloadArchived(); + }) + .catch((err: unknown) => onError(err, t('sidebar.archiveFailed'))) + .finally(() => { + if (busySessionIdRef.current === sessionId) { + busySessionIdRef.current = null; + } + setBusySessionId((current) => + current === sessionId ? null : current, + ); + }); + }, + [archiveSession, currentSessionId, onError, reloadArchived, t], + ); + + const handleUnarchive = useCallback( + (session: DaemonSessionSummary) => { + const sessionId = session.sessionId; + if (busySessionIdRef.current !== null) return; + busySessionIdRef.current = sessionId; + setBusySessionId(sessionId); + unarchiveSession(sessionId) + .then(() => { + void reload(); + }) + .catch((err: unknown) => onError(err, t('sidebar.unarchiveFailed'))) + .finally(() => { + if (busySessionIdRef.current === sessionId) { + busySessionIdRef.current = null; + } + setBusySessionId((current) => + current === sessionId ? null : current, + ); + }); + }, + [onError, reload, t, unarchiveSession], + ); + + const closeMenu = useCallback(() => setMenuState(null), []); + + const openMenu = useCallback( + ( + event: ReactMouseEvent, + session: DaemonSessionSummary, + isArchived: boolean, + ) => { + event.stopPropagation(); + const anchorEl = event.currentTarget; + setMenuState((prev) => + prev && + prev.session.sessionId === session.sessionId && + prev.isArchived === isArchived + ? null + : { session, isArchived, anchorEl }, + ); + }, + [], + ); + const filteredSessions = useMemo(() => { const query = searchQuery.trim().toLowerCase(); const nextSessions = query @@ -629,6 +857,9 @@ export function WebShellSidebar({ const busy = busySessionId === session.sessionId; const completedUnread = !isCurrent && completedUnreadIds.has(session.sessionId); + const isMenuOpen = + menuState?.session.sessionId === session.sessionId && + !menuState.isArchived; return (
- +
@@ -753,11 +985,12 @@ export function WebShellSidebar({ editingSessionId, error, filteredSessions, - handleDeleteSession, + handleArchive, handleLoadSession, - handleRenameFromMenu, hideTooltip, loading, + menuState, + openMenu, projectExpanded, reload, saveRename, @@ -768,6 +1001,191 @@ export function WebShellSidebar({ t, ]); + const archivedSection = useMemo(() => { + if (collapsed || !projectExpanded || searchQuery.trim()) return null; + + const header = ( + + ); + + if (!archivedExpanded) { + return
{header}
; + } + + let content: ReactNode; + if (archivedLoading && archivedSessions.length === 0) { + content = ( +
{t('sidebar.loadingSessions')}
+ ); + } else if (archivedError && archivedSessions.length === 0) { + content = ( + + ); + } else if (archivedSessions.length === 0) { + content = ( +
{t('sidebar.archivedEmpty')}
+ ); + } else { + content = archivedSessions.map((session) => { + const label = getSessionLabel(session); + const stamp = session.updatedAt || session.createdAt; + const time = stamp ? formatRelativeTime(stamp, t) : ''; + const busy = busySessionId === session.sessionId; + const isMenuOpen = + menuState?.session.sessionId === session.sessionId && + menuState.isArchived; + return ( +
+ {label} +
+ {time} +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + +
+
+
+ ); + }); + } + + return ( +
+ {header} +
{content}
+
+ ); + }, [ + archivedError, + archivedExpanded, + archivedLoading, + archivedSessions, + busySessionId, + collapsed, + handleUnarchive, + menuState, + openMenu, + projectExpanded, + reloadArchived, + searchQuery, + t, + ]); + + const menuItems = useMemo(() => { + if (!menuState) return []; + const { session, isArchived } = menuState; + if (isArchived) { + return [ + { + key: 'unarchive', + label: t('sidebar.unarchive'), + icon: , + onSelect: () => handleUnarchive(session), + }, + { + key: 'delete', + label: t('sidebar.delete'), + icon: , + danger: true, + onSelect: () => handleDeleteSession(session), + }, + ]; + } + const isCurrent = session.sessionId === currentSessionId; + return [ + { + key: 'rename', + label: t('sidebar.rename'), + icon: , + disabled: !isCurrent, + disabledTitle: t('sidebar.renameCurrentOnly'), + onSelect: () => handleRenameFromMenu(session), + }, + { + key: 'archive', + label: t('sidebar.archive'), + icon: , + disabled: isCurrent, + disabledTitle: t('sidebar.archiveCurrentDisabled'), + onSelect: () => handleArchive(session), + }, + { + key: 'delete', + label: t('sidebar.delete'), + icon: , + danger: true, + disabled: isCurrent, + disabledTitle: t('sidebar.currentDeleteDisabled'), + onSelect: () => handleDeleteSession(session), + }, + ]; + }, [ + currentSessionId, + handleArchive, + handleDeleteSession, + handleRenameFromMenu, + handleUnarchive, + menuState, + t, + ]); + return (