From 06688f99ca7224f462c2a1197b540fcc8c89326a Mon Sep 17 00:00:00 2001 From: Joob1n Date: Mon, 31 Aug 2026 21:37:08 +0800 Subject: [PATCH 1/8] feat(desktop): select several tasks in the Session rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail could act on one task at a time. Clearing out a run of finished ones meant opening the ⋯ menu, confirming, and repeating — once per task. ⌘/Ctrl-click marks a row instead of opening it, Shift-click marks a run, and a bar in the rail's sticky chrome offers archive and delete over the marked set. Escape clears it; Delete asks for the marked set rather than the focused row, because deleting one of several rows a user marked is the shape of an unrecoverable surprise. Nothing renders until something is marked, so the rail at rest is unchanged. A range never leaves its group. A project group's collapsed state lives inside Astryx's `SideNavItem` and is not readable from the rail, so a range across groups could quietly include rows nobody can see. Within one group the question does not arise: both endpoints had to be clicked, and a row that can be clicked is on screen. The selection is a THIRD rail context, for the reason the chrome is a second one. It changes on every modified click while the list does not, and folding it into `SessionRailData` would give that value a new identity per click — the ~1,000-fiber render the split exists to prevent (#4109). Rows read it directly rather than receiving it through the memoized row renderer, so a selection change re-renders the rows on screen and nothing above them. Both sweeps reuse the machinery the archived-task purge already had rather than copying its accounting. `purgeSessions` becomes `sweepSessions`, parameterized by how each id decides the archived premise: Settings asserts it for every target, and 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. Bulk archive accounts by count and first failure instead, since archiving has no third disposition to report. Neither raises a toast per task: one sweep is one message. Generated-by: Claude Opus 5 via Claude Code --- .../session-navigation-selection.test.ts | 166 +++++++++ .../session-navigation-session-purge.test.ts | 164 +++++++- .../controller/session-row-actions.ts | 115 +++++- .../use-session-navigation-controller.ts | 28 +- .../controller/use-session-selection.ts | 165 +++++++++ .../model/session-selection.ts | 123 ++++++ .../features/session-navigation/testing.ts | 7 + .../ui/session-navigation-provider.tsx | 2 +- .../src/renderer/locales/shell-copy.ts | 39 ++ apps/desktop/src/renderer/styles/sidebar.css | 41 ++ .../session-history-multi-select.test.tsx | 350 ++++++++++++++++++ packages/ui/src/components.tsx | 4 +- packages/ui/src/conversation-copy.ts | 10 +- packages/ui/src/session-history-list.tsx | 72 +++- packages/ui/src/session-list-panel.tsx | 8 + packages/ui/src/session-rail-context.tsx | 73 +++- packages/ui/src/session-selection-bar.tsx | 83 +++++ 17 files changed, 1428 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-navigation-selection.test.ts create mode 100644 apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts create mode 100644 apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts create mode 100644 packages/ui/src/__tests__/session-history-multi-select.test.tsx create mode 100644 packages/ui/src/session-selection-bar.tsx 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..3c3895f52c --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts @@ -0,0 +1,166 @@ +/* + * 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 { sessionSelectionGestureMode } from '@maka/ui'; +import { + applySessionSelectionGesture, + EMPTY_SESSION_SELECTION, + pruneSessionSelection, + 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(); +} + +function toggle(selection: SessionSelection, sessionId: string): SessionSelection { + return applySessionSelectionGesture(selection, { + sessionId, + mode: 'toggle', + groupSessionIds: GROUP, + }); +} + +function range(selection: SessionSelection, sessionId: string, group = GROUP): SessionSelection { + return applySessionSelectionGesture(selection, { + sessionId, + mode: 'range', + groupSessionIds: group, + }); +} + +describe('sessionSelectionGestureMode', () => { + test('a plain click is not a selection gesture', () => { + // The rail is a navigation surface first: an unmodified click must keep + // opening the task, or selecting would cost the rail its primary job. + assert.equal( + sessionSelectionGestureMode({ metaKey: false, ctrlKey: false, shiftKey: false }), + undefined, + ); + }); + + test('either platform modifier toggles', () => { + assert.equal(sessionSelectionGestureMode({ metaKey: true, ctrlKey: false, shiftKey: false }), 'toggle'); + assert.equal(sessionSelectionGestureMode({ metaKey: false, ctrlKey: true, shiftKey: false }), 'toggle'); + }); + + test('shift wins over the toggle modifier', () => { + // A range is the more specific request; resolving both as a toggle would + // drop it. + assert.equal(sessionSelectionGestureMode({ metaKey: true, ctrlKey: false, shiftKey: true }), 'range'); + }); +}); + +describe('toggle', () => { + test('adds, removes, and anchors on the row last acted on', () => { + const one = toggle(EMPTY_SESSION_SELECTION, 'b'); + assert.deepEqual(ids(one), ['b']); + assert.equal(one.anchorId, 'b'); + + const two = toggle(one, 'd'); + assert.deepEqual(ids(two), ['b', 'd']); + assert.equal(two.anchorId, 'd'); + + const removed = toggle(two, 'b'); + assert.deepEqual(ids(removed), ['d']); + // The anchor follows a removal too: leaving it on a row that is no longer + // marked would measure the next range from somewhere invisible. + assert.equal(removed.anchorId, 'b'); + }); + + test('the input selection is never mutated', () => { + const one = toggle(EMPTY_SESSION_SELECTION, 'b'); + toggle(one, 'c'); + assert.deepEqual(ids(one), ['b']); + }); +}); + +describe('range', () => { + test('spans from the anchor in either direction', () => { + const anchored = toggle(EMPTY_SESSION_SELECTION, 'd'); + assert.deepEqual(ids(range(anchored, 'b')), ['b', 'c', 'd']); + assert.deepEqual(ids(range(toggle(EMPTY_SESSION_SELECTION, 'b'), 'd')), ['b', 'c', 'd']); + }); + + test('a corrected range does not accumulate', () => { + // Shift-click one row too far, then Shift-click back. Re-anchoring on each + // target would union the two spans and the selection could never shrink. + const anchored = toggle(EMPTY_SESSION_SELECTION, 'a'); + const tooFar = range(anchored, 'e'); + assert.deepEqual(ids(tooFar), ['a', 'b', 'c', 'd', 'e']); + const corrected = range(tooFar, 'b'); + assert.equal(corrected.anchorId, 'a'); + // The span itself is a..b; the rows the first Shift-click added stay + // selected because a range adds, and the anchor is what had to stay put. + assert.deepEqual(ids(corrected), ['a', 'b', 'c', 'd', 'e']); + }); + + test('shift on a fresh rail selects the row it lands on', () => { + // Otherwise the first Shift-click does nothing at all, and a modifier that + // appears broken is a modifier nobody tries twice. + const first = range(EMPTY_SESSION_SELECTION, 'c'); + assert.deepEqual(ids(first), ['c']); + assert.equal(first.anchorId, 'c'); + }); + + test('an anchor in another group re-anchors instead of spanning', () => { + // The two groups have no common order, so there is no span to compute. + const other = toggle(EMPTY_SESSION_SELECTION, 'a'); + const next = range(other, 'y', ['x', 'y', 'z']); + assert.deepEqual(ids(next), ['a', 'y']); + assert.equal(next.anchorId, 'y'); + }); + + test('a row the group does not list is not an endpoint', () => { + const anchored = toggle(EMPTY_SESSION_SELECTION, 'a'); + const next = range(anchored, 'missing'); + assert.deepEqual(ids(next), ['a']); + assert.equal(next.anchorId, 'a'); + }); +}); + +describe('pruneSessionSelection', () => { + test('drops ids the catalog no longer lists', () => { + const selection = toggle(toggle(EMPTY_SESSION_SELECTION, 'a'), 'b'); + const pruned = pruneSessionSelection(selection, ['a']); + assert.deepEqual(ids(pruned), ['a']); + }); + + test('clears an anchor that went with them', () => { + const selection = toggle(toggle(EMPTY_SESSION_SELECTION, 'a'), 'b'); + assert.equal(selection.anchorId, 'b'); + assert.equal(pruneSessionSelection(selection, ['a']).anchorId, undefined); + }); + + 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 for no change. + const selection = toggle(EMPTY_SESSION_SELECTION, 'a'); + assert.equal(pruneSessionSelection(selection, ['a', 'b']), selection); + }); + + test('an emptied selection settles on the shared empty value', () => { + const selection = toggle(EMPTY_SESSION_SELECTION, 'a'); + assert.equal(pruneSessionSelection(selection, []), EMPTY_SESSION_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..b51dc011be 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,6 +50,8 @@ function restored(id: string): SessionSummary { type SweepHarness = { removed: string[]; + /** Each `archive` call, in order. */ + archived: string[]; /** Each `remove` call as `[sessionId, requireArchived]`. */ removeOptions: Array<[string, boolean]>; cleared: string[]; @@ -68,6 +70,7 @@ function installService( harness: SweepHarness, options: { rejectIds?: readonly string[]; + rejectArchiveIds?: readonly string[]; rejectWithUndefinedIds?: readonly string[]; surviving?: readonly SessionSummary[]; /** Runs after each accepted removal, to model what another client did meanwhile. */ @@ -89,7 +92,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) => { @@ -146,6 +152,7 @@ function createActions(input: { function harness(): SweepHarness { return { removed: [], + archived: [], removeOptions: [], cleared: [], selections: [], @@ -420,3 +427,158 @@ 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); + }); +}); 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..c2bc7c0326 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,8 @@ 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; } export function createSessionNavigationRowActions(deps: { @@ -254,12 +271,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 +299,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 +321,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 +375,80 @@ 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 }; + } + return { flagSession, archiveSession, @@ -357,5 +456,7 @@ export function createSessionNavigationRowActions(deps: { renameSession, deleteSession, purgeSessions, + deleteSessions, + archiveSessions, }; } 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..0ddaec7694 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,7 @@ 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 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 +37,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 +100,7 @@ export interface SessionNavigationController { layout: SessionRailLayoutState; selectors: SessionNavigationSelectors; commands: SessionNavigationRowActions; + selection: SessionRailSelection; } /** @@ -190,8 +192,28 @@ export function useSessionNavigationController( [groups, sessionMeta, worktreeSessionIds], ); + // The toast API is reached through the ref for the same reason the row + // actions reach it that way: the shell rebuilds it per render, and the + // selection's callbacks must not change identity with it. + const selectionToastApi = useMemo( + () => ({ + success: (title, description) => portsRef.current.toastApi.success(title, description), + error: (title, description, details, target) => + portsRef.current.toastApi.error(title, description, details, target), + confirm: (options) => portsRef.current.toastApi.confirm(options), + }), + [], + ); + + const selection = useSessionSelection({ + sessions: rail.sessions, + commands, + locale, + toastApi: selectionToastApi, + }); + return useMemo( - () => ({ layout, selectors, commands }), - [commands, layout, selectors], + () => ({ layout, selectors, commands, selection }), + [commands, layout, 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..fea6f0c3b0 --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts @@ -0,0 +1,165 @@ +/* + * 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 { UiLocale } from '@maka/core/ui-locale'; +import type { SessionRailSelection } from '@maka/ui'; +import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; +import { + applySessionSelectionGesture, + EMPTY_SESSION_SELECTION, + pruneSessionSelection, +} from '../model/session-selection.js'; +import type { SessionNavigationRowActions } from './session-row-actions.js'; +import type { SessionNavigationToastApi } from './use-session-navigation-controller.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; + locale: UiLocale; + toastApi: SessionNavigationToastApi; +}): SessionRailSelection { + const { sessions, commands, locale, toastApi } = input; + const [selection, setSelection] = useState(EMPTY_SESSION_SELECTION); + const [busy, setBusy] = useState(false); + const copy = getShellCopy(locale).sessionRowActions; + + 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; + }); + + const onGesture = useCallback((gesture) => { + setSelection((current) => applySessionSelectionGesture(current, gesture)); + }, []); + + const onClear = useCallback(() => setSelection(EMPTY_SESSION_SELECTION), []); + + const runSweep = useCallback( + async (kind: 'archive' | 'delete') => { + if (busyRef.current) return; + // 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. + const sessionIds = [...selectionRef.current.selectedIds]; + if (sessionIds.length === 0) return; + const confirmed = await toastApi.confirm({ + title: + kind === 'delete' + ? copy.bulkDeleteTitle(sessionIds.length) + : copy.bulkArchiveTitle(sessionIds.length), + description: + kind === 'delete' ? copy.bulkDeleteDescription : copy.bulkArchiveDescription, + confirmLabel: kind === 'delete' ? copy.deleteLabel : copy.bulkArchiveLabel, + cancelLabel: copy.cancelLabel, + destructive: kind === 'delete', + }); + if (!confirmed) return; + setBusy(true); + try { + if (kind === 'archive') { + const outcome = await commands.archiveSessions(sessionIds); + if (outcome.failed.length > 0) { + toastApi.error( + copy.bulkArchiveFailedTitle, + outcome.firstFailure + ? localizedShellErrorMessage( + outcome.firstFailure.error, + copy.actionFallback, + locale, + ) + : copy.bulkFailedBody(outcome.failed.length), + undefined, + outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, + ); + } else { + toastApi.success(copy.bulkArchivedTitle(outcome.archived)); + } + return; + } + const outcome = await commands.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; + if (!outcome.verified || outcome.remaining.length > 0) { + const reason = !outcome.verified + ? copy.bulkUnverified + : outcome.firstFailure + ? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, locale) + : copy.bulkFailedBody(outcome.remaining.length); + toastApi.error( + copy.bulkDeleteFailedTitle, + kept ? `${reason} ${kept}` : reason, + undefined, + outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, + ); + } else { + toastApi.success(copy.bulkDeletedTitle(outcome.removed), kept); + } + } finally { + setBusy(false); + // Whatever survived is reconciled by the catalog refresh the sweep + // already ran; clearing here is about the request, which is answered + // either way. Leaving a marked set behind after a destructive sweep + // invites a second click on rows that may no longer exist. + setSelection(EMPTY_SESSION_SELECTION); + } + }, + [commands, copy, locale, toastApi], + ); + + const onArchiveSelected = useCallback(() => runSweep('archive'), [runSweep]); + const onDeleteSelected = useCallback(() => runSweep('delete'), [runSweep]); + + return useMemo( + () => ({ + selectedIds: selection.selectedIds, + onGesture, + onClear, + onArchiveSelected, + onDeleteSelected, + busy, + }), + [busy, onArchiveSelected, onClear, onDeleteSelected, onGesture, selection.selectedIds], + ); +} 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..ba5ac5f5eb --- /dev/null +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts @@ -0,0 +1,123 @@ +/* + * 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 selected for a bulk action, and where a range would start. + * + * The anchor is the last row toggled, not the last row selected: a range is + * measured from the row the user last acted on, which is what makes a second + * Shift-click re-measure rather than accumulate. + */ +export interface SessionSelection { + readonly selectedIds: ReadonlySet; + readonly anchorId?: string; +} + +export const EMPTY_SESSION_SELECTION: SessionSelection = Object.freeze({ + selectedIds: Object.freeze(new Set()) as ReadonlySet, +}); + +/** + * One click on a row, as the rail saw it. + * + * `groupSessionIds` is the rendered order of the group the row sits in, and a + * range never leaves it. The rail cannot do better: a project group's collapsed + * state lives inside Astryx's `SideNavItem` and is not readable from here, so a + * range spanning groups could silently include rows nobody can see. Within one + * group the question does not arise — both endpoints had to be clicked, and a + * row that can be clicked is a row that is on screen. + */ +export interface SessionSelectionGesture { + readonly sessionId: string; + readonly mode: 'toggle' | 'range'; + readonly groupSessionIds: readonly string[]; +} + +export function applySessionSelectionGesture( + selection: SessionSelection, + gesture: SessionSelectionGesture, +): SessionSelection { + if (gesture.mode === 'toggle') return toggleSession(selection, gesture.sessionId); + return extendRange(selection, gesture); +} + +function toggleSession(selection: SessionSelection, sessionId: string): SessionSelection { + const selectedIds = new Set(selection.selectedIds); + if (selectedIds.has(sessionId)) { + selectedIds.delete(sessionId); + // The anchor follows the row the user last acted on even when that act was + // a removal: leaving it on a row no longer in the selection would measure + // the next range from somewhere the user cannot see marked. + return { selectedIds, anchorId: sessionId }; + } + selectedIds.add(sessionId); + return { selectedIds, anchorId: sessionId }; +} + +function extendRange( + selection: SessionSelection, + gesture: SessionSelectionGesture, +): SessionSelection { + const targetIndex = gesture.groupSessionIds.indexOf(gesture.sessionId); + // A row the rail did not list in this group is not a range endpoint. This is + // the shape a stale render produces, and adding it alone would be a silent + // half-answer to a request for a span. + if (targetIndex === -1) return selection; + const anchorIndex = + selection.anchorId === undefined ? -1 : gesture.groupSessionIds.indexOf(selection.anchorId); + // No anchor, or an anchor in another group: this click IS the anchor. Shift + // on a fresh rail selects the one row rather than nothing, which is what + // makes the modifier discoverable by trying it. + if (anchorIndex === -1) { + return { selectedIds: new Set([...selection.selectedIds, gesture.sessionId]), anchorId: gesture.sessionId }; + } + const from = Math.min(anchorIndex, targetIndex); + const to = Math.max(anchorIndex, targetIndex); + const selectedIds = new Set(selection.selectedIds); + for (const sessionId of gesture.groupSessionIds.slice(from, to + 1)) selectedIds.add(sessionId); + // The anchor stays put. Re-anchoring on the target is what turns a corrected + // range — Shift-click one row too far, then Shift-click back — into two + // unions that can never shrink. + return { selectedIds, anchorId: selection.anchorId }; +} + +/** + * 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; + const anchorId = + selection.anchorId !== undefined && listed.has(selection.anchorId) + ? selection.anchorId + : undefined; + return selectedIds.size === 0 ? EMPTY_SESSION_SELECTION : { selectedIds, anchorId }; +} diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index 283e209bcc..36eb1419ce 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -41,6 +41,13 @@ 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 { + applySessionSelectionGesture, + EMPTY_SESSION_SELECTION, + pruneSessionSelection, + type SessionSelection, + type SessionSelectionGesture, +} 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..0405401d4a 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,7 @@ 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..bd41681ebb 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -274,6 +274,20 @@ 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; }; skillActions: { refreshSkillsFailedTitle: string; @@ -902,6 +916,18 @@ 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: '无法确认处理结果,请刷新后查看。', }, skillActions: { refreshSkillsFailedTitle: '刷新技能失败', @@ -1433,6 +1459,19 @@ 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.', }, 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..95c02b53f7 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -530,3 +530,44 @@ 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 sits in the sticky top region, below the grouping switch, and takes + * the same block padding so the three chrome rows keep one rhythm. The hairline + * is what separates a set of commands from the list they act on; without it the + * count reads as the first row of the list. + */ +.maka-session-selection-bar { + padding-block: var(--spacing-1) var(--spacing-2); + border-block-end: var(--border-width-hairline) solid var(--border-soft); +} + +/* Pushes the actions to the trailing edge, so the count keeps the leading one + whatever the locale does to the button labels' width. */ +.maka-session-selection-bar-spacer { + flex: 1 1 auto; +} 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..036c4d6767 --- /dev/null +++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx @@ -0,0 +1,350 @@ +/* + * 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 SessionRailSelection, + type SessionRailSelectionGesture, +} 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 = { + gestures: SessionSelectionGestureLog[]; + opened: string[]; + cleared: number; + deleteRequests: number; + pressKey(key: string, focusedSessionId?: string): Promise; + dispose(): Promise; + clickRow(sessionId: string, modifiers?: Partial): Promise; + document: Document; +}; + +type SessionSelectionGestureLog = SessionRailSelectionGesture; + +async function mount(options: { selectedIds?: readonly string[] } = {}): 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 gestures: SessionSelectionGestureLog[] = []; + const opened: string[] = []; + let cleared = 0; + let deleteRequests = 0; + const selection: SessionRailSelection = { + selectedIds: new Set(options.selectedIds ?? []), + onGesture: (gesture) => gestures.push(gesture), + onClear: () => { + cleared += 1; + }, + 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 = { + gestures, + opened, + get cleared() { + return cleared; + }, + 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)); + }); + }, + 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']); + assert.deepEqual(harness.gestures, []); + } finally { + await harness.dispose(); + } +}); + +test('a modified click marks the row instead of opening it', async () => { + const harness = await mount(); + try { + await harness.clickRow('b', { metaKey: true }); + // Not opened: navigating away from what the user is marking is the whole + // failure this branch exists to prevent. + assert.deepEqual(harness.opened, []); + assert.deepEqual(harness.gestures, [ + { sessionId: 'b', mode: 'toggle', groupSessionIds: ['a', 'b', 'c'] }, + ]); + } finally { + await harness.dispose(); + } +}); + +test('Shift reports a range and carries the group it may reach across', async () => { + const harness = await mount(); + try { + await harness.clickRow('c', { shiftKey: true }); + assert.deepEqual(harness.opened, []); + assert.deepEqual(harness.gestures, [ + { sessionId: 'c', mode: 'range', groupSessionIds: ['a', 'b', 'c'] }, + ]); + } finally { + await harness.dispose(); + } +}); + +test('Ctrl toggles too, for a rail that is not on a Mac', async () => { + const harness = await mount(); + try { + await harness.clickRow('a', { ctrlKey: true }); + assert.equal(harness.gestures[0]?.mode, 'toggle'); + } 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 a selection', async () => { + const harness = await mount({ selectedIds: ['b'] }); + try { + await harness.pressKey('Escape'); + assert.equal(harness.cleared, 1); + } finally { + await harness.dispose(); + } +}); + +test('Escape with nothing marked is not this handler\'s business', async () => { + const harness = await mount(); + try { + await harness.pressKey('Escape'); + assert.equal(harness.cleared, 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({ selectedIds: ['a', 'c'] }); + try { + await harness.pressKey('Delete'); + assert.equal(harness.deleteRequests, 1); + } finally { + await harness.dispose(); + } +}); diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index bfde2290b7..2292291ce5 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -28,10 +28,12 @@ export { ModuleHubSelector } from './module-hub-selector.js'; export type { ModuleHubHeader } from './module-hub-selector.js'; export { SearchModal } from './search-modal.js'; export { SessionListPanel } from './session-list-panel.js'; -export { SessionRailProvider } from './session-rail-context.js'; +export { SessionRailProvider, sessionSelectionGestureMode } from './session-rail-context.js'; export type { SessionRailChrome, SessionRailData, + SessionRailSelection, + SessionRailSelectionGesture, 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..c038881624 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -423,6 +423,12 @@ export interface ConversationCopy { promptRailAriaLabel: string; emptyPrompt: string; jumpToPrompt: (preview: string) => string; + selectionBarAriaLabel: string; + selectedCount: (count: number) => string; + selectionHint: string; + selectionArchive: string; + selectionDelete: string; + selectionClear: string; }; } @@ -547,7 +553,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}`, selectionBarAriaLabel: '已选任务的批量操作', selectedCount: (count) => `已选 ${count} 项`, selectionHint: '按住 ⌘ 或 Ctrl 点选,按住 Shift 选一段', selectionArchive: '归档', selectionDelete: '删除', selectionClear: '取消选择', }, }, en: { @@ -696,7 +702,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}`, selectionBarAriaLabel: 'Bulk actions for selected tasks', selectedCount: (count) => `${count} selected`, selectionHint: 'Hold ⌘ or Ctrl to pick rows, Shift to pick a run', selectionArchive: 'Archive', selectionDelete: 'Delete', selectionClear: 'Clear', }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index 2ad18d9747..b80a404a07 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -60,7 +60,11 @@ 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 { + sessionSelectionGestureMode, + useSessionRailData, + 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 +138,34 @@ 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 marked = selection !== null && selection.selectedIds.size > 0; + if (event.key === 'Escape') { + // Without this the only way out of a selection is un-clicking every row, + // and a modifier pressed by accident becomes a mode the user is stuck in. + if (!marked) return; + event.preventDefault(); + selection?.onClear(); + 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]', ); @@ -241,10 +267,11 @@ function SessionListGroups(props: { // any one of them changing identity upstream rebuilt every row. It arrives as // `rail` now, so this array says what it always meant (#4109). const renderSessionRow = useCallback( - (session: SessionSummary): ReactNode => ( + (session: SessionSummary, groupSessionIds: readonly string[]): ReactNode => ( {renameDialog} {props.groups.map((group) => { - const items = group.sessions.map((session) => renderSessionRow(session)); + // 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 groupSessionIds = group.sessions.map((session) => session.id); + const items = group.sessions.map((session) => renderSessionRow(session, groupSessionIds)); if (!group.label) { return (
@@ -352,7 +382,7 @@ function ProjectNavRow(props: { streamingSessionIds?: ReadonlySet; projectActions?: ProjectRowActions; onStartRename(opener: HTMLElement | null): void; - renderSession(session: SessionSummary): ReactNode; + renderSession(session: SessionSummary, groupSessionIds: readonly string[]): ReactNode; }) { const containerRef = useRef(null); const hoverDescriptionId = useId(); @@ -369,6 +399,13 @@ function ProjectNavRow(props: { // still truthy children for Astryx (!!children) and fabricates a disclosure. const hasSessions = props.sessions.length > 0; const hasActions = props.project !== undefined && props.projectActions !== undefined; + // Hoisted and memoized: built inside the row loop it would be rebuilt once + // per row, and each row would receive a new array identity — which is + // exactly what `SessionNavRow`'s memo exists to avoid. + const groupSessionIds = useMemo( + () => props.sessions.map((session) => session.id), + [props.sessions], + ); return (
{/* 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, groupSessionIds))} + ) : undefined} (null); const hoverDescriptionId = useId(); const locale = useUiLocale(); + // Read here rather than passed down: `renderSessionRow` is memoized on the + // rail value, so threading the selection through it would rebuild every row + // on every click — the thing #4109 took the props out of the tree to stop. + const selection = useSessionRailSelection(); + const marked = selection?.selectedIds.has(props.session.id) ?? false; const copy = getConversationCopy(locale).sessions; const signals = sessionRowSignals( props.session, @@ -496,6 +542,7 @@ 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} > { + // A modified click marks the row instead of opening it. Checked + // before the double-click branch: Shift-clicking a range lands two + // clicks on the same row often enough that a rename dialog in the + // middle of a selection is a real outcome, not a corner case. + const mode = selection ? sessionSelectionGestureMode(event) : undefined; + if (mode && selection) { + selection.onGesture({ + sessionId: props.session.id, + mode, + groupSessionIds: props.groupSessionIds, + }); + return; + } if (event.detail > 1 && props.actions) { props.onStartRename( { diff --git a/packages/ui/src/session-list-panel.tsx b/packages/ui/src/session-list-panel.tsx index 1f7522162c..fb2717b1b9 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, @@ -124,6 +125,13 @@ export function SessionListPanel() { <> {groupingSwitch} + {/* Sticky, with the rest of the permanent chrome. A bar that + scrolled with the list would be one the user has to scroll back + to after marking rows at the bottom — the case multi-select + exists for. It renders nothing until rows are marked, and the + list element below is a constant, so a selection change + re-renders this panel and skips the ~1,000 fibers under it. */} + {!collapsed ? : null} } footer={} diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx index ced9c8e13e..d9043da1df 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -92,8 +92,68 @@ export interface SessionRailChrome { }; } +/** + * One row's click, reported rather than interpreted. + * + * The rail says what was clicked and with which modifier; the host decides what + * that means for its selection. `groupSessionIds` is the rendered order of the + * group the row sits in, which is as far as a range can reach: a project + * group's collapsed state lives inside Astryx's `SideNavItem` and is not + * readable here, so a range across groups could quietly include rows nobody can + * see. Inside one group the question never arises — both endpoints had to be + * clicked, and a row that can be clicked is a row that is on screen. + */ +export interface SessionRailSelectionGesture { + sessionId: string; + mode: 'toggle' | 'range'; + groupSessionIds: readonly string[]; +} + +/** + * What the rail has marked for a bulk action. + * + * A THIRD context, for the reason the chrome is a second one: this changes on + * every modified click while the list does not, and folding it into + * `SessionRailData` would give that value a new identity per click — which is + * the ~1,000-fiber render the split exists to prevent (#4109). Rows that read + * this context still re-render on a selection change; that is the cost, and it + * is bounded by the rows on screen rather than by the shell. + * + * 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 { + selectedIds: ReadonlySet; + onGesture(gesture: SessionRailSelectionGesture): void; + onClear(): void; + onArchiveSelected(): void | Promise; + onDeleteSelected(): void | Promise; + /** A sweep is running. The bar disables while one is, so a second click + * cannot ask for the same set twice. */ + busy?: boolean; +} + +/** + * Which selection gesture a click's modifiers ask for, if any. + * + * An unmodified click is not a gesture: the rail is a navigation surface first + * and must keep opening the task. Shift outranks the toggle modifier when both + * are held — a range is the more specific request, and answering it with a + * toggle drops what was asked for. + */ +export function sessionSelectionGestureMode(modifiers: { + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; +}): SessionRailSelectionGesture['mode'] | undefined { + if (modifiers.shiftKey) return 'range'; + if (modifiers.metaKey || modifiers.ctrlKey) return 'toggle'; + return undefined; +} + const SessionRailDataContext = createContext(null); const SessionRailChromeContext = createContext(null); +const SessionRailSelectionContext = createContext(null); /** * `chrome` is optional so the list can be rendered on its own — a test or a @@ -103,12 +163,15 @@ const SessionRailChromeContext = createContext(null); export function SessionRailProvider(props: { data: SessionRailData; chrome?: SessionRailChrome; + selection?: SessionRailSelection; children?: ReactNode; }) { return ( - {props.children} + + {props.children} + ); @@ -125,3 +188,11 @@ 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); +} diff --git a/packages/ui/src/session-selection-bar.tsx b/packages/ui/src/session-selection-bar.tsx new file mode 100644 index 0000000000..1673c170bb --- /dev/null +++ b/packages/ui/src/session-selection-bar.tsx @@ -0,0 +1,83 @@ +/* + * 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 { HStack } from '@astryxdesign/core/HStack'; +import { Text } from '@astryxdesign/core/Text'; +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'; + +/** + * What the rail shows once rows are marked. + * + * It lives in the rail's sticky top region rather than above the rows: a bar + * that scrolls away with the list is a bar the user has to scroll back to after + * marking the rows at the bottom, which is exactly the case multi-select is for. + * + * Nothing renders until something is marked. The rail at rest is unchanged, so + * the count is the only thing that ever announces itself, and it announces a + * number the user produced. + */ +export function SessionSelectionBar() { + const selection = useSessionRailSelection(); + const copy = getConversationCopy(useUiLocale()).sessions; + const count = selection?.selectedIds.size ?? 0; + if (!selection || count === 0) return null; + const busy = selection.busy === true; + return ( +
+ + {/* `aria-live` so the count reaches a screen reader as it changes: the + bar is not focused while the user is clicking rows, so nothing else + would say how many are marked. */} + + {copy.selectedCount(count)} + + +
+ ); +} From fa7942e7db60b72149af9241573f4986285137ae Mon Sep 17 00:00:00 2001 From: Joob1n Date: Mon, 31 Aug 2026 22:35:36 +0800 Subject: [PATCH 2/8] feat(desktop): make the rail's multi-select an explicit mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the first design found it undiscoverable and, at the rail's real width, broken. ⌘/Shift-click is invisible to anyone who has not been told about it, and the bar put a count and three text buttons on one line: at 244px the buttons measured 228px and the count was squeezed to 12px, where it wrapped one character per line. Selection is now a mode. It is entered from the row's ⋯ menu — where a person already looks for what a row can do — and while it is on, every row carries a checkbox and a master box sits above them showing "已选 1 / 3". The master box ticks and unticks every listed row and reads `indeterminate` in between, which is the usual state during a selection and the one a checked/unchecked pair cannot express. "All" means the rows the rail is listing, not every task in the catalog. A box that silently reached past what sits under it would name a number the user never agreed to. 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 in. Pruning to nothing keeps the mode for the same reason — the rows went away because the catalog changed, not because the user was finished. Escape and the 取消 button leave. The bar heads the LIST rather than the chrome, and moved out of SideNav's `topContent` to say so. Up there it landed above the one hairline SideNav draws under the whole sticky region — grouped with 按时间 / 按项目 and cut off from the rows it governs, with its own rule making a second line 9px from the first. It is `position: sticky` inside the scroller instead, so it still does not scroll away, over the rail's own ground colour. Two rows, not one, because one does not fit. The commands are `secondary` rather than `ghost`: a bare label beside a checkbox and a count does not read as something to press. Generated-by: Claude Opus 5 via Claude Code --- .../session-navigation-selection.test.ts | 74 ++++++++++++- .../controller/use-session-selection.ts | 62 ++++++++++- .../model/session-selection.ts | 68 +++++++++++- .../features/session-navigation/testing.ts | 4 + apps/desktop/src/renderer/styles/sidebar.css | 99 +++++++++++++++-- .../session-history-multi-select.test.tsx | 102 +++++++++++++++--- packages/ui/src/conversation-copy.ts | 9 +- packages/ui/src/session-history-list.tsx | 49 ++++++++- packages/ui/src/session-list-panel.tsx | 27 +++-- packages/ui/src/session-rail-context.tsx | 16 ++- packages/ui/src/session-selection-bar.tsx | 83 ++++++++------ 11 files changed, 512 insertions(+), 81 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts b/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts index 3c3895f52c..f4edc1eaa8 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts @@ -23,7 +23,11 @@ import { sessionSelectionGestureMode } from '@maka/ui'; import { applySessionSelectionGesture, EMPTY_SESSION_SELECTION, + enterSessionSelection, + exitSessionSelection, pruneSessionSelection, + sessionSelectionMasterState, + setAllSessionsSelected, type SessionSelection, } from '../../renderer/features/session-navigation/testing.js'; @@ -159,8 +163,74 @@ describe('pruneSessionSelection', () => { assert.equal(pruneSessionSelection(selection, ['a', 'b']), selection); }); - test('an emptied selection settles on the shared empty value', () => { + 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 selection = toggle(EMPTY_SESSION_SELECTION, 'a'); - assert.equal(pruneSessionSelection(selection, []), EMPTY_SESSION_SELECTION); + const pruned = pruneSessionSelection(selection, []); + assert.deepEqual(ids(pruned), []); + assert.equal(pruned.active, true); + }); +}); + +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', () => { + const marked = toggle(EMPTY_SESSION_SELECTION, 'b'); + assert.equal(marked.active, true); + 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(toggle(EMPTY_SESSION_SELECTION, 'a'), GROUP, true); + const none = setAllSessionsSelected(all, GROUP, false); + assert.deepEqual(ids(none), []); + assert.equal(none.active, true); + }); + + test('pruning to nothing keeps the mode', () => { + // The rows went away because the catalog changed, not because the user was + // finished. + const pruned = pruneSessionSelection(toggle(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. + const all = setAllSessionsSelected(EMPTY_SESSION_SELECTION, ['a', 'b'], true); + assert.deepEqual(ids(all), ['a', 'b']); + }); + + test('reads unchecked, indeterminate, then checked', () => { + assert.equal(sessionSelectionMasterState(EMPTY_SESSION_SELECTION, GROUP), false); + assert.equal(sessionSelectionMasterState(toggle(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', () => { + const stray = toggle(EMPTY_SESSION_SELECTION, 'zzz'); + assert.equal(sessionSelectionMasterState(stray, GROUP), 'indeterminate'); }); }); diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts index fea6f0c3b0..4378552fa2 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts @@ -25,7 +25,10 @@ import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell import { applySessionSelectionGesture, EMPTY_SESSION_SELECTION, + enterSessionSelection, + exitSessionSelection, pruneSessionSelection, + setAllSessionsSelected, } from '../model/session-selection.js'; import type { SessionNavigationRowActions } from './session-row-actions.js'; import type { SessionNavigationToastApi } from './use-session-navigation-controller.js'; @@ -66,13 +69,43 @@ export function useSessionSelection(input: { useLayoutEffect(() => { selectionRef.current = selection; busyRef.current = busy; + listedRef.current = listedSessionIds; }); + const listedSessionIds = useMemo(() => sessions.map((session) => session.id), [sessions]); + const listedRef = useRef(listedSessionIds); + const onGesture = useCallback((gesture) => { setSelection((current) => applySessionSelectionGesture(current, gesture)); }, []); - const onClear = useCallback(() => setSelection(EMPTY_SESSION_SELECTION), []); + 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, anchorId: sessionId }; + }); + }, []); + + const onEnter = useCallback((sessionId) => { + setSelection((current) => { + const entered = enterSessionSelection(current); + if (sessionId === undefined) return entered; + return { + active: true, + selectedIds: new Set([...entered.selectedIds, sessionId]), + anchorId: sessionId, + }; + }); + }, []); + + const onExit = useCallback(() => setSelection(exitSessionSelection()), []); + + const onToggleAll = useCallback((selected) => { + setSelection((current) => setAllSessionsSelected(current, listedRef.current, selected)); + }, []); const runSweep = useCallback( async (kind: 'archive' | 'delete') => { @@ -142,7 +175,11 @@ export function useSessionSelection(input: { // already ran; clearing here is about the request, which is answered // either way. Leaving a marked set behind after a destructive sweep // invites a second click on rows that may no longer exist. - setSelection(EMPTY_SESSION_SELECTION); + // + // 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) => ({ active: current.active, selectedIds: new Set() })); } }, [commands, copy, locale, toastApi], @@ -153,13 +190,30 @@ export function useSessionSelection(input: { return useMemo( () => ({ + active: selection.active, selectedIds: selection.selectedIds, + listedSessionIds, onGesture, - onClear, + onToggleRow, + onEnter, + onExit, + onToggleAll, onArchiveSelected, onDeleteSelected, busy, }), - [busy, onArchiveSelected, onClear, onDeleteSelected, onGesture, selection.selectedIds], + [ + busy, + listedSessionIds, + onArchiveSelected, + onDeleteSelected, + onEnter, + onExit, + onGesture, + onToggleAll, + onToggleRow, + selection.active, + selection.selectedIds, + ], ); } 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 index ba5ac5f5eb..cbeb9b5916 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts @@ -25,11 +25,21 @@ * Shift-click re-measure rather than accumulate. */ 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; readonly anchorId?: string; } export const EMPTY_SESSION_SELECTION: SessionSelection = Object.freeze({ + active: false, selectedIds: Object.freeze(new Set()) as ReadonlySet, }); @@ -64,10 +74,10 @@ function toggleSession(selection: SessionSelection, sessionId: string): SessionS // The anchor follows the row the user last acted on even when that act was // a removal: leaving it on a row no longer in the selection would measure // the next range from somewhere the user cannot see marked. - return { selectedIds, anchorId: sessionId }; + return { active: true, selectedIds, anchorId: sessionId }; } selectedIds.add(sessionId); - return { selectedIds, anchorId: sessionId }; + return { active: true, selectedIds, anchorId: sessionId }; } function extendRange( @@ -85,7 +95,11 @@ function extendRange( // on a fresh rail selects the one row rather than nothing, which is what // makes the modifier discoverable by trying it. if (anchorIndex === -1) { - return { selectedIds: new Set([...selection.selectedIds, gesture.sessionId]), anchorId: gesture.sessionId }; + return { + active: true, + selectedIds: new Set([...selection.selectedIds, gesture.sessionId]), + anchorId: gesture.sessionId, + }; } const from = Math.min(anchorIndex, targetIndex); const to = Math.max(anchorIndex, targetIndex); @@ -94,7 +108,7 @@ function extendRange( // The anchor stays put. Re-anchoring on the target is what turns a corrected // range — Shift-click one row too far, then Shift-click back — into two // unions that can never shrink. - return { selectedIds, anchorId: selection.anchorId }; + return { active: true, selectedIds, anchorId: selection.anchorId }; } /** @@ -119,5 +133,49 @@ export function pruneSessionSelection( selection.anchorId !== undefined && listed.has(selection.anchorId) ? selection.anchorId : undefined; - return selectedIds.size === 0 ? EMPTY_SESSION_SELECTION : { selectedIds, anchorId }; + // 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, anchorId }; +} + +/** 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), + anchorId: selection.anchorId, + }; +} + +/** 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 36eb1419ce..ecd40810d3 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -44,7 +44,11 @@ export { deriveSessionRevisionNavigation } from './model/session-revisions.js'; export { applySessionSelectionGesture, EMPTY_SESSION_SELECTION, + enterSessionSelection, + exitSessionSelection, pruneSessionSelection, + sessionSelectionMasterState, + setAllSessionsSelected, type SessionSelection, type SessionSelectionGesture, } from './model/session-selection.js'; diff --git a/apps/desktop/src/renderer/styles/sidebar.css b/apps/desktop/src/renderer/styles/sidebar.css index 95c02b53f7..bc56960aa3 100644 --- a/apps/desktop/src/renderer/styles/sidebar.css +++ b/apps/desktop/src/renderer/styles/sidebar.css @@ -556,18 +556,101 @@ } /* - * The bar sits in the sticky top region, below the grouping switch, and takes - * the same block padding so the three chrome rows keep one rhythm. The hairline - * is what separates a set of commands from the list they act on; without it the - * count reads as the first row of the list. + * 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); - border-block-end: var(--border-width-hairline) solid var(--border-soft); + /* + * 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); } -/* Pushes the actions to the trailing edge, so the count keeps the leading one - whatever the locale does to the button labels' width. */ -.maka-session-selection-bar-spacer { +/* + * 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/packages/ui/src/__tests__/session-history-multi-select.test.tsx b/packages/ui/src/__tests__/session-history-multi-select.test.tsx index 036c4d6767..2963546c2e 100644 --- a/packages/ui/src/__tests__/session-history-multi-select.test.tsx +++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx @@ -107,17 +107,23 @@ const SESSIONS = ['a', 'b', 'c'].map(summary); type Harness = { gestures: SessionSelectionGestureLog[]; opened: string[]; - cleared: number; + 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; }; type SessionSelectionGestureLog = SessionRailSelectionGesture; -async function mount(options: { selectedIds?: readonly string[] } = {}): Promise { +async function mount( + options: { selectedIds?: readonly string[]; active?: boolean } = {}, +): Promise { const original = { document: globalThis.document, window: globalThis.window, @@ -131,14 +137,22 @@ async function mount(options: { selectedIds?: readonly string[] } = {}): Promise const gestures: SessionSelectionGestureLog[] = []; const opened: string[] = []; - let cleared = 0; + const toggles: Array<[string, boolean]> = []; + const toggleAll: boolean[] = []; + const entered: Array = []; + let exits = 0; let deleteRequests = 0; const selection: SessionRailSelection = { + active: options.active ?? false, selectedIds: new Set(options.selectedIds ?? []), + listedSessionIds: SESSIONS.map((session) => session.id), onGesture: (gesture) => gestures.push(gesture), - onClear: () => { - cleared += 1; + onToggleRow: (sessionId, selected) => toggles.push([sessionId, selected]), + onEnter: (sessionId) => entered.push(sessionId), + onExit: () => { + exits += 1; }, + onToggleAll: (selected) => toggleAll.push(selected), onArchiveSelected: () => undefined, onDeleteSelected: () => { deleteRequests += 1; @@ -167,8 +181,11 @@ async function mount(options: { selectedIds?: readonly string[] } = {}): Promise const harness: Harness = { gestures, opened, - get cleared() { - return cleared; + toggles, + toggleAll, + entered, + get exits() { + return exits; }, get deleteRequests() { return deleteRequests; @@ -200,6 +217,19 @@ async function mount(options: { selectedIds?: readonly string[] } = {}): Promise 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); @@ -316,21 +346,21 @@ test('a rail with no selection wired up behaves exactly as before', async () => } }); -test('Escape leaves a selection', async () => { - const harness = await mount({ selectedIds: ['b'] }); +test('Escape leaves the mode', async () => { + const harness = await mount({ active: true, selectedIds: ['b'] }); try { await harness.pressKey('Escape'); - assert.equal(harness.cleared, 1); + assert.equal(harness.exits, 1); } finally { await harness.dispose(); } }); -test('Escape with nothing marked is not this handler\'s business', async () => { +test('Escape outside the mode is not this handler\'s business', async () => { const harness = await mount(); try { await harness.pressKey('Escape'); - assert.equal(harness.cleared, 0); + assert.equal(harness.exits, 0); } finally { await harness.dispose(); } @@ -340,7 +370,7 @@ 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({ selectedIds: ['a', 'c'] }); + const harness = await mount({ active: true, selectedIds: ['a', 'c'] }); try { await harness.pressKey('Delete'); assert.equal(harness.deleteRequests, 1); @@ -348,3 +378,49 @@ test('Delete asks for the marked set, not the focused row', async () => { 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/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index c038881624..502b9aeb50 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -423,9 +423,10 @@ export interface ConversationCopy { promptRailAriaLabel: string; emptyPrompt: string; jumpToPrompt: (preview: string) => string; + selectRow: string; selectionBarAriaLabel: string; - selectedCount: (count: number) => string; - selectionHint: string; + selectedCount: (selected: number, total: number) => string; + selectAllAriaLabel: string; selectionArchive: string; selectionDelete: string; selectionClear: string; @@ -553,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}`, selectionBarAriaLabel: '已选任务的批量操作', selectedCount: (count) => `已选 ${count} 项`, selectionHint: '按住 ⌘ 或 Ctrl 点选,按住 Shift 选一段', selectionArchive: '归档', selectionDelete: '删除', selectionClear: '取消选择', + listAriaLabel: '任务列表', showMore: '显示更多', showMoreAriaLabel: (count) => `显示 ${count} 条更多任务`, renameAriaLabel: '重命名任务', renameProjectTitle: '重命名项目', renameSubmit: '保存', respondingAriaLabel: '正在响应', respondingTitle: '任务正在流式响应中', staleTitle: '此任务使用的模型连接已不可用,发送时会切换到默认连接', staleAriaLabel: '任务已过期', stale: '已过期', unreadAriaLabel: '未读消息', actionsAriaLabel: (name) => `${name} 任务操作`, pin: '置顶', unpin: '取消置顶', rename: '重命名', archive: '归档', unarchive: '取消归档', delete: '删除', pinned: '置顶', recent: '最近', groupByTime: '按时间', groupByProject: '按项目', groupingAriaLabel: '任务分组方式', projectActionsAriaLabel: (name) => `${name} 项目操作`, projectNewTask: '新建任务', projectRename: '重命名', projectArchive: '归档', projectRestore: '恢复', projectRelink: '重新定位', projectUnavailable: '项目目录不可用', archivedProjects: '已归档项目', archivedProjectsAriaLabel: '展开已归档项目', worktreeAriaLabel: 'Git 工作树', promptRailAriaLabel: '按提问跳转', emptyPrompt: '(空提问)', jumpToPrompt: (preview) => `跳到提问:${preview}`, selectRow: '选择', selectionBarAriaLabel: '已选任务的批量操作', selectedCount: (selected, total) => `已选 ${selected} / ${total}`, selectAllAriaLabel: '全选或全不选', selectionArchive: '归档', selectionDelete: '删除', selectionClear: '取消', }, }, en: { @@ -702,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}`, selectionBarAriaLabel: 'Bulk actions for selected tasks', selectedCount: (count) => `${count} selected`, selectionHint: 'Hold ⌘ or Ctrl to pick rows, Shift to pick a run', selectionArchive: 'Archive', selectionDelete: 'Delete', selectionClear: 'Clear', + 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 b80a404a07..cdc2016456 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,6 +61,7 @@ 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 { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { sessionSelectionGestureMode, useSessionRailData, @@ -149,13 +151,15 @@ export function SessionHistoryList() { // effect of one keypress. const active = document.activeElement as HTMLElement | null; if (!active || active.matches('input, textarea, [contenteditable="true"]')) return; - const marked = selection !== null && selection.selectedIds.size > 0; + const selecting = selection?.active === true; + const marked = selecting && selection.selectedIds.size > 0; if (event.key === 'Escape') { - // Without this the only way out of a selection is un-clicking every row, - // and a modifier pressed by accident becomes a mode the user is stuck in. - if (!marked) return; + // 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?.onClear(); + selection.onExit(); return; } if (marked) { @@ -543,7 +547,23 @@ const SessionNavRow = memo(function SessionNavRow(props: { 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} )} @@ -991,9 +1012,15 @@ function ProjectItemActions(props: { function SessionItemActions(props: { session: SessionSummary; actions: SessionRowActions; + groupSessionIds: readonly string[]; onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void; }) { const trailingRef = useRef(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. + // Once one row is marked the bar teaches the modifier that extends it. + const selection = useSessionRailSelection(); const locale = useUiLocale(); const copy = getConversationCopy(locale).sessions; const actionContext = [ @@ -1054,6 +1081,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 fb2717b1b9..71bc4502b2 100644 --- a/packages/ui/src/session-list-panel.tsx +++ b/packages/ui/src/session-list-panel.tsx @@ -125,18 +125,29 @@ export function SessionListPanel() { <> {groupingSwitch} - {/* Sticky, with the rest of the permanent chrome. A bar that - scrolled with the list would be one the user has to scroll back - to after marking rows at the bottom — the case multi-select - exists for. It renders nothing until rows are marked, and the - list element below is a constant, so a selection change - re-renders this panel and skips the ~1,000 fibers under it. */} - {!collapsed ? : null} } 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 d9043da1df..0725c63498 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -123,12 +123,24 @@ export interface SessionRailSelectionGesture { * 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[]; onGesture(gesture: SessionRailSelectionGesture): void; - onClear(): void; + 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 bar disables while one is, so a second click + /** A sweep is running. The commands disable while one is, so a second click * cannot ask for the same set twice. */ busy?: boolean; } diff --git a/packages/ui/src/session-selection-bar.tsx b/packages/ui/src/session-selection-bar.tsx index 1673c170bb..5c36c440bf 100644 --- a/packages/ui/src/session-selection-bar.tsx +++ b/packages/ui/src/session-selection-bar.tsx @@ -18,66 +18,89 @@ */ import { Button } from '@astryxdesign/core/Button'; -import { HStack } from '@astryxdesign/core/HStack'; -import { Text } from '@astryxdesign/core/Text'; +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'; /** - * What the rail shows once rows are marked. + * The rail's selection mode, headed. * - * It lives in the rail's sticky top region rather than above the rows: a bar - * that scrolls away with the list is a bar the user has to scroll back to after - * marking the rows at the bottom, which is exactly the case multi-select is for. + * 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. * - * Nothing renders until something is marked. The rail at rest is unchanged, so - * the count is the only thing that ever announces itself, and it announces a - * number the user produced. + * "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; - const count = selection?.selectedIds.size ?? 0; - if (!selection || count === 0) return null; + 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 clicking rows, so nothing else + bar is not focused while the user is ticking rows, so nothing else would say how many are marked. */} - - {copy.selectedCount(count)} - - + + {copy.selectedCount(count, listed.length)} +
+
+
); } From 9097ea8fbc4d790fa2c2f953d28cc6eb0976ec34 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Mon, 31 Aug 2026 22:41:34 +0800 Subject: [PATCH 3/8] fix(desktop): keep the rail's bulk wording inside its budgeted module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's renderer architecture check refused the selection hook: features/session-navigation/controller/use-session-selection.ts: feature imports unbudgeted renderer legacy code: ../../../locales/shell-copy.js A feature may not reach into renderer legacy copy without a line in `renderer-architecture.json`, and `session-row-actions.ts` has one only because it predates the rule. Adding a second entry would grow the ledger the check exists to shrink, so the wording moved instead of the budget. `archiveSelected` and `deleteSelected` now live in `session-row-actions`, which already holds this feature's copy: each confirms, runs its sweep, and reports the outcome. The bare sweeps below them stay silent, because Settings' purge still phrases its own confirm — the caller that genuinely owns different wording. The rail's phrasing was never that caller; it is the feature's own, and it belongs where the feature keeps its strings. The hook keeps what is actually its business: the marked set, freezing it at the press, the busy flag, and clearing it afterwards while the mode stays on. It no longer needs a locale or a toast API at all. `npm run check:architecture` now passes locally, along with check:app-shell-hooks and check:asf-headers — gates this branch had never run before CI ran them for it. Generated-by: Claude Opus 5 via Claude Code --- .../controller/session-row-actions.ts | 71 +++++++++++++++ .../use-session-navigation-controller.ts | 20 +---- .../controller/use-session-selection.ts | 88 +++++-------------- 3 files changed, 93 insertions(+), 86 deletions(-) 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 c2bc7c0326..957bf652a2 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 @@ -107,6 +107,9 @@ export interface SessionNavigationRowActions { 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: { @@ -449,6 +452,72 @@ export function createSessionNavigationRowActions(deps: { 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. */ + async function deleteSelected(sessionIds: readonly string[]): Promise { + if (sessionIds.length === 0) return; + const ok = await toastApi.confirm({ + title: copy.bulkDeleteTitle(sessionIds.length), + description: 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; + if (outcome.verified && outcome.remaining.length === 0) { + toastApi.success(copy.bulkDeletedTitle(outcome.removed), kept); + 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, + kept ? `${reason} ${kept}` : reason, + undefined, + outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, + ); + } + return { flagSession, archiveSession, @@ -458,5 +527,7 @@ export function createSessionNavigationRowActions(deps: { 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 0ddaec7694..b22d5454dd 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 @@ -192,25 +192,7 @@ export function useSessionNavigationController( [groups, sessionMeta, worktreeSessionIds], ); - // The toast API is reached through the ref for the same reason the row - // actions reach it that way: the shell rebuilds it per render, and the - // selection's callbacks must not change identity with it. - const selectionToastApi = useMemo( - () => ({ - success: (title, description) => portsRef.current.toastApi.success(title, description), - error: (title, description, details, target) => - portsRef.current.toastApi.error(title, description, details, target), - confirm: (options) => portsRef.current.toastApi.confirm(options), - }), - [], - ); - - const selection = useSessionSelection({ - sessions: rail.sessions, - commands, - locale, - toastApi: selectionToastApi, - }); + const selection = useSessionSelection({ sessions: rail.sessions, commands }); return useMemo( () => ({ layout, selectors, commands, selection }), diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts index 4378552fa2..85e8322a89 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts @@ -19,9 +19,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { SessionSummary } from '@maka/core/session'; -import type { UiLocale } from '@maka/core/ui-locale'; import type { SessionRailSelection } from '@maka/ui'; -import { getShellCopy, localizedShellErrorMessage } from '../../../locales/shell-copy.js'; import { applySessionSelectionGesture, EMPTY_SESSION_SELECTION, @@ -31,7 +29,6 @@ import { setAllSessionsSelected, } from '../model/session-selection.js'; import type { SessionNavigationRowActions } from './session-row-actions.js'; -import type { SessionNavigationToastApi } from './use-session-navigation-controller.js'; /** * The rail's multi-select: which rows are marked, and the two sweeps they feed. @@ -44,13 +41,10 @@ import type { SessionNavigationToastApi } from './use-session-navigation-control export function useSessionSelection(input: { sessions: readonly SessionSummary[]; commands: SessionNavigationRowActions; - locale: UiLocale; - toastApi: SessionNavigationToastApi; }): SessionRailSelection { - const { sessions, commands, locale, toastApi } = input; + const { sessions, commands } = input; const [selection, setSelection] = useState(EMPTY_SESSION_SELECTION); const [busy, setBusy] = useState(false); - const copy = getShellCopy(locale).sessionRowActions; const listedIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]); useEffect(() => { @@ -107,74 +101,28 @@ export function useSessionSelection(input: { 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 (kind: 'archive' | 'delete') => { + async (run: (sessionIds: readonly string[]) => Promise) => { if (busyRef.current) return; - // 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. const sessionIds = [...selectionRef.current.selectedIds]; if (sessionIds.length === 0) return; - const confirmed = await toastApi.confirm({ - title: - kind === 'delete' - ? copy.bulkDeleteTitle(sessionIds.length) - : copy.bulkArchiveTitle(sessionIds.length), - description: - kind === 'delete' ? copy.bulkDeleteDescription : copy.bulkArchiveDescription, - confirmLabel: kind === 'delete' ? copy.deleteLabel : copy.bulkArchiveLabel, - cancelLabel: copy.cancelLabel, - destructive: kind === 'delete', - }); - if (!confirmed) return; setBusy(true); try { - if (kind === 'archive') { - const outcome = await commands.archiveSessions(sessionIds); - if (outcome.failed.length > 0) { - toastApi.error( - copy.bulkArchiveFailedTitle, - outcome.firstFailure - ? localizedShellErrorMessage( - outcome.firstFailure.error, - copy.actionFallback, - locale, - ) - : copy.bulkFailedBody(outcome.failed.length), - undefined, - outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, - ); - } else { - toastApi.success(copy.bulkArchivedTitle(outcome.archived)); - } - return; - } - const outcome = await commands.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; - if (!outcome.verified || outcome.remaining.length > 0) { - const reason = !outcome.verified - ? copy.bulkUnverified - : outcome.firstFailure - ? localizedShellErrorMessage(outcome.firstFailure.error, copy.actionFallback, locale) - : copy.bulkFailedBody(outcome.remaining.length); - toastApi.error( - copy.bulkDeleteFailedTitle, - kept ? `${reason} ${kept}` : reason, - undefined, - outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, - ); - } else { - toastApi.success(copy.bulkDeletedTitle(outcome.removed), kept); - } + await run(sessionIds); } finally { setBusy(false); // Whatever survived is reconciled by the catalog refresh the sweep // already ran; clearing here is about the request, which is answered // either way. Leaving a marked set behind after a destructive sweep - // invites a second click on rows that may no longer exist. + // invites a second press on rows that may no longer exist. // // 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 @@ -182,11 +130,17 @@ export function useSessionSelection(input: { setSelection((current) => ({ active: current.active, selectedIds: new Set() })); } }, - [commands, copy, locale, toastApi], + [], ); - const onArchiveSelected = useCallback(() => runSweep('archive'), [runSweep]); - const onDeleteSelected = useCallback(() => runSweep('delete'), [runSweep]); + const onArchiveSelected = useCallback( + () => runSweep((ids) => commands.archiveSelected(ids)), + [commands, runSweep], + ); + const onDeleteSelected = useCallback( + () => runSweep((ids) => commands.deleteSelected(ids)), + [commands, runSweep], + ); return useMemo( () => ({ From b00791542597d21f4e3be2c76168cb06d4a52a71 Mon Sep 17 00:00:00 2001 From: Joob1n Date: Mon, 31 Aug 2026 23:16:50 +0800 Subject: [PATCH 4/8] docs(desktop): record the selection bar in the Astryx surface inventory CI's Astryx surface gate failed on the new file: astryx surface inventory is stale - on disk but not in .paths (1): packages/ui/src/session-selection-bar.tsx The inventory tracks every renderer file that renders Astryx components and what each one reaches for, so a new component file has to be recorded. It is generated, not hand-written: this is the output of `npm run astryx:surface-inventory:write`, unedited. The new file lands as `aligned` (Button, CheckboxInput), and `session-history-list.tsx` gains `CheckboxInput` in its own row. No blocker or reimplementation entries appear. I had run the architecture gate this time but not this one. Rather than find the next gate the same way, I enumerated every `npm run` CI invokes and ran the ones this branch can affect: astryx:surface-inventory and its tests, astryx:theme, check:renderer-architecture (base mode), check:app-shell-hooks, check:asf-headers, format:check, lint, typecheck, and the @maka/ui and @maka/desktop suites. All pass, and astryx:theme leaves the tree clean. Generated-by: Claude Opus 5 via Claude Code --- docs/astryx-surface-file-inventory.md | 3 ++- docs/astryx-surface-file-inventory.paths | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d245447256..3afda4a28d 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -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 From fd9818463131866360a6165f11ab8eec42cb0b1e Mon Sep 17 00:00:00 2001 From: Joob1n Date: Mon, 31 Aug 2026 23:39:07 +0800 Subject: [PATCH 5/8] chore(desktop): stop exporting an unused selection type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's Knip gate found dead surface: Unused exported types (1) SessionSelectionGesture features/session-navigation/testing.ts I exported it beside the selection model for tests to use, and then the tests never did — they pass object literals, which TypeScript checks structurally against `applySessionSelectionGesture`'s own parameter. The type still exists where it is used; only the testing re-export is gone. Third gate this branch has failed one at a time, so this time I read the CI workflow and ran every step it invokes that this branch can affect: knip for both workspaces, script-entrypoints, the app-shell hook gate and its own test, astryx:theme --check, the app-icon drift tests, the Astryx surface inventory and its tests, check:renderer-architecture in base mode, check:asf-headers, lint, format:check, the full build, repo-wide typecheck, and the @maka/ui and @maka/desktop suites. All pass, and `npm run build` leaves the tree clean. Generated-by: Claude Opus 5 via Claude Code --- apps/desktop/src/renderer/features/session-navigation/testing.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index ecd40810d3..cc4f4fd0a8 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -50,7 +50,6 @@ export { sessionSelectionMasterState, setAllSessionsSelected, type SessionSelection, - type SessionSelectionGesture, } from './model/session-selection.js'; export { readSessionListViewMode, From bbac2fc7fe21173d238b8a2bfbeb39ff5a1be90d Mon Sep 17 00:00:00 2001 From: Joob1n Date: Tue, 1 Sep 2026 00:38:12 +0800 Subject: [PATCH 6/8] fix(desktop): keep one session switch off every rail row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's e2e render contract caught a real regression, and it is the very contract this branch claimed to respect: switching sessions does not rewrite the whole Session rail rail rows touched by one session switch, of 12 Expected: <= 2 Received: 12 Two independent causes, which is why fixing either alone left the count at 12 and why I measured them one at a time before believing either. **A per-render array prop.** Rows were handed their group's session ids so a Shift-click range could know where it may reach. Computed in the render — even once per group rather than once per row — that array has a new identity every time, and `SessionNavRow`'s `memo` compares props, not the identity of the factory that produced them. Memoizing it does not help: every candidate key is derived from `rail.sessions`, whose identity moves on a session switch, so the memo rebuilds exactly when it must not. The prop is gone, and with it the modifier-click gestures it existed for. The rail's selection is checkboxes and a master box now; ⌘-click was the earlier design's affordance and nothing in the current one needs a range. `SessionSelectionGesture`, the range and anchor in the model, and `sessionSelectionGestureMode` go with it. **A context every row subscribes to.** A context consumer re-renders when its value changes and `memo` cannot stop it, so a row reading the whole selection re-rendered whenever `listedSessionIds` moved — again, on every session switch. `SessionRailRowSelection` is now a separate, narrower context holding only what a row needs, memoized on the selection alone; the bar keeps the wide one. Verified by running the spec locally, which this branch had never done: failing at 12 before, `1 passed` after, and passing on `main` throughout. Also rebased onto current main, where `@maka/ui`'s test script is now `test:dist`. Two `app-update-attestation` cases fail on this machine with and without these changes — the packaged Electron runtime's TUF/Sigstore checks — so they are environmental, not this branch's. Generated-by: Claude Opus 5 via Claude Code --- .../session-navigation-selection.test.ts | 181 ++++-------------- .../use-session-navigation-controller.ts | 15 +- .../controller/use-session-selection.ts | 45 +++-- .../model/session-selection.ts | 89 +-------- .../features/session-navigation/testing.ts | 1 - .../ui/session-navigation-provider.tsx | 7 +- .../session-history-multi-select.test.tsx | 56 +----- packages/ui/src/components.tsx | 4 +- packages/ui/src/session-history-list.tsx | 88 ++++----- packages/ui/src/session-rail-context.tsx | 76 ++++---- 10 files changed, 162 insertions(+), 400 deletions(-) diff --git a/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts b/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts index f4edc1eaa8..c260776e8a 100644 --- a/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts +++ b/apps/desktop/src/main/__tests__/session-navigation-selection.test.ts @@ -19,9 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { sessionSelectionGestureMode } from '@maka/ui'; import { - applySessionSelectionGesture, EMPTY_SESSION_SELECTION, enterSessionSelection, exitSessionSelection, @@ -37,143 +35,14 @@ function ids(selection: SessionSelection): string[] { return [...selection.selectedIds].sort(); } -function toggle(selection: SessionSelection, sessionId: string): SessionSelection { - return applySessionSelectionGesture(selection, { - sessionId, - mode: 'toggle', - groupSessionIds: GROUP, - }); +/** 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]), + }; } -function range(selection: SessionSelection, sessionId: string, group = GROUP): SessionSelection { - return applySessionSelectionGesture(selection, { - sessionId, - mode: 'range', - groupSessionIds: group, - }); -} - -describe('sessionSelectionGestureMode', () => { - test('a plain click is not a selection gesture', () => { - // The rail is a navigation surface first: an unmodified click must keep - // opening the task, or selecting would cost the rail its primary job. - assert.equal( - sessionSelectionGestureMode({ metaKey: false, ctrlKey: false, shiftKey: false }), - undefined, - ); - }); - - test('either platform modifier toggles', () => { - assert.equal(sessionSelectionGestureMode({ metaKey: true, ctrlKey: false, shiftKey: false }), 'toggle'); - assert.equal(sessionSelectionGestureMode({ metaKey: false, ctrlKey: true, shiftKey: false }), 'toggle'); - }); - - test('shift wins over the toggle modifier', () => { - // A range is the more specific request; resolving both as a toggle would - // drop it. - assert.equal(sessionSelectionGestureMode({ metaKey: true, ctrlKey: false, shiftKey: true }), 'range'); - }); -}); - -describe('toggle', () => { - test('adds, removes, and anchors on the row last acted on', () => { - const one = toggle(EMPTY_SESSION_SELECTION, 'b'); - assert.deepEqual(ids(one), ['b']); - assert.equal(one.anchorId, 'b'); - - const two = toggle(one, 'd'); - assert.deepEqual(ids(two), ['b', 'd']); - assert.equal(two.anchorId, 'd'); - - const removed = toggle(two, 'b'); - assert.deepEqual(ids(removed), ['d']); - // The anchor follows a removal too: leaving it on a row that is no longer - // marked would measure the next range from somewhere invisible. - assert.equal(removed.anchorId, 'b'); - }); - - test('the input selection is never mutated', () => { - const one = toggle(EMPTY_SESSION_SELECTION, 'b'); - toggle(one, 'c'); - assert.deepEqual(ids(one), ['b']); - }); -}); - -describe('range', () => { - test('spans from the anchor in either direction', () => { - const anchored = toggle(EMPTY_SESSION_SELECTION, 'd'); - assert.deepEqual(ids(range(anchored, 'b')), ['b', 'c', 'd']); - assert.deepEqual(ids(range(toggle(EMPTY_SESSION_SELECTION, 'b'), 'd')), ['b', 'c', 'd']); - }); - - test('a corrected range does not accumulate', () => { - // Shift-click one row too far, then Shift-click back. Re-anchoring on each - // target would union the two spans and the selection could never shrink. - const anchored = toggle(EMPTY_SESSION_SELECTION, 'a'); - const tooFar = range(anchored, 'e'); - assert.deepEqual(ids(tooFar), ['a', 'b', 'c', 'd', 'e']); - const corrected = range(tooFar, 'b'); - assert.equal(corrected.anchorId, 'a'); - // The span itself is a..b; the rows the first Shift-click added stay - // selected because a range adds, and the anchor is what had to stay put. - assert.deepEqual(ids(corrected), ['a', 'b', 'c', 'd', 'e']); - }); - - test('shift on a fresh rail selects the row it lands on', () => { - // Otherwise the first Shift-click does nothing at all, and a modifier that - // appears broken is a modifier nobody tries twice. - const first = range(EMPTY_SESSION_SELECTION, 'c'); - assert.deepEqual(ids(first), ['c']); - assert.equal(first.anchorId, 'c'); - }); - - test('an anchor in another group re-anchors instead of spanning', () => { - // The two groups have no common order, so there is no span to compute. - const other = toggle(EMPTY_SESSION_SELECTION, 'a'); - const next = range(other, 'y', ['x', 'y', 'z']); - assert.deepEqual(ids(next), ['a', 'y']); - assert.equal(next.anchorId, 'y'); - }); - - test('a row the group does not list is not an endpoint', () => { - const anchored = toggle(EMPTY_SESSION_SELECTION, 'a'); - const next = range(anchored, 'missing'); - assert.deepEqual(ids(next), ['a']); - assert.equal(next.anchorId, 'a'); - }); -}); - -describe('pruneSessionSelection', () => { - test('drops ids the catalog no longer lists', () => { - const selection = toggle(toggle(EMPTY_SESSION_SELECTION, 'a'), 'b'); - const pruned = pruneSessionSelection(selection, ['a']); - assert.deepEqual(ids(pruned), ['a']); - }); - - test('clears an anchor that went with them', () => { - const selection = toggle(toggle(EMPTY_SESSION_SELECTION, 'a'), 'b'); - assert.equal(selection.anchorId, 'b'); - assert.equal(pruneSessionSelection(selection, ['a']).anchorId, undefined); - }); - - 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 for no change. - const selection = toggle(EMPTY_SESSION_SELECTION, 'a'); - assert.equal(pruneSessionSelection(selection, ['a', 'b']), selection); - }); - - 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 selection = toggle(EMPTY_SESSION_SELECTION, 'a'); - const pruned = pruneSessionSelection(selection, []); - assert.deepEqual(ids(pruned), []); - assert.equal(pruned.active, true); - }); -}); - describe('selection mode', () => { test('entering marks nothing on its own', () => { const entered = enterSessionSelection(EMPTY_SESSION_SELECTION); @@ -182,8 +51,6 @@ describe('selection mode', () => { }); test('leaving drops the mode and the marks together', () => { - const marked = toggle(EMPTY_SESSION_SELECTION, 'b'); - assert.equal(marked.active, true); assert.equal(exitSessionSelection().active, false); assert.deepEqual(ids(exitSessionSelection()), []); }); @@ -191,16 +58,17 @@ describe('selection mode', () => { 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(toggle(EMPTY_SESSION_SELECTION, 'a'), GROUP, true); + const all = setAllSessionsSelected(EMPTY_SESSION_SELECTION, GROUP, true); const none = setAllSessionsSelected(all, GROUP, false); assert.deepEqual(ids(none), []); assert.equal(none.active, true); }); - test('pruning to nothing keeps the mode', () => { - // The rows went away because the catalog changed, not because the user was - // finished. - const pruned = pruneSessionSelection(toggle(EMPTY_SESSION_SELECTION, 'a'), []); + 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); }); @@ -210,13 +78,15 @@ 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. - const all = setAllSessionsSelected(EMPTY_SESSION_SELECTION, ['a', 'b'], true); - assert.deepEqual(ids(all), ['a', 'b']); + 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(toggle(EMPTY_SESSION_SELECTION, 'b'), GROUP), 'indeterminate'); + assert.equal(sessionSelectionMasterState(mark(EMPTY_SESSION_SELECTION, 'b'), GROUP), 'indeterminate'); assert.equal( sessionSelectionMasterState(setAllSessionsSelected(EMPTY_SESSION_SELECTION, GROUP, true), GROUP), true, @@ -230,7 +100,20 @@ describe('the master box', () => { }); test('a mark outside the listed rows does not make it checked', () => { - const stray = toggle(EMPTY_SESSION_SELECTION, 'zzz'); - assert.equal(sessionSelectionMasterState(stray, GROUP), 'indeterminate'); + 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/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 b22d5454dd..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, type SessionRailSelection } 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'; @@ -101,6 +106,8 @@ export interface SessionNavigationController { selectors: SessionNavigationSelectors; commands: SessionNavigationRowActions; selection: SessionRailSelection; + /** The narrow half every row subscribes to; see `useSessionSelection`. */ + rowSelection: SessionRailRowSelection; } /** @@ -192,10 +199,10 @@ export function useSessionNavigationController( [groups, sessionMeta, worktreeSessionIds], ); - const selection = useSessionSelection({ sessions: rail.sessions, commands }); + const { selection, rowSelection } = useSessionSelection({ sessions: rail.sessions, commands }); return useMemo( - () => ({ layout, selectors, commands, selection }), - [commands, layout, selection, 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 index 85e8322a89..167618a649 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts @@ -19,9 +19,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { SessionSummary } from '@maka/core/session'; -import type { SessionRailSelection } from '@maka/ui'; +import type { SessionRailRowSelection, SessionRailSelection } from '@maka/ui'; import { - applySessionSelectionGesture, EMPTY_SESSION_SELECTION, enterSessionSelection, exitSessionSelection, @@ -41,7 +40,7 @@ import type { SessionNavigationRowActions } from './session-row-actions.js'; export function useSessionSelection(input: { sessions: readonly SessionSummary[]; commands: SessionNavigationRowActions; -}): SessionRailSelection { +}): { selection: SessionRailSelection; rowSelection: SessionRailRowSelection } { const { sessions, commands } = input; const [selection, setSelection] = useState(EMPTY_SESSION_SELECTION); const [busy, setBusy] = useState(false); @@ -69,17 +68,13 @@ export function useSessionSelection(input: { const listedSessionIds = useMemo(() => sessions.map((session) => session.id), [sessions]); const listedRef = useRef(listedSessionIds); - const onGesture = useCallback((gesture) => { - setSelection((current) => applySessionSelectionGesture(current, gesture)); - }, []); - 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, anchorId: sessionId }; + return { active: true, selectedIds }; }); }, []); @@ -87,11 +82,7 @@ export function useSessionSelection(input: { setSelection((current) => { const entered = enterSessionSelection(current); if (sessionId === undefined) return entered; - return { - active: true, - selectedIds: new Set([...entered.selectedIds, sessionId]), - anchorId: sessionId, - }; + return { active: true, selectedIds: new Set([...entered.selectedIds, sessionId]) }; }); }, []); @@ -142,12 +133,30 @@ export function useSessionSelection(input: { [commands, runSweep], ); - return useMemo( + /** + * 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, - onGesture, onToggleRow, onEnter, onExit, @@ -163,11 +172,15 @@ export function useSessionSelection(input: { onDeleteSelected, onEnter, onExit, - onGesture, 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 index cbeb9b5916..95953feefe 100644 --- a/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts +++ b/apps/desktop/src/renderer/features/session-navigation/model/session-selection.ts @@ -17,13 +17,7 @@ * under the License. */ -/** - * What the rail has selected for a bulk action, and where a range would start. - * - * The anchor is the last row toggled, not the last row selected: a range is - * measured from the row the user last acted on, which is what makes a second - * Shift-click re-measure rather than accumulate. - */ +/** What the rail has marked, and whether the mode is on at all. */ export interface SessionSelection { /** * Whether the rail is in selection mode. @@ -35,7 +29,6 @@ export interface SessionSelection { */ readonly active: boolean; readonly selectedIds: ReadonlySet; - readonly anchorId?: string; } export const EMPTY_SESSION_SELECTION: SessionSelection = Object.freeze({ @@ -43,74 +36,6 @@ export const EMPTY_SESSION_SELECTION: SessionSelection = Object.freeze({ selectedIds: Object.freeze(new Set()) as ReadonlySet, }); -/** - * One click on a row, as the rail saw it. - * - * `groupSessionIds` is the rendered order of the group the row sits in, and a - * range never leaves it. The rail cannot do better: a project group's collapsed - * state lives inside Astryx's `SideNavItem` and is not readable from here, so a - * range spanning groups could silently include rows nobody can see. Within one - * group the question does not arise — both endpoints had to be clicked, and a - * row that can be clicked is a row that is on screen. - */ -export interface SessionSelectionGesture { - readonly sessionId: string; - readonly mode: 'toggle' | 'range'; - readonly groupSessionIds: readonly string[]; -} - -export function applySessionSelectionGesture( - selection: SessionSelection, - gesture: SessionSelectionGesture, -): SessionSelection { - if (gesture.mode === 'toggle') return toggleSession(selection, gesture.sessionId); - return extendRange(selection, gesture); -} - -function toggleSession(selection: SessionSelection, sessionId: string): SessionSelection { - const selectedIds = new Set(selection.selectedIds); - if (selectedIds.has(sessionId)) { - selectedIds.delete(sessionId); - // The anchor follows the row the user last acted on even when that act was - // a removal: leaving it on a row no longer in the selection would measure - // the next range from somewhere the user cannot see marked. - return { active: true, selectedIds, anchorId: sessionId }; - } - selectedIds.add(sessionId); - return { active: true, selectedIds, anchorId: sessionId }; -} - -function extendRange( - selection: SessionSelection, - gesture: SessionSelectionGesture, -): SessionSelection { - const targetIndex = gesture.groupSessionIds.indexOf(gesture.sessionId); - // A row the rail did not list in this group is not a range endpoint. This is - // the shape a stale render produces, and adding it alone would be a silent - // half-answer to a request for a span. - if (targetIndex === -1) return selection; - const anchorIndex = - selection.anchorId === undefined ? -1 : gesture.groupSessionIds.indexOf(selection.anchorId); - // No anchor, or an anchor in another group: this click IS the anchor. Shift - // on a fresh rail selects the one row rather than nothing, which is what - // makes the modifier discoverable by trying it. - if (anchorIndex === -1) { - return { - active: true, - selectedIds: new Set([...selection.selectedIds, gesture.sessionId]), - anchorId: gesture.sessionId, - }; - } - const from = Math.min(anchorIndex, targetIndex); - const to = Math.max(anchorIndex, targetIndex); - const selectedIds = new Set(selection.selectedIds); - for (const sessionId of gesture.groupSessionIds.slice(from, to + 1)) selectedIds.add(sessionId); - // The anchor stays put. Re-anchoring on the target is what turns a corrected - // range — Shift-click one row too far, then Shift-click back — into two - // unions that can never shrink. - return { active: true, selectedIds, anchorId: selection.anchorId }; -} - /** * Drops ids the catalog no longer lists. * @@ -129,13 +54,9 @@ export function pruneSessionSelection( if (listed.has(sessionId)) selectedIds.add(sessionId); } if (selectedIds.size === selection.selectedIds.size) return selection; - const anchorId = - selection.anchorId !== undefined && listed.has(selection.anchorId) - ? selection.anchorId - : undefined; // 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, anchorId }; + return { active: selection.active, selectedIds }; } /** Enters selection mode with nothing marked. */ @@ -162,11 +83,7 @@ export function setAllSessionsSelected( selected: boolean, ): SessionSelection { if (!selected) return { active: selection.active, selectedIds: new Set() }; - return { - active: true, - selectedIds: new Set(listedSessionIds), - anchorId: selection.anchorId, - }; + return { active: true, selectedIds: new Set(listedSessionIds) }; } /** What the master box shows: all, none, or some. */ diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index cc4f4fd0a8..1881d5883a 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -42,7 +42,6 @@ export { deriveBranchBanner } from './model/branch-banner.js'; export { deriveSessionRail } from './model/session-rail.js'; export { deriveSessionRevisionNavigation } from './model/session-revisions.js'; export { - applySessionSelectionGesture, EMPTY_SESSION_SELECTION, enterSessionSelection, exitSessionSelection, 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 0405401d4a..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/packages/ui/src/__tests__/session-history-multi-select.test.tsx b/packages/ui/src/__tests__/session-history-multi-select.test.tsx index 2963546c2e..98bc493010 100644 --- a/packages/ui/src/__tests__/session-history-multi-select.test.tsx +++ b/packages/ui/src/__tests__/session-history-multi-select.test.tsx @@ -35,8 +35,8 @@ import { SessionHistoryList } from '../session-history-list.js'; import { SessionRailProvider, type SessionRailData, + type SessionRailRowSelection, type SessionRailSelection, - type SessionRailSelectionGesture, } from '../session-rail-context.js'; /** @@ -105,7 +105,6 @@ function summary(id: string): SessionSummary { const SESSIONS = ['a', 'b', 'c'].map(summary); type Harness = { - gestures: SessionSelectionGestureLog[]; opened: string[]; toggles: Array<[string, boolean]>; toggleAll: boolean[]; @@ -119,8 +118,6 @@ type Harness = { document: Document; }; -type SessionSelectionGestureLog = SessionRailSelectionGesture; - async function mount( options: { selectedIds?: readonly string[]; active?: boolean } = {}, ): Promise { @@ -135,20 +132,21 @@ async function mount( installDomStubs(window); Object.assign(globalThis, { document, window, IS_REACT_ACT_ENVIRONMENT: true }); - const gestures: SessionSelectionGestureLog[] = []; const opened: string[] = []; const toggles: Array<[string, boolean]> = []; const toggleAll: boolean[] = []; const entered: Array = []; let exits = 0; let deleteRequests = 0; - const selection: SessionRailSelection = { + const rowSelection: SessionRailRowSelection = { active: options.active ?? false, selectedIds: new Set(options.selectedIds ?? []), - listedSessionIds: SESSIONS.map((session) => session.id), - onGesture: (gesture) => gestures.push(gesture), 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; }, @@ -171,7 +169,7 @@ async function mount( await act(() => root.render( - + , @@ -179,7 +177,6 @@ async function mount( ); const harness: Harness = { - gestures, opened, toggles, toggleAll, @@ -243,45 +240,6 @@ test('a plain click still opens the task', async () => { try { await harness.clickRow('b'); assert.deepEqual(harness.opened, ['b']); - assert.deepEqual(harness.gestures, []); - } finally { - await harness.dispose(); - } -}); - -test('a modified click marks the row instead of opening it', async () => { - const harness = await mount(); - try { - await harness.clickRow('b', { metaKey: true }); - // Not opened: navigating away from what the user is marking is the whole - // failure this branch exists to prevent. - assert.deepEqual(harness.opened, []); - assert.deepEqual(harness.gestures, [ - { sessionId: 'b', mode: 'toggle', groupSessionIds: ['a', 'b', 'c'] }, - ]); - } finally { - await harness.dispose(); - } -}); - -test('Shift reports a range and carries the group it may reach across', async () => { - const harness = await mount(); - try { - await harness.clickRow('c', { shiftKey: true }); - assert.deepEqual(harness.opened, []); - assert.deepEqual(harness.gestures, [ - { sessionId: 'c', mode: 'range', groupSessionIds: ['a', 'b', 'c'] }, - ]); - } finally { - await harness.dispose(); - } -}); - -test('Ctrl toggles too, for a rail that is not on a Mac', async () => { - const harness = await mount(); - try { - await harness.clickRow('a', { ctrlKey: true }); - assert.equal(harness.gestures[0]?.mode, 'toggle'); } finally { await harness.dispose(); } diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 2292291ce5..e8d4c055be 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -28,12 +28,12 @@ export { ModuleHubSelector } from './module-hub-selector.js'; export type { ModuleHubHeader } from './module-hub-selector.js'; export { SearchModal } from './search-modal.js'; export { SessionListPanel } from './session-list-panel.js'; -export { SessionRailProvider, sessionSelectionGestureMode } from './session-rail-context.js'; +export { SessionRailProvider } from './session-rail-context.js'; export type { SessionRailChrome, SessionRailData, + SessionRailRowSelection, SessionRailSelection, - SessionRailSelectionGesture, SessionViewMode, } from './session-rail-context.js'; export type { SidebarUpdateReminder } from './session-sidebar-nav.js'; diff --git a/packages/ui/src/session-history-list.tsx b/packages/ui/src/session-history-list.tsx index cdc2016456..944d6e2f53 100644 --- a/packages/ui/src/session-history-list.tsx +++ b/packages/ui/src/session-history-list.tsx @@ -63,8 +63,8 @@ import { dotForStatus } from './status-vocabulary.js'; import { SessionRenameDialog, type SessionRenameTarget } from './session-rename-dialog.js'; import { CheckboxInput } from '@astryxdesign/core/CheckboxInput'; import { - sessionSelectionGestureMode, useSessionRailData, + useSessionRailRowSelection, useSessionRailSelection, } from './session-rail-context.js'; import { useUiLocale } from './locale-context.js'; @@ -182,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 @@ -189,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, - })) - } - /> +
); } @@ -271,11 +277,10 @@ function SessionListGroups(props: { // any one of them changing identity upstream rebuilt every row. It arrives as // `rail` now, so this array says what it always meant (#4109). const renderSessionRow = useCallback( - (session: SessionSummary, groupSessionIds: readonly string[]): ReactNode => ( + (session: SessionSummary): ReactNode => ( { // 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 groupSessionIds = group.sessions.map((session) => session.id); - const items = group.sessions.map((session) => renderSessionRow(session, groupSessionIds)); + const items = group.sessions.map((session) => renderSessionRow(session)); if (!group.label) { return (
@@ -386,7 +390,7 @@ function ProjectNavRow(props: { streamingSessionIds?: ReadonlySet; projectActions?: ProjectRowActions; onStartRename(opener: HTMLElement | null): void; - renderSession(session: SessionSummary, groupSessionIds: readonly string[]): ReactNode; + renderSession(session: SessionSummary): ReactNode; }) { const containerRef = useRef(null); const hoverDescriptionId = useId(); @@ -403,13 +407,6 @@ function ProjectNavRow(props: { // still truthy children for Astryx (!!children) and fabricates a disclosure. const hasSessions = props.sessions.length > 0; const hasActions = props.project !== undefined && props.projectActions !== undefined; - // Hoisted and memoized: built inside the row loop it would be rebuilt once - // per row, and each row would receive a new array identity — which is - // exactly what `SessionNavRow`'s memo exists to avoid. - const groupSessionIds = useMemo( - () => props.sessions.map((session) => session.id), - [props.sessions], - ); return (
- {props.sessions.map((session) => props.renderSession(session, groupSessionIds))} + {props.sessions.map((session) => props.renderSession(session))} ) : undefined} @@ -493,8 +490,6 @@ const SessionNavRow = memo(function SessionNavRow(props: { stale: boolean; worktree: boolean; meta?: string; - /** Rendered order of this row's group, so a range knows where it may reach. */ - groupSessionIds: readonly string[]; onSelectSession(sessionId: string): void; actions?: SessionRowActions; onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void; @@ -502,10 +497,12 @@ const SessionNavRow = memo(function SessionNavRow(props: { const containerRef = useRef(null); const hoverDescriptionId = useId(); const locale = useUiLocale(); - // Read here rather than passed down: `renderSessionRow` is memoized on the - // rail value, so threading the selection through it would rebuild every row - // on every click — the thing #4109 took the props out of the tree to stop. - const selection = useSessionRailSelection(); + // 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( @@ -586,19 +583,6 @@ const SessionNavRow = memo(function SessionNavRow(props: { ) } onClick={(event) => { - // A modified click marks the row instead of opening it. Checked - // before the double-click branch: Shift-clicking a range lands two - // clicks on the same row often enough that a rename dialog in the - // middle of a selection is a real outcome, not a corner case. - const mode = selection ? sessionSelectionGestureMode(event) : undefined; - if (mode && selection) { - selection.onGesture({ - sessionId: props.session.id, - mode, - groupSessionIds: props.groupSessionIds, - }); - return; - } if (event.detail > 1 && props.actions) { props.onStartRename( { @@ -657,7 +641,6 @@ const SessionNavRow = memo(function SessionNavRow(props: { )} @@ -1012,15 +995,14 @@ function ProjectItemActions(props: { function SessionItemActions(props: { session: SessionSummary; actions: SessionRowActions; - groupSessionIds: readonly string[]; onStartRename(target: SessionRenameTarget, opener: HTMLElement | null): void; }) { const trailingRef = useRef(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. - // Once one row is marked the bar teaches the modifier that extends it. - const selection = useSessionRailSelection(); + // The narrow context again: this renders once per row. + const selection = useSessionRailRowSelection(); const locale = useUiLocale(); const copy = getConversationCopy(locale).sessions; const actionContext = [ diff --git a/packages/ui/src/session-rail-context.tsx b/packages/ui/src/session-rail-context.tsx index 0725c63498..ca747c830b 100644 --- a/packages/ui/src/session-rail-context.tsx +++ b/packages/ui/src/session-rail-context.tsx @@ -93,31 +93,33 @@ export interface SessionRailChrome { } /** - * One row's click, reported rather than interpreted. + * What a ROW reads: whether the mode is on, whether this row is marked, and the + * two ways a row changes that. * - * The rail says what was clicked and with which modifier; the host decides what - * that means for its selection. `groupSessionIds` is the rendered order of the - * group the row sits in, which is as far as a range can reach: a project - * group's collapsed state lives inside Astryx's `SideNavItem` and is not - * readable here, so a range across groups could quietly include rows nobody can - * see. Inside one group the question never arises — both endpoints had to be - * clicked, and a row that can be clicked is a row that is on screen. + * 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 SessionRailSelectionGesture { - sessionId: string; - mode: 'toggle' | 'range'; - groupSessionIds: readonly string[]; +export interface SessionRailRowSelection { + active: boolean; + selectedIds: ReadonlySet; + onToggleRow(sessionId: string, selected: boolean): void; + onEnter(sessionId?: string): void; } /** - * What the rail has marked for a bulk action. + * 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: this changes on - * every modified click while the list does not, and folding it into - * `SessionRailData` would give that value a new identity per click — which is - * the ~1,000-fiber render the split exists to prevent (#4109). Rows that read - * this context still re-render on a selection change; that is the cost, and it - * is bounded by the rows on screen rather than by the shell. + * 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. @@ -132,7 +134,6 @@ export interface SessionRailSelection { selectedIds: ReadonlySet; /** Every row the rail is listing, in rendered order. What "all" means. */ listedSessionIds: readonly string[]; - onGesture(gesture: SessionRailSelectionGesture): void; onToggleRow(sessionId: string, selected: boolean): void; onEnter(sessionId?: string): void; /** Leaves the mode and drops what was marked. */ @@ -145,27 +146,10 @@ export interface SessionRailSelection { busy?: boolean; } -/** - * Which selection gesture a click's modifiers ask for, if any. - * - * An unmodified click is not a gesture: the rail is a navigation surface first - * and must keep opening the task. Shift outranks the toggle modifier when both - * are held — a range is the more specific request, and answering it with a - * toggle drops what was asked for. - */ -export function sessionSelectionGestureMode(modifiers: { - metaKey: boolean; - ctrlKey: boolean; - shiftKey: boolean; -}): SessionRailSelectionGesture['mode'] | undefined { - if (modifiers.shiftKey) return 'range'; - if (modifiers.metaKey || modifiers.ctrlKey) return 'toggle'; - return undefined; -} - 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 @@ -176,13 +160,22 @@ 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} + @@ -208,3 +201,8 @@ export function useSessionRailChrome(): SessionRailChrome { 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); +} From d1aa4797c07f3e476cd61927088bf49a2ba44e1b Mon Sep 17 00:00:00 2001 From: Joob1n Date: Tue, 1 Sep 2026 10:35:24 +0800 Subject: [PATCH 7/8] fix(desktop): answer both review findings on the rail's bulk sweeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two P2s from review, both reproduced before fixing and both covered by a test that fails when the fix is reverted. **A settled sweep cleared whatever was marked, not what it asked about.** `Done` stays enabled during a sweep because leaving asks nothing of the Host — so a person can leave the selection, re-enter from another row's menu and mark B while A's request is still out. The `finally` replaced the whole set with an empty one, discarding B to answer A's completion. It now removes exactly the submitted ids and leaves anything else alone; the mode still stays on. A deferred-command regression drives that sequence, and reverting the fix fails it. **Bulk delete bypassed the linked-subtask contract.** The Host archives a deleted parent's ordinary subagent tasks rather than deleting them, and `main` now exposes `previewRemoval` plus `archivedSubtaskCount` so a destructive confirm can warn about the survivors and the toast can report how many moved. Single-row delete uses both; this new path used neither, so deleting a selected parent made its subtasks reappear under Archived with no warning and no explanation. `deleteSelected` now asks the Host for a preview per selected task before confirming — the renderer's projection cannot answer it — and a single preview failure makes the warning uncertain rather than silently under-reporting a destructive set. The sweep already accumulated `archivedSubtasks` once rebased; the toast reports that executed total, not the estimate. Four cases cover the warning, the uncertain fallback, the silent case, and a declined confirm. Rebased onto `9249bf3f`, which is where those Host affordances arrived. The conflicts were the two halves of one line — main's destructured `archivedSubtaskCount` and this branch's per-id archived premise — and two additive copy blocks. Generated-by: Claude Opus 5 via Claude Code --- .../session-navigation-session-purge.test.ts | 130 +++++++++++++- .../session-selection-sweep-race.test.ts | 170 ++++++++++++++++++ .../controller/session-row-actions.ts | 45 ++++- .../controller/use-session-selection.ts | 20 ++- .../features/session-navigation/testing.ts | 2 + .../src/renderer/locales/shell-copy.ts | 10 ++ 6 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-selection-sweep-race.test.ts 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 b51dc011be..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,6 +50,8 @@ 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]`. */ @@ -58,6 +60,8 @@ type SweepHarness = { 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; }; @@ -71,6 +75,11 @@ function installService( 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. */ @@ -112,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; + }, }; } @@ -123,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', @@ -140,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, }, }); } @@ -152,11 +168,13 @@ function createActions(input: { function harness(): SweepHarness { return { removed: [], + previews: [], archived: [], removeOptions: [], cleared: [], selections: [], toasts: [], + toastDescriptions: [], listCalls: 0, }; } @@ -582,3 +600,109 @@ describe('archiveSessions', () => { 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 957bf652a2..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 @@ -485,12 +485,43 @@ export function createSessionNavigationRowActions(deps: { ); } - /** The rail's own bulk delete. See `archiveSelected` for why the wording is here. */ + /** + * 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: copy.bulkDeleteDescription, + description: subtaskNote + ? `${copy.bulkDeleteDescription} ${subtaskNote}` + : copy.bulkDeleteDescription, confirmLabel: copy.deleteLabel, cancelLabel: copy.cancelLabel, destructive: true, @@ -501,8 +532,14 @@ export function createSessionNavigationRowActions(deps: { // 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); + toastApi.success( + copy.bulkDeletedTitle(outcome.removed), + [kept, archived].filter(Boolean).join(' ') || undefined, + ); return; } const reason = !outcome.verified @@ -512,7 +549,7 @@ export function createSessionNavigationRowActions(deps: { : copy.bulkFailedBody(outcome.remaining.length); toastApi.error( copy.bulkDeleteFailedTitle, - kept ? `${reason} ${kept}` : reason, + [reason, kept, archived].filter(Boolean).join(' '), undefined, outcome.firstFailure ? { sessionId: outcome.firstFailure.sessionId } : undefined, ); diff --git a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts index 167618a649..7521f49293 100644 --- a/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts +++ b/apps/desktop/src/renderer/features/session-navigation/controller/use-session-selection.ts @@ -110,15 +110,25 @@ export function useSessionSelection(input: { await run(sessionIds); } finally { setBusy(false); - // Whatever survived is reconciled by the catalog refresh the sweep - // already ran; clearing here is about the request, which is answered - // either way. Leaving a marked set behind after a destructive sweep - // invites a second press on rows that may no longer exist. + // 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) => ({ active: current.active, selectedIds: new Set() })); + 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; + }); } }, [], diff --git a/apps/desktop/src/renderer/features/session-navigation/testing.ts b/apps/desktop/src/renderer/features/session-navigation/testing.ts index 1881d5883a..8fe566ef5b 100644 --- a/apps/desktop/src/renderer/features/session-navigation/testing.ts +++ b/apps/desktop/src/renderer/features/session-navigation/testing.ts @@ -36,6 +36,8 @@ 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'; diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index bd41681ebb..8ac76a9df9 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -288,6 +288,10 @@ type ShellCopy = { 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; @@ -928,6 +932,9 @@ const SHELL_COPY_BY_LOCALE = { bulkArchiveFailedTitle: '部分任务未能归档', bulkFailedBody: (count: number) => `还有 ${count} 个没有处理成功。`, bulkUnverified: '无法确认处理结果,请刷新后查看。', + bulkDeleteSubtaskNote: () => '它们的普通子任务不会被删除,将保留并移入归档。', + bulkDeleteSubtaskNoteUncertain: () => + '它们的普通子任务(如有)不会被删除,将保留并移入归档。', }, skillActions: { refreshSkillsFailedTitle: '刷新技能失败', @@ -1472,6 +1479,9 @@ const SHELL_COPY_BY_LOCALE = { 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', From bbdaeca474a38b4f881bb47e25c3f8cfb1cf42da Mon Sep 17 00:00:00 2001 From: Joob1n Date: Tue, 1 Sep 2026 11:12:02 +0800 Subject: [PATCH 8/8] chore(docs): refresh the Astryx surface inventory total MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed on the surface gate with only the `.md` stale: - docs/astryx-surface-file-inventory.md does not match generator output The diff is one line — `232 files … aligned 231` becomes `233 … 232`. A file that renders Astryx arrived on `main` while this branch was out, and `.paths` came with it; the totals line in the table did not. Worth naming because it is a shape this branch has now hit twice: the gate passed locally and failed in CI, because CI checks the branch MERGED with current `main` and I was four commits behind. Running the gates on an un-rebased branch proves less than it looks like it does. Rebased onto `8fa1e894` and regenerated with `astryx:surface-inventory:write`. Generated-by: Claude Opus 5 via Claude Code --- docs/astryx-surface-file-inventory.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index 3afda4a28d..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)