feat(web-shell): manage sessions from the sidebar (archive, unarchive, delete) (#6293)

Add an Archive quick action and a "..." overflow menu (Rename / Archive / Delete) to each session row in the web-shell sidebar, plus a collapsible "Archived" section that lazily lists archived sessions with Restore / Delete. Thread the daemon's existing archiveState filter and archive/unarchive endpoints through the webui workspace facade and the useDaemonSessions hook; rename stays limited to the current live session.
This commit is contained in:
Shaojin Wen 2026-07-04 12:27:22 +08:00 committed by GitHub
parent 2a6a9514e3
commit c37cb23ccc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 863 additions and 48 deletions

View file

@ -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;

View file

@ -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> = {},
): 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<void> {
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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[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<HTMLButtonElement>('[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<HTMLButtonElement>(
'[aria-label="Restore"]',
);
expect(restoreBtn).not.toBeNull();
await clickAsync(restoreBtn);
expect(mockArchived.unarchiveSession).toHaveBeenCalledWith('bbbbbbbb');
});
});

View file

@ -162,6 +162,36 @@ function IconTrash() {
);
}
function IconArchive() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="4" rx="1" />
<path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" />
<path d="M10 12h4" />
</svg>
);
}
function IconUnarchive() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="4" rx="1" />
<path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8" />
<path d="M12 18v-6M9 15l3-3 3 3" />
</svg>
);
}
function IconMore() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none" />
<circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none" />
</svg>
);
}
function IconCollapse({ collapsed }: { collapsed: boolean }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
@ -178,6 +208,99 @@ function IconChevron({ expanded }: { expanded: boolean }) {
);
}
interface SessionMenuItem {
key: string;
label: string;
icon: ReactNode;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
disabledTitle?: string;
}
/**
* Overflow ("...") action menu for a session row. Rendered at the sidebar
* root with `position: fixed` (like the row tooltip) so it escapes the
* session list's `overflow: auto` clipping. Closes on outside pointer,
* Escape, scroll, or resize. The anchor button is excluded from the
* outside-pointer check so its own click can toggle the menu shut.
*/
function SessionActionsMenu({
anchorEl,
items,
onClose,
}: {
anchorEl: HTMLElement;
items: SessionMenuItem[];
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (ref.current?.contains(target)) return;
if (anchorEl.contains(target)) return;
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
}
};
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown, true);
window.addEventListener('scroll', onClose, true);
window.addEventListener('resize', onClose);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('scroll', onClose, true);
window.removeEventListener('resize', onClose);
};
}, [anchorEl, onClose]);
const anchor = anchorEl.getBoundingClientRect();
const estimatedHeight = items.length * 34 + 8;
const openUp = anchor.bottom + estimatedHeight > window.innerHeight - 8;
const style: CSSProperties = {
right: Math.max(8, window.innerWidth - anchor.right),
...(openUp
? { bottom: window.innerHeight - anchor.top + 4 }
: { top: anchor.bottom + 4 }),
};
return (
<div ref={ref} className={styles.actionMenu} role="menu" style={style}>
{items.map((item) => (
<button
key={item.key}
type="button"
role="menuitem"
className={cx(
styles.actionMenuItem,
item.danger && styles.actionMenuItemDanger,
)}
disabled={item.disabled}
title={item.disabled ? item.disabledTitle : undefined}
onClick={() => {
if (item.disabled) return;
onClose();
item.onSelect();
}}
>
<span className={styles.actionMenuIcon} aria-hidden="true">
{item.icon}
</span>
<span className={styles.actionMenuLabel}>{item.label}</span>
</button>
))}
</div>
);
}
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<string | null>(null);
const [editingName, setEditingName] = useState('');
const [busySessionId, setBusySessionId] = useState<string | null>(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<HTMLButtonElement>,
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 (
<div
key={session.sessionId}
@ -637,6 +868,7 @@ export function WebShellSidebar({
isCurrent && styles.currentSession,
session.hasActivePrompt && styles.runningSession,
busy && styles.busySession,
isMenuOpen && styles.menuActive,
)}
role="button"
tabIndex={0}
@ -706,33 +938,33 @@ export function WebShellSidebar({
setTooltip(null);
}}
>
<button
className={styles.sessionActionButton}
type="button"
disabled={!isCurrent}
title={
isCurrent
? t('sidebar.rename')
: t('sidebar.renameCurrentOnly')
}
aria-label={t('sidebar.rename')}
onClick={() => handleRenameFromMenu(session)}
>
<IconRename />
</button>
<button
className={styles.sessionActionButton}
type="button"
disabled={isCurrent}
title={
isCurrent
? t('sidebar.currentDeleteDisabled')
: t('sidebar.delete')
? t('sidebar.archiveCurrentDisabled')
: t('sidebar.archive')
}
aria-label={t('sidebar.delete')}
onClick={() => handleDeleteSession(session)}
aria-label={t('sidebar.archive')}
onClick={() => handleArchive(session)}
>
<IconTrash />
<IconArchive />
</button>
<button
className={cx(
styles.sessionActionButton,
isMenuOpen && styles.sessionActionButtonActive,
)}
type="button"
aria-label={t('sidebar.moreActions')}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
title={t('sidebar.moreActions')}
onClick={(event) => openMenu(event, session, false)}
>
<IconMore />
</button>
</div>
</div>
@ -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 = (
<button
type="button"
className={styles.archivedHeader}
aria-expanded={archivedExpanded}
onClick={() => setArchivedExpanded((expanded) => !expanded)}
>
<span className={styles.archivedChevron} aria-hidden="true">
<IconChevron expanded={archivedExpanded} />
</span>
<span className={styles.archivedTitle}>
{t('sidebar.archivedTitle')}
</span>
{archivedExpanded && archivedSessions.length > 0 && (
<span className={styles.archivedCount}>
{archivedSessions.length}
</span>
)}
</button>
);
if (!archivedExpanded) {
return <div className={styles.archivedSection}>{header}</div>;
}
let content: ReactNode;
if (archivedLoading && archivedSessions.length === 0) {
content = (
<div className={styles.notice}>{t('sidebar.loadingSessions')}</div>
);
} else if (archivedError && archivedSessions.length === 0) {
content = (
<button
className={styles.retry}
type="button"
onClick={() => reloadArchived()}
>
{t('sidebar.loadFailed')}
</button>
);
} else if (archivedSessions.length === 0) {
content = (
<div className={styles.notice}>{t('sidebar.archivedEmpty')}</div>
);
} 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 (
<div
key={session.sessionId}
className={cx(
styles.sessionRow,
styles.archivedRow,
busy && styles.busySession,
isMenuOpen && styles.menuActive,
)}
title={label}
>
<span className={styles.sessionText}>{label}</span>
<div className={styles.sessionMetaSlot}>
<span className={styles.sessionTime}>{time}</span>
<div
className={styles.sessionActions}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<button
className={styles.sessionActionButton}
type="button"
title={t('sidebar.unarchive')}
aria-label={t('sidebar.unarchive')}
onClick={() => handleUnarchive(session)}
>
<IconUnarchive />
</button>
<button
className={cx(
styles.sessionActionButton,
isMenuOpen && styles.sessionActionButtonActive,
)}
type="button"
aria-label={t('sidebar.moreActions')}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
title={t('sidebar.moreActions')}
onClick={(event) => openMenu(event, session, true)}
>
<IconMore />
</button>
</div>
</div>
</div>
);
});
}
return (
<div className={styles.archivedSection}>
{header}
<div className={styles.archivedList}>{content}</div>
</div>
);
}, [
archivedError,
archivedExpanded,
archivedLoading,
archivedSessions,
busySessionId,
collapsed,
handleUnarchive,
menuState,
openMenu,
projectExpanded,
reloadArchived,
searchQuery,
t,
]);
const menuItems = useMemo<SessionMenuItem[]>(() => {
if (!menuState) return [];
const { session, isArchived } = menuState;
if (isArchived) {
return [
{
key: 'unarchive',
label: t('sidebar.unarchive'),
icon: <IconUnarchive />,
onSelect: () => handleUnarchive(session),
},
{
key: 'delete',
label: t('sidebar.delete'),
icon: <IconTrash />,
danger: true,
onSelect: () => handleDeleteSession(session),
},
];
}
const isCurrent = session.sessionId === currentSessionId;
return [
{
key: 'rename',
label: t('sidebar.rename'),
icon: <IconRename />,
disabled: !isCurrent,
disabledTitle: t('sidebar.renameCurrentOnly'),
onSelect: () => handleRenameFromMenu(session),
},
{
key: 'archive',
label: t('sidebar.archive'),
icon: <IconArchive />,
disabled: isCurrent,
disabledTitle: t('sidebar.archiveCurrentDisabled'),
onSelect: () => handleArchive(session),
},
{
key: 'delete',
label: t('sidebar.delete'),
icon: <IconTrash />,
danger: true,
disabled: isCurrent,
disabledTitle: t('sidebar.currentDeleteDisabled'),
onSelect: () => handleDeleteSession(session),
},
];
}, [
currentSessionId,
handleArchive,
handleDeleteSession,
handleRenameFromMenu,
handleUnarchive,
menuState,
t,
]);
return (
<aside
className={cx(
@ -793,6 +1211,13 @@ export function WebShellSidebar({
{tooltip.content}
</div>
)}
{menuState && (
<SessionActionsMenu
anchorEl={menuState.anchorEl}
items={menuItems}
onClose={closeMenu}
/>
)}
{deleteCandidate && (
<DialogShell
title={t('delete.title')}
@ -942,7 +1367,10 @@ export function WebShellSidebar({
}}
/>
)}
<div className={styles.sessionList}>{body}</div>
<div className={styles.sessionList}>
{body}
{archivedSection}
</div>
</div>
<div className={styles.footer}>

View file

@ -504,6 +504,14 @@ const EN: Messages = {
'sidebar.rename': 'Rename',
'sidebar.renameCurrentOnly': 'Only the current session can be renamed',
'sidebar.delete': 'Delete',
'sidebar.archive': 'Archive',
'sidebar.unarchive': 'Restore',
'sidebar.moreActions': 'More actions',
'sidebar.archiveCurrentDisabled': 'The current session cannot be archived',
'sidebar.archivedTitle': 'Archived',
'sidebar.archivedEmpty': 'No archived sessions.',
'sidebar.archiveFailed': 'Failed to archive session',
'sidebar.unarchiveFailed': 'Failed to restore session',
'sidebar.loadingSessions': 'Loading sessions...',
'sidebar.loadFailed': 'Failed to load sessions. Click to retry.',
'sidebar.renameFailed': 'Failed to rename session',
@ -1802,6 +1810,14 @@ const ZH: Messages = {
'sidebar.rename': '重命名',
'sidebar.renameCurrentOnly': '暂仅支持重命名当前会话',
'sidebar.delete': '删除',
'sidebar.archive': '归档',
'sidebar.unarchive': '恢复',
'sidebar.moreActions': '更多操作',
'sidebar.archiveCurrentDisabled': '不能归档当前会话',
'sidebar.archivedTitle': '已归档',
'sidebar.archivedEmpty': '没有已归档的会话。',
'sidebar.archiveFailed': '归档会话失败',
'sidebar.unarchiveFailed': '恢复会话失败',
'sidebar.loadingSessions': '正在加载会话...',
'sidebar.loadFailed': '会话加载失败,点击重试。',
'sidebar.renameFailed': '重命名会话失败',

View file

@ -58,6 +58,30 @@ export function createDaemonWorkspaceActions({
);
},
async archiveSession(sessionId: string) {
const client = requireClient(getClient, 'Archive session failed');
const result = await withActionTimeout(
client.archiveSessionsData([sessionId]),
'Archive session timed out',
);
if (result.errors.length > 0) {
throw new Error(result.errors[0].error);
}
return result.archived.length > 0 || result.alreadyArchived.length > 0;
},
async unarchiveSession(sessionId: string) {
const client = requireClient(getClient, 'Unarchive session failed');
const result = await withActionTimeout(
client.unarchiveSessionsData([sessionId]),
'Unarchive session timed out',
);
if (result.errors.length > 0) {
throw new Error(result.errors[0].error);
}
return result.unarchived.length > 0 || result.alreadyActive.length > 0;
},
async loadMcpStatus() {
const client = requireClient(getClient, 'Load MCP status failed');
return withActionTimeout(

View file

@ -5,6 +5,7 @@
*/
import { useCallback } from 'react';
import type { DaemonSessionArchiveState } from '@qwen-code/sdk/daemon';
import { useOptionalDaemonActions } from '../../session/DaemonSessionProvider.js';
import { useDaemonWorkspace } from '../DaemonWorkspaceProvider.js';
import type { DaemonResourceOptions } from '../types.js';
@ -12,15 +13,17 @@ import { useDaemonResource } from './useDaemonResource.js';
export interface DaemonSessionsOptions extends DaemonResourceOptions {
pageSize?: number;
/** Which session directory to list. Defaults to the daemon's `active`. */
archiveState?: DaemonSessionArchiveState;
}
export function useDaemonSessions(options: DaemonSessionsOptions = {}) {
const { pageSize, ...resourceOptions } = options;
const { pageSize, archiveState, ...resourceOptions } = options;
const workspace = useDaemonWorkspace();
const sessionActions = useOptionalDaemonActions();
const load = useCallback(
() => workspace.actions.listSessions({ pageSize }),
[pageSize, workspace.actions],
() => workspace.actions.listSessions({ pageSize, archiveState }),
[archiveState, pageSize, workspace.actions],
);
const workspaceReady = !!workspace.workspaceCwd;
const result = useDaemonResource(load, {
@ -44,6 +47,22 @@ export function useDaemonSessions(options: DaemonSessionsOptions = {}) {
},
[workspace.actions, reload],
);
const archiveSession = useCallback(
async (sessionId: string) => {
const archived = await workspace.actions.archiveSession(sessionId);
if (archived) reload();
return archived;
},
[workspace.actions, reload],
);
const unarchiveSession = useCallback(
async (sessionId: string) => {
const unarchived = await workspace.actions.unarchiveSession(sessionId);
if (unarchived) reload();
return unarchived;
},
[workspace.actions, reload],
);
return {
...result,
sessions: result.data ?? [],
@ -53,5 +72,7 @@ export function useDaemonSessions(options: DaemonSessionsOptions = {}) {
releaseSession: sessionActions?.releaseSession,
deleteSession,
deleteSessions,
archiveSession,
unarchiveSession,
};
}

View file

@ -29,6 +29,7 @@ import type {
DaemonMcpRestartResult,
DaemonMcpManageAction,
DaemonMcpManageResult,
DaemonSessionArchiveState,
DaemonUpdateAgentRequest,
DaemonWorkspaceAgentDetail,
DaemonWorkspaceAgentsStatus,
@ -146,6 +147,7 @@ export interface DaemonWorkspaceActions {
// Sessions
listSessions(options?: {
pageSize?: number;
archiveState?: DaemonSessionArchiveState;
}): Promise<DaemonSessionSummary[]>;
deleteSession(sessionId: string): Promise<boolean>;
deleteSessions(sessionIds: string[]): Promise<{
@ -153,6 +155,14 @@ export interface DaemonWorkspaceActions {
notFound: string[];
errors: Array<{ sessionId: string; error: string }>;
}>;
/**
* Move a session to the archived directory. Idempotent: an
* already-archived session resolves `true`. Rejects if the daemon
* reports a per-session error (e.g. an archive/unarchive conflict).
*/
archiveSession(sessionId: string): Promise<boolean>;
/** Restore an archived session to the active directory. Idempotent. */
unarchiveSession(sessionId: string): Promise<boolean>;
// MCP
loadMcpStatus(): Promise<DaemonWorkspaceMcpStatus>;