From 2651a2e636c0d31842d336bf2afa86fc03203d1e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 1 Sep 2026 16:31:11 +0800 Subject: [PATCH 1/5] refactor(desktop): move task deletion out of the Session rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a task is the one row action that cannot be undone, and the rail is where a mis-click is likeliest: rows are dense, the ⋯ menu is one hover away, and the row under the cursor moves as the catalog refreshes. The rail now stops at 归档 — the row menu, the selection bar's second button, and the Delete/Backspace binding are all gone. Deleting still exists, in Settings › 已归档任务, which can only reach a task that was archived first. That archive step is what makes the intent deliberate, and it is the same route the bulk purge already took. Two behaviours change for the user. Deleting a task that was never archived is now two steps instead of one: archive it in the rail, then delete it in Settings. Delete and Backspace no longer act on the focused row. With the rail's bulk delete gone, `sweepSessions` had one caller left and one live value for its `requireArchivedFor` parameter, so it collapses back into `purgeSessions`. Generated-by: Claude Opus 5 via Claude Code --- .../e2e/composer-plus-menu-stability.spec.ts | 11 +- .../e2e/parent-session-deletion.spec.ts | 33 ++- .../session-navigation-session-purge.test.ts | 261 ------------------ .../controller/session-row-actions.ts | 117 +------- .../controller/use-session-selection.ts | 8 +- .../ui/session-navigation-provider.tsx | 6 +- .../src/renderer/locales/shell-copy.ts | 31 --- apps/desktop/src/renderer/styles/sidebar.css | 8 +- apps/desktop/stories/app-shell.stories.tsx | 1 - .../session-history-multi-select.test.tsx | 21 -- .../session-history-row-actions.test.tsx | 1 - packages/ui/src/conversation-copy.ts | 5 +- packages/ui/src/session-history-list.tsx | 63 ++--- packages/ui/src/session-rail-context.tsx | 6 +- packages/ui/src/session-selection-bar.tsx | 20 +- .../ui/stories/session-list-panel.stories.tsx | 1 - 16 files changed, 76 insertions(+), 517 deletions(-) diff --git a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts index 5c5275726b..e90ff398ad 100644 --- a/apps/desktop/e2e/composer-plus-menu-stability.spec.ts +++ b/apps/desktop/e2e/composer-plus-menu-stability.spec.ts @@ -359,13 +359,18 @@ test('latest Plan intent still reaches the Host after a catalog refresh fails', await expect(planRow).toHaveAttribute('aria-checked', 'false'); }); -test('deleting the session while a toggle is pending settles clean', async ({ +test('removing the session while a toggle is pending settles clean', async ({ invocableSkillsWindow: page, }) => { // pending → cleanup → settle: the Session's renderer lifecycle ends while a // mode commit (and a queued follow-up intent) is still in flight. Cleanup // must drop the queued ask with the rest of the Session state, so the // commit's tail has nothing to replay against the removed Session. + // + // Archiving is what ends that lifecycle from the rail now that deleting has + // moved to Settings. It is the same ending as far as this case is concerned: + // `archiveSession` clears the active id, the active messages and the whole + // family's renderer state, exactly as removal did. const composer = page.locator(COMPOSER_INPUT); await composer.fill('alpha-marker'); await composer.press('Enter'); @@ -389,9 +394,7 @@ test('deleting the session while a toggle is pending settles clean', async ({ const row = sidebar.locator('[data-maka-contract="session-row"]').first(); await row.hover(); await row.getByRole('button', { name: '任务操作' }).click(); - await page.getByRole('menuitem', { name: '删除', exact: true }).click(); - const confirm = page.getByRole('alertdialog'); - await confirm.getByRole('button', { name: '删除', exact: true }).click(); + await page.getByRole('menuitem', { name: '归档', exact: true }).click(); await releaseBridgeLatch(page, 'sessions.list'); await expect(sidebar.locator('[data-maka-contract="session-row"]')).toHaveCount(0); diff --git a/apps/desktop/e2e/parent-session-deletion.spec.ts b/apps/desktop/e2e/parent-session-deletion.spec.ts index 629022dfe4..c42e4c88ff 100644 --- a/apps/desktop/e2e/parent-session-deletion.spec.ts +++ b/apps/desktop/e2e/parent-session-deletion.spec.ts @@ -24,6 +24,13 @@ import { test, } from './fixtures'; +/** + * The route is two steps on purpose: the rail archives, Settings deletes. The + * rail has no delete at all — a mis-click there is one hover away from an + * irreversible loss — so a task must have been archived once before anything + * can remove it. This walks the whole route rather than calling the command, + * because the route is the thing that changed. + */ test('deleting a parent task archives its linked subagent task', async ({ parentRemovalWindow: page, }) => { @@ -38,7 +45,23 @@ test('deleting a parent task archives its linked subagent task', async ({ await parentRow.hover(); await parentRow.getByRole('button', { name: '任务操作' }).click(); - await page.getByRole('menuitem', { name: '删除', exact: true }).click(); + // The rail's menu ends at 归档. Deleting is not one of the things a row can + // be asked to do here. + await expect(page.getByRole('menuitem', { name: '删除', exact: true })).toHaveCount(0); + await page.getByRole('menuitem', { name: '归档', exact: true }).click(); + await expect(parentRow).toHaveCount(0); + + await page.getByRole('button', { name: '设置', exact: true }).click(); + await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); + await page.getByRole('button', { name: '已归档任务', exact: true }).click(); + const archivedTasks = page.getByRole('main', { name: '设置内容' }); + + await archivedTasks + .getByRole('button', { name: `「${PARENT_REMOVAL_PARENT_NAME}」的更多操作` }) + .click(); + // 彻底删除, not 删除: Settings names the irreversible verb in full, which is + // the point of routing every deletion through a surface reached by archiving. + await page.getByRole('menuitem', { name: '彻底删除', exact: true }).click(); const confirm = page.getByRole('alertdialog', { name: `删除 "${PARENT_REMOVAL_PARENT_NAME}"`, }); @@ -49,14 +72,6 @@ test('deleting a parent task archives its linked subagent task', async ({ await expect(confirm.getByText(/子任务.*归档/)).toBeVisible(); await confirm.getByRole('button', { name: '删除', exact: true }).click(); - await expect(parentRow).toHaveCount(0); - await expect(taskList.getByText(PARENT_REMOVAL_CHILD_NAME, { exact: true })).toHaveCount(0); - - await page.getByRole('button', { name: '设置', exact: true }).click(); - await expect(page.getByRole('main', { name: '设置内容' })).toBeVisible(); - await page.getByRole('button', { name: '已归档任务', exact: true }).click(); - - const archivedTasks = page.getByRole('main', { name: '设置内容' }); await expect(archivedTasks.getByText(PARENT_REMOVAL_CHILD_NAME, { exact: true })).toBeVisible(); await expect(archivedTasks.getByText(/原父任务已删除/)).toBeVisible(); await expect(archivedTasks.getByText(PARENT_REMOVAL_PARENT_NAME, { exact: true })).toHaveCount(0); diff --git a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts index c9bdeb51d4..83a5f7099b 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-session-purge.test.ts @@ -445,264 +445,3 @@ describe('deleteSession', () => { assert.deepEqual(h.toasts, ['rescued was restored, so it was kept']); }); }); - -describe('deleteSessions', () => { - it('reads the archived premise per task instead of asserting it', async () => { - const h = harness(); - // The rail lists unarchived tasks. Asserting the archived premise for them - // — which is what the Settings purge does — would make the Host refuse - // every deletion the rail can ask for. - const sessions = [summary('live', { isArchived: false }), summary('filed')]; - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h, { catalog: sessions }); - const actions = createActions({ harness: h, sessions, activeIdRef, service }); - - const outcome = await actions.deleteSessions(['live', 'filed']); - - assert.deepEqual(h.removed, ['live', 'filed']); - assert.deepEqual(h.removeOptions, [ - ['live', false], - ['filed', true], - ]); - assert.equal(outcome.removed, 2); - assert.deepEqual(outcome.remaining, []); - assert.equal(outcome.verified, true); - }); - - it('accounts for a rejection against the catalog, exactly as the purge does', async () => { - const h = harness(); - const sessions = [summary('a', { isArchived: false }), summary('b', { isArchived: false })]; - const activeIdRef = { current: undefined as string | undefined }; - // 'b' rejects but is gone from the catalog anyway: the delete IPC commits - // the removal before it releases renderer resources, so a rejection is not - // evidence the task survived. - const service = installService(h, { rejectIds: ['b'], surviving: [] }); - const actions = createActions({ harness: h, sessions, activeIdRef, service }); - - const outcome = await actions.deleteSessions(['a', 'b']); - - assert.equal(outcome.removed, 2); - assert.deepEqual(outcome.remaining, []); - assert.equal(outcome.verified, true); - assert.ok(outcome.firstFailure); - assert.equal((outcome.firstFailure.error as Error).message, 'busy:b'); - }); - - it('claims nothing when the catalog cannot be read back', async () => { - const h = harness(); - const sessions = [summary('a', { isArchived: false })]; - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h, { rejectIds: ['a'] }); - const actions = createActions({ harness: h, sessions, activeIdRef, service }); - - const outcome = await actions.deleteSessions(['a']); - - assert.equal(outcome.verified, false); - assert.equal(outcome.removed, 0); - assert.deepEqual(outcome.remaining, []); - }); -}); - -describe('archiveSessions', () => { - it('archives the set, clears each family, and refreshes once', async () => { - const h = harness(); - const sessions = [ - summary('a', { isArchived: false }), - summary('a-v2', { - isArchived: false, - revisionRootSessionId: 'a', - revisionParentSessionId: 'a', - }), - summary('b', { isArchived: false }), - ]; - const activeIdRef = { current: 'a-v2' as string | undefined }; - let refreshes = 0; - const actions = createSessionNavigationRowActions({ - uiLocale: 'en', - activeIdRef, - clearActiveMessages: () => undefined, - clearSessionRendererState: (id) => { - h.cleared.push(id); - }, - pendingSessionRowActionsRef: { current: new Set() }, - refreshSessions: async () => { - refreshes += 1; - return []; - }, - service: installService(h), - sessionsRef: { current: sessions }, - setActiveId: (id) => { - h.selections.push(id); - activeIdRef.current = id; - }, - toastApi: { - success: (title: string) => { - h.toasts.push(title); - }, - error: () => undefined, - confirm: async () => true, - }, - }); - - const outcome = await actions.archiveSessions(['a-v2', 'b']); - - assert.deepEqual(h.archived, ['a-v2', 'b']); - assert.deepEqual(outcome, { archived: 2, failed: [], firstFailure: undefined }); - // The whole revision family leaves the rail, so the renderer drops all of - // it — and the active task went with it. - assert.deepEqual(h.cleared, ['a', 'a-v2', 'b']); - assert.deepEqual(h.selections, [undefined]); - // Once for the sweep, not once per task: a refresh per id re-renders the - // rail under the cursor for every row in the selection. - assert.equal(refreshes, 1); - }); - - it('keeps going past a failure and names the first one', async () => { - const h = harness(); - const sessions = ['a', 'b', 'c'].map((id) => summary(id, { isArchived: false })); - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h, { rejectArchiveIds: ['b'] }); - const actions = createActions({ harness: h, sessions, activeIdRef, service }); - - const outcome = await actions.archiveSessions(['a', 'b', 'c']); - - // 'c' is still attempted: one Host refusing is not the sweep's answer for - // every task in it. - assert.deepEqual(h.archived, ['a', 'b', 'c']); - assert.equal(outcome.archived, 2); - assert.deepEqual(outcome.failed, ['b']); - assert.ok(outcome.firstFailure); - assert.equal((outcome.firstFailure.error as Error).message, 'archive-busy:b'); - assert.equal(outcome.firstFailure?.sessionId, 'b'); - // No per-task toast: the caller phrases one message for the sweep. - assert.deepEqual(h.toasts, []); - }); - - it('skips a task whose row action is already in flight', async () => { - const h = harness(); - const sessions = [summary('a', { isArchived: false }), summary('b', { isArchived: false })]; - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h); - const actions = createActions({ - harness: h, - sessions, - activeIdRef, - pending: new Set(['a:rename']), - service, - }); - - const outcome = await actions.archiveSessions(['a', 'b']); - - assert.deepEqual(h.archived, ['b']); - // Reported rather than silently dropped: the count the caller shows has to - // add up to the number of tasks the user selected. - assert.deepEqual(outcome.failed, ['a']); - assert.equal(outcome.archived, 1); - }); -}); - -describe('deleteSelected', () => { - it('warns that linked subtasks will be archived, and reports what was', async () => { - // The Host archives a deleted parent's ordinary subagent tasks rather than - // deleting them. Without the warning they reappear under Archived with no - // explanation; without the report the user never learns how many did. - const h = harness(); - const sessions = ['a', 'b'].map((id) => summary(id, { isArchived: false })); - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h, { - previewSubtasks: { a: 2 }, - archivedByRemoval: { a: 2, b: 1 }, - }); - const confirms: Array<{ description: string }> = []; - const actions = createActions({ - harness: h, - sessions, - activeIdRef, - service, - onConfirm: (options) => { - confirms.push({ description: options.description }); - return true; - }, - }); - - await actions.deleteSelected(['a', 'b']); - - // One preview per selected task: the renderer's projection cannot answer - // this, so it is asked for every id the confirm is about to name. - assert.deepEqual(h.previews, ['a', 'b']); - assert.equal(confirms.length, 1); - assert.match(confirms[0]!.description, /moved to Archived/); - assert.deepEqual(h.toasts, ['Deleted 2 tasks']); - assert.deepEqual(h.toastDescriptions, ['3 subtasks moved to Archived']); - }); - - it('says the subtask warning is uncertain when one preview cannot be read', async () => { - // Under-reporting a destructive set is worse than admitting the number is - // unknown: a silent zero reads as "nothing else will move". - const h = harness(); - const sessions = ['a', 'b'].map((id) => summary(id, { isArchived: false })); - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h, { - previewSubtasks: { a: 2 }, - rejectPreviewIds: ['b'], - }); - const confirms: string[] = []; - const actions = createActions({ - harness: h, - sessions, - activeIdRef, - service, - onConfirm: (options) => { - confirms.push(options.description); - return true; - }, - }); - - await actions.deleteSelected(['a', 'b']); - - assert.equal(confirms.length, 1); - assert.match(confirms[0]!, /Any ordinary subtasks/); - }); - - it('says nothing about subtasks when the selection has none', async () => { - const h = harness(); - const sessions = [summary('a', { isArchived: false })]; - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h); - const confirms: string[] = []; - const actions = createActions({ - harness: h, - sessions, - activeIdRef, - service, - onConfirm: (options) => { - confirms.push(options.description); - return true; - }, - }); - - await actions.deleteSelected(['a']); - - assert.doesNotMatch(confirms[0]!, /Archived/); - assert.deepEqual(h.toastDescriptions, [undefined]); - }); - - it('does nothing when the confirm is declined', async () => { - const h = harness(); - const sessions = [summary('a', { isArchived: false })]; - const activeIdRef = { current: undefined as string | undefined }; - const service = installService(h); - const actions = createActions({ - harness: h, - sessions, - activeIdRef, - service, - onConfirm: () => false, - }); - - await actions.deleteSelected(['a']); - - assert.deepEqual(h.removed, []); - assert.deepEqual(h.toasts, []); - }); -}); diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts index 06fb83f931..0e2d437994 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/session-row-actions.ts @@ -105,11 +105,9 @@ export interface SessionNavigationRowActions { renameSession(sessionId: string, name: string): Promise; deleteSession(sessionId: string): Promise; purgeSessions(sessionIds: readonly string[]): Promise; - deleteSessions(sessionIds: readonly string[]): Promise; archiveSessions(sessionIds: readonly string[]): Promise; /** Confirms, sweeps, and reports — the rail's own wording. */ archiveSelected(sessionIds: readonly string[]): Promise; - deleteSelected(sessionIds: readonly string[]): Promise; } export function createSessionNavigationRowActions(deps: { @@ -274,12 +272,10 @@ export function createSessionNavigationRowActions(deps: { } /** - * Deletes a set of tasks in one sweep. - * - * `requireArchivedFor` decides, per id, whether the deletion asserts that the - * task is still archived. Settings' purge asserts it for every target; the - * rail reads it off the task, exactly as single-row delete does, because the - * rail lists unarchived tasks and asserting it there would refuse them all. + * Settings › 已归档任务, sweeping a set of archived tasks in one pass. The one + * bulk delete the product has: the rail cannot delete at all, so every target + * here is archived by definition, and the premise is asserted anyway so a task + * restored between the confirm and the write is kept rather than removed. * * Every id takes one path and lands in exactly one outcome. A task whose * premise still holds is removed; one restored meanwhile answers `restored` @@ -302,10 +298,7 @@ export function createSessionNavigationRowActions(deps: { * No confirm and no toast: the caller owns the wording for a sweep, which is * the one thing single-row delete cannot phrase. */ - async function sweepSessions( - sessionIds: readonly string[], - requireArchivedFor: (sessionId: string) => boolean, - ): Promise { + async function purgeSessions(sessionIds: readonly string[]): Promise { const unsettled: string[] = []; const restored: string[] = []; let firstFailure: SessionPurgeOutcome['firstFailure']; @@ -324,7 +317,7 @@ export function createSessionNavigationRowActions(deps: { pendingSessionRowActionsRef.current.add(key); try { const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { - requireArchived: requireArchivedFor(sessionId), + requireArchived: true, }); if (disposition === 'restored') restored.push(sessionId); else { @@ -378,32 +371,6 @@ export function createSessionNavigationRowActions(deps: { }; } - /** - * Settings › archived tasks. Every target is archived by definition, and the - * premise is asserted anyway so a task restored between the confirm and the - * write is kept rather than removed. - */ - async function purgeSessions(sessionIds: readonly string[]): Promise { - return sweepSessions(sessionIds, () => true); - } - - /** - * The rail's multi-select delete. The rail lists unarchived tasks, so the - * archived premise is read per task exactly as single-row delete reads it: - * asserting it for a task that was never archived would refuse every - * deletion the rail can actually ask for. - * - * No confirm here either — one sweep is one question, and only the caller - * knows how many tasks it is about to name. - */ - async function deleteSessions(sessionIds: readonly string[]): Promise { - return sweepSessions( - sessionIds, - (sessionId) => - sessionsRef.current.find((entry) => entry.id === sessionId)?.isArchived === true, - ); - } - /** * The rail's multi-select archive. * @@ -485,76 +452,6 @@ export function createSessionNavigationRowActions(deps: { ); } - /** - * The rail's own bulk delete. See `archiveSelected` for why the wording is here. - * - * The confirm warns about linked subtasks for the same reason single-row - * delete does: the Host archives a deleted parent's ordinary subagent tasks - * rather than deleting them, so without the warning they reappear under - * Archived with no explanation. The Host owns that plan — the renderer's - * catalog projection lacks the operator marker — so the count is asked for, - * one preview per selected task, and a single failure makes the whole warning - * uncertain rather than silently under-reporting the set. - * - * N previews before a destructive confirm is N round trips, which is the - * price of naming a number the user can act on. The toast afterwards reports - * the Host's executed total, not this estimate. - */ - async function deleteSelected(sessionIds: readonly string[]): Promise { - if (sessionIds.length === 0) return; - let previewedSubtasks: number | undefined = 0; - for (const sessionId of sessionIds) { - try { - const count = await service.previewRemoval(sessionId); - if (previewedSubtasks !== undefined) previewedSubtasks += count; - } catch { - previewedSubtasks = undefined; - } - } - const subtaskNote = - previewedSubtasks === undefined - ? copy.bulkDeleteSubtaskNoteUncertain() - : previewedSubtasks > 0 - ? copy.bulkDeleteSubtaskNote() - : undefined; - const ok = await toastApi.confirm({ - title: copy.bulkDeleteTitle(sessionIds.length), - description: subtaskNote - ? `${copy.bulkDeleteDescription} ${subtaskNote}` - : copy.bulkDeleteDescription, - confirmLabel: copy.deleteLabel, - cancelLabel: copy.cancelLabel, - destructive: true, - }); - if (!ok) return; - const outcome = await deleteSessions(sessionIds); - // Kept tasks and failures are independent, and reporting one while dropping - // the other is how a count quietly stops adding up. - const kept = - outcome.restored.length > 0 ? copy.bulkKeptRestored(outcome.restored.length) : undefined; - // The Host's executed number, not the preview's estimate. - const archived = - outcome.archivedSubtasks > 0 ? copy.deletedSubtaskNote(outcome.archivedSubtasks) : undefined; - if (outcome.verified && outcome.remaining.length === 0) { - toastApi.success( - copy.bulkDeletedTitle(outcome.removed), - [kept, archived].filter(Boolean).join(' ') || undefined, - ); - return; - } - const reason = !outcome.verified - ? copy.bulkUnverified - : outcome.firstFailure - ? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale) - : copy.bulkFailedBody(outcome.remaining.length); - toastApi.error( - copy.bulkDeleteFailedTitle, - [reason, kept, archived].filter(Boolean).join(' '), - undefined, - outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, - ); - } - return { flagSession, archiveSession, @@ -562,9 +459,7 @@ export function createSessionNavigationRowActions(deps: { renameSession, deleteSession, purgeSessions, - deleteSessions, archiveSessions, archiveSelected, - deleteSelected, }; } diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts index 7521f49293..7c7d7df854 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts @@ -30,7 +30,7 @@ import { import type { SessionNavigationRowActions } from './session-row-actions.js'; /** - * The rail's multi-select: which rows are marked, and the two sweeps they feed. + * The rail's multi-select: which rows are marked, and the sweep they feed. * * The selection is reconciled against the catalog on every change to it, not * only after a sweep. Another window deleting a task, a Host going away, or a @@ -138,10 +138,6 @@ export function useSessionSelection(input: { () => runSweep((ids) => commands.archiveSelected(ids)), [commands, runSweep], ); - const onDeleteSelected = useCallback( - () => runSweep((ids) => commands.deleteSelected(ids)), - [commands, runSweep], - ); /** * The rows' half, memoized on the selection ALONE. @@ -172,14 +168,12 @@ export function useSessionSelection(input: { onExit, onToggleAll, onArchiveSelected, - onDeleteSelected, busy, }), [ busy, listedSessionIds, onArchiveSelected, - onDeleteSelected, onEnter, onExit, onToggleAll, diff --git a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx index 9abab676ca..a39a9ddb1c 100644 --- a/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx +++ b/apps/desktop/src/renderer/features/session-navigation/ui/session-navigation-provider.tsx @@ -118,9 +118,9 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) onRename: (sessionId, name) => { void controller.commands.renameSession(sessionId, name); }, - onDelete: (sessionId) => { - void controller.commands.deleteSession(sessionId); - }, + // No `onDelete`: the rail cannot delete. `deleteSession` is still a + // command, reached from Settings › 已归档任务, where the task has already + // been archived once. }), [controller.commands], ); diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 4de4e1a9c5..b8f2921049 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -277,24 +277,12 @@ type ShellCopy = { deleteSubtaskNoteUncertain(): string; /** Toast description after deleting a task that had linked subagent subtasks. */ deletedSubtaskNote(count: number): string; - bulkDeleteTitle(count: number): string; - bulkDeleteDescription: string; bulkArchiveTitle(count: number): string; bulkArchiveDescription: string; bulkArchiveLabel: string; - bulkDeletedTitle(count: number): string; bulkArchivedTitle(count: number): string; - /** Tasks restored while the sweep was reaching them, so they were kept. */ - bulkKeptRestored(count: number): string; - bulkDeleteFailedTitle: string; bulkArchiveFailedTitle: string; bulkFailedBody(count: number): string; - /** The catalog could not be read back, so nothing can be claimed. */ - bulkUnverified: string; - /** Appended to the bulk delete confirm when the selection has linked subtasks. */ - bulkDeleteSubtaskNote(): string; - /** Appended when the subtask preview could not be read for the whole selection. */ - bulkDeleteSubtaskNoteUncertain(): string; }; skillActions: { refreshSkillsFailedTitle: string; @@ -919,21 +907,12 @@ const SHELL_COPY_BY_LOCALE = { deleteSubtaskNote: () => '其普通子任务不会被删除,将保留并移入归档。', deleteSubtaskNoteUncertain: () => '其普通子任务(如有)不会被删除,将保留并移入归档。', deletedSubtaskNote: (count: number) => `${count} 个子任务已移入归档`, - bulkDeleteTitle: (count: number) => `删除选中的 ${count} 个任务?`, - bulkDeleteDescription: '删除后无法恢复,任务的全部修订版本都会一并删除。', bulkArchiveTitle: (count: number) => `归档选中的 ${count} 个任务?`, bulkArchiveDescription: '归档后可在「设置 › 活动 › 已归档任务」中找回。', bulkArchiveLabel: '归档', - bulkDeletedTitle: (count: number) => `已删除 ${count} 个任务`, bulkArchivedTitle: (count: number) => `已归档 ${count} 个任务`, - bulkKeptRestored: (count: number) => `另有 ${count} 个已被恢复,未删除。`, - bulkDeleteFailedTitle: '部分任务未能删除', bulkArchiveFailedTitle: '部分任务未能归档', bulkFailedBody: (count: number) => `还有 ${count} 个没有处理成功。`, - bulkUnverified: '无法确认处理结果,请刷新后查看。', - bulkDeleteSubtaskNote: () => '它们的普通子任务不会被删除,将保留并移入归档。', - bulkDeleteSubtaskNoteUncertain: () => - '它们的普通子任务(如有)不会被删除,将保留并移入归档。', }, skillActions: { refreshSkillsFailedTitle: '刷新技能失败', @@ -1437,22 +1416,12 @@ const SHELL_COPY_BY_LOCALE = { 'Its ordinary subtasks, if any, will be kept and moved to Archived.', deletedSubtaskNote: (count: number) => count === 1 ? '1 subtask moved to Archived' : `${count} subtasks moved to Archived`, - bulkDeleteTitle: (count: number) => `Delete ${count} selected tasks?`, - bulkDeleteDescription: - 'This cannot be undone, and every revision of each task goes with it.', bulkArchiveTitle: (count: number) => `Archive ${count} selected tasks?`, bulkArchiveDescription: 'Archived tasks stay available under Settings › Activity.', bulkArchiveLabel: 'Archive', - bulkDeletedTitle: (count: number) => `Deleted ${count} tasks`, bulkArchivedTitle: (count: number) => `Archived ${count} tasks`, - bulkKeptRestored: (count: number) => `${count} were restored meanwhile and kept.`, - bulkDeleteFailedTitle: 'Some tasks were not deleted', bulkArchiveFailedTitle: 'Some tasks were not archived', bulkFailedBody: (count: number) => `${count} of them did not go through.`, - bulkUnverified: 'The outcome could not be confirmed. Refresh to see what remains.', - bulkDeleteSubtaskNote: () => 'Their ordinary subtasks will be kept and moved to Archived.', - bulkDeleteSubtaskNoteUncertain: () => - 'Any ordinary subtasks they have will be kept and moved to Archived.', }, skillActions: { refreshSkillsFailedTitle: 'Could not refresh Skills', diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 67b7ddc359..5415b592d3 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -642,14 +642,12 @@ } /* - * Two equal halves, not a flex row that lets the longer label win. Grid keeps - * 归档 and 删除 the same size at every rail width, so neither reads as the - * primary action — one of them is destructive and must not be emphasised by - * accident. + * One full-width command. Grid rather than a flex row so the button spans the + * bar at every rail width instead of shrinking to its label. */ .maka-session-selection-actions { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; gap: var(--space-1); } diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 858aa5867a..41256327ab 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -157,7 +157,6 @@ const sidebarRowActions: NonNullable = { onArchive: noop, onUnarchive: noop, onRename: noop, - onDelete: noop, }; const projectRowActions: NonNullable = { onNew: noop, diff --git a/packages/ui/src/__tests__/session-history-multi-select.test.tsx b/packages/ui/src/__tests__/session-history-multi-select.test.tsx index 98bc493010..6c5d015eb9 100644 --- a/packages/ui/src/__tests__/session-history-multi-select.test.tsx +++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx @@ -110,7 +110,6 @@ type Harness = { toggleAll: boolean[]; entered: Array; exits: number; - deleteRequests: number; pressKey(key: string, focusedSessionId?: string): Promise; dispose(): Promise; clickRow(sessionId: string, modifiers?: Partial): Promise; @@ -137,7 +136,6 @@ async function mount( const toggleAll: boolean[] = []; const entered: Array = []; let exits = 0; - let deleteRequests = 0; const rowSelection: SessionRailRowSelection = { active: options.active ?? false, selectedIds: new Set(options.selectedIds ?? []), @@ -152,9 +150,6 @@ async function mount( }, onToggleAll: (selected) => toggleAll.push(selected), onArchiveSelected: () => undefined, - onDeleteSelected: () => { - deleteRequests += 1; - }, }; const data: SessionRailData = { sessions: SESSIONS, @@ -184,9 +179,6 @@ async function mount( get exits() { return exits; }, - get deleteRequests() { - return deleteRequests; - }, pressKey: async (key: string, focusedSessionId = 'a') => { // linkedom has no focus model and reports `activeElement` as null, where a // browser reports when nothing is focused — and here a row really @@ -324,19 +316,6 @@ test('Escape outside the mode is not this handler\'s business', async () => { } }); -test('Delete asks for the marked set, not the focused row', async () => { - // Deleting the focused row while several are marked is the shape of an - // unrecoverable surprise: the user sees N marked and loses one they did not - // single out. - const harness = await mount({ active: true, selectedIds: ['a', 'c'] }); - try { - await harness.pressKey('Delete'); - assert.equal(harness.deleteRequests, 1); - } finally { - await harness.dispose(); - } -}); - test('no checkbox exists until the mode is on', async () => { const harness = await mount(); try { diff --git a/packages/ui/src/__tests__/session-history-row-actions.test.tsx b/packages/ui/src/__tests__/session-history-row-actions.test.tsx index e8bf08478b..15d7523263 100644 --- a/packages/ui/src/__tests__/session-history-row-actions.test.tsx +++ b/packages/ui/src/__tests__/session-history-row-actions.test.tsx @@ -68,7 +68,6 @@ const rowActions: SessionRowActions = { onArchive: () => undefined, onUnarchive: () => undefined, onRename: () => undefined, - onDelete: () => undefined, }; const project: ProjectRecord = { diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 1046134007..feb543547a 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -437,7 +437,6 @@ export interface ConversationCopy { selectedCount: (selected: number, total: number) => string; selectAllAriaLabel: string; selectionArchive: string; - selectionDelete: string; selectionClear: string; }; } @@ -572,7 +571,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: '可继续', running: '进行中', waiting_for_user: '等你确认', blocked: '需要处理', aborted: '已中止' }, blockedReason: { NO_REAL_CONNECTION: '等待配置可用模型连接', auth: '需要重新登录', permission_required: '等待权限确认', tool_failed: '工具调用失败', unknown: '运行中断,可重试' }, - listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, selectRow: '选择', selectionBarAriaLabel: '已选任务的批量操作', selectedCount: (selected, total) => `已选 ${selected} / ${total}`, selectAllAriaLabel: '全选或全不选', selectionArchive: '归档', selectionDelete: '删除', selectionClear: '取消', + listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, selectRow: '选择', selectionBarAriaLabel: '已选任务的批量操作', selectedCount: (selected, total) => `已选 ${selected} / ${total}`, selectAllAriaLabel: '全选或全不选', selectionArchive: '归档', selectionClear: '取消', }, }, en: { @@ -730,7 +729,7 @@ const CONVERSATION_COPY = { sessions: { status: { active: 'Ready', running: 'Running', waiting_for_user: 'Waiting for you', blocked: 'Needs attention', aborted: 'Stopped' }, blockedReason: { NO_REAL_CONNECTION: 'Waiting for an available model connection', auth: 'Sign in again', permission_required: 'Waiting for permission', tool_failed: 'Tool call failed', unknown: 'Run interrupted; retry available' }, - listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, selectRow: 'Select', selectionBarAriaLabel: 'Bulk actions for selected tasks', selectedCount: (selected, total) => `${selected} / ${total} selected`, selectAllAriaLabel: 'Select all or none', selectionArchive: 'Archive', selectionDelete: 'Delete', selectionClear: 'Done', + listAriaLabel: 'Task list', showMore: 'Show more', showMoreAriaLabel: (count) => `Show ${count} more tasks`, renameAriaLabel: 'Rename task', renameProjectTitle: 'Rename project', renameSubmit: 'Save', respondingAriaLabel: 'Responding', respondingTitle: 'This task is streaming a response', staleTitle: 'This task\'s model connection is unavailable; sending will switch to the default connection', staleAriaLabel: 'Stale task', stale: 'Stale', unreadAriaLabel: 'Unread messages', actionsAriaLabel: (name) => `${name} task actions`, pin: 'Pin', unpin: 'Unpin', rename: 'Rename', archive: 'Archive', unarchive: 'Unarchive', delete: 'Delete', pinned: 'Pinned', recent: 'Recent', groupByTime: 'By time', groupByProject: 'By project', groupingAriaLabel: 'Task grouping', projectActionsAriaLabel: (name) => `${name} project actions`, projectNewTask: 'New task', projectRename: 'Rename', projectArchive: 'Archive', projectRestore: 'Restore', projectRelink: 'Relocate', projectUnavailable: 'Project directory unavailable', archivedProjects: 'Archived projects', archivedProjectsAriaLabel: 'Expand archived projects', worktreeAriaLabel: 'Git worktree', promptRailAriaLabel: 'Jump by prompt', emptyPrompt: '(empty prompt)', jumpToPrompt: (preview) => `Jump to prompt: ${preview}`, selectRow: 'Select', selectionBarAriaLabel: 'Bulk actions for selected tasks', selectedCount: (selected, total) => `${selected} / ${total} selected`, selectAllAriaLabel: 'Select all or none', selectionArchive: 'Archive', selectionClear: 'Done', }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 51a136574e..ab093d0442 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -45,7 +45,6 @@ import { PinOff, Plug, SquarePen, - Trash2, } from './icons.js'; import { RelativeTime } from './relative-time.js'; import { formatAbsoluteTimestamp } from '@maka/core/relative-time'; @@ -72,7 +71,7 @@ import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { getSessionHoverCardCopy } from './session-hover-card-copy.js'; -type SessionRowActionId = 'flag' | 'archive' | 'rename' | 'delete'; +type SessionRowActionId = 'flag' | 'archive' | 'rename'; type ProjectRowActionId = 'new' | 'relink' | 'rename' | 'archive' | 'restore'; type SessionHistoryGroupVariant = 'conversation' | 'project'; @@ -121,7 +120,6 @@ export interface SessionRowActions { onArchive(sessionId: string): void | Promise; onUnarchive(sessionId: string): void | Promise; onRename(sessionId: string, name: string): void | Promise; - onDelete(sessionId: string): void | Promise; } export interface ProjectRowActions { @@ -145,42 +143,18 @@ export function SessionHistoryList() { const locale = useUiLocale(); function handleListKeyDown(event: KeyboardEvent) { - if (event.key !== 'Escape' && event.key !== 'Delete' && event.key !== 'Backspace') return; - // The text-entry guard comes first, and now covers Escape too: a rename - // field is where Escape means "abandon this edit", and answering it by - // clearing the selection behind the dialog would be a second, invisible - // effect of one keypress. + if (event.key !== 'Escape') return; + // The text-entry guard: a rename field is where Escape means "abandon this + // edit", and answering it by clearing the selection behind the dialog would + // be a second, invisible effect of one keypress. const active = document.activeElement as HTMLElement | null; if (!active || active.matches('input, textarea, [contenteditable="true"]')) return; - const selecting = selection?.active === true; - const marked = selecting && selection.selectedIds.size > 0; - if (event.key === 'Escape') { - // Escape leaves the mode, matching the 取消 button. Without it the only - // way out is finding that button, and a mode entered by accident from the - // row menu becomes one the user is stuck in. - if (!selecting) return; - event.preventDefault(); - selection.onExit(); - return; - } - if (marked) { - // Delete is about the marked set once one exists. Deleting the focused - // row instead would act on one of several rows the user marked, which is - // the shape of an unrecoverable surprise. The sweep confirms first. - event.preventDefault(); - void selection?.onDeleteSelected(); - return; - } - const row = active.closest( - '[data-maka-contract="session-row"], [data-session-id]', - ); - const sessionId = - row?.dataset.sessionId ?? - row?.querySelector('[data-session-id]')?.dataset.sessionId; - if (sessionId && rail.rowActions) { - event.preventDefault(); - void rail.rowActions.onDelete(sessionId); - } + // Escape leaves the mode, matching the 取消 button. Without it the only way + // out is finding that button, and a mode entered by accident from the row + // menu becomes one the user is stuck in. + if (selection?.active !== true) return; + event.preventDefault(); + selection.onExit(); } // Memoized on what it derives from. Rebuilt per render it would give @@ -1108,6 +1082,12 @@ function SessionItemActions(props: { ); }, }, + // Archive is where the rail stops. Deleting is the one row action + // that cannot be undone, and the rail is where a mis-click is + // likeliest: rows are dense, the menu is one hover away, and the row + // under the cursor moves as the catalog refreshes. It lives in + // Settings › 已归档任务 instead — reachable only for a task already + // archived, which is the step that makes the intent deliberate. { label: props.session.isArchived ? copy.unarchive : copy.archive, icon: props.session.isArchived ? ArchiveRestore : Archive, @@ -1118,15 +1098,6 @@ function SessionItemActions(props: { : actions.onArchive(props.session.id), ), }, - { type: 'divider' }, - { - label: copy.delete, - icon: Trash2, - onClick: () => { - pendingMenuIntentRef.current = () => - runRowAction('delete', () => actions.onDelete(props.session.id)); - }, - }, ]} /> diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx index 17bdcc3f85..53205c1faf 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -140,8 +140,12 @@ export interface SessionRailSelection { /** Leaves the mode and drops what was marked. */ onExit(): void; onToggleAll(selected: boolean): void; + /** + * The rail's only sweep. There is no delete here: it cannot be undone, and it + * belongs to Settings › 已归档任务, which can only reach a task that was + * archived first. + */ onArchiveSelected(): void | Promise; - onDeleteSelected(): void | Promise; /** A sweep is running. The commands disable while one is, so a second click * cannot ask for the same set twice. */ busy?: boolean; diff --git a/packages/ui/src/session-selection-bar.tsx b/packages/ui/src/session-selection-bar.tsx index 5c36c440bf..919d187048 100644 --- a/packages/ui/src/session-selection-bar.tsx +++ b/packages/ui/src/session-selection-bar.tsx @@ -20,7 +20,7 @@ import { Button } from '@astryxdesign/core/Button'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { getConversationCopy } from './conversation-copy.js'; -import { ICON_SIZE, Archive, Trash2 } from './icons.js'; +import { ICON_SIZE, Archive } from './icons.js'; import { useUiLocale } from './locale-context.js'; import { useSessionRailSelection } from './session-rail-context.js'; @@ -37,10 +37,14 @@ import { useSessionRailSelection } from './session-rail-context.js'; * Not every task in the catalog: a box that silently included rows behind a * collapsed project would name a number the user never agreed to. * - * The commands are `secondary`, not `ghost`: ghost renders as bare text, and a + * The command is `secondary`, not `ghost`: ghost renders as bare text, and a * label with no container beside a checkbox and a count does not read as - * something to press. Both carry the same container so neither is the primary — - * one of them is destructive and must not be emphasised by accident. + * something to press. + * + * Archive is the only sweep. Delete is irreversible and lives in Settings › + * 已归档任务, where a task has to be archived first — the step that makes + * deleting a set deliberate rather than one click away from a count the user + * built by dragging. * * TWO ROWS, because one does not fit. The rail is 180px at its narrowest and * 260px by default; the first attempt put count and three text buttons on one @@ -92,14 +96,6 @@ export function SessionSelectionBar() { onClick={() => void selection.onArchiveSelected()} label={copy.selectionArchive} /> -