diff --git a/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts b/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts new file mode 100644 index 0000000000..c260776e8a --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + EMPTY_SESSION_SELECTION, + enterSessionSelection, + exitSessionSelection, + pruneSessionSelection, + sessionSelectionMasterState, + setAllSessionsSelected, + type SessionSelection, +} from '../../renderer/features/session-navigation/testing.js'; + +const GROUP = ['a', 'b', 'c', 'd', 'e']; + +function ids(selection: SessionSelection): string[] { + return [...selection.selectedIds].sort(); +} + +/** What a row's checkbox does, as the hook applies it. */ +function mark(selection: SessionSelection, sessionId: string): SessionSelection { + return { + active: true, + selectedIds: new Set([...selection.selectedIds, sessionId]), + }; +} + +describe('selection mode', () => { + test('entering marks nothing on its own', () => { + const entered = enterSessionSelection(EMPTY_SESSION_SELECTION); + assert.equal(entered.active, true); + assert.deepEqual(ids(entered), []); + }); + + test('leaving drops the mode and the marks together', () => { + assert.equal(exitSessionSelection().active, false); + assert.deepEqual(ids(exitSessionSelection()), []); + }); + + test('unticking every row is select-none, not leave', () => { + // A mode that ended itself on the last untick would take the checkboxes + // away mid-gesture, and one mis-click would cost the user the way back. + const all = setAllSessionsSelected(EMPTY_SESSION_SELECTION, GROUP, true); + const none = setAllSessionsSelected(all, GROUP, false); + assert.deepEqual(ids(none), []); + assert.equal(none.active, true); + }); + + test('an emptied selection keeps the mode it was in', () => { + // It used to settle on the shared EMPTY value, which also carries + // `active: false` — so a catalog change that pruned the last row would have + // taken the checkboxes away while the user was still selecting. + const pruned = pruneSessionSelection(mark(EMPTY_SESSION_SELECTION, 'a'), []); + assert.deepEqual(ids(pruned), []); + assert.equal(pruned.active, true); + }); +}); + +describe('the master box', () => { + test('marks exactly the rows the rail is listing', () => { + // Not every task in the catalog: the box sits above these rows, and a + // selection that reached past them would name a number nobody agreed to. + assert.deepEqual(ids(setAllSessionsSelected(EMPTY_SESSION_SELECTION, ['a', 'b'], true)), [ + 'a', + 'b', + ]); + }); + + test('reads unchecked, indeterminate, then checked', () => { + assert.equal(sessionSelectionMasterState(EMPTY_SESSION_SELECTION, GROUP), false); + assert.equal(sessionSelectionMasterState(mark(EMPTY_SESSION_SELECTION, 'b'), GROUP), 'indeterminate'); + assert.equal( + sessionSelectionMasterState(setAllSessionsSelected(EMPTY_SESSION_SELECTION, GROUP, true), GROUP), + true, + ); + }); + + test('an empty list is unchecked, never checked', () => { + // `every` over an empty array is vacuously true, which would tick the box + // above no rows at all. + assert.equal(sessionSelectionMasterState(EMPTY_SESSION_SELECTION, []), false); + }); + + test('a mark outside the listed rows does not make it checked', () => { + assert.equal(sessionSelectionMasterState(mark(EMPTY_SESSION_SELECTION, 'zzz'), GROUP), 'indeterminate'); + }); +}); + +describe('pruneSessionSelection', () => { + test('drops ids the catalog no longer lists', () => { + const selection = setAllSessionsSelected(EMPTY_SESSION_SELECTION, ['a', 'b'], true); + assert.deepEqual(ids(pruneSessionSelection(selection, ['a'])), ['a']); + }); + + test('returns the same value when nothing was dropped', () => { + // Identity matters here: this runs on every catalog refresh, and a new Set + // each time would re-render every row of the rail. + const selection = mark(EMPTY_SESSION_SELECTION, 'a'); + assert.equal(pruneSessionSelection(selection, ['a', 'b']), selection); + }); +}); 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 44f863b727..c9bdeb51d4 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 @@ -50,12 +50,18 @@ function restored(id: string): SessionSummary { type SweepHarness = { removed: string[]; + /** Each `previewRemoval` call, in order. */ + previews: string[]; + /** Each `archive` call, in order. */ + archived: string[]; /** Each `remove` call as `[sessionId, requireArchived]`. */ removeOptions: Array<[string, boolean]>; cleared: string[]; selections: Array; /** Titles of the success toasts a row action raised. */ toasts: string[]; + /** Their descriptions, positionally — the half that carries the counts. */ + toastDescriptions: (string | undefined)[]; listCalls: number; }; @@ -68,6 +74,12 @@ function installService( harness: SweepHarness, options: { rejectIds?: readonly string[]; + rejectArchiveIds?: readonly string[]; + /** Subtasks the Host says a delete would archive, per source id. */ + previewSubtasks?: Readonly>; + /** Ids whose preview rejects, standing in for a Host that cannot answer. */ + rejectPreviewIds?: readonly string[]; + rejectWithUndefinedIds?: readonly string[]; surviving?: readonly SessionSummary[]; /** Runs after each accepted removal, to model what another client did meanwhile. */ @@ -89,7 +101,10 @@ function installService( return [...options.surviving]; }, setFlagged: async () => undefined, - archive: async () => undefined, + archive: async (id) => { + harness.archived.push(id); + if (options.rejectArchiveIds?.includes(id)) throw new Error(`archive-busy:${id}`); + }, unarchive: async () => undefined, rename: async () => undefined, remove: async (id, removeOptions) => { @@ -106,7 +121,11 @@ function installService( options.onRemove?.(id); return { disposition: 'removed', archivedSubtaskCount: options.archivedByRemoval?.[id] ?? 0 }; }, - previewRemoval: async () => 0, + previewRemoval: async (id: string) => { + harness.previews.push(id); + if (options.rejectPreviewIds?.includes(id)) throw new Error(`preview-unavailable:${id}`); + return options.previewSubtasks?.[id] ?? 0; + }, }; } @@ -117,6 +136,8 @@ function createActions(input: { pending?: Set; refreshed?: SessionSummary[]; service: SessionNavigationSessionService; + /** Answers the confirm and records what it was asked, for the sweeps. */ + onConfirm?: (options: { title: string; description: string }) => boolean; }) { return createSessionNavigationRowActions({ uiLocale: 'en', @@ -134,11 +155,12 @@ function createActions(input: { input.activeIdRef.current = id; }, toastApi: { - success: (title: string) => { + success: (title: string, description?: string) => { input.harness.toasts.push(title); + input.harness.toastDescriptions.push(description); }, error: () => undefined, - confirm: async () => true, + confirm: async (options) => input.onConfirm?.(options) ?? true, }, }); } @@ -146,10 +168,13 @@ function createActions(input: { function harness(): SweepHarness { return { removed: [], + previews: [], + archived: [], removeOptions: [], cleared: [], selections: [], toasts: [], + toastDescriptions: [], listCalls: 0, }; } @@ -420,3 +445,264 @@ 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/main/__tests__/session-selection-sweep-race.test.ts b/apps/desktop/src/main/__tests__/session-selection-sweep-race.test.ts new file mode 100644 index 0000000000..d3d8dd8dca --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-selection-sweep-race.test.ts @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * `Done` stays enabled while a sweep runs — leaving asks nothing of the Host — + * so a person can leave the selection, re-enter it from another row's menu, and + * mark a different task before the first request settles. What the sweep + * unmarks on completion has to be the set it asked about, not whatever happens + * to be marked when it lands. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { SessionSummary } from '@maka/core/session'; +import type { SessionRailSelection } from '@maka/ui'; +import { useSessionSelection } from '../../renderer/features/session-navigation/testing.js'; +import type { SessionNavigationRowActions } from '../../renderer/features/session-navigation/testing.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +function summary(id: string): SessionSummary { + return { + id, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'fake', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'test', + permissionMode: 'ask', + }; +} + +test('a settled sweep unmarks what it asked about, not what is marked now', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + + let releaseArchive: (() => void) | undefined; + const archived: string[][] = []; + const commands = { + archiveSelected: (sessionIds: readonly string[]) => { + archived.push([...sessionIds]); + return new Promise((resolve) => { + releaseArchive = resolve; + }); + }, + deleteSelected: async () => undefined, + } as unknown as SessionNavigationRowActions; + + let latest: SessionRailSelection | undefined; + function Probe(): ReactNode { + const { selection } = useSessionSelection({ + sessions: [summary('a'), summary('b')], + commands, + }); + latest = selection; + return null; + } + + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + await act(() => root.render(createElement(Probe))); + assert.ok(latest); + + // Mark A and start the sweep. It does not settle yet. + await act(() => latest?.onEnter('a')); + await act(() => { + void latest?.onArchiveSelected(); + }); + assert.deepEqual(archived, [['a']]); + + // Leave the mode and come back on a different row, exactly as the rail + // allows while a sweep is in flight. + await act(() => latest?.onExit()); + await act(() => latest?.onEnter('b')); + assert.deepEqual([...(latest?.selectedIds ?? [])], ['b']); + + // Now the first request lands. + await act(async () => { + releaseArchive?.(); + await Promise.resolve(); + }); + + // B survives. Clearing the whole set here would answer A's completion by + // discarding a selection the user made afterwards and never submitted. + assert.deepEqual([...(latest?.selectedIds ?? [])], ['b']); + assert.equal(latest?.active, true); +}); + +test('a settled sweep does unmark its own set when nothing else happened', async () => { + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + + let releaseArchive: (() => void) | undefined; + const commands = { + archiveSelected: () => + new Promise((resolve) => { + releaseArchive = resolve; + }), + deleteSelected: async () => undefined, + } as unknown as SessionNavigationRowActions; + + let latest: SessionRailSelection | undefined; + function Probe(): ReactNode { + const { selection } = useSessionSelection({ + sessions: [summary('a'), summary('b')], + commands, + }); + latest = selection; + return null; + } + + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + await act(() => root.render(createElement(Probe))); + + await act(() => latest?.onEnter('a')); + await act(() => { + void latest?.onArchiveSelected(); + }); + await act(async () => { + releaseArchive?.(); + await Promise.resolve(); + }); + + assert.deepEqual([...(latest?.selectedIds ?? [])], []); + // The mode stays on: the person was tidying up, and taking the checkboxes + // away after each sweep would make them re-enter for the next one. + assert.equal(latest?.active, true); +}); 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 6b13529764..06fb83f931 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 @@ -83,6 +83,21 @@ export interface SessionPurgeOutcome { }; } +/** + * What a bulk archive can honestly say afterwards. There is no third + * disposition: a task is archived or its call failed. + */ +export interface SessionArchiveOutcome { + archived: number; + /** Tasks the sweep could not archive, including ones it had to skip. */ + failed: string[]; + /** First rejection and the Session whose Host produced it. */ + firstFailure?: { + error: unknown; + sessionId: string; + }; +} + export interface SessionNavigationRowActions { flagSession(sessionId: string, flagged: boolean): Promise; archiveSession(sessionId: string): Promise; @@ -90,6 +105,11 @@ 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: { @@ -254,12 +274,17 @@ export function createSessionNavigationRowActions(deps: { } /** - * Deletes a set of archived tasks in one sweep. + * Deletes a set of tasks in one sweep. * - * Every id takes one path and lands in exactly one outcome. A task still - * archived is removed; one restored meanwhile answers `restored` and is kept; - * one already gone elsewhere rejects and settles as removed against the - * catalog; anything else is an error to explain. The archived premise is + * `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. + * + * Every id takes one path and lands in exactly one outcome. A task whose + * premise still holds is removed; one restored meanwhile answers `restored` + * and is kept; one already gone elsewhere rejects and settles as removed + * against the catalog; anything else is an error to explain. The premise is * asserted where it can be held — inside the Host's compare-and-set (#3050) — * rather than against a renderer snapshot that a second window can outdate * between the check and the write. @@ -277,7 +302,10 @@ 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 purgeSessions(sessionIds: readonly string[]): Promise { + async function sweepSessions( + sessionIds: readonly string[], + requireArchivedFor: (sessionId: string) => boolean, + ): Promise { const unsettled: string[] = []; const restored: string[] = []; let firstFailure: SessionPurgeOutcome['firstFailure']; @@ -296,7 +324,7 @@ export function createSessionNavigationRowActions(deps: { pendingSessionRowActionsRef.current.add(key); try { const { disposition, archivedSubtaskCount } = await removeSessionFamily(sessionId, { - requireArchived: true, + requireArchived: requireArchivedFor(sessionId), }); if (disposition === 'restored') restored.push(sessionId); else { @@ -350,6 +378,183 @@ 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. + * + * Archiving has no disposition to report — a task is archived or the call + * failed — so this accounts by count and first failure rather than reusing + * the delete sweep's shape, which would carry a `restored` field that can + * never be anything but empty. + * + * Like the sweep, it raises no toast per task: one action is one message, and + * a run of them is what a sweep exists to avoid. + */ + async function archiveSessions(sessionIds: readonly string[]): Promise { + const failed: string[] = []; + let firstFailure: SessionArchiveOutcome['firstFailure']; + let archived = 0; + for (const sessionId of sessionIds) { + const key = `${sessionId}:archive`; + if ( + Array.from(pendingSessionRowActionsRef.current).some((pending) => + pending.startsWith(`${sessionId}:`), + ) + ) { + failed.push(sessionId); + continue; + } + pendingSessionRowActionsRef.current.add(key); + try { + const familyIds = revisionFamilySessionIds(sessionsRef.current, sessionId); + await service.archive(sessionId, { revisionFamily: true }); + if (activeIdRef.current && familyIds.includes(activeIdRef.current)) { + setActiveId(undefined); + clearActiveMessages(); + } + for (const id of familyIds) clearSessionRendererState(id); + archived += 1; + } catch (error) { + failed.push(sessionId); + firstFailure ??= { error, sessionId }; + } finally { + pendingSessionRowActionsRef.current.delete(key); + } + } + // Once, after the whole sweep. Refreshing per task would re-render the rail + // under the user's cursor for every id in the selection. + await refreshSessions(); + return { archived, failed, firstFailure }; + } + + /** + * The rail's own bulk archive, wording included. + * + * The sweeps below it stay silent on purpose — Settings' purge phrases its + * own confirm — but the rail's phrasing belongs to the rail, and this module + * is where the feature already holds its copy. Putting it in the selection + * hook instead would have made that hook the feature's second importer of + * renderer legacy copy, which the architecture check refuses. + */ + async function archiveSelected(sessionIds: readonly string[]): Promise { + if (sessionIds.length === 0) return; + const ok = await toastApi.confirm({ + title: copy.bulkArchiveTitle(sessionIds.length), + description: copy.bulkArchiveDescription, + confirmLabel: copy.bulkArchiveLabel, + cancelLabel: copy.cancelLabel, + }); + if (!ok) return; + const outcome = await archiveSessions(sessionIds); + if (outcome.failed.length === 0) { + toastApi.success(copy.bulkArchivedTitle(outcome.archived)); + return; + } + toastApi.error( + copy.bulkArchiveFailedTitle, + outcome.firstFailure + ? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, uiLocale) + : copy.bulkFailedBody(outcome.failed.length), + undefined, + outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, + ); + } + + /** + * 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, @@ -357,5 +562,9 @@ export function createSessionNavigationRowActions(deps: { renameSession, deleteSession, purgeSessions, + deleteSessions, + archiveSessions, + archiveSelected, + deleteSelected, }; } diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts index b7207ad072..5b81001996 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-navigation-controller.ts @@ -21,7 +21,12 @@ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import type { ProjectRecord } from '@maka/core/project'; import type { SessionSummary } from '@maka/core/session'; import { runtimeHostProfileUsesHostWorkspace } from '@maka/runtime-host/profile-kind'; -import { useUiLocale, type SessionHistoryGroup } from '@maka/ui'; +import { + useUiLocale, + type SessionHistoryGroup, + type SessionRailRowSelection, + type SessionRailSelection, +} from '@maka/ui'; import { useExternalStoreSelector } from '../../../use-external-store-selector.js'; import { deriveSessionNavigationGroups } from '../model/session-navigation-groups.js'; import { deriveWorktreeSessionIds } from '../model/session-project-grouping.js'; @@ -37,6 +42,7 @@ import { createSessionNavigationRowActions, type SessionNavigationRowActions, } from './session-row-actions.js'; +import { useSessionSelection } from './use-session-selection.js'; export type SessionNavigationToastApi = { success(title: string, description?: string): void; @@ -99,6 +105,9 @@ export interface SessionNavigationController { layout: SessionRailLayoutState; selectors: SessionNavigationSelectors; commands: SessionNavigationRowActions; + selection: SessionRailSelection; + /** The narrow half every row subscribes to; see `useSessionSelection`. */ + rowSelection: SessionRailRowSelection; } /** @@ -190,8 +199,10 @@ export function useSessionNavigationController( [groups, sessionMeta, worktreeSessionIds], ); + const { selection, rowSelection } = useSessionSelection({ sessions: rail.sessions, commands }); + return useMemo( - () => ({ layout, selectors, commands }), - [commands, layout, selectors], + () => ({ layout, selectors, commands, selection, rowSelection }), + [commands, layout, rowSelection, selection, selectors], ); } 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 new file mode 100644 index 0000000000..7521f49293 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { SessionSummary } from '@maka/core/session'; +import type { SessionRailRowSelection, SessionRailSelection } from '@maka/ui'; +import { + EMPTY_SESSION_SELECTION, + enterSessionSelection, + exitSessionSelection, + pruneSessionSelection, + setAllSessionsSelected, +} from '../model/session-selection.js'; +import type { SessionNavigationRowActions } from './session-row-actions.js'; + +/** + * The rail's multi-select: which rows are marked, and the two sweeps 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 + * grouping change that drops rows all leave ids behind, and a count that + * includes them is a count that does not match what the confirm names. + */ +export function useSessionSelection(input: { + sessions: readonly SessionSummary[]; + commands: SessionNavigationRowActions; +}): { selection: SessionRailSelection; rowSelection: SessionRailRowSelection } { + const { sessions, commands } = input; + const [selection, setSelection] = useState(EMPTY_SESSION_SELECTION); + const [busy, setBusy] = useState(false); + + const listedIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]); + useEffect(() => { + // `pruneSessionSelection` returns its input untouched when nothing was + // dropped, so this settles after one pass instead of looping on a new Set. + setSelection((current) => pruneSessionSelection(current, listedIds)); + }, [listedIds]); + + // The sweep reads the ids at the moment it runs, and the state it reads is + // the one the confirm was built from — held in a ref so the callbacks below + // do not change identity per selection change and re-render the bar's buttons. + const selectionRef = useRef(selection); + const busyRef = useRef(busy); + // Published on commit, not during render: a render React throws away must not + // leave a ref pointing at a selection that was never shown. + useLayoutEffect(() => { + selectionRef.current = selection; + busyRef.current = busy; + listedRef.current = listedSessionIds; + }); + + const listedSessionIds = useMemo(() => sessions.map((session) => session.id), [sessions]); + const listedRef = useRef(listedSessionIds); + + const onToggleRow = useCallback((sessionId, selected) => { + setSelection((current) => { + if (current.selectedIds.has(sessionId) === selected) return current; + const selectedIds = new Set(current.selectedIds); + if (selected) selectedIds.add(sessionId); + else selectedIds.delete(sessionId); + return { active: true, selectedIds }; + }); + }, []); + + const onEnter = useCallback((sessionId) => { + setSelection((current) => { + const entered = enterSessionSelection(current); + if (sessionId === undefined) return entered; + return { active: true, selectedIds: new Set([...entered.selectedIds, sessionId]) }; + }); + }, []); + + const onExit = useCallback(() => setSelection(exitSessionSelection()), []); + + const onToggleAll = useCallback((selected) => { + setSelection((current) => setAllSessionsSelected(current, listedRef.current, selected)); + }, []); + + /** + * One sweep at a time, over the ids the user could see when they pressed. + * + * Frozen at the click for the reason the archived-task purge freezes its own + * set: a confirm names a number to a person, and a set re-read after the + * dialog can be a different one. The confirm, the sweep and the report all + * live in `session-row-actions`, which is where this feature keeps its copy. + */ + const runSweep = useCallback( + async (run: (sessionIds: readonly string[]) => Promise) => { + if (busyRef.current) return; + const sessionIds = [...selectionRef.current.selectedIds]; + if (sessionIds.length === 0) return; + setBusy(true); + try { + await run(sessionIds); + } finally { + setBusy(false); + // Unmark exactly what this sweep asked about — never whatever happens + // to be marked when it lands. + // + // `Done` stays enabled during a sweep, so a person can leave the mode, + // re-enter it from another row's menu and mark B while A's request is + // still with the Host. Clearing the whole set here would then answer + // A's completion by discarding B, which the user never asked about. + // + // The MODE stays on. The person was in the middle of tidying up, and + // taking the checkboxes away after each sweep would make them re-enter + // for the next one. + setSelection((current) => { + const remaining = new Set(current.selectedIds); + let changed = false; + for (const sessionId of sessionIds) { + if (remaining.delete(sessionId)) changed = true; + } + return changed ? { active: current.active, selectedIds: remaining } : current; + }); + } + }, + [], + ); + + const onArchiveSelected = useCallback( + () => 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. + * + * Every row subscribes to this, and a context consumer re-renders whenever + * the value it reads changes — `memo` cannot stop it. Keeping + * `listedSessionIds` out of it is the whole point: that array is derived from + * the catalog and moves on a session switch, which would re-render all of the + * rail's rows for a switch that changed two of them (#4109). + */ + const rowSelection = useMemo( + () => ({ + active: selection.active, + selectedIds: selection.selectedIds, + onToggleRow, + onEnter, + }), + [onEnter, onToggleRow, selection.active, selection.selectedIds], + ); + + const wholeSelection = useMemo( + () => ({ + active: selection.active, + selectedIds: selection.selectedIds, + listedSessionIds, + onToggleRow, + onEnter, + onExit, + onToggleAll, + onArchiveSelected, + onDeleteSelected, + busy, + }), + [ + busy, + listedSessionIds, + onArchiveSelected, + onDeleteSelected, + onEnter, + onExit, + onToggleAll, + onToggleRow, + selection.active, + selection.selectedIds, + ], + ); + + return useMemo( + () => ({ selection: wholeSelection, rowSelection }), + [rowSelection, wholeSelection], + ); +} diff --git a/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts b/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts new file mode 100644 index 0000000000..95953feefe --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** What the rail has marked, and whether the mode is on at all. */ +export interface SessionSelection { + /** + * Whether the rail is in selection mode. + * + * Separate from `selectedIds` being empty, because unticking the master box + * is "select none" and not "leave". A mode that exited itself the moment the + * last row was cleared would take the checkboxes away mid-gesture, and the + * user would have to find the way back in to correct one mis-click. + */ + readonly active: boolean; + readonly selectedIds: ReadonlySet; +} + +export const EMPTY_SESSION_SELECTION: SessionSelection = Object.freeze({ + active: false, + selectedIds: Object.freeze(new Set()) as ReadonlySet, +}); + +/** + * Drops ids the catalog no longer lists. + * + * A selection outlives the list it was made from: another client deletes a + * task, a filter narrows, a bulk action removes what it removed. Acting on an + * id that is gone is at best a no-op and at worst a count that does not add up, + * so the selection is reconciled against the catalog rather than trusted. + */ +export function pruneSessionSelection( + selection: SessionSelection, + listedSessionIds: Iterable, +): SessionSelection { + const listed = listedSessionIds instanceof Set ? listedSessionIds : new Set(listedSessionIds); + const selectedIds = new Set(); + for (const sessionId of selection.selectedIds) { + if (listed.has(sessionId)) selectedIds.add(sessionId); + } + if (selectedIds.size === selection.selectedIds.size) return selection; + // Pruning empties the set; it does not end the mode. The rows went away + // because the catalog changed, not because the user was finished. + return { active: selection.active, selectedIds }; +} + +/** Enters selection mode with nothing marked. */ +export function enterSessionSelection(selection: SessionSelection): SessionSelection { + return selection.active ? selection : { ...selection, active: true }; +} + +/** Leaves selection mode and drops what was marked. */ +export function exitSessionSelection(): SessionSelection { + return EMPTY_SESSION_SELECTION; +} + +/** + * The master box: every listed row, or none of them. + * + * "All" means every row the rail is listing right now, which is what the user + * can see the box sitting above — not every task in the catalog. A box that + * silently included rows behind a collapsed project, or filtered out of view, + * would name a number the user never agreed to. + */ +export function setAllSessionsSelected( + selection: SessionSelection, + listedSessionIds: readonly string[], + selected: boolean, +): SessionSelection { + if (!selected) return { active: selection.active, selectedIds: new Set() }; + return { active: true, selectedIds: new Set(listedSessionIds) }; +} + +/** What the master box shows: all, none, or some. */ +export function sessionSelectionMasterState( + selection: SessionSelection, + listedSessionIds: readonly string[], +): boolean | 'indeterminate' { + if (selection.selectedIds.size === 0) return false; + if (listedSessionIds.length === 0) return false; + const allListed = listedSessionIds.every((id) => selection.selectedIds.has(id)); + return allListed ? true : 'indeterminate'; +} diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index 283e209bcc..8fe566ef5b 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -36,11 +36,22 @@ export { type SessionNavigationPorts, type UseSessionNavigationControllerInput, } from './controller/use-session-navigation-controller.js'; +export { useSessionSelection } from './controller/use-session-selection.js'; +export type { SessionNavigationRowActions } from './controller/session-row-actions.js'; export { useSessionNavigationReads } from './controller/use-session-navigation-reads.js'; export { sessionMatchesRail } from './model/session-nav-filter.js'; export { deriveBranchBanner } from './model/branch-banner.js'; export { deriveSessionRail } from './model/session-rail.js'; export { deriveSessionRevisionNavigation } from './model/session-revisions.js'; +export { + EMPTY_SESSION_SELECTION, + enterSessionSelection, + exitSessionSelection, + pruneSessionSelection, + sessionSelectionMasterState, + setAllSessionsSelected, + type SessionSelection, +} from './model/session-selection.js'; export { readSessionListViewMode, SESSION_LIST_EXPANDED_MAX_WIDTH, 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 c7322afe22..61734b865c 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 @@ -209,7 +209,12 @@ export function SessionNavigationProvider(props: SessionNavigationProviderProps) }; return ( - + {props.children} ); diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 5bb79e8396..8ac76a9df9 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -274,6 +274,24 @@ 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; @@ -902,6 +920,21 @@ 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: '刷新技能失败', @@ -1433,6 +1466,22 @@ 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 91ab94264c..bc56960aa3 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -530,3 +530,127 @@ letter-spacing: var(--tracking-wide); white-space: nowrap; } + +/* + * Multi-select. + * + * The marked state is a background, not a border: the rail already spends its + * border vocabulary on the active row's own chrome, and a second outlined + * treatment beside it reads as two competing "current" marks rather than one + * current row among several marked ones. `--color-overlay-pressed` is the + * heavier of the rail's two overlays, so a marked row sits visibly above a + * hovered one without inventing a colour. + */ +.maka-session-row[data-selected='true'] > div > .astryx-side-nav-item { + background: var(--color-overlay-pressed); +} + +/* + * Shift-click extends a text selection from wherever the last one was anchored, + * and in a list of titles that paints a blue smear across the rows the user is + * marking. It cannot be fixed from the click handler — the browser starts the + * selection on mousedown, before any click exists — so it is refused here. + */ +.maka-session-row { + user-select: none; +} + +/* + * The bar heads the LIST, not the chrome, so it carries no rule of its own. + * SideNav already draws one under the whole sticky top region; a second one + * here put two hairlines 9px apart and grouped the bar upwards, away from the + * rows it governs. + */ +.maka-session-selection-bar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + flex-direction: column; + gap: var(--space-1); + padding-block: var(--spacing-1) var(--spacing-2); + /* + * Opaque, because rows scroll underneath it. `--surface-canvas` is not a + * guess: the rail's ground is painted by `astryx-layout-panel` and resolves + * to the same oklch(0.975 0 0) this token does. + */ + background: var(--surface-canvas); +} + +/* + * Master box, count, way out. The box leads so it shares the rows' leading + * column: "all of these" is a claim the eye should be able to check against the + * boxes directly beneath it, not one it has to take on trust. + */ +.maka-session-selection-bar-head { + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* Takes the leftover width so the way-out button stays on the trailing edge. */ +.maka-session-selection-count { + flex: 1 1 auto; +} + +/* + * The row's own leading column, which exists only while the mode does. Same + * gap as the head above, so a box and the box governing it sit on one line. + */ +.maka-session-row-check { + position: absolute; + inset-inline-start: 0; + top: 0; + bottom: 0; + display: flex; + align-items: center; + z-index: 1; +} + +/* + * Rows shift out of the way of their own box rather than the box overlapping + * the status dot. Padding on the row, not a margin on the item, so the hover + * and selected backgrounds still span the full width. + */ +.maka-session-row[data-selecting='true'] { + padding-inline-start: calc(20px + var(--space-2)); +} + +/* + * `min-width: 0` is what a flex child needs to be allowed to ellipsize rather + * than be crushed, and `nowrap` is what stops the crush from becoming one + * character per line — which is what a three-button single row did to it at + * 244px. Neither is decorative: the count is the number the confirm will name. + */ +/* + * `min-width: 0` is what a flex child needs to be allowed to ellipsize rather + * than be crushed, and `nowrap` is what stops the crush from becoming one + * character per line — which is what a three-button single row did to it at + * 244px. Neither is decorative: this is the number the confirm will name. + */ +.maka-session-selection-count { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font: var(--typography-caption); + color: var(--maka-text-supporting); + font-variant-numeric: tabular-nums; +} + +/* + * 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. + */ +.maka-session-selection-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-1); +} + +.maka-session-selection-actions > button { + width: 100%; + min-width: 0; +} diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d245447256..7df6c86044 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.5.0` (194 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 232 files — blocker 0, reimplementation 0, polish 1, aligned 231. +**Totals:** 233 files — blocker 0, reimplementation 0, polish 1, aligned 232. ## Exclusions (explicit) @@ -240,10 +240,11 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/scheduled-task-panel.tsx` | module-hub | Button, Divider, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuItem, EmptyState, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, StatusDot, Text, TextInput, Toolbar | aligned — uses Astryx (Button, Divider, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuItem, EmptyState, List, ListItem) | aligned | | `packages/ui/src/search-modal.tsx` | dialog-overlay | CommandPalette, CommandPaletteFooter, CommandPaletteInput | aligned — uses Astryx (CommandPalette, CommandPaletteFooter, CommandPaletteInput) | aligned | | `packages/ui/src/session-context-layer.tsx` | shell-chrome-or-panel | BreadcrumbItem, Breadcrumbs, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList, StatusDot, Text, Token, Tooltip | aligned — uses Astryx (BreadcrumbItem, Breadcrumbs, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList) | aligned | -| `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack | aligned — uses Astryx (Badge, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack) | aligned | +| `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, CheckboxInput, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack | aligned — uses Astryx (Badge, CheckboxInput, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack) | aligned | | `packages/ui/src/session-list-panel.tsx` | shell-chrome-or-panel | SegmentedControl, SegmentedControlItem, SideNav | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, SideNav) | aligned | | `packages/ui/src/session-rail-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | +| `packages/ui/src/session-selection-bar.tsx` | shell-chrome-or-panel | Button, CheckboxInput | aligned — uses Astryx (Button, CheckboxInput) | aligned | | `packages/ui/src/session-sidebar-nav.tsx` | shell-chrome-or-panel | Icon, IconButton, SideNavItem, SideNavSection, Tooltip | aligned — uses Astryx (Icon, IconButton, SideNavItem, SideNavSection, Tooltip) | aligned | | `packages/ui/src/session-todo-panel.tsx` | shell-chrome-or-panel | Banner, EmptyState, IconButton, Spinner | aligned — uses Astryx (Banner, EmptyState, IconButton, Spinner) | aligned | | `packages/ui/src/skill-inspector.tsx` | shell-chrome-or-panel | Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot, Switch, Text, VStack | aligned — uses Astryx (Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index d5000d7d86..228465236a 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -215,6 +215,7 @@ packages/ui/src/session-history-list.tsx packages/ui/src/session-list-panel.tsx packages/ui/src/session-rail-context.tsx packages/ui/src/session-rename-dialog.tsx +packages/ui/src/session-selection-bar.tsx packages/ui/src/session-sidebar-nav.tsx packages/ui/src/session-todo-panel.tsx packages/ui/src/skill-inspector.tsx diff --git a/packages/ui/src/__tests__/session-history-multi-select.test.tsx b/packages/ui/src/__tests__/session-history-multi-select.test.tsx new file mode 100644 index 0000000000..98bc493010 --- /dev/null +++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx @@ -0,0 +1,384 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * The rail is a navigation surface that also has to be selectable, so what + * matters is which of the two a click is. These cases drive real clicks rather + * than asserting markup: the branch under test is chosen inside the row's + * handler, and markup cannot say which branch ran. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import type { SessionSummary } from '@maka/core/session'; +import { LocaleProvider } from '../locale-context.js'; +import { SessionHistoryList } from '../session-history-list.js'; +import { + SessionRailProvider, + type SessionRailData, + type SessionRailRowSelection, + type SessionRailSelection, +} from '../session-rail-context.js'; + +/** + * What Astryx's SideNavItem reaches for that linkedom does not ship: a computed + * style and `matchMedia`, which its hover hook subscribes to. Neither is what + * these cases are about, so both answer the least interesting truth. + */ +/** + * linkedom ships no `MouseEvent`, and React reads the modifier flags straight + * off the native event, so a plain Event carrying them is exactly as much event + * as the handler under test looks at. + */ +function clickEvent( + window: ReturnType['window'], + modifiers: Partial, +): Event { + const event = new window.Event('click', { bubbles: true, cancelable: true }); + Object.assign(event, { + detail: 1, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + ...modifiers, + }); + return event as unknown as Event; +} + +function installDomStubs(window: ReturnType['window']): void { + window.getComputedStyle = () => + ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + (window as unknown as { matchMedia: unknown }).matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }); +} + +function summary(id: string): SessionSummary { + return { + id, + name: id, + isFlagged: false, + isArchived: false, + labels: [], + hasUnread: false, + status: 'active', + backend: 'ai-sdk', + llmConnectionSlug: 'test-connection', + connectionLocked: true, + model: 'test-model', + permissionMode: 'ask', + }; +} + +const SESSIONS = ['a', 'b', 'c'].map(summary); + +type Harness = { + opened: string[]; + toggles: Array<[string, boolean]>; + toggleAll: boolean[]; + entered: Array; + exits: number; + deleteRequests: number; + pressKey(key: string, focusedSessionId?: string): Promise; + dispose(): Promise; + clickRow(sessionId: string, modifiers?: Partial): Promise; + clickCheckbox(sessionId: string, checked: boolean): Promise; + document: Document; +}; + +async function mount( + options: { selectedIds?: readonly string[]; active?: boolean } = {}, +): Promise { + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + installDomStubs(window); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + + const opened: string[] = []; + const toggles: Array<[string, boolean]> = []; + const toggleAll: boolean[] = []; + const entered: Array = []; + let exits = 0; + let deleteRequests = 0; + const rowSelection: SessionRailRowSelection = { + active: options.active ?? false, + selectedIds: new Set(options.selectedIds ?? []), + onToggleRow: (sessionId, selected) => toggles.push([sessionId, selected]), + onEnter: (sessionId) => entered.push(sessionId), + }; + const selection: SessionRailSelection = { + ...rowSelection, + listedSessionIds: SESSIONS.map((session) => session.id), + onExit: () => { + exits += 1; + }, + onToggleAll: (selected) => toggleAll.push(selected), + onArchiveSelected: () => undefined, + onDeleteSelected: () => { + deleteRequests += 1; + }, + }; + const data: SessionRailData = { + sessions: SESSIONS, + groupVariant: 'conversation', + groups: [{ id: 'recent', label: 'Recent', sessions: [...SESSIONS] }], + onSelectSession: (sessionId) => opened.push(sessionId), + }; + + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + await act(() => + root.render( + + + + + , + ), + ); + + const harness: Harness = { + opened, + toggles, + toggleAll, + entered, + 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 + // is focused, because the user just clicked one. The handler's first + // guard reads it, so the test has to answer it. + const focused = document.querySelector(`[data-session-id="${focusedSessionId}"] button`); + assert.ok(focused); + Object.defineProperty(document, 'activeElement', { + configurable: true, + get: () => focused, + }); + const list = document.querySelector('.maka-session-list'); + assert.ok(list); + await act(() => { + const event = new window.Event('keydown', { bubbles: true, cancelable: true }); + Object.assign(event, { key }); + list.dispatchEvent(event); + }); + }, + document: document as unknown as Document, + clickRow: async (sessionId, modifiers = {}) => { + const row = document.querySelector(`[data-session-id="${sessionId}"] button`); + assert.ok(row, `no clickable row for ${sessionId}`); + await act(() => { + row.dispatchEvent(clickEvent(window, modifiers)); + }); + }, + clickCheckbox: async (sessionId, checked) => { + const box = document.querySelector( + `[data-session-id="${sessionId}"] .maka-session-row-check input`, + ) as HTMLInputElement | null; + assert.ok(box, `no checkbox for ${sessionId}`); + // React's checkbox `onChange` is driven by the native CLICK, not by a + // `change` event, and its value tracker swallows a programmatic + // `.checked` write that is not followed by one. + await act(() => { + box.checked = checked; + box.dispatchEvent(new window.Event('click', { bubbles: true, cancelable: true })); + }); + }, + dispose: async () => { + await act(() => root.unmount()); + Object.assign(globalThis, original); + }, + }; + return harness; +} + +test('a plain click still opens the task', async () => { + const harness = await mount(); + try { + await harness.clickRow('b'); + assert.deepEqual(harness.opened, ['b']); + } finally { + await harness.dispose(); + } +}); + +test('a marked row says so in the DOM', async () => { + const harness = await mount({ selectedIds: ['b'] }); + try { + const marked = harness.document.querySelectorAll('[data-selected="true"]'); + assert.equal(marked.length, 1); + assert.equal((marked[0] as HTMLElement).dataset.sessionId, 'b'); + } finally { + await harness.dispose(); + } +}); + +test('a rail with no selection wired up behaves exactly as before', async () => { + // The context is optional so a surface that never adopts multi-select — or a + // story that renders rows alone — keeps plain clicks and gains no chrome. + const original = { + document: globalThis.document, + window: globalThis.window, + IS_REACT_ACT_ENVIRONMENT: ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT, + }; + const { document, window } = parseHTML('
'); + installDomStubs(window); + Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); + const opened: string[] = []; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + try { + await act(() => + root.render( + + opened.push(sessionId), + }} + > + + + , + ), + ); + const row = document.querySelector('[data-session-id="b"] button'); + assert.ok(row); + await act(() => { + row.dispatchEvent(clickEvent(window, { metaKey: true })); + }); + // A modifier with nothing wired up is still a click on a task. + assert.deepEqual(opened, ['b']); + assert.equal(document.querySelector('[data-selected="true"]'), null); + } finally { + await act(() => root.unmount()); + Object.assign(globalThis, original); + } +}); + +test('Escape leaves the mode', async () => { + const harness = await mount({ active: true, selectedIds: ['b'] }); + try { + await harness.pressKey('Escape'); + assert.equal(harness.exits, 1); + } finally { + await harness.dispose(); + } +}); + +test('Escape outside the mode is not this handler\'s business', async () => { + const harness = await mount(); + try { + await harness.pressKey('Escape'); + assert.equal(harness.exits, 0); + } finally { + await harness.dispose(); + } +}); + +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 { + assert.equal(harness.document.querySelector('.maka-session-row-check'), null); + } finally { + await harness.dispose(); + } +}); + +test('in the mode every row carries a box, ticked to match', async () => { + const harness = await mount({ active: true, selectedIds: ['b'] }); + try { + const boxes = [...harness.document.querySelectorAll('.maka-session-row-check input')]; + assert.equal(boxes.length, 3); + assert.deepEqual( + boxes.map((box) => (box as HTMLInputElement).checked), + [false, true, false], + ); + } finally { + await harness.dispose(); + } +}); + +test('ticking a box reports the row and the direction', async () => { + const harness = await mount({ active: true }); + try { + await harness.clickCheckbox('c', true); + assert.deepEqual(harness.toggles, [['c', true]]); + } finally { + await harness.dispose(); + } +}); + +test('a row click still opens the task while the mode is on', async () => { + // The box is the selection affordance; the row keeps its job. Making the + // whole row toggle would cost the rail the one thing it is for. + const harness = await mount({ active: true }); + try { + await harness.clickRow('b'); + assert.deepEqual(harness.opened, ['b']); + assert.deepEqual(harness.toggles, []); + } finally { + await harness.dispose(); + } +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index bfde2290b7..e8d4c055be 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -32,6 +32,8 @@ export { SessionRailProvider } from './session-rail-context.js'; export type { SessionRailChrome, SessionRailData, + SessionRailRowSelection, + SessionRailSelection, SessionViewMode, } from './session-rail-context.js'; export type { SidebarUpdateReminder } from './session-sidebar-nav.js'; diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 39e5d395f2..502b9aeb50 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -423,6 +423,13 @@ export interface ConversationCopy { promptRailAriaLabel: string; emptyPrompt: string; jumpToPrompt: (preview: string) => string; + selectRow: string; + selectionBarAriaLabel: string; + selectedCount: (selected: number, total: number) => string; + selectAllAriaLabel: string; + selectionArchive: string; + selectionDelete: string; + selectionClear: string; }; } @@ -547,7 +554,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}`, + 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: '取消', }, }, en: { @@ -696,7 +703,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}`, + 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', }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 2ad18d9747..944d6e2f53 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -38,6 +38,7 @@ import { AlertTriangle, Archive, ArchiveRestore, + CircleCheckBig, FolderOpen, Pencil, Pin, @@ -60,7 +61,12 @@ import { StatusDot, type StatusDotVariant } from '@astryxdesign/core/StatusDot'; import { describeBlockedReason, presentSessionStatus } from './session-status-presentation.js'; import { dotForStatus } from './status-vocabulary.js'; import { SessionRenameDialog, type SessionRenameTarget } from './session-rename-dialog.js'; -import { useSessionRailData } from './session-rail-context.js'; +import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; +import { + useSessionRailData, + useSessionRailRowSelection, + useSessionRailSelection, +} from './session-rail-context.js'; import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { getSessionHoverCardCopy } from './session-hover-card-copy.js'; @@ -134,12 +140,36 @@ export interface SessionHistoryGroup { export function SessionHistoryList() { const rail = useSessionRailData(); + const selection = useSessionRailSelection(); const locale = useUiLocale(); function handleListKeyDown(event: KeyboardEvent) { - if (event.key !== 'Delete' && event.key !== 'Backspace') return; + 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. 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]', ); @@ -152,6 +182,27 @@ export function SessionHistoryList() { } } + // Memoized on what it derives from. Rebuilt per render it would give + // `SessionListGroups` a new `props.groups` every time, which defeats the + // per-group memo below it — and this component re-renders on every session + // switch, because `rail.activeId` is part of the value it reads. + const groups = useMemo( + () => + rail.groups + ? rail.groups.map((g) => ({ + key: g.id, + label: g.label, + sessions: g.sessions, + project: g.project, + })) + : groupSessionsForHistory(rail.sessions, locale).map((g) => ({ + key: g.id, + label: g.label, + sessions: g.sessions, + })), + [locale, rail.groups, rail.sessions], + ); + // Outer SideNav is the sole navigation landmark and it already carries this // panel's name; naming this element too put "任务列表" inside "任务列表", // which is one ambiguous match for anything selecting by that name and no @@ -159,22 +210,7 @@ export function SessionHistoryList() { // handler, nothing an assistive tech user needs to be told about separately. return (
- ({ - key: g.id, - label: g.label, - sessions: g.sessions, - project: g.project, - })) - : groupSessionsForHistory(rail.sessions, locale).map((g) => ({ - key: g.id, - label: g.label, - sessions: g.sessions, - })) - } - /> +
); } @@ -326,6 +362,8 @@ function SessionListGroups(props: { <> {renameDialog} {props.groups.map((group) => { + // Once per group, never per row: a fresh array for each row would hand + // every `SessionNavRow` a new prop identity and defeat its memo. const items = group.sessions.map((session) => renderSessionRow(session)); if (!group.label) { return ( @@ -397,7 +435,9 @@ function ProjectNavRow(props: { > {/* sidebar.css keeps an 8px nest so session titles share the project x. */} {hasSessions ? ( - {props.sessions.map((session) => props.renderSession(session))} + + {props.sessions.map((session) => props.renderSession(session))} + ) : undefined} (null); const hoverDescriptionId = useId(); const locale = useUiLocale(); + // The ROW half of the selection, not the whole of it. Read here rather than + // threaded through `renderSessionRow`, which is memoized on the rail value — + // but read from the narrow context, because a consumer re-renders on any + // change to the value it subscribes to and the wide one carries + // `listedSessionIds`, which moves whenever the catalog does (#4109). + const selection = useSessionRailRowSelection(); + const marked = selection?.selectedIds.has(props.session.id) ?? false; const copy = getConversationCopy(locale).sessions; const signals = sessionRowSignals( props.session, @@ -496,7 +543,24 @@ const SessionNavRow = memo(function SessionNavRow(props: { data-session-id={props.session.id} data-stale={props.stale ? 'true' : undefined} data-worktree={props.worktree ? 'true' : undefined} + data-selected={marked ? 'true' : undefined} + data-selecting={selection?.active ? 'true' : undefined} > + {/* Outside the SideNavItem, not in its icon slot: that slot is the status + dot's fixed gutter, and a checkbox there would take the one column the + rail uses to say what a task is doing. The box gets its own leading + column, which only exists while the mode does. */} + {selection?.active ? ( + + selection.onToggleRow(props.session.id, checked)} + /> + + ) : null} (null); + // The way in. ⌘-click is invisible to anyone who has not been told about it, + // and this menu is where a person already looks for what a row can do — so + // selection is discoverable from the same place as pin, rename, and archive. + // The narrow context again: this renders once per row. + const selection = useSessionRailRowSelection(); const locale = useUiLocale(); const copy = getConversationCopy(locale).sessions; const actionContext = [ @@ -994,6 +1063,18 @@ function SessionItemActions(props: { if (intent) window.requestAnimationFrame(intent); }} items={[ + ...(selection && !selection.active + ? [ + { + label: copy.selectRow, + icon: CircleCheckBig, + // Enters the mode AND marks this row: the person picked a + // row, not an abstract mode, and landing them in an empty + // selection would discard the choice they just made. + onClick: () => selection.onEnter(props.session.id), + }, + ] + : []), { label: props.session.isFlagged ? copy.unpin : copy.pin, icon: props.session.isFlagged ? PinOff : Pin, diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 1f7522162c..71bc4502b2 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -23,6 +23,7 @@ import { } from '@astryxdesign/core/SegmentedControl'; import { SideNav } from '@astryxdesign/core/SideNav'; import { SessionHistoryList } from './session-history-list.js'; +import { SessionSelectionBar } from './session-selection-bar.js'; import { useSessionRailChrome, type SessionViewMode, @@ -128,7 +129,25 @@ export function SessionListPanel() { } footer={} > - {!collapsed ? SESSION_HISTORY_LIST : null} + {/* The selection bar belongs to the LIST, not to the chrome, and is + rendered here rather than in `topContent` for that reason. SideNav + draws one hairline under the whole top region, so a bar up there + landed above that line — grouped with 按时间 / 按项目 and cut off + from the rows it governs, with its own hairline making a second line + 9px from the first. + + It is `position: sticky` inside the scroller instead, so it still + does not scroll away while the user marks rows further down. + + `SESSION_HISTORY_LIST` keeps its element identity through this + fragment, so a selection change still skips the ~1,000 fibers under + it (#4109). */} + {!collapsed ? ( + <> + + {SESSION_HISTORY_LIST} + + ) : null} ); diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx index ced9c8e13e..ca747c830b 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -92,8 +92,64 @@ export interface SessionRailChrome { }; } +/** + * What a ROW reads: whether the mode is on, whether this row is marked, and the + * two ways a row changes that. + * + * Split from `SessionRailSelection` because a context consumer re-renders + * whenever the value it subscribes to changes, and `memo` cannot stop it. The + * wide value carries `listedSessionIds`, which is derived from the catalog and + * gets a fresh identity on a session switch — a row reading that would + * re-render along with every other row for a switch that changed two of them. + * The e2e render contract measures exactly this and budgets 2 rows (#4109). + * + * This half moves only when the selection itself does. + */ +export interface SessionRailRowSelection { + active: boolean; + selectedIds: ReadonlySet; + onToggleRow(sessionId: string, selected: boolean): void; + onEnter(sessionId?: string): void; +} + +/** + * What the selection BAR reads: the row half plus everything only the bar + * needs — what "all" means, the sweeps, and whether one is running. + * + * A THIRD context, for the reason the chrome is a second one: it changes as the + * user marks rows while the list does not, and folding it into + * `SessionRailData` would give that value a new identity per click — the + * ~1,000-fiber render that split exists to prevent (#4109). + * + * Absent means the rail has no multi-select: rows navigate, nothing marks, and + * a surface that never wired it up renders exactly as before. + */ +export interface SessionRailSelection { + /** + * Whether the rail is in selection mode: rows carry a checkbox and the master + * row is above them. Distinct from an empty `selectedIds` — unticking the + * master box selects none, it does not leave. + */ + active: boolean; + selectedIds: ReadonlySet; + /** Every row the rail is listing, in rendered order. What "all" means. */ + listedSessionIds: readonly string[]; + onToggleRow(sessionId: string, selected: boolean): void; + onEnter(sessionId?: string): void; + /** Leaves the mode and drops what was marked. */ + onExit(): void; + onToggleAll(selected: boolean): void; + 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; +} + const SessionRailDataContext = createContext(null); const SessionRailChromeContext = createContext(null); +const SessionRailSelectionContext = createContext(null); +const SessionRailRowSelectionContext = createContext(null); /** * `chrome` is optional so the list can be rendered on its own — a test or a @@ -103,12 +159,24 @@ const SessionRailChromeContext = createContext(null); export function SessionRailProvider(props: { data: SessionRailData; chrome?: SessionRailChrome; + selection?: SessionRailSelection; + /** + * The rows' half. Supplied separately rather than derived here so its + * identity is the producer's business: deriving it in this component would + * rebuild it on every render of the tree above, which is the churn the split + * exists to avoid. + */ + rowSelection?: SessionRailRowSelection; children?: ReactNode; }) { return ( - {props.children} + + + {props.children} + + ); @@ -125,3 +193,16 @@ export function useSessionRailChrome(): SessionRailChrome { if (!chrome) throw new Error('SessionRailProvider is missing'); return chrome; } + +/** + * Null rather than throwing, unlike the other two: multi-select is optional + * chrome, and a rail without it is a rail, not a misconfiguration. + */ +export function useSessionRailSelection(): SessionRailSelection | null { + return useContext(SessionRailSelectionContext); +} + +/** What a row reads. Null when the rail has no multi-select. */ +export function useSessionRailRowSelection(): SessionRailRowSelection | null { + return useContext(SessionRailRowSelectionContext); +} diff --git a/packages/ui/src/session-selection-bar.tsx b/packages/ui/src/session-selection-bar.tsx new file mode 100644 index 0000000000..5c36c440bf --- /dev/null +++ b/packages/ui/src/session-selection-bar.tsx @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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 { useUiLocale } from './locale-context.js'; +import { useSessionRailSelection } from './session-rail-context.js'; + +/** + * The rail's selection mode, headed. + * + * The master box sits directly above the rows it governs and in the same + * leading column as theirs, so "all of these" is a claim the eye can check + * rather than a label to be trusted. It is `indeterminate` whenever some but + * not all listed rows are marked — the one state a plain checked/unchecked pair + * cannot express, and the usual state during a selection. + * + * "All" means every row the rail is listing, which is what sits under the box. + * 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 + * 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. + * + * 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 + * line, which measured 228px of a 244px bar and squeezed the count to 12px, + * where it wrapped one character per line. Vertical space in a rail is cheap. + */ +export function SessionSelectionBar() { + const selection = useSessionRailSelection(); + const copy = getConversationCopy(useUiLocale()).sessions; + if (!selection?.active) return null; + const listed = selection.listedSessionIds; + const count = selection.selectedIds.size; + const busy = selection.busy === true; + const allMarked = listed.length > 0 && listed.every((id) => selection.selectedIds.has(id)); + const master: boolean | 'indeterminate' = count === 0 ? false : allMarked ? true : 'indeterminate'; + return ( +
+
+ selection.onToggleAll(checked)} + /> + {/* `aria-live` so the count reaches a screen reader as it changes: the + bar is not focused while the user is ticking rows, so nothing else + would say how many are marked. */} + + {copy.selectedCount(count, listed.length)} + +
+
+
+
+ ); +}