mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
2a6a9514e3
commit
c37cb23ccc
7 changed files with 863 additions and 48 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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': '重命名会话失败',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue