From f226af6cf9ac728fa126fc0af74103f199a04586 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 31 Jul 2026 16:21:51 -0700 Subject: [PATCH 1/2] feat: add declarative command engine --- frontend/src/commands/contracts.js | 126 +++++++++++++++++++++++ frontend/src/commands/contracts.test.js | 95 +++++++++++++++++ frontend/src/commands/controller.js | 84 +++++++++++++++ frontend/src/commands/controller.test.js | 108 +++++++++++++++++++ frontend/src/commands/registry.js | 34 ++++++ frontend/src/commands/registry.test.js | 48 +++++++++ frontend/src/commands/search.js | 60 +++++++++++ frontend/src/commands/search.test.js | 55 ++++++++++ frontend/src/commands/shortcuts.js | 43 ++++++++ frontend/src/commands/shortcuts.test.js | 43 ++++++++ frontend/src/commands/targets.js | 28 +++++ frontend/src/commands/targets.test.js | 55 ++++++++++ 12 files changed, 779 insertions(+) create mode 100644 frontend/src/commands/contracts.js create mode 100644 frontend/src/commands/contracts.test.js create mode 100644 frontend/src/commands/controller.js create mode 100644 frontend/src/commands/controller.test.js create mode 100644 frontend/src/commands/registry.js create mode 100644 frontend/src/commands/registry.test.js create mode 100644 frontend/src/commands/search.js create mode 100644 frontend/src/commands/search.test.js create mode 100644 frontend/src/commands/shortcuts.js create mode 100644 frontend/src/commands/shortcuts.test.js create mode 100644 frontend/src/commands/targets.js create mode 100644 frontend/src/commands/targets.test.js diff --git a/frontend/src/commands/contracts.js b/frontend/src/commands/contracts.js new file mode 100644 index 00000000..41ae2d6c --- /dev/null +++ b/frontend/src/commands/contracts.js @@ -0,0 +1,126 @@ +export const TARGET_MODES = Object.freeze({ + GLOBAL: 'global', + ACCOUNT: 'account', + DRAFT: 'draft', + SINGLE_CONVERSATION: 'single_conversation', + BULK_SAFE: 'bulk_safe', +}); + +export const SURFACES = Object.freeze(['list', 'conversation', 'compose', 'settings', 'picker']); +export const PLATFORMS = Object.freeze(['mac', 'windows', 'linux']); + +/** @typedef {string | {default?: string, mac?: string, windows?: string, linux?: string}} KeySpec */ + +/** + * @typedef {object} CommandDefinition + * @property {string} id + * @property {string} titleKey + * @property {string[]} aliasKeys + * @property {string} icon + * @property {string} group + * @property {{primary: KeySpec | null, secondary: KeySpec[]}} defaultKeys + * @property {{base: number, boost?: (context: CommandContext) => number}} rank + * @property {(context: CommandContext) => boolean} isAvailable + * @property {'global'|'account'|'draft'|'single_conversation'|'bulk_safe'} targetMode + * @property {string} executorId + * @property {Readonly>} [params] + */ + +/** + * @typedef {object} CommandContext + * @property {'list'|'conversation'|'compose'|'settings'|'picker'} surface + * @property {string | null} activeConversationId + * @property {object | null} activeMessage + * @property {readonly string[]} selectedConversationIds + * @property {readonly string[]} visibleConversationIds + * @property {Readonly>} conversationsById + * @property {string | null} accountId + * @property {string | null} folder + * @property {object | null} draft + * @property {boolean} gtdAvailable + * @property {boolean} cardDavConnected + * @property {object | null} modal + * @property {boolean} editing + * @property {boolean} undoAvailable + * @property {'mac'|'windows'|'linux'} platform + * @property {Readonly>} shortcutOverrides + * @property {(key: string, values?: object) => string} translate + */ + +function freezeKeySpec(spec) { + return spec && typeof spec === 'object' ? Object.freeze({ ...spec }) : spec; +} + +export function stableConversationId(message) { + const accountId = message?.account_id; + const withinAccountId = message?.message_id || message?.id; + return accountId && withinAccountId ? `${accountId}:${withinAccountId}` : null; +} + +export function validateCommandDefinition(input) { + if (!input?.id?.includes('.')) throw new TypeError('command id must be namespaced'); + for (const key of ['titleKey', 'icon', 'group']) { + if (typeof input[key] !== 'string' || !input[key]) throw new TypeError(`${key} must be a non-empty string`); + } + if (!Array.isArray(input.aliasKeys) || input.aliasKeys.some(key => typeof key !== 'string')) { + throw new TypeError('aliasKeys must be an array of localization keys'); + } + if (!Object.values(TARGET_MODES).includes(input.targetMode)) { + throw new TypeError(`unsupported targetMode "${input.targetMode}"`); + } + if (typeof input.executorId !== 'string' || !input.executorId) { + throw new TypeError('executorId must be a non-empty string'); + } + if (typeof input.isAvailable !== 'function') throw new TypeError('isAvailable must be a function'); + if (!Number.isFinite(input.rank?.base)) throw new TypeError('rank.base must be a finite number'); + if (input.rank.boost != null && typeof input.rank.boost !== 'function') { + throw new TypeError('rank.boost must be a function'); + } + const primary = freezeKeySpec(input.defaultKeys?.primary ?? null); + const secondary = Object.freeze((input.defaultKeys?.secondary || []).map(freezeKeySpec)); + return Object.freeze({ + ...input, + aliasKeys: Object.freeze([...input.aliasKeys]), + defaultKeys: Object.freeze({ primary, secondary }), + rank: Object.freeze({ ...input.rank }), + params: Object.freeze({ ...(input.params || {}) }), + }); +} + +export function createCommandContext(input) { + if (!SURFACES.includes(input.surface)) throw new TypeError(`unsupported surface "${input.surface}"`); + if (!PLATFORMS.includes(input.platform)) throw new TypeError(`unsupported platform "${input.platform}"`); + if (typeof input.translate !== 'function') throw new TypeError('translate must be a function'); + + const conversationsById = {}; + for (const message of input.conversations || []) { + const id = stableConversationId(message); + if (!id || conversationsById[id]) continue; + conversationsById[id] = Object.freeze({ + id, + rowId: message.id, + accountId: message.account_id ?? null, + message, + }); + } + const selectedConversationIds = [...new Set(input.selectedConversationIds || [])]; + return Object.freeze({ + surface: input.surface, + activeConversationId: input.activeConversationId || null, + activeMessage: input.activeMessage || null, + selectedConversationIds: Object.freeze(selectedConversationIds), + visibleConversationIds: Object.freeze([...new Set(input.visibleConversationIds || [])]), + conversationsById: Object.freeze(conversationsById), + accountId: input.accountId || null, + folder: input.folder || null, + draft: input.draft || null, + gtdAvailable: Boolean(input.gtdAvailable), + cardDavConnected: Boolean(input.cardDavConnected), + modal: input.modal || null, + editing: Boolean(input.editing), + undoAvailable: Boolean(input.undoAvailable), + platform: input.platform, + shortcutOverrides: Object.freeze({ ...(input.shortcutOverrides || {}) }), + translate: input.translate, + }); +} diff --git a/frontend/src/commands/contracts.test.js b/frontend/src/commands/contracts.test.js new file mode 100644 index 00000000..01d5d4aa --- /dev/null +++ b/frontend/src/commands/contracts.test.js @@ -0,0 +1,95 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + TARGET_MODES, + createCommandContext, + stableConversationId, + validateCommandDefinition, +} from './contracts.js'; + +const validDefinition = { + id: 'mail.archive', + titleKey: 'commands.mail.archive.title', + aliasKeys: ['commands.mail.archive.alias.done'], + icon: 'archive', + group: 'mail', + defaultKeys: { primary: 'e', secondary: ['o'] }, + rank: { base: 100, boost: context => context.selectedConversationIds.length ? 25 : 0 }, + isAvailable: context => context.surface !== 'settings', + targetMode: 'bulk_safe', + executorId: 'mail.archive', +}; + +describe('command contracts', () => { + it('uses the five approved target-mode values', () => { + assert.deepEqual(Object.values(TARGET_MODES), [ + 'global', 'account', 'draft', 'single_conversation', 'bulk_safe', + ]); + }); + + it('validates and deeply freezes a complete definition', () => { + const definition = validateCommandDefinition(validDefinition); + assert.equal(definition.id, 'mail.archive'); + assert.ok(Object.isFrozen(definition)); + assert.ok(Object.isFrozen(definition.aliasKeys)); + assert.ok(Object.isFrozen(definition.defaultKeys.secondary)); + assert.throws(() => definition.aliasKeys.push('commands.other')); + }); + + it('rejects missing, unnamespaced, and unsupported fields with exact errors', () => { + assert.throws( + () => validateCommandDefinition({ ...validDefinition, id: 'archive' }), + /command id must be namespaced/, + ); + assert.throws( + () => validateCommandDefinition({ ...validDefinition, targetMode: 'message' }), + /unsupported targetMode "message"/, + ); + assert.throws( + () => validateCommandDefinition({ ...validDefinition, executorId: '' }), + /executorId must be a non-empty string/, + ); + }); + + it('scopes RFC Message-ID and row-id fallback identities by account', () => { + assert.equal(stableConversationId({ account_id: 'acct-1', id: 'row-1', message_id: '' }), 'acct-1:'); + assert.equal(stableConversationId({ account_id: 'acct-2', id: 'row-1', message_id: '' }), 'acct-2:'); + assert.equal(stableConversationId({ account_id: 'acct-1', id: 'row-2' }), 'acct-1:row-2'); + assert.equal(stableConversationId({ id: 'row-2' }), null); + assert.equal(stableConversationId({}), null); + }); + + it('normalizes and freezes a complete command context snapshot', () => { + const context = createCommandContext({ + surface: 'list', + activeConversationId: 'acct-1:', + activeMessage: { id: 'row-a', message_id: '', account_id: 'acct-1' }, + selectedConversationIds: ['acct-1:', 'acct-1:'], + visibleConversationIds: ['acct-1:', 'acct-1:'], + conversations: [ + { id: 'row-a', message_id: '', account_id: 'acct-1' }, + { id: 'row-b', message_id: '', account_id: 'acct-1' }, + ], + accountId: 'acct-1', + folder: 'INBOX', + draft: null, + gtdAvailable: true, + cardDavConnected: false, + modal: null, + editing: false, + undoAvailable: true, + platform: 'mac', + shortcutOverrides: { 'mail.archive': 'x' }, + translate: key => ({ 'commands.mail.archive.title': 'Archive' })[key] || key, + }); + + assert.deepEqual(context.selectedConversationIds, ['acct-1:']); + assert.deepEqual(context.visibleConversationIds, ['acct-1:', 'acct-1:']); + assert.equal(context.conversationsById['acct-1:'].rowId, 'row-a'); + assert.equal(context.activeMessage.id, 'row-a'); + assert.equal(context.undoAvailable, true); + assert.equal(context.translate('commands.mail.archive.title'), 'Archive'); + assert.ok(Object.isFrozen(context)); + assert.ok(Object.isFrozen(context.selectedConversationIds)); + }); +}); diff --git a/frontend/src/commands/controller.js b/frontend/src/commands/controller.js new file mode 100644 index 00000000..41befe51 --- /dev/null +++ b/frontend/src/commands/controller.js @@ -0,0 +1,84 @@ +import { hasTargets, resolveTargetIds } from './targets.js'; + +function failed(commandId, error, targetIds = []) { + return { status: 'failed', commandId, targetIds, error: error instanceof Error ? error : new Error(String(error)) }; +} + +function terminalOutcome(commandId, targetIds, result) { + if (!['success', 'cancelled', 'partial', 'failed'].includes(result?.status)) { + return failed(commandId, new Error(`Invalid executor outcome for "${commandId}"`), targetIds); + } + return { commandId, targetIds, ...result }; +} + +export function createCommandController({ + registry, + getContext, + executors, + onContinuation = () => {}, + onOutcome = () => {}, +}) { + const inFlight = new Map(); + + function execute(commandId, { source = 'unknown', input, frozenTargetIds } = {}) { + const initialContext = getContext(); + const command = registry.get(commandId); + if (!command) { + const outcome = failed(commandId, new Error(`Unknown command "${commandId}"`)); + onOutcome(outcome); + return Promise.resolve(outcome); + } + if (!command.isAvailable(initialContext) || (!frozenTargetIds && !hasTargets(command, initialContext))) { + const outcome = failed(commandId, new Error(`Command "${commandId}" is not available`)); + onOutcome(outcome); + return Promise.resolve(outcome); + } + + const frozen = frozenTargetIds == null + ? resolveTargetIds(command, initialContext).targetIds + : [...new Set(frozenTargetIds)]; + const dedupeKey = `${commandId}:${[...frozen].sort().join('|')}`; + if (inFlight.has(dedupeKey)) return inFlight.get(dedupeKey); + + const promise = (async () => { + const context = getContext(); + const { targetIds, missingTargetIds } = resolveTargetIds(command, context, frozen); + const executor = executors[command.executorId]; + if (!executor) return failed(commandId, new Error(`Missing executor "${command.executorId}"`), targetIds); + if (frozen.length && !targetIds.length) { + return { status: 'partial', commandId, targetIds, missingTargetIds, succeededIds: [], failed: [] }; + } + try { + const result = await executor({ command, context, source, input, targetIds }); + if (result?.status === 'needs_input') { + const continuation = Object.freeze({ + commandId, + kind: result.continuation.kind, + targetIds: Object.freeze([...frozen]), + props: Object.freeze({ ...(result.continuation.props || {}) }), + }); + onContinuation(continuation); + return { status: 'needs_input', continuation }; + } + const outcome = terminalOutcome(commandId, targetIds, result); + if (missingTargetIds.length && outcome.status === 'success') { + return { + status: 'partial', commandId, targetIds, missingTargetIds, + succeededIds: [...targetIds], failed: [], value: outcome.value, + }; + } + return missingTargetIds.length ? { ...outcome, missingTargetIds } : outcome; + } catch (error) { + return failed(commandId, error, targetIds); + } + })().then(outcome => { + if (outcome.status !== 'needs_input') onOutcome(outcome); + return outcome; + }).finally(() => inFlight.delete(dedupeKey)); + + inFlight.set(dedupeKey, promise); + return promise; + } + + return Object.freeze({ execute }); +} diff --git a/frontend/src/commands/controller.test.js b/frontend/src/commands/controller.test.js new file mode 100644 index 00000000..f9dc6e60 --- /dev/null +++ b/frontend/src/commands/controller.test.js @@ -0,0 +1,108 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext } from './contracts.js'; +import { createCommandRegistry } from './registry.js'; +import { createCommandController } from './controller.js'; + +const definition = (overrides = {}) => ({ + id: 'mail.move', titleKey: 'move', aliasKeys: [], icon: 'move', group: 'mail', + defaultKeys: { primary: 'v', secondary: [] }, rank: { base: 1 }, + isAvailable: () => true, targetMode: 'bulk_safe', executorId: 'mail.move', ...overrides, +}); +const makeContext = (ids = ['acct:', 'acct:']) => createCommandContext({ + surface: 'list', activeConversationId: 'acct:', selectedConversationIds: ids, + conversations: ids.map((id, index) => ({ id: `row-${index}`, message_id: id.slice('acct:'.length), account_id: 'acct' })), + accountId: 'acct', folder: 'INBOX', draft: null, gtdAvailable: false, + cardDavConnected: false, modal: null, editing: false, platform: 'mac', + shortcutOverrides: {}, translate: key => key, +}); + +describe('createCommandController', () => { + it('rejects unknown, unavailable, and missing executors as failed outcomes', async () => { + const registry = createCommandRegistry([definition({ isAvailable: () => false })]); + const controller = createCommandController({ registry, getContext: () => makeContext(), executors: {} }); + assert.match((await controller.execute('missing.command')).error.message, /Unknown command/); + assert.match((await controller.execute('mail.move')).error.message, /not available/); + + const available = createCommandRegistry([definition()]); + const noExecutor = createCommandController({ registry: available, getContext: () => makeContext(), executors: {} }); + assert.match((await noExecutor.execute('mail.move')).error.message, /Missing executor/); + }); + + it('freezes targets into a continuation and reuses them on resume', async () => { + let context = makeContext(); + const calls = []; + const continuations = []; + const registry = createCommandRegistry([definition()]); + const controller = createCommandController({ + registry, + getContext: () => context, + executors: { + 'mail.move': args => { + calls.push(args); + return args.input + ? { status: 'success', value: { folder: args.input.folder } } + : { status: 'needs_input', continuation: { kind: 'move', props: { titleKey: 'move.title' } } }; + }, + }, + onContinuation: value => continuations.push(value), + }); + + const first = await controller.execute('mail.move', { source: 'palette' }); + assert.deepEqual(first.continuation, { + commandId: 'mail.move', kind: 'move', targetIds: ['acct:', 'acct:'], props: { titleKey: 'move.title' }, + }); + assert.deepEqual(continuations, [first.continuation]); + + context = makeContext(['acct:']); + const resumed = await controller.execute('mail.move', { + source: 'palette', input: { folder: 'Archive' }, frozenTargetIds: first.continuation.targetIds, + }); + assert.equal(resumed.status, 'partial'); + assert.deepEqual(resumed.targetIds, ['acct:']); + assert.deepEqual(resumed.missingTargetIds, ['acct:']); + assert.deepEqual(calls[1].targetIds, ['acct:']); + }); + + it('deduplicates only concurrent execution of the same command and target set', async () => { + let release; + let callCount = 0; + const gate = new Promise(resolve => { release = resolve; }); + const registry = createCommandRegistry([definition()]); + const controller = createCommandController({ + registry, getContext: () => makeContext(), + executors: { 'mail.move': async () => { callCount += 1; await gate; return { status: 'success' }; } }, + }); + const one = controller.execute('mail.move'); + const two = controller.execute('mail.move'); + assert.strictEqual(one, two); + release(); + await one; + await controller.execute('mail.move'); + assert.equal(callCount, 2); + }); + + it('normalizes success, cancelled, partial, thrown failures, and callback delivery', async () => { + const terminal = []; + const outcomes = ['success', 'cancelled', 'partial']; + for (const status of outcomes) { + const registry = createCommandRegistry([definition()]); + const controller = createCommandController({ + registry, getContext: () => makeContext(['acct:']), + executors: { 'mail.move': () => status === 'partial' + ? { status, succeededIds: [], failed: [{ targetId: 'acct:', error: new Error('nope') }] } + : { status } }, + onOutcome: outcome => terminal.push(outcome.status), + }); + assert.equal((await controller.execute('mail.move')).status, status); + } + const registry = createCommandRegistry([definition()]); + const failed = createCommandController({ + registry, getContext: () => makeContext(['acct:']), + executors: { 'mail.move': () => { throw new Error('boom'); } }, + onOutcome: outcome => terminal.push(outcome.status), + }); + assert.equal((await failed.execute('mail.move')).status, 'failed'); + assert.deepEqual(terminal, ['success', 'cancelled', 'partial', 'failed']); + }); +}); diff --git a/frontend/src/commands/registry.js b/frontend/src/commands/registry.js new file mode 100644 index 00000000..0da4eea9 --- /dev/null +++ b/frontend/src/commands/registry.js @@ -0,0 +1,34 @@ +import { validateCommandDefinition } from './contracts.js'; +import { hasTargets } from './targets.js'; +import { rankCommands } from './search.js'; +import { effectiveCommandKeys } from './shortcuts.js'; + +export function createCommandRegistry(definitions) { + const ordered = []; + const byId = new Map(); + for (const input of definitions) { + const command = validateCommandDefinition(input); + if (byId.has(command.id)) throw new TypeError(`duplicate command id "${command.id}"`); + ordered.push(command); + byId.set(command.id, command); + } + Object.freeze(ordered); + + const available = context => ordered.filter(command => command.isAvailable(context) && hasTargets(command, context)); + const decorate = (results, context) => results.map(result => ({ + ...result, + bindings: effectiveCommandKeys(result.command, context).bindings, + })); + + return Object.freeze({ + get(id) { + return byId.get(id) || null; + }, + list(context) { + return decorate(rankCommands(available(context), '', context), context); + }, + search(query, context) { + return decorate(rankCommands(available(context), query, context), context); + }, + }); +} diff --git a/frontend/src/commands/registry.test.js b/frontend/src/commands/registry.test.js new file mode 100644 index 00000000..c93bb3c7 --- /dev/null +++ b/frontend/src/commands/registry.test.js @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext } from './contracts.js'; +import { createCommandRegistry } from './registry.js'; + +const raw = (id, overrides = {}) => ({ + id, titleKey: `${id}.title`, aliasKeys: [], icon: 'test', group: 'test', + defaultKeys: { primary: null, secondary: [] }, rank: { base: 0 }, + isAvailable: () => true, targetMode: 'global', executorId: id, ...overrides, +}); +const context = createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], conversations: [], + accountId: null, folder: null, draft: null, gtdAvailable: false, cardDavConnected: false, + modal: null, editing: false, platform: 'mac', shortcutOverrides: {}, translate: key => key, +}); + +describe('createCommandRegistry', () => { + it('rejects duplicate IDs before exposing a partial registry', () => { + assert.throws(() => createCommandRegistry([raw('test.one'), raw('test.one')]), /duplicate command id "test.one"/); + }); + + it('returns definitions by ID without exposing mutation', () => { + const registry = createCommandRegistry([raw('test.one')]); + assert.equal(registry.get('test.one').executorId, 'test.one'); + assert.equal(registry.get('test.missing'), null); + assert.ok(Object.isFrozen(registry.get('test.one'))); + }); + + it('omits explicit and target-mode-unavailable commands', () => { + const registry = createCommandRegistry([ + raw('global.visible'), + raw('global.hidden', { isAvailable: () => false }), + raw('mail.archive', { targetMode: 'bulk_safe' }), + ]); + assert.deepEqual(registry.list(context).map(entry => entry.command.id), ['global.visible']); + }); + + it('returns effective bindings and localized alias search metadata', () => { + const registry = createCommandRegistry([raw('mail.archive', { + titleKey: 'archive', aliasKeys: ['done'], defaultKeys: { primary: 'e', secondary: [] }, + })]); + const localized = { ...context, translate: key => ({ archive: 'Archive', done: 'Done' })[key] || key }; + const [result] = registry.search('done', localized); + assert.equal(result.title, 'Archive'); + assert.equal(result.matchedAlias, 'Done'); + assert.deepEqual(result.bindings, [{ key: 'e', kind: 'primary' }]); + }); +}); diff --git a/frontend/src/commands/search.js b/frontend/src/commands/search.js new file mode 100644 index 00000000..f754123e --- /dev/null +++ b/frontend/src/commands/search.js @@ -0,0 +1,60 @@ +function normalize(value) { + return String(value || '').normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLowerCase().trim(); +} + +function editDistance(a, b) { + const rows = Array.from({ length: a.length + 1 }, (_, i) => [i]); + for (let j = 1; j <= b.length; j += 1) rows[0][j] = j; + for (let i = 1; i <= a.length; i += 1) { + for (let j = 1; j <= b.length; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { + rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1); + } + } + } + return rows[a.length][b.length]; +} + +function fuzzyScore(candidate, query) { + const text = normalize(candidate); + const needle = normalize(query); + if (!needle) return 0; + if (text === needle) return 1000; + if (text.startsWith(needle)) return 900 - (text.length - needle.length); + if (text.includes(needle)) return 800 - text.indexOf(needle); + const distance = editDistance(text, needle); + const limit = Math.max(1, Math.floor(Math.max(text.length, needle.length) * 0.34)); + return distance <= limit ? 600 - (distance * 40) - Math.abs(text.length - needle.length) : null; +} + +export function rankCommands(commands, query, context) { + return commands.map((command, index) => { + const title = context.translate(command.titleKey, command.params); + const aliases = command.aliasKeys.map(key => ({ key, value: context.translate(key, command.params) })); + const candidates = [{ key: null, value: title }, ...aliases] + .map(item => ({ ...item, fuzzy: fuzzyScore(item.value, query) })) + .filter(item => item.fuzzy != null) + .sort((a, b) => b.fuzzy - a.fuzzy); + if (query.trim() && !candidates.length) return null; + const match = candidates[0] || { key: null, value: title, fuzzy: 0 }; + const boost = command.rank.boost ? command.rank.boost(context) : 0; + return { + command, + title, + matchedAlias: match.key ? match.value : null, + matchedAliasKey: match.key, + score: match.fuzzy + command.rank.base + boost, + index, + }; + }).filter(Boolean) + .sort((a, b) => b.score - a.score || a.index - b.index) + .map(result => ({ + command: result.command, + title: result.title, + matchedAlias: result.matchedAlias, + matchedAliasKey: result.matchedAliasKey, + score: result.score, + })); +} diff --git a/frontend/src/commands/search.test.js b/frontend/src/commands/search.test.js new file mode 100644 index 00000000..65573612 --- /dev/null +++ b/frontend/src/commands/search.test.js @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext, validateCommandDefinition } from './contracts.js'; +import { rankCommands } from './search.js'; + +const make = (id, titleKey, aliasKeys, base, boost = () => 0) => validateCommandDefinition({ + id, titleKey, aliasKeys, icon: 'test', group: 'test', + defaultKeys: { primary: null, secondary: [] }, rank: { base, boost }, + isAvailable: () => true, targetMode: 'global', executorId: id, +}); +const strings = { + archive: 'Archive', done: 'Done', compose: 'Compose', search: 'Search', settings: 'Settings', +}; +const context = createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], conversations: [], + accountId: null, folder: null, draft: null, gtdAvailable: false, cardDavConnected: false, + modal: null, editing: false, platform: 'mac', shortcutOverrides: {}, + translate: key => strings[key] || key, +}); +const archive = make('mail.archive', 'archive', ['done'], 50, ctx => ctx.selectedConversationIds.length ? 30 : 0); +const compose = make('compose.new', 'compose', [], 60); +const settings = make('settings.open', 'settings', [], 10); + +describe('rankCommands', () => { + it('finds case-insensitive title matches', () => { + assert.equal(rankCommands([archive, compose], 'ARCHIVE', context)[0].command.id, 'mail.archive'); + }); + + it('keeps the Mailflow title and discloses the matching alias', () => { + const [result] = rankCommands([archive, compose], 'done', context); + assert.equal(result.title, 'Archive'); + assert.equal(result.matchedAlias, 'Done'); + assert.equal(result.matchedAliasKey, 'done'); + }); + + it('tolerates a close transposition misspelling', () => { + assert.equal(rankCommands([archive, compose], 'arhcive', context)[0].command.id, 'mail.archive'); + }); + + it('uses stable base priority for an empty query', () => { + assert.deepEqual(rankCommands([settings, compose, archive], '', context).map(x => x.command.id), [ + 'compose.new', 'mail.archive', 'settings.open', + ]); + }); + + it('adds contextual boost and preserves definition order for exact ties', () => { + const selected = { ...context, selectedConversationIds: ['acct:'] }; + const equalA = make('navigation.a', 'search', [], 1); + const equalB = make('navigation.b', 'search', [], 1); + assert.equal(rankCommands([compose, archive], '', selected)[0].command.id, 'mail.archive'); + assert.deepEqual(rankCommands([equalA, equalB], 'search', context).map(x => x.command.id), [ + 'navigation.a', 'navigation.b', + ]); + }); +}); diff --git a/frontend/src/commands/shortcuts.js b/frontend/src/commands/shortcuts.js new file mode 100644 index 00000000..b684bbe6 --- /dev/null +++ b/frontend/src/commands/shortcuts.js @@ -0,0 +1,43 @@ +function selectKey(spec, platform) { + if (!spec) return null; + if (typeof spec === 'string') return spec; + return spec[platform] || spec.default || null; +} + +export function effectiveCommandKeys(command, context) { + const hasOverride = Object.hasOwn(context.shortcutOverrides, command.id); + const override = hasOverride ? context.shortcutOverrides[command.id] : undefined; + const bindings = []; + const primary = hasOverride ? override : selectKey(command.defaultKeys.primary, context.platform); + if (primary) bindings.push({ key: primary, kind: hasOverride ? 'user' : 'primary' }); + for (const spec of command.defaultKeys.secondary) { + const key = selectKey(spec, context.platform); + if (key && !bindings.some(binding => binding.key === key)) bindings.push({ key, kind: 'secondary' }); + } + return { bindings, conflicts: [] }; +} + +export function formatCommandKey(key, platform) { + const mac = platform === 'mac'; + const labels = mac + ? { meta: '⌘', ctrl: '⌃', alt: '⌥', shift: '⇧', enter: '↵' } + : { meta: 'Win+', ctrl: 'Ctrl+', alt: 'Alt+', shift: 'Shift+', enter: 'Enter' }; + return key.split('+').map((part, index, all) => { + const normalized = part.toLowerCase(); + const label = labels[normalized] || (part.length === 1 ? part.toUpperCase() : part); + return mac || index === all.length - 1 ? label : label; + }).join(mac ? '' : ''); +} + +export function findBindingConflicts(commands, context) { + const owners = new Map(); + for (const command of commands) { + for (const { key } of effectiveCommandKeys(command, context).bindings) { + if (!owners.has(key)) owners.set(key, []); + owners.get(key).push(command.id); + } + } + return [...owners.entries()] + .filter(([, commandIds]) => commandIds.length > 1) + .map(([key, commandIds]) => ({ key, commandIds })); +} diff --git a/frontend/src/commands/shortcuts.test.js b/frontend/src/commands/shortcuts.test.js new file mode 100644 index 00000000..8bcc80b2 --- /dev/null +++ b/frontend/src/commands/shortcuts.test.js @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { effectiveCommandKeys, findBindingConflicts, formatCommandKey } from './shortcuts.js'; + +const command = { + id: 'palette.toggle', + defaultKeys: { + primary: { mac: 'meta+k', windows: 'ctrl+k', linux: 'ctrl+k' }, + secondary: ['alt+k'], + }, +}; + +describe('command shortcut metadata', () => { + it('selects the platform primary and retains secondary bindings', () => { + assert.deepEqual(effectiveCommandKeys(command, { platform: 'mac', shortcutOverrides: {} }).bindings, [ + { key: 'meta+k', kind: 'primary' }, { key: 'alt+k', kind: 'secondary' }, + ]); + assert.equal(effectiveCommandKeys(command, { platform: 'linux', shortcutOverrides: {} }).bindings[0].key, 'ctrl+k'); + }); + + it('uses the unchanged user override as primary without deleting secondary keys', () => { + assert.deepEqual(effectiveCommandKeys(command, { + platform: 'windows', shortcutOverrides: { 'palette.toggle': 'ctrl+shift+k' }, + }).bindings, [ + { key: 'ctrl+shift+k', kind: 'user' }, { key: 'alt+k', kind: 'secondary' }, + ]); + }); + + it('supports explicit primary unbinding and formats platform labels', () => { + assert.deepEqual(effectiveCommandKeys(command, { + platform: 'mac', shortcutOverrides: { 'palette.toggle': null }, + }).bindings, [{ key: 'alt+k', kind: 'secondary' }]); + assert.equal(formatCommandKey('meta+shift+k', 'mac'), '⌘⇧K'); + assert.equal(formatCommandKey('ctrl+shift+k', 'windows'), 'Ctrl+Shift+K'); + }); + + it('reports conflicts instead of silently dropping either command', () => { + const commands = [command, { ...command, id: 'navigation.search', defaultKeys: { primary: 'alt+k', secondary: [] } }]; + assert.deepEqual(findBindingConflicts(commands, { platform: 'linux', shortcutOverrides: {} }), [ + { key: 'alt+k', commandIds: ['palette.toggle', 'navigation.search'] }, + ]); + }); +}); diff --git a/frontend/src/commands/targets.js b/frontend/src/commands/targets.js new file mode 100644 index 00000000..e911aa83 --- /dev/null +++ b/frontend/src/commands/targets.js @@ -0,0 +1,28 @@ +import { TARGET_MODES } from './contracts.js'; + +function contextualIds(context) { + return context.selectedConversationIds.length + ? context.selectedConversationIds + : context.activeConversationId ? [context.activeConversationId] : []; +} + +export function hasTargets(command, context) { + switch (command.targetMode) { + case TARGET_MODES.GLOBAL: return true; + case TARGET_MODES.ACCOUNT: return Boolean(context.accountId); + case TARGET_MODES.DRAFT: return Boolean(context.draft?.id); + case TARGET_MODES.SINGLE_CONVERSATION: return contextualIds(context).length === 1; + case TARGET_MODES.BULK_SAFE: return contextualIds(context).length > 0; + default: return false; + } +} + +export function resolveTargetIds(command, context, frozenTargetIds) { + if (![TARGET_MODES.SINGLE_CONVERSATION, TARGET_MODES.BULK_SAFE].includes(command.targetMode)) { + return { targetIds: [], missingTargetIds: [] }; + } + const requested = frozenTargetIds == null ? contextualIds(context) : [...new Set(frozenTargetIds)]; + const targetIds = requested.filter(id => context.conversationsById[id]); + const missingTargetIds = requested.filter(id => !context.conversationsById[id]); + return { targetIds, missingTargetIds }; +} diff --git a/frontend/src/commands/targets.test.js b/frontend/src/commands/targets.test.js new file mode 100644 index 00000000..7c8df9b0 --- /dev/null +++ b/frontend/src/commands/targets.test.js @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext, validateCommandDefinition } from './contracts.js'; +import { hasTargets, resolveTargetIds } from './targets.js'; + +const command = targetMode => validateCommandDefinition({ + id: `test.${targetMode}`, + titleKey: 'test.title', aliasKeys: [], icon: 'test', group: 'test', + defaultKeys: { primary: null, secondary: [] }, rank: { base: 0 }, + isAvailable: () => true, targetMode, executorId: 'test.execute', +}); +const context = overrides => createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], + conversations: [ + { id: 'row-a', message_id: '', account_id: 'acct' }, + { id: 'row-b', message_id: '', account_id: 'acct' }, + ], + accountId: 'acct', folder: 'INBOX', draft: null, gtdAvailable: false, + cardDavConnected: false, modal: null, editing: false, platform: 'linux', + shortcutOverrides: {}, translate: key => key, ...overrides, +}); + +describe('command targets', () => { + it('omits conversation commands without an active row or checkbox selection', () => { + assert.equal(hasTargets(command('single_conversation'), context({})), false); + assert.equal(hasTargets(command('bulk_safe'), context({})), false); + }); + + it('makes single and bulk-safe commands available for one active conversation', () => { + const ctx = context({ activeConversationId: 'acct:' }); + assert.equal(hasTargets(command('single_conversation'), ctx), true); + assert.deepEqual(resolveTargetIds(command('bulk_safe'), ctx), { + targetIds: ['acct:'], missingTargetIds: [], + }); + }); + + it('uses the complete selection and hides single-conversation commands in bulk', () => { + const ctx = context({ activeConversationId: 'acct:', selectedConversationIds: ['acct:', 'acct:'] }); + assert.equal(hasTargets(command('single_conversation'), ctx), false); + assert.deepEqual(resolveTargetIds(command('bulk_safe'), ctx).targetIds, ['acct:', 'acct:']); + }); + + it('re-resolves a frozen continuation target set and reports missing targets', () => { + assert.deepEqual(resolveTargetIds(command('bulk_safe'), context({}), ['acct:', 'acct:']), { + targetIds: ['acct:'], missingTargetIds: ['acct:'], + }); + }); + + it('requires current account and draft state for their scoped modes', () => { + assert.equal(hasTargets(command('account'), context({ accountId: null })), false); + assert.equal(hasTargets(command('account'), context({ accountId: 'acct' })), true); + assert.equal(hasTargets(command('draft'), context({ draft: null })), false); + assert.equal(hasTargets(command('draft'), context({ draft: { id: 'draft-1' } })), true); + }); +}); From 16b0864a651272b5e070b202070c095e06044d3b Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 31 Jul 2026 17:06:27 -0700 Subject: [PATCH 2/2] feat: add contextual command palette --- .../src/commands/CommandRuntimeContext.jsx | 13 + .../commands/CommandRuntimeContext.test.js | 12 + frontend/src/commands/appCommands.js | 113 +++++++ frontend/src/commands/appCommands.test.js | 83 +++++ frontend/src/commands/appContext.js | 92 ++++++ frontend/src/commands/appContext.test.js | 86 +++++ frontend/src/commands/paletteFocus.js | 14 + frontend/src/commands/paletteFocus.test.js | 21 ++ frontend/src/commands/paletteShortcut.js | 9 + frontend/src/commands/paletteShortcut.test.js | 31 ++ frontend/src/commands/paletteState.js | 29 ++ frontend/src/commands/paletteState.test.js | 49 +++ frontend/src/commands/selection.js | 5 + frontend/src/commands/selection.test.js | 20 ++ .../src/components/CommandContinuation.jsx | 22 ++ frontend/src/components/CommandIcon.jsx | 16 + frontend/src/components/CommandPalette.jsx | 311 +++++++----------- .../src/components/CommandPalette.test.js | 63 ++++ frontend/src/components/MailApp.jsx | 44 ++- frontend/src/components/MessageList.jsx | 29 +- frontend/src/hooks/useCommandRuntime.js | 57 ++++ frontend/src/hooks/useCommandRuntime.test.js | 21 ++ frontend/src/index.css | 23 ++ frontend/src/locales/de.json | 56 +++- frontend/src/locales/en.json | 56 +++- frontend/src/locales/es.json | 56 +++- frontend/src/locales/fr.json | 56 +++- frontend/src/locales/i18n.test.js | 28 ++ frontend/src/locales/it.json | 56 +++- frontend/src/locales/ru.json | 56 +++- frontend/src/locales/zhCN.json | 56 +++- frontend/src/store/index.js | 13 +- 32 files changed, 1309 insertions(+), 287 deletions(-) create mode 100644 frontend/src/commands/CommandRuntimeContext.jsx create mode 100644 frontend/src/commands/CommandRuntimeContext.test.js create mode 100644 frontend/src/commands/appCommands.js create mode 100644 frontend/src/commands/appCommands.test.js create mode 100644 frontend/src/commands/appContext.js create mode 100644 frontend/src/commands/appContext.test.js create mode 100644 frontend/src/commands/paletteFocus.js create mode 100644 frontend/src/commands/paletteFocus.test.js create mode 100644 frontend/src/commands/paletteShortcut.js create mode 100644 frontend/src/commands/paletteShortcut.test.js create mode 100644 frontend/src/commands/paletteState.js create mode 100644 frontend/src/commands/paletteState.test.js create mode 100644 frontend/src/commands/selection.js create mode 100644 frontend/src/commands/selection.test.js create mode 100644 frontend/src/components/CommandContinuation.jsx create mode 100644 frontend/src/components/CommandIcon.jsx create mode 100644 frontend/src/components/CommandPalette.test.js create mode 100644 frontend/src/hooks/useCommandRuntime.js create mode 100644 frontend/src/hooks/useCommandRuntime.test.js diff --git a/frontend/src/commands/CommandRuntimeContext.jsx b/frontend/src/commands/CommandRuntimeContext.jsx new file mode 100644 index 00000000..2e7a63cb --- /dev/null +++ b/frontend/src/commands/CommandRuntimeContext.jsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +const CommandRuntimeContext = createContext(null); + +export function CommandRuntimeProvider({ runtime, children }) { + return {children}; +} + +export function useCommandRuntimeContext() { + const runtime = useContext(CommandRuntimeContext); + if (!runtime) throw new Error('useCommandRuntimeContext must be used inside CommandRuntimeProvider'); + return runtime; +} diff --git a/frontend/src/commands/CommandRuntimeContext.test.js b/frontend/src/commands/CommandRuntimeContext.test.js new file mode 100644 index 00000000..60a50e35 --- /dev/null +++ b/frontend/src/commands/CommandRuntimeContext.test.js @@ -0,0 +1,12 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +describe('CommandRuntimeContext source contract', () => { + it('exports one provider and one strict consumer hook', () => { + const source = fs.readFileSync(new URL('./CommandRuntimeContext.jsx', import.meta.url), 'utf8'); + assert.match(source, /export function CommandRuntimeProvider/); + assert.match(source, /export function useCommandRuntimeContext/); + assert.match(source, /must be used inside CommandRuntimeProvider/); + }); +}); diff --git a/frontend/src/commands/appCommands.js b/frontend/src/commands/appCommands.js new file mode 100644 index 00000000..6f26a0bf --- /dev/null +++ b/frontend/src/commands/appCommands.js @@ -0,0 +1,113 @@ +const globalCommand = (id, titleKey, icon, group, executorId, params = {}, overrides = {}) => ({ + id, titleKey, aliasKeys: [], icon, group, + defaultKeys: { primary: null, secondary: [] }, + rank: { base: 50 }, isAvailable: () => true, targetMode: 'global', executorId, params, + ...overrides, +}); + +const settingsTabs = ['accounts', 'notifications', 'rules', 'categories', 'appearance', 'shortcuts', + 'security', 'integrations', 'ai-actions', 'about']; +const adminTabs = ['users', 'sso', 'ai']; +const gtdSections = ['todo', 'watch', 'delegated', 'reference', 'someday']; + +export function createAppCommandDefinitions({ accounts = [], folders = {}, themes = {}, user = {} }) { + const definitions = [ + globalCommand('compose.new', 'commands.compose.new.title', 'compose', 'compose', 'app.compose', {}, { + aliasKeys: ['commands.compose.new.alias.write'], + defaultKeys: { primary: 'c', secondary: [] }, rank: { base: 100 }, + isAvailable: context => context.surface !== 'compose' && context.surface !== 'settings', + }), + globalCommand('navigation.search', 'commands.navigation.search.title', 'search', 'navigation', 'navigation.search', {}, { + defaultKeys: { primary: '/', secondary: [] }, rank: { base: 90 }, + isAvailable: context => context.surface !== 'compose' && context.surface !== 'settings', + }), + globalCommand('navigation.contacts', 'commands.navigation.contacts.title', 'contacts', 'navigation', 'navigation.contacts'), + globalCommand('navigation.unified-inbox', 'commands.navigation.unifiedInbox.title', 'inbox', 'navigation', 'navigation.inbox', { + accountId: null, folder: 'INBOX', + }), + ]; + + for (const account of accounts) { + definitions.push(globalCommand( + `navigation.account-inbox.${account.id}`, + 'commands.navigation.accountInbox.title', + 'inbox', 'navigation', 'navigation.inbox', + { accountId: account.id, folder: 'INBOX', name: account.name || account.email_address }, + )); + for (const folder of folders[account.id] || []) { + if (folder.path === 'INBOX') continue; + definitions.push(globalCommand( + `navigation.folder.${account.id}.${folder.path}`, + 'commands.navigation.folder.title', + 'folder', 'navigation', 'navigation.folder', + { accountId: account.id, folder: folder.path, name: folder.name || folder.path }, + )); + } + } + + for (const section of gtdSections) { + definitions.push(globalCommand( + `navigation.gtd.${section}`, + `commands.navigation.gtd.${section}.title`, + 'gtd', 'navigation', 'navigation.gtd', { section }, + { isAvailable: context => context.gtdAvailable }, + )); + } + for (const [theme, value] of Object.entries(themes)) { + definitions.push(globalCommand( + `appearance.theme.${theme}`, + 'commands.appearance.theme.title', + 'appearance', 'appearance', 'appearance.theme', { theme, name: value.label || theme }, + )); + } + for (const tab of [...settingsTabs, ...(user.isAdmin ? adminTabs : [])]) { + definitions.push(globalCommand( + `settings.${tab}`, + `commands.settings.${tab}.title`, + 'settings', 'settings', 'settings.open', { tab }, + )); + } + return definitions; +} + +export function createAppCommandExecutors({ getState, emitShortcut }) { + return Object.freeze({ + 'app.compose': () => { + const state = getState(); + state.openCompose({ accountId: state.selectedAccountId || undefined }); + return { status: 'success' }; + }, + 'navigation.search': () => { + emitShortcut('focusSearch'); + return { status: 'success' }; + }, + 'navigation.contacts': () => { + getState().setShowContacts(true); + return { status: 'success' }; + }, + 'navigation.inbox': ({ command }) => { + getState().setSelectedAccount(command.params.accountId, 'INBOX'); + return { status: 'success' }; + }, + 'navigation.folder': ({ command }) => { + getState().setSelectedAccount(command.params.accountId, command.params.folder); + return { status: 'success' }; + }, + 'navigation.gtd': ({ command }) => { + const state = getState(); + state.setSelectedAccount(state.selectedAccountId, 'INBOX'); + state.setActiveGtdTab(command.params.section); + return { status: 'success' }; + }, + 'appearance.theme': ({ command }) => { + getState().setTheme(command.params.theme); + return { status: 'success' }; + }, + 'settings.open': ({ command }) => { + const state = getState(); + state.setAdminTab(command.params.tab); + state.setShowAdmin(true); + return { status: 'success' }; + }, + }); +} diff --git a/frontend/src/commands/appCommands.test.js b/frontend/src/commands/appCommands.test.js new file mode 100644 index 00000000..92e8dddb --- /dev/null +++ b/frontend/src/commands/appCommands.test.js @@ -0,0 +1,83 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCommandContext } from './contracts.js'; +import { createCommandRegistry } from './registry.js'; +import { createAppCommandDefinitions, createAppCommandExecutors } from './appCommands.js'; + +const snapshot = { + accounts: [{ id: 'acct-1', name: 'Work', gtd_enabled: true }], + folders: { 'acct-1': [{ path: 'INBOX', name: 'Inbox' }, { path: 'Projects', name: 'Projects' }] }, + themes: { system: { label: 'System' }, midnight: { label: 'Midnight' } }, + user: { isAdmin: false }, +}; +const context = createCommandContext({ + surface: 'list', activeConversationId: null, selectedConversationIds: [], conversations: [], + accountId: 'acct-1', folder: 'INBOX', draft: null, gtdAvailable: true, + cardDavConnected: false, modal: null, editing: false, platform: 'mac', shortcutOverrides: {}, + translate: (key, values) => values?.name || key, +}); + +describe('application commands', () => { + it('builds Compose, Search, Contacts, unified/account/folder/GTD, theme, and settings commands', () => { + const ids = createAppCommandDefinitions(snapshot).map(command => command.id); + for (const id of [ + 'compose.new', 'navigation.search', 'navigation.contacts', 'navigation.unified-inbox', + 'navigation.account-inbox.acct-1', 'navigation.folder.acct-1.Projects', + 'navigation.gtd.todo', 'appearance.theme.system', 'settings.accounts', 'settings.shortcuts', + ]) assert.ok(ids.includes(id), `missing ${id}`); + assert.equal(ids.some(id => id.startsWith('mail.')), false); + }); + + it('keeps administrator-only settings absent for non-admin users', () => { + const userIds = createAppCommandDefinitions(snapshot).map(command => command.id); + assert.equal(userIds.includes('settings.users'), false); + const adminIds = createAppCommandDefinitions({ ...snapshot, user: { isAdmin: true } }).map(command => command.id); + assert.equal(adminIds.includes('settings.users'), true); + assert.equal(adminIds.includes('settings.sso'), true); + assert.equal(adminIds.includes('settings.ai'), true); + }); + + it('searches account/folder labels through localized dynamic keys', () => { + const registry = createCommandRegistry(createAppCommandDefinitions(snapshot)); + assert.equal(registry.search('Projects', context)[0].command.id, 'navigation.folder.acct-1.Projects'); + }); + + it('routes execution through injected state services', async () => { + const calls = []; + const state = { + openCompose: value => calls.push(['compose', value]), + setSelectedAccount: (accountId, folder) => calls.push(['navigate', accountId, folder]), + setShowContacts: value => calls.push(['contacts', value]), + setActiveGtdTab: value => calls.push(['gtd', value]), + setAdminTab: value => calls.push(['tab', value]), + setShowAdmin: value => calls.push(['admin', value]), + setTheme: value => calls.push(['theme', value]), + }; + const executors = createAppCommandExecutors({ getState: () => state, emitShortcut: id => calls.push(['shortcut', id]) }); + await executors['navigation.folder']({ command: { params: { accountId: 'acct-1', folder: 'Projects' } } }); + await executors['settings.open']({ command: { params: { tab: 'appearance' } } }); + await executors['navigation.search']({ command: { params: {} } }); + assert.deepEqual(calls, [ + ['navigate', 'acct-1', 'Projects'], ['tab', 'appearance'], ['admin', true], ['shortcut', 'focusSearch'], + ]); + }); + + it('selects a GTD destination after navigation clears the previous tab', async () => { + const state = { + selectedAccountId: 'acct-1', + activeGtdTab: 'watch', + setSelectedAccount(accountId, folder) { + this.selectedAccountId = accountId; + this.selectedFolder = folder; + this.activeGtdTab = null; + }, + setActiveGtdTab(section) { + this.activeGtdTab = section; + }, + }; + const executors = createAppCommandExecutors({ getState: () => state, emitShortcut() {} }); + await executors['navigation.gtd']({ command: { params: { section: 'delegated' } } }); + assert.equal(state.selectedFolder, 'INBOX'); + assert.equal(state.activeGtdTab, 'delegated'); + }); +}); diff --git a/frontend/src/commands/appContext.js b/frontend/src/commands/appContext.js new file mode 100644 index 00000000..6ede21a4 --- /dev/null +++ b/frontend/src/commands/appContext.js @@ -0,0 +1,92 @@ +import { createCommandContext, stableConversationId } from './contracts.js'; + +export function detectCommandPlatform(navigatorLike = {}) { + const value = navigatorLike.userAgentData?.platform || navigatorLike.platform || ''; + if (/mac|iphone|ipad|ipod/i.test(value)) return 'mac'; + if (/win/i.test(value)) return 'windows'; + return 'linux'; +} + +function allConversations(state) { + const gtd = Object.values(state.gtdSections || {}).flatMap(section => section?.threads || []); + return [ + ...(state.messages || []), + ...(state.searchResults || []), + ...Object.values(state.threadMessages || {}).flat(), + ...gtd, + ].filter((message, index, all) => { + const id = stableConversationId(message); + return id && all.findIndex(candidate => stableConversationId(candidate) === id) === index; + }); +} + +export function buildAppCommandContext(state, { + translate, + platform = detectCommandPlatform(globalThis.navigator), + editing = false, + modal = null, +} = {}) { + const conversations = allConversations(state); + const visibleMessages = state.searchQuery?.trim() + ? (state.searchResults || []) + : state.activeGtdTab + ? (state.gtdSections?.[state.activeGtdTab]?.threads || []) + : (state.messages || []); + const selectedRows = new Set(state.selectedMessageIds || []); + const selectedConversationIds = conversations + .filter(message => selectedRows.has(message.id)) + .map(stableConversationId); + const active = conversations.find(message => message.id === state.selectedMessageId); + const targetedMessages = selectedConversationIds.length + ? conversations.filter(message => selectedConversationIds.includes(stableConversationId(message))) + : active ? [active] : []; + const targetedAccountIds = [...new Set(targetedMessages.map(message => message.account_id).filter(Boolean))]; + const accounts = state.accounts || []; + const gtdAvailable = targetedAccountIds.length + ? targetedAccountIds.every(id => accounts.find(account => account.id === id)?.gtd_enabled) + : state.selectedAccountId + ? Boolean(accounts.find(account => account.id === state.selectedAccountId)?.gtd_enabled) + : accounts.some(account => account.gtd_enabled); + const surface = modal ? 'picker' + : state.showAdmin ? 'settings' + : state.composing ? 'compose' + : state.selectedMessageId ? 'conversation' : 'list'; + const legacyShortcutIds = { + compose: 'compose.new', + focusSearch: 'navigation.search', + goInbox: 'navigation.unified-inbox', + }; + const shortcutOverrides = Object.fromEntries(Object.entries(state.shortcuts || {}) + .filter(([legacyId]) => legacyShortcutIds[legacyId]) + .map(([legacyId, key]) => [legacyShortcutIds[legacyId], key])); + + return createCommandContext({ + surface, + activeConversationId: stableConversationId(active), + activeMessage: active || null, + selectedConversationIds, + visibleConversationIds: visibleMessages.map(stableConversationId).filter(Boolean), + conversations, + accountId: state.selectedAccountId, + folder: state.selectedFolder, + draft: state.composing ? (state.composeData || { id: 'active-compose' }) : null, + gtdAvailable, + cardDavConnected: Boolean(state.carddavStatus?.connected), + modal, + editing, + undoAvailable: (state.notifications || []).some(notification => typeof notification.onUndo === 'function'), + platform, + shortcutOverrides, + translate, + }); +} + +export function commandTargetLabel(context) { + if (context.selectedConversationIds.length > 1) { + return { key: 'commandPalette.target.selected', values: { count: context.selectedConversationIds.length } }; + } + if (context.selectedConversationIds.length === 1 || context.activeConversationId) { + return { key: 'commandPalette.target.conversation', values: {} }; + } + return { key: 'commandPalette.target.application', values: {} }; +} diff --git a/frontend/src/commands/appContext.test.js b/frontend/src/commands/appContext.test.js new file mode 100644 index 00000000..f6081d83 --- /dev/null +++ b/frontend/src/commands/appContext.test.js @@ -0,0 +1,86 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildAppCommandContext, commandTargetLabel, detectCommandPlatform } from './appContext.js'; + +const state = overrides => ({ + messages: [ + { id: 'row-a', message_id: '', account_id: 'acct-1' }, + { id: 'row-b', message_id: '', account_id: 'acct-1' }, + ], + searchResults: [], searchQuery: '', threadMessages: {}, gtdSections: null, + selectedMessageId: null, selectedMessageIds: new Set(), selectedAccountId: 'acct-1', + selectedFolder: 'INBOX', composing: false, composeData: null, showAdmin: false, + accounts: [{ id: 'acct-1', gtd_enabled: true }], + carddavStatus: { connected: true }, notifications: [], activeGtdTab: null, + shortcuts: {}, ...overrides, +}); +const translate = (key, values) => values?.count == null ? key : `${key}:${values.count}`; + +describe('buildAppCommandContext', () => { + it('maps list, conversation, compose, and settings surfaces', () => { + assert.equal(buildAppCommandContext(state(), { translate, platform: 'mac' }).surface, 'list'); + assert.equal(buildAppCommandContext(state({ selectedMessageId: 'row-a' }), { translate, platform: 'mac' }).surface, 'conversation'); + assert.equal(buildAppCommandContext(state({ composing: true, composeData: { id: 'draft-1' } }), { translate, platform: 'mac' }).surface, 'compose'); + assert.equal(buildAppCommandContext(state({ showAdmin: true }), { translate, platform: 'mac' }).surface, 'settings'); + }); + + it('converts selected row IDs to account-scoped RFC identities and exposes integrations', () => { + const context = buildAppCommandContext(state({ selectedMessageIds: new Set(['row-a', 'row-b']) }), { + translate, platform: 'linux', + }); + assert.deepEqual(context.selectedConversationIds, ['acct-1:', 'acct-1:']); + assert.deepEqual(context.visibleConversationIds, ['acct-1:', 'acct-1:']); + assert.equal(context.gtdAvailable, true); + assert.equal(context.cardDavConnected, true); + }); + + it('requires every targeted account to support GTD and never reads CardDAV from accounts', () => { + const context = buildAppCommandContext(state({ + messages: [ + { id: 'row-a', message_id: '', account_id: 'acct-1' }, + { id: 'row-c', message_id: '', account_id: 'acct-2' }, + ], + selectedMessageIds: new Set(['row-a', 'row-c']), + selectedAccountId: null, + accounts: [{ id: 'acct-1', gtd_enabled: true }, { id: 'acct-2', gtd_enabled: false }], + carddavStatus: null, + }), { translate, platform: 'linux' }); + assert.equal(context.gtdAvailable, false); + assert.equal(context.cardDavConnected, false); + }); + + it('exposes active message and current undo availability', () => { + const context = buildAppCommandContext(state({ + selectedMessageId: 'row-a', notifications: [{ id: 'undo-1', onUndo() {} }], + }), { translate, platform: 'mac' }); + assert.equal(context.activeMessage.id, 'row-a'); + assert.equal(context.undoAvailable, true); + }); + + it('describes application, single, and bulk targets', () => { + assert.deepEqual(commandTargetLabel(buildAppCommandContext(state(), { translate, platform: 'mac' })), { + key: 'commandPalette.target.application', values: {}, + }); + assert.equal(commandTargetLabel(buildAppCommandContext(state({ selectedMessageId: 'row-a' }), { + translate, platform: 'mac', + })).key, 'commandPalette.target.conversation'); + assert.deepEqual(commandTargetLabel(buildAppCommandContext(state({ selectedMessageIds: new Set(['row-a', 'row-b']) }), { + translate, platform: 'mac', + })), { key: 'commandPalette.target.selected', values: { count: 2 } }); + }); + + it('detects all three supported platform values', () => { + assert.equal(detectCommandPlatform({ userAgentData: { platform: 'macOS' } }), 'mac'); + assert.equal(detectCommandPlatform({ userAgentData: { platform: 'Windows' } }), 'windows'); + assert.equal(detectCommandPlatform({ platform: 'Linux x86_64' }), 'linux'); + }); + + it('maps existing persisted shortcut keys without mutating stored preferences', () => { + const shortcuts = { compose: 'q', focusSearch: 'ctrl+f', goInbox: 'g u' }; + const context = buildAppCommandContext(state({ shortcuts }), { translate, platform: 'linux' }); + assert.deepEqual(context.shortcutOverrides, { + 'compose.new': 'q', 'navigation.search': 'ctrl+f', 'navigation.unified-inbox': 'g u', + }); + assert.deepEqual(shortcuts, { compose: 'q', focusSearch: 'ctrl+f', goInbox: 'g u' }); + }); +}); diff --git a/frontend/src/commands/paletteFocus.js b/frontend/src/commands/paletteFocus.js new file mode 100644 index 00000000..0dc36603 --- /dev/null +++ b/frontend/src/commands/paletteFocus.js @@ -0,0 +1,14 @@ +export function isRestorableFocus(element) { + return Boolean( + element?.isConnected + && !element.disabled + && element.tabIndex !== -1 + && element.getClientRects?.().length, + ); +} + +export function nextFocusIndex(count, currentIndex, backwards) { + if (count <= 0) return -1; + if (backwards) return currentIndex <= 0 ? count - 1 : currentIndex - 1; + return currentIndex >= count - 1 ? 0 : currentIndex + 1; +} diff --git a/frontend/src/commands/paletteFocus.test.js b/frontend/src/commands/paletteFocus.test.js new file mode 100644 index 00000000..cac24155 --- /dev/null +++ b/frontend/src/commands/paletteFocus.test.js @@ -0,0 +1,21 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { isRestorableFocus, nextFocusIndex } from './paletteFocus.js'; + +describe('palette focus helpers', () => { + it('restores only connected, enabled, visible, focusable elements', () => { + const base = { isConnected: true, disabled: false, tabIndex: 0, getClientRects: () => [{ width: 1 }] }; + assert.equal(isRestorableFocus(base), true); + assert.equal(isRestorableFocus({ ...base, isConnected: false }), false); + assert.equal(isRestorableFocus({ ...base, disabled: true }), false); + assert.equal(isRestorableFocus({ ...base, tabIndex: -1 }), false); + assert.equal(isRestorableFocus({ ...base, getClientRects: () => [] }), false); + }); + + it('wraps Tab and Shift+Tab indices inside the dialog', () => { + assert.equal(nextFocusIndex(3, 0, false), 1); + assert.equal(nextFocusIndex(3, 2, false), 0); + assert.equal(nextFocusIndex(3, 0, true), 2); + assert.equal(nextFocusIndex(0, -1, false), -1); + }); +}); diff --git a/frontend/src/commands/paletteShortcut.js b/frontend/src/commands/paletteShortcut.js new file mode 100644 index 00000000..47b1f878 --- /dev/null +++ b/frontend/src/commands/paletteShortcut.js @@ -0,0 +1,9 @@ +export function commandPaletteShortcut(event, previousEditorPress, now = Date.now()) { + const chord = (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === 'k'; + if (!chord || event.isMobile) return { handled: false, toggle: false, nextEditorPress: previousEditorPress }; + if (!event.target?.isContentEditable) return { handled: true, toggle: true, nextEditorPress: null }; + const second = previousEditorPress?.target === event.target && now - previousEditorPress.at <= 1500; + return second + ? { handled: true, toggle: true, nextEditorPress: null } + : { handled: true, toggle: false, nextEditorPress: { target: event.target, at: now } }; +} diff --git a/frontend/src/commands/paletteShortcut.test.js b/frontend/src/commands/paletteShortcut.test.js new file mode 100644 index 00000000..b940b485 --- /dev/null +++ b/frontend/src/commands/paletteShortcut.test.js @@ -0,0 +1,31 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { commandPaletteShortcut } from './paletteShortcut.js'; + +describe('commandPaletteShortcut', () => { + it('ignores unrelated and mobile events', () => { + assert.equal(commandPaletteShortcut({ key: 'x', metaKey: true }, null, 1).handled, false); + assert.equal(commandPaletteShortcut({ key: 'k', metaKey: true, isMobile: true }, null, 1).handled, false); + }); + + it('toggles ordinary Cmd/Ctrl+K immediately', () => { + assert.deepEqual(commandPaletteShortcut({ key: 'k', metaKey: true, target: {} }, null, 1), { + handled: true, toggle: true, nextEditorPress: null, + }); + }); + + it('preserves first rich-editor Insert Link and opens on the next press', () => { + const editor = { isContentEditable: true }; + const first = commandPaletteShortcut({ key: 'k', ctrlKey: true, target: editor }, null, 1000); + assert.equal(first.toggle, false); + const second = commandPaletteShortcut({ key: 'k', ctrlKey: true, target: editor }, first.nextEditorPress, 2000); + assert.equal(second.toggle, true); + assert.equal(second.nextEditorPress, null); + }); + + it('expires the editor second-press window after 1500 ms', () => { + const editor = { isContentEditable: true }; + const old = { target: editor, at: 1000 }; + assert.equal(commandPaletteShortcut({ key: 'k', metaKey: true, target: editor }, old, 2501).toggle, false); + }); +}); diff --git a/frontend/src/commands/paletteState.js b/frontend/src/commands/paletteState.js new file mode 100644 index 00000000..0c605c38 --- /dev/null +++ b/frontend/src/commands/paletteState.js @@ -0,0 +1,29 @@ +export function createPaletteState() { + return { query: '', activeIndex: 0, resultCount: 0, continuation: null }; +} + +export function paletteKeyIntent(event) { + if (event.isComposing || event.nativeEvent?.isComposing || event.keyCode === 229 || event.nativeEvent?.keyCode === 229) { + return null; + } + const key = event.key.toLowerCase(); + if (event.key === 'ArrowDown' || (event.ctrlKey && (key === 'n' || key === 'j'))) return 'next'; + if (event.key === 'ArrowUp' || (event.ctrlKey && (key === 'p' || key === 'k'))) return 'previous'; + if (event.key === 'Enter') return 'execute'; + if (event.key === 'Escape') return 'back'; + return null; +} + +export function reducePaletteState(state, event) { + switch (event.type) { + case 'open': return { ...createPaletteState(), resultCount: state.resultCount }; + case 'query': return { ...state, query: event.query, activeIndex: 0 }; + case 'results': return { ...state, resultCount: event.count, activeIndex: Math.min(state.activeIndex, Math.max(0, event.count - 1)) }; + case 'move': return { ...state, activeIndex: Math.max(0, Math.min(state.resultCount - 1, state.activeIndex + event.delta)) }; + case 'continuation': return { ...state, continuation: event.value, query: '', activeIndex: 0 }; + case 'back': return state.continuation + ? { ...state, continuation: null, query: '', activeIndex: 0 } + : state; + default: return state; + } +} diff --git a/frontend/src/commands/paletteState.test.js b/frontend/src/commands/paletteState.test.js new file mode 100644 index 00000000..4a3702c9 --- /dev/null +++ b/frontend/src/commands/paletteState.test.js @@ -0,0 +1,49 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createPaletteState, paletteKeyIntent, reducePaletteState } from './paletteState.js'; + +describe('palette state', () => { + it('maps every approved local navigation key', () => { + assert.equal(paletteKeyIntent({ key: 'ArrowDown', ctrlKey: false }), 'next'); + assert.equal(paletteKeyIntent({ key: 'n', ctrlKey: true }), 'next'); + assert.equal(paletteKeyIntent({ key: 'j', ctrlKey: true }), 'next'); + assert.equal(paletteKeyIntent({ key: 'ArrowUp', ctrlKey: false }), 'previous'); + assert.equal(paletteKeyIntent({ key: 'p', ctrlKey: true }), 'previous'); + assert.equal(paletteKeyIntent({ key: 'k', ctrlKey: true }), 'previous'); + assert.equal(paletteKeyIntent({ key: 'Enter', ctrlKey: false }), 'execute'); + assert.equal(paletteKeyIntent({ key: 'Escape', ctrlKey: false }), 'back'); + }); + + it('ignores navigation and execution keys while an IME is composing', () => { + assert.equal(paletteKeyIntent({ key: 'Enter', isComposing: true }), null); + assert.equal(paletteKeyIntent({ key: 'ArrowDown', nativeEvent: { isComposing: true } }), null); + assert.equal(paletteKeyIntent({ key: 'Enter', keyCode: 229 }), null); + }); + + it('resets highlight on query/results and clamps movement', () => { + let state = reducePaletteState(createPaletteState(), { type: 'results', count: 2 }); + state = reducePaletteState(state, { type: 'move', delta: 1 }); + state = reducePaletteState(state, { type: 'move', delta: 1 }); + assert.equal(state.activeIndex, 1); + state = reducePaletteState(state, { type: 'query', query: 'arch' }); + assert.equal(state.activeIndex, 0); + }); + + it('opens with the current result count so keyboard movement works immediately', () => { + let state = reducePaletteState(createPaletteState(), { type: 'results', count: 38 }); + state = reducePaletteState(state, { type: 'open' }); + state = reducePaletteState(state, { type: 'move', delta: 1 }); + assert.equal(state.resultCount, 38); + assert.equal(state.activeIndex, 1); + }); + + it('backs out one continuation without latching a close request across reopen', () => { + let state = reducePaletteState(createPaletteState(), { type: 'continuation', value: { kind: 'move' } }); + state = reducePaletteState(state, { type: 'back' }); + assert.equal(state.continuation, null); + state = reducePaletteState(state, { type: 'back' }); + assert.equal('closeRequested' in state, false); + state = reducePaletteState(state, { type: 'open' }); + assert.equal('closeRequested' in state, false); + }); +}); diff --git a/frontend/src/commands/selection.js b/frontend/src/commands/selection.js new file mode 100644 index 00000000..ee4ca9d9 --- /dev/null +++ b/frontend/src/commands/selection.js @@ -0,0 +1,5 @@ +export function nextSelection(current, nextOrUpdater) { + const currentCopy = new Set(current || []); + const next = typeof nextOrUpdater === 'function' ? nextOrUpdater(currentCopy) : nextOrUpdater; + return new Set(next || []); +} diff --git a/frontend/src/commands/selection.test.js b/frontend/src/commands/selection.test.js new file mode 100644 index 00000000..0a2275b3 --- /dev/null +++ b/frontend/src/commands/selection.test.js @@ -0,0 +1,20 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { nextSelection } from './selection.js'; + +describe('nextSelection', () => { + it('clones direct Sets and deduplicates iterables', () => { + const input = new Set(['row-a']); + const output = nextSelection(input, input); + assert.deepEqual([...output], ['row-a']); + assert.notStrictEqual(output, input); + assert.deepEqual([...nextSelection(input, ['row-a', 'row-a', 'row-b'])], ['row-a', 'row-b']); + }); + + it('runs updater functions against a defensive Set copy', () => { + const input = new Set(['row-a']); + const output = nextSelection(input, current => current.add('row-b')); + assert.deepEqual([...input], ['row-a']); + assert.deepEqual([...output], ['row-a', 'row-b']); + }); +}); diff --git a/frontend/src/components/CommandContinuation.jsx b/frontend/src/components/CommandContinuation.jsx new file mode 100644 index 00000000..929be412 --- /dev/null +++ b/frontend/src/components/CommandContinuation.jsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; + +export default function CommandContinuation({ continuation, controller, onFinished, activeIndex, onActiveIndex }) { + const { t } = useTranslation(); + const items = continuation.props.items || []; + const choose = async item => { + const outcome = await controller.execute(continuation.commandId, { + source: 'palette', input: { value: item.id }, frozenTargetIds: continuation.targetIds, + }); + if (['success', 'cancelled', 'partial'].includes(outcome.status)) onFinished(outcome); + }; + return
+ {items.map((item, index) => )} +
; +} diff --git a/frontend/src/components/CommandIcon.jsx b/frontend/src/components/CommandIcon.jsx new file mode 100644 index 00000000..28135eab --- /dev/null +++ b/frontend/src/components/CommandIcon.jsx @@ -0,0 +1,16 @@ +const paths = { + compose: <>, + search: <>, + contacts: <>, + inbox: <>, + folder: , + gtd: <>, + appearance: <>, + settings: <>, +}; + +export default function CommandIcon({ name }) { + return ; +} diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 35e5d86a..ba6f27db 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -1,215 +1,126 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useEffect, useMemo, useReducer, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useStore } from '../store/index.js'; -import { useMobile } from '../hooks/useMobile.js'; -import { THEMES } from '../themes.js'; - -const THEME_NAMES = Object.keys(THEMES); - -function buildActions({ t, openCompose, setSelectedAccount, setShowAdmin, setAdminTab, theme, setTheme, accounts, selectedAccountId }) { - const actions = [ - { - id: 'compose', - label: t('commandPalette.actions.compose'), - icon: , - run: () => openCompose({ accountId: selectedAccountId || undefined }), - }, - { - id: 'inbox', - label: t('commandPalette.actions.inbox'), - icon: , - run: () => setSelectedAccount(null, 'INBOX'), - }, - { - id: 'settings', - label: t('commandPalette.actions.settings'), - icon: , - run: () => { setShowAdmin(true); setAdminTab('accounts'); }, - }, - { - id: 'themes', - label: t('commandPalette.actions.themes'), - icon: , - run: () => { setShowAdmin(true); setAdminTab('appearance'); }, - }, - ]; - - // Theme switch actions - for (const themeKey of THEME_NAMES) { - const label = THEMES[themeKey]?.label || themeKey; - actions.push({ - id: `theme:${themeKey}`, - label: t('commandPalette.actions.switchTheme', { theme: label }), - icon: , - active: theme === themeKey, - run: () => setTheme(themeKey), - }); - } - - // Per-account inbox shortcuts - for (const a of accounts) { - actions.push({ - id: `account:${a.id}`, - label: t('commandPalette.actions.accountInbox', { name: a.name }), - icon: , - run: () => setSelectedAccount(a.id, 'INBOX'), - }); - } - - return actions; -} +import { commandTargetLabel } from '../commands/appContext.js'; +import { formatCommandKey } from '../commands/shortcuts.js'; +import { createPaletteState, paletteKeyIntent, reducePaletteState } from '../commands/paletteState.js'; +import { isRestorableFocus, nextFocusIndex } from '../commands/paletteFocus.js'; +import { useCommandRuntimeContext } from '../commands/CommandRuntimeContext.jsx'; +import CommandContinuation from './CommandContinuation.jsx'; +import CommandIcon from './CommandIcon.jsx'; export default function CommandPalette({ open, onClose }) { const { t } = useTranslation(); - const isMobile = useMobile(); - const { openCompose, setSelectedAccount, setShowAdmin, setAdminTab, theme, setTheme, accounts, selectedAccountId } = useStore(); - const [query, setQuery] = useState(''); - const [activeIdx, setActiveIdx] = useState(0); - const [listScrolled, setListScrolled] = useState(false); + const { registry, controller, getContext, continuation, clearContinuation } = useCommandRuntimeContext(); + const [state, dispatch] = useReducer(reducePaletteState, undefined, createPaletteState); const inputRef = useRef(null); - const listRef = useRef(null); - - const actions = buildActions({ t, openCompose, setSelectedAccount, setShowAdmin, setAdminTab, theme, setTheme, accounts, selectedAccountId }); - - const filtered = query.trim() - ? actions.filter(a => a.label.toLowerCase().includes(query.toLowerCase())) - : actions; + const priorFocusRef = useRef(null); + const dialogRef = useRef(null); + const context = getContext(); + const results = useMemo( + () => registry.search(state.query, context), + [registry, state.query, context], + ); + const target = commandTargetLabel(context); + const resultCount = continuation?.props.items?.length ?? results.length; + useEffect(() => { dispatch({ type: 'results', count: resultCount }); }, [resultCount]); useEffect(() => { - if (open) { - setQuery(''); - setActiveIdx(0); - setTimeout(() => inputRef.current?.focus(), 30); - } + if (!open) return; + priorFocusRef.current = document.activeElement; + dispatch({ type: 'open' }); + queueMicrotask(() => (inputRef.current || dialogRef.current?.querySelector('button'))?.focus()); + return () => { + const prior = priorFocusRef.current; + if (isRestorableFocus(prior)) prior.focus(); + }; }, [open]); - - useEffect(() => { setActiveIdx(0); }, [query]); - - const runAction = useCallback((action) => { - action.run(); - onClose(); - }, [onClose]); - - const handleKeyDown = (e) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setActiveIdx(i => Math.min(i + 1, filtered.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setActiveIdx(i => Math.max(i - 1, 0)); - } else if (e.key === 'Enter') { - e.preventDefault(); - if (filtered[activeIdx]) runAction(filtered[activeIdx]); - } else if (e.key === 'Escape') { - onClose(); - } - }; - - // Scroll active item into view + useEffect(() => { dispatch({ type: 'continuation', value: continuation }); }, [continuation]); useEffect(() => { - const el = listRef.current?.children[activeIdx]; - el?.scrollIntoView({ block: 'nearest' }); - }, [activeIdx]); + if (!open) return; + dialogRef.current?.querySelectorAll('[role="option"]')[state.activeIndex] + ?.scrollIntoView({ block: 'nearest' }); + }, [continuation, open, resultCount, state.activeIndex, state.query]); if (!open) return null; - return ( -
-
e.stopPropagation()} - > - {/* Search input */} -
- - - - setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={t('commandPalette.placeholder')} - style={{ - flex: 1, background: 'none', border: 'none', outline: 'none', - color: 'var(--text-primary)', fontSize: 15, - }} - /> - {!isMobile && Esc} -
- - {/* Results */} -
setListScrolled(e.currentTarget.scrollTop > 4)} - style={{ - maxHeight: 360, overflowY: 'auto', padding: '6px 0', - boxShadow: listScrolled ? 'inset 0 8px 8px -8px rgba(0,0,0,0.25)' : 'none', - transition: 'box-shadow 0.2s ease', - }} - > - {filtered.length === 0 ? ( -
- {t('commandPalette.noResults')} -
- ) : filtered.map((action, i) => ( -
runAction(action)} - onMouseEnter={() => setActiveIdx(i)} - onMouseDown={e => { e.currentTarget.style.transform = 'scale(0.98)'; }} - onMouseUp={e => { e.currentTarget.style.transform = ''; }} - onMouseLeave={e => { e.currentTarget.style.transform = ''; }} - style={{ - display: 'flex', alignItems: 'center', gap: 12, - padding: '9px 16px', cursor: 'pointer', - background: i === activeIdx ? 'var(--bg-hover)' : 'transparent', - transition: 'background 0.08s, transform 0.08s', - }} - > - - {action.icon} - - - {action.label} - - {action.active && ( - - - - )} -
- ))} -
+ const execute = async result => { + const outcome = await controller.execute(result.command.id, { source: 'palette' }); + if (['success', 'cancelled', 'partial'].includes(outcome.status)) onClose(); + }; + const onKeyDown = event => { + const intent = paletteKeyIntent(event); + if (!intent) { + if (event.key === 'Tab') { + const nodes = [...dialogRef.current.querySelectorAll('input,button,[tabindex]:not([tabindex="-1"])')]; + const index = nodes.indexOf(document.activeElement); + const next = nodes[nextFocusIndex(nodes.length, index, event.shiftKey)]; + event.preventDefault(); + event.stopPropagation(); + next?.focus(); + } + return; + } + event.preventDefault(); + event.stopPropagation(); + if (intent === 'next') dispatch({ type: 'move', delta: 1 }); + if (intent === 'previous') dispatch({ type: 'move', delta: -1 }); + if (intent === 'execute' && continuation) { + dialogRef.current.querySelectorAll('[role="option"]')[state.activeIndex]?.click(); + } + if (intent === 'execute' && !continuation && results[state.activeIndex]) execute(results[state.activeIndex]); + if (intent === 'back') { + if (continuation) clearContinuation(); + else onClose(); + } + }; - {!isMobile && ( -
- ↑↓ {t('commandPalette.hint.navigate')} - {t('commandPalette.hint.select')} - Esc {t('commandPalette.hint.close')} -
- )} + return
event.target === event.currentTarget && onClose()}> +
+

{t('commandPalette.title')}

+
+ + {!continuation ? dispatch({ type: 'query', query: event.target.value })} + placeholder={t('commandPalette.placeholder')} + /> : + {t(continuation.props.titleKey)} + } + Esc
-
- ); +
+ {t('commandPalette.announcement', { count: continuation?.props.items?.length ?? results.length, target: t(target.key, target.values) })} +
+ {continuation ? dispatch({ type: 'move', delta: index - state.activeIndex })} + onFinished={() => { clearContinuation(); onClose(); }} /> : +
+ {results.map((result, index) => )} + {!results.length &&

{t('commandPalette.noResults')}

} +
} +
+ {t(target.key, target.values)} + +
+ +
; } diff --git a/frontend/src/components/CommandPalette.test.js b/frontend/src/components/CommandPalette.test.js new file mode 100644 index 00000000..a4eb8fde --- /dev/null +++ b/frontend/src/components/CommandPalette.test.js @@ -0,0 +1,63 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +describe('CommandPalette source wiring', () => { + const source = fs.readFileSync(new URL('./CommandPalette.jsx', import.meta.url), 'utf8'); + const continuation = fs.readFileSync(new URL('./CommandContinuation.jsx', import.meta.url), 'utf8'); + const css = fs.readFileSync(new URL('../index.css', import.meta.url), 'utf8'); + + it('consumes the shared runtime and required accessible semantics', () => { + assert.match(source, /useCommandRuntimeContext\(\)/); + for (const token of [ + 'role="dialog"', 'aria-modal="true"', 'role="combobox"', 'aria-controls="command-palette-results"', + 'aria-activedescendant', 'role="listbox"', 'role="option"', 'aria-live="polite"', + ]) assert.ok(source.includes(token), `missing ${token}`); + }); + + it('uses registry results, alias hints, shortcut formatting, and focus helpers', () => { + assert.match(source, /registry\.search\(state\.query, context\)/); + assert.match(source, /result\.matchedAlias/); + assert.match(source, /formatCommandKey/); + assert.match(source, /isRestorableFocus/); + assert.match(source, /nextFocusIndex/); + }); + + it('closes directly so a rapid reopen cannot inherit a stale close request', () => { + assert.match(source, /if \(continuation\) clearContinuation\(\);\s*else onClose\(\);/); + assert.doesNotMatch(source, /closeRequested/); + }); + + it('keeps the active keyboard option inside the bounded scroll window', () => { + assert.match(source, /scrollIntoView\(\{ block: 'nearest' \}\)/); + assert.match(source, /\[state\.activeIndex\]/); + }); + + it('resumes continuations with their frozen account-scoped targets', () => { + assert.match(continuation, /frozenTargetIds: continuation\.targetIds/); + assert.match(continuation, /source: 'palette'/); + }); + + it('localizes and bounds continuation options inside the palette', () => { + assert.match(continuation, /useTranslation\(\)/); + assert.match(continuation, /aria-label=\{t\(continuation\.props\.titleKey\)\}/); + assert.match(continuation, /className="command-palette__results"/); + }); + + it('uses the MailFlow search header, escape chip, and keyboard footer', () => { + assert.match(source, /className="command-palette__search"/); + assert.match(source, /Esc<\/kbd>/); + assert.match(source, /className="command-palette__footer"/); + assert.match(source, /className="command-palette__hints"/); + for (const key of ['navigate', 'select', 'close']) { + assert.ok(source.includes(`commandPalette.hint.${key}`)); + } + }); + + it('keeps the approved 5.5-row viewport and theme-aware selected row', () => { + assert.match(css, /\.command-palette__results\s*\{[^}]*max-height:\s*286px/); + assert.match(css, /\.command-palette__row\s*\{[^}]*min-height:\s*52px/); + assert.match(css, /\.command-palette__row\[aria-selected="true"\]\s*\{[^}]*var\(--accent-dim\)/); + }); +}); diff --git a/frontend/src/components/MailApp.jsx b/frontend/src/components/MailApp.jsx index 8e2df0e8..17ad3bc3 100644 --- a/frontend/src/components/MailApp.jsx +++ b/frontend/src/components/MailApp.jsx @@ -4,6 +4,7 @@ import { useStore } from '../store/index.js'; import { api } from '../utils/api.js'; import { useWebSocket } from '../hooks/useWebSocket.js'; import { useMobile } from '../hooks/useMobile.js'; +import { useCommandRuntime } from '../hooks/useCommandRuntime.js'; import { LAYOUTS } from '../layouts.js'; import { updateFaviconBadge } from '../themes.js'; import { shortcutBus } from '../utils/shortcutBus.js'; @@ -16,6 +17,8 @@ import GtdSidebarContent from './GtdSidebarContent.jsx'; import NotificationToasts from './NotificationToasts.jsx'; import CommandPalette from './CommandPalette.jsx'; import { gtdActiveForContext } from '../utils/gtd.js'; +import { CommandRuntimeProvider } from '../commands/CommandRuntimeContext.jsx'; +import { commandPaletteShortcut } from '../commands/paletteShortcut.js'; const ContactsPage = lazy(() => import('./ContactsPage.jsx')); @@ -59,6 +62,8 @@ const lazyFallback = ( export default function MailApp() { const { t } = useTranslation(); + const commandRuntime = useCommandRuntime({ t }); + const editorPalettePressRef = useRef(null); const { setAccounts, setUnreadCounts, showAdmin, setShowAdmin, setAdminTab, composing, sidebarCollapsed, layout, @@ -517,6 +522,7 @@ export default function MailApp() { } if (paletteOpenRef.current) { + commandRuntime.clearContinuation(); setPaletteOpen(false); return true; } @@ -542,7 +548,7 @@ export default function MailApp() { return () => { if (window.__mailflowHandleAndroidBack) delete window.__mailflowHandleAndroidBack; }; - }, [setMobileSidebarOpen, setSelectedMessage, setShowAdmin]); + }, [commandRuntime, setMobileSidebarOpen, setSelectedMessage, setShowAdmin]); useEffect(() => { if (isMobile) return; @@ -659,17 +665,32 @@ export default function MailApp() { return () => document.removeEventListener('keydown', handler); }, [showShortcutHelp]); - // Cmd+K / Ctrl+K opens command palette + // Cmd+K / Ctrl+K toggles the command palette. In a rich-text editor, the + // first press remains available to the editor and a second press opens it. useEffect(() => { - const handler = (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - setPaletteOpen(v => !v); - } + if (isMobile) return undefined; + const handler = event => { + const decision = commandPaletteShortcut({ + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + key: event.key, + target: event.target, + isMobile, + }, editorPalettePressRef.current); + if (!decision.handled) return; + editorPalettePressRef.current = decision.nextEditorPress; + if (!decision.toggle) return; + event.preventDefault(); + event.stopPropagation(); + setPaletteOpen(open => { + if (open) commandRuntime.clearContinuation(); + return !open; + }); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - }, []); + }, [commandRuntime, isMobile]); // Handle same-tab OAuth callback redirects (e.g. /?oauth_success=microsoft). // The popup case (window.opener present) is handled earlier in App.jsx before @@ -702,6 +723,7 @@ export default function MailApp() { }, []); // eslint-disable-line react-hooks/exhaustive-deps return ( +
{showAdmin && } {hasNativeBridge && } - setPaletteOpen(false)} /> + { + commandRuntime.clearContinuation(); + setPaletteOpen(false); + }} /> {/* Keyboard shortcut help overlay — toggled by the '?' key */} {showShortcutHelp && ( @@ -884,6 +909,7 @@ export default function MailApp() { )}
+ ); } diff --git a/frontend/src/components/MessageList.jsx b/frontend/src/components/MessageList.jsx index 43ebe780..6ca7d871 100644 --- a/frontend/src/components/MessageList.jsx +++ b/frontend/src/components/MessageList.jsx @@ -125,6 +125,8 @@ export default function MessageList() { // RFC message_id of the open message, so a row highlights when it is a different DB copy // of the selected message (multi-folder model) — e.g. the inbox copy of a GTD sidebar click. const selectedMid = useStore(selectSelectedMessageMid); + const selectedIds = useStore(state => state.selectedMessageIds); + const setSelectedIds = useStore(state => state.setSelectedMessageIds); const isMobile = useMobile(); const isUnified = selectedAccountId === null; @@ -188,7 +190,6 @@ export default function MessageList() { const deferredRefreshTimerRef = useRef(null); // Bulk selection state - const [selectedIds, setSelectedIds] = useState(new Set()); const [selectionModeActive, setSelectionModeActive] = useState(false); const [showFolderPicker, setShowFolderPicker] = useState(false); const [pickerFolders, setPickerFolders] = useState([]); @@ -204,7 +205,7 @@ export default function MessageList() { const layoutPickerRef = useRef(null); useEffect(() => { currentPageRef.current = currentPage; }, [currentPage]); - useEffect(() => { setActiveCategory('primary'); setActiveGtdTab(null); }, [selectedAccountId, selectedFolder, setActiveGtdTab]); + useEffect(() => { setActiveCategory('primary'); }, [selectedAccountId, selectedFolder]); useEffect(() => { const markOpening = () => { recentMessageOpenUntilRef.current = Date.now() + 1500; @@ -277,7 +278,7 @@ export default function MessageList() { setSelectionModeActive(false); setShowFolderPicker(false); lastSelectIdxRef.current = -1; - }, [messagesRefreshToken]); + }, [messagesRefreshToken, setSelectedIds]); // Escape clears selection; click-outside closes folder picker useEffect(() => { @@ -303,7 +304,7 @@ export default function MessageList() { document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onPointer); }; - }, []); + }, [setSelectedIds]); useEffect(() => { if (!showFolderPicker) setPickerSearch(''); @@ -1311,18 +1312,18 @@ export default function MessageList() { next.has(id) ? next.delete(id) : next.add(id); return next; }); - }, []); + }, [setSelectedIds]); const selectAll = useCallback((msgs) => { setSelectedIds(new Set(msgs.map(m => m.id))); - }, []); + }, [setSelectedIds]); const clearSelection = useCallback(() => { setSelectedIds(new Set()); setSelectionModeActive(false); setShowFolderPicker(false); lastSelectIdxRef.current = -1; - }, []); + }, [setSelectedIds]); // Derived from store — must be declared before callbacks that use it in dependency arrays const displayMessages = searchQuery.trim() ? searchResults : messages; @@ -1366,7 +1367,7 @@ export default function MessageList() { next.has(id) ? next.delete(id) : next.add(id); return next; }); - }, [displayMessages]); + }, [displayMessages, setSelectedIds]); // Called for normal (non-shift) row checkbox toggles — tracks anchor for range select const handleRowToggleSelect = useCallback((id) => { @@ -1377,7 +1378,7 @@ export default function MessageList() { next.has(id) ? next.delete(id) : next.add(id); return next; }); - }, [displayMessages]); + }, [displayMessages, setSelectedIds]); // Called on shift-click: selects all rows between anchor and current index const handleRangeSelect = useCallback((id) => { @@ -1393,7 +1394,7 @@ export default function MessageList() { return next; }); lastSelectIdxRef.current = clickedIdx; - }, [displayMessages]); + }, [displayMessages, setSelectedIds]); const handleBulkDelete = useCallback(async (ids, msgs) => { const key = `bulk:${ids[0]}`; @@ -1467,7 +1468,7 @@ export default function MessageList() { }); }, }); - }, [searchHasMore, removeMessage, prefetchSearchAfterRemoval, resolveMessagesForThreadAction, decrementUnread, incrementUnread, addNotification, t]); + }, [searchHasMore, removeMessage, prefetchSearchAfterRemoval, resolveMessagesForThreadAction, decrementUnread, incrementUnread, addNotification, setSelectedIds, t]); const handleBulkMove = useCallback(async (ids, msgs, folder) => { // Selected thread rows move the whole conversation. A folder path is @@ -1519,7 +1520,7 @@ export default function MessageList() { msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); }); }, }); - }, [removeMessage, decrementUnread, incrementUnread, resolveMessagesForThreadAction, addNotification, t]); + }, [removeMessage, decrementUnread, incrementUnread, resolveMessagesForThreadAction, addNotification, setSelectedIds, t]); const handleRowMove = useCallback((e, msg) => { e.stopPropagation(); @@ -1586,7 +1587,7 @@ export default function MessageList() { msgs.forEach(msg => { if (!msg.is_read) incrementUnread(msg.account_id); }); }, }); - }, [removeMessages, decrementUnread, incrementUnread, addNotification, t]); + }, [removeMessages, decrementUnread, incrementUnread, addNotification, setSelectedIds, t]); const handleBulkMarkRead = useCallback(async (ids, msgs) => { const markAsRead = msgs.some(m => !m.is_read); @@ -1622,7 +1623,7 @@ export default function MessageList() { if (delta > 0) adjustCategoryCount(cat, markAsRead ? delta : -delta); }); } - }, [updateMessage, decrementUnread, incrementUnread, adjustCategoryCount]); + }, [updateMessage, decrementUnread, incrementUnread, adjustCategoryCount, setSelectedIds]); const autoMarkReadTimerRef = useRef(null); useEffect(() => () => clearTimeout(autoMarkReadTimerRef.current), []); diff --git a/frontend/src/hooks/useCommandRuntime.js b/frontend/src/hooks/useCommandRuntime.js new file mode 100644 index 00000000..986e204a --- /dev/null +++ b/frontend/src/hooks/useCommandRuntime.js @@ -0,0 +1,57 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useStore } from '../store/index.js'; +import { THEMES } from '../themes.js'; +import { shortcutBus } from '../utils/shortcutBus.js'; +import { buildAppCommandContext, detectCommandPlatform } from '../commands/appContext.js'; +import { createAppCommandDefinitions, createAppCommandExecutors } from '../commands/appCommands.js'; +import { createCommandRegistry } from '../commands/registry.js'; +import { createCommandController } from '../commands/controller.js'; + +export function useCommandRuntime({ t }) { + const accounts = useStore(state => state.accounts); + const folders = useStore(state => state.folders); + const user = useStore(state => state.user); + const [continuation, setContinuation] = useState(null); + const platform = useMemo(() => detectCommandPlatform(navigator), []); + const commandDefinitions = useMemo(() => createAppCommandDefinitions({ + accounts, folders, themes: THEMES, user, + }), [accounts, folders, user]); + const registry = useMemo(() => createCommandRegistry(commandDefinitions), [commandDefinitions]); + const executors = useMemo(() => createAppCommandExecutors({ + getState: useStore.getState, + emitShortcut: action => shortcutBus.emit(action), + }), []); + const getContext = useCallback(() => buildAppCommandContext(useStore.getState(), { + translate: (key, values) => t(key, values), + platform, + modal: continuation ? { kind: continuation.kind } : null, + }), [continuation, platform, t]); + const onOutcome = useCallback(outcome => { + if (outcome.status === 'failed') { + useStore.getState().addNotification({ + type: 'error', title: t('commandPalette.outcome.failedTitle'), body: outcome.error.message, + }); + } + if (outcome.status === 'partial') { + useStore.getState().addNotification({ + title: t('commandPalette.outcome.partialTitle'), + body: t('commandPalette.outcome.partialBody', { + succeeded: outcome.succeededIds?.length || 0, + failed: (outcome.failed?.length || 0) + (outcome.missingTargetIds?.length || 0), + }), + }); + } + }, [t]); + const controller = useMemo(() => createCommandController({ + registry, + getContext, + executors, + onContinuation: setContinuation, + onOutcome, + }), [registry, getContext, executors, onOutcome]); + const clearContinuation = useCallback(() => setContinuation(null), []); + + return useMemo(() => ({ + registry, controller, getContext, continuation, clearContinuation, + }), [registry, controller, getContext, continuation, clearContinuation]); +} diff --git a/frontend/src/hooks/useCommandRuntime.test.js b/frontend/src/hooks/useCommandRuntime.test.js new file mode 100644 index 00000000..108fef7a --- /dev/null +++ b/frontend/src/hooks/useCommandRuntime.test.js @@ -0,0 +1,21 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +describe('useCommandRuntime composition boundary', () => { + it('owns the one registry/controller composition and exposes the fixed runtime shape', () => { + const source = fs.readFileSync(new URL('./useCommandRuntime.js', import.meta.url), 'utf8'); + assert.match(source, /createCommandRegistry\(commandDefinitions\)/); + assert.match(source, /createCommandController\(\{/); + assert.match(source, /createAppCommandExecutors\(/); + assert.match(source, /return useMemo\(\(\) => \(\{[\s\S]*registry[\s\S]*controller[\s\S]*getContext[\s\S]*continuation[\s\S]*clearContinuation/); + }); + + it('keeps controller composition out of MailApp and React out of engine modules', () => { + const mailApp = fs.readFileSync(new URL('../components/MailApp.jsx', import.meta.url), 'utf8'); + const registry = fs.readFileSync(new URL('../commands/registry.js', import.meta.url), 'utf8'); + const controller = fs.readFileSync(new URL('../commands/controller.js', import.meta.url), 'utf8'); + assert.doesNotMatch(mailApp, /createCommandController/); + assert.doesNotMatch(registry + controller, /from ['"]react['"]/); + }); +}); diff --git a/frontend/src/index.css b/frontend/src/index.css index 80c7d6cf..85a20beb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -384,3 +384,26 @@ body, #root { max-width: 100%; color-scheme: light; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.command-palette__backdrop { position: fixed; inset: 0; z-index: 9500; display: flex; justify-content: center; align-items: flex-start; padding-top: 15vh; background: rgb(0 0 0 / 55%); backdrop-filter: blur(8px); } +.command-palette { width: min(560px, calc(100vw - 32px)); overflow: hidden; border: 1px solid var(--border); border-radius: 12px; color: var(--text-primary); background: var(--bg-secondary); box-shadow: var(--shadow-modal); animation: modal-enter var(--motion-fast) var(--ease-emphasized) both; } +.command-palette__search { min-height: 60px; display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); } +.command-palette__search > svg { flex: none; color: var(--text-tertiary); } +.command-palette__search [role="combobox"] { min-width: 0; flex: 1; padding: 0; border: 0; outline: 0; color: var(--text-primary); background: transparent; font-size: 16px; } +.command-palette__continuation-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; } +.command-palette__escape, .command-palette__row kbd, .command-palette__footer kbd { flex: none; padding: 2px 6px; border: 1px solid var(--border); border-radius: 4px; color: var(--text-tertiary); background: var(--bg-tertiary); font-size: 11px; font-weight: 500; } +.command-palette__results { max-height: 286px; overflow-y: auto; padding: 6px 0; } +.command-palette__row { width: 100%; min-height: 52px; display: flex; align-items: center; gap: 12px; padding: 8px 16px; border: 0; color: var(--text-primary); background: transparent; text-align: left; cursor: pointer; transition: background var(--motion-fast) var(--ease-standard); } +.command-palette__row[aria-selected="true"] { background: var(--accent-dim); } +.command-palette__icon { width: 18px; height: 18px; flex: none; display: grid; place-items: center; color: var(--text-tertiary); } +.command-palette__copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; } +.command-palette__copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; } +.command-palette__copy small { overflow: hidden; color: var(--text-tertiary); text-overflow: ellipsis; white-space: nowrap; } +.command-palette__empty { padding: 24px 16px; color: var(--text-tertiary); text-align: center; } +.command-palette__footer { min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 16px; border-top: 1px solid var(--border-subtle); color: var(--text-tertiary); font-size: 11px; } +.command-palette__target { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.command-palette__hints { flex: none; display: flex; align-items: center; gap: 12px; } +.command-palette__hints > span { display: flex; align-items: center; gap: 5px; } +@media (max-width: 560px) { .command-palette__target { display: none; } .command-palette__footer { justify-content: center; } } +@media (prefers-reduced-motion: reduce) { .command-palette, .command-palette__backdrop { animation: none; } } diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 82f671bd..d6e85e0b 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -447,20 +447,58 @@ "markAsHam": "Als kein Spam markieren" }, "commandPalette": { - "placeholder": "Aktionen suchen…", - "noResults": "Keine Aktionen gefunden", + "title": "Befehlspalette", + "placeholder": "Befehle durchsuchen…", + "noResults": "Keine Befehle gefunden", + "matchedAlias": "Treffer: {{alias}}", + "announcement": "{{count}} Befehle. Ziel: {{target}}.", + "target": { + "application": "Anwendung", + "conversation": "Unterhaltung", + "selected": "{{count}} Unterhaltungen ausgewählt" + }, + "outcome": { + "failedTitle": "Befehl fehlgeschlagen", + "partialTitle": "Befehl teilweise ausgeführt", + "partialBody": "{{succeeded}} erfolgreich; {{failed}} fehlgeschlagen." + }, "hint": { "navigate": "navigieren", "select": "auswählen", "close": "schließen" + } + }, + "commands": { + "compose": { "new": { "title": "Verfassen", "alias": { "write": "Schreiben" } } }, + "navigation": { + "search": { "title": "E-Mails durchsuchen" }, + "contacts": { "title": "Kontakte öffnen" }, + "unifiedInbox": { "title": "Zum vereinheitlichten Posteingang" }, + "accountInbox": { "title": "{{name}} — Posteingang" }, + "folder": { "title": "{{name}}" }, + "gtd": { + "todo": { "title": "Zu Aufgaben" }, + "watch": { "title": "Zu Beobachten" }, + "delegated": { "title": "Zu Delegiert" }, + "reference": { "title": "Zu Referenz" }, + "someday": { "title": "Zu Irgendwann" } + } }, - "actions": { - "compose": "Neue Nachricht verfassen", - "inbox": "Zum Posteingang", - "settings": "Einstellungen öffnen", - "themes": "Designs öffnen", - "switchTheme": "Zu {{theme}} wechseln", - "accountInbox": "{{name}} — Posteingang" + "appearance": { "theme": { "title": "Zum Design {{name}} wechseln" } }, + "settings": { + "accounts": { "title": "Kontoeinstellungen öffnen" }, + "notifications": { "title": "Benachrichtigungseinstellungen öffnen" }, + "rules": { "title": "Regeln öffnen" }, + "categories": { "title": "Kategorien öffnen" }, + "appearance": { "title": "Darstellungseinstellungen öffnen" }, + "shortcuts": { "title": "Tastaturkürzel-Einstellungen öffnen" }, + "security": { "title": "Sicherheitseinstellungen öffnen" }, + "integrations": { "title": "Integrationen öffnen" }, + "ai-actions": { "title": "KI-Aktionen öffnen" }, + "about": { "title": "Über öffnen" }, + "users": { "title": "Benutzerverwaltung öffnen" }, + "sso": { "title": "SSO-Einstellungen öffnen" }, + "ai": { "title": "KI-Einstellungen öffnen" } } }, "shortcuts": { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a38e68cf..78ae90e2 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -447,20 +447,58 @@ } }, "commandPalette": { - "placeholder": "Search actions…", - "noResults": "No actions found", + "title": "Command palette", + "placeholder": "Search commands…", + "noResults": "No commands found", + "matchedAlias": "Matched: {{alias}}", + "announcement": "{{count}} commands. Target: {{target}}.", + "target": { + "application": "Application", + "conversation": "Conversation", + "selected": "{{count}} conversations selected" + }, + "outcome": { + "failedTitle": "Command failed", + "partialTitle": "Command partially completed", + "partialBody": "{{succeeded}} succeeded; {{failed}} failed." + }, "hint": { "navigate": "navigate", "select": "select", "close": "close" + } + }, + "commands": { + "compose": { "new": { "title": "Compose", "alias": { "write": "Write" } } }, + "navigation": { + "search": { "title": "Search mail" }, + "contacts": { "title": "Open Contacts" }, + "unifiedInbox": { "title": "Go to Unified Inbox" }, + "accountInbox": { "title": "{{name}} — Inbox" }, + "folder": { "title": "{{name}}" }, + "gtd": { + "todo": { "title": "Go to Todo" }, + "watch": { "title": "Go to Watch" }, + "delegated": { "title": "Go to Delegated" }, + "reference": { "title": "Go to Reference" }, + "someday": { "title": "Go to Someday" } + } }, - "actions": { - "compose": "Compose new message", - "inbox": "Go to Inbox", - "settings": "Open Settings", - "themes": "Open Themes", - "switchTheme": "Switch to {{theme}} theme", - "accountInbox": "{{name}} — Inbox" + "appearance": { "theme": { "title": "Switch to {{name}} theme" } }, + "settings": { + "accounts": { "title": "Open Account Settings" }, + "notifications": { "title": "Open Notification Settings" }, + "rules": { "title": "Open Rules" }, + "categories": { "title": "Open Categories" }, + "appearance": { "title": "Open Appearance Settings" }, + "shortcuts": { "title": "Open Shortcut Settings" }, + "security": { "title": "Open Security Settings" }, + "integrations": { "title": "Open Integrations" }, + "ai-actions": { "title": "Open AI Actions" }, + "about": { "title": "Open About" }, + "users": { "title": "Open User Administration" }, + "sso": { "title": "Open SSO Settings" }, + "ai": { "title": "Open AI Settings" } } }, "shortcuts": { diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 3f272fa3..06171ecb 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -447,20 +447,58 @@ } }, "commandPalette": { - "placeholder": "Buscar acciones…", - "noResults": "No se encontraron acciones", + "title": "Paleta de comandos", + "placeholder": "Buscar comandos…", + "noResults": "No se encontraron comandos", + "matchedAlias": "Coincidencia: {{alias}}", + "announcement": "{{count}} comandos. Destino: {{target}}.", + "target": { + "application": "Aplicación", + "conversation": "Conversación", + "selected": "{{count}} conversaciones seleccionadas" + }, + "outcome": { + "failedTitle": "El comando falló", + "partialTitle": "El comando se completó parcialmente", + "partialBody": "{{succeeded}} correctos; {{failed}} fallidos." + }, "hint": { "navigate": "navegar", "select": "seleccionar", "close": "cerrar" + } + }, + "commands": { + "compose": { "new": { "title": "Redactar", "alias": { "write": "Escribir" } } }, + "navigation": { + "search": { "title": "Buscar correo" }, + "contacts": { "title": "Abrir Contactos" }, + "unifiedInbox": { "title": "Ir a la bandeja de entrada unificada" }, + "accountInbox": { "title": "{{name}} — Bandeja de entrada" }, + "folder": { "title": "{{name}}" }, + "gtd": { + "todo": { "title": "Ir a Por hacer" }, + "watch": { "title": "Ir a Vigilar" }, + "delegated": { "title": "Ir a Delegado" }, + "reference": { "title": "Ir a Referencia" }, + "someday": { "title": "Ir a Algún día" } + } }, - "actions": { - "compose": "Redactar nuevo mensaje", - "inbox": "Ir a la bandeja de entrada", - "settings": "Abrir ajustes", - "themes": "Abrir temas", - "switchTheme": "Cambiar al tema {{theme}}", - "accountInbox": "{{name}} — Bandeja de entrada" + "appearance": { "theme": { "title": "Cambiar al tema {{name}}" } }, + "settings": { + "accounts": { "title": "Abrir ajustes de cuentas" }, + "notifications": { "title": "Abrir ajustes de notificaciones" }, + "rules": { "title": "Abrir Reglas" }, + "categories": { "title": "Abrir Categorías" }, + "appearance": { "title": "Abrir ajustes de apariencia" }, + "shortcuts": { "title": "Abrir ajustes de atajos" }, + "security": { "title": "Abrir ajustes de seguridad" }, + "integrations": { "title": "Abrir Integraciones" }, + "ai-actions": { "title": "Abrir Acciones de IA" }, + "about": { "title": "Abrir Acerca de" }, + "users": { "title": "Abrir administración de usuarios" }, + "sso": { "title": "Abrir ajustes de SSO" }, + "ai": { "title": "Abrir ajustes de IA" } } }, "shortcuts": { diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index edfb7754..7bb3c981 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -447,20 +447,58 @@ } }, "commandPalette": { - "placeholder": "Rechercher des actions…", - "noResults": "Aucune action trouvée", + "title": "Palette de commandes", + "placeholder": "Rechercher des commandes…", + "noResults": "Aucune commande trouvée", + "matchedAlias": "Correspondance : {{alias}}", + "announcement": "{{count}} commandes. Cible : {{target}}.", + "target": { + "application": "Application", + "conversation": "Conversation", + "selected": "{{count}} conversations sélectionnées" + }, + "outcome": { + "failedTitle": "Échec de la commande", + "partialTitle": "Commande partiellement exécutée", + "partialBody": "{{succeeded}} réussies ; {{failed}} échouées." + }, "hint": { "navigate": "naviguer", "select": "sélectionner", "close": "fermer" + } + }, + "commands": { + "compose": { "new": { "title": "Composer", "alias": { "write": "Écrire" } } }, + "navigation": { + "search": { "title": "Rechercher dans les e-mails" }, + "contacts": { "title": "Ouvrir Contacts" }, + "unifiedInbox": { "title": "Accéder à la boîte de réception unifiée" }, + "accountInbox": { "title": "{{name}} — Boîte de réception" }, + "folder": { "title": "{{name}}" }, + "gtd": { + "todo": { "title": "Aller à À faire" }, + "watch": { "title": "Aller à Surveiller" }, + "delegated": { "title": "Aller à Délégué" }, + "reference": { "title": "Aller à Référence" }, + "someday": { "title": "Aller à Un jour" } + } }, - "actions": { - "compose": "Composer un nouveau message", - "inbox": "Aller à la boîte de réception", - "settings": "Ouvrir les paramètres", - "themes": "Ouvrir les thèmes", - "switchTheme": "Passer au thème {{theme}}", - "accountInbox": "{{name}} — Boîte de réception" + "appearance": { "theme": { "title": "Passer au thème {{name}}" } }, + "settings": { + "accounts": { "title": "Ouvrir les paramètres des comptes" }, + "notifications": { "title": "Ouvrir les paramètres de notifications" }, + "rules": { "title": "Ouvrir Règles" }, + "categories": { "title": "Ouvrir Catégories" }, + "appearance": { "title": "Ouvrir les paramètres d’apparence" }, + "shortcuts": { "title": "Ouvrir les paramètres des raccourcis" }, + "security": { "title": "Ouvrir les paramètres de sécurité" }, + "integrations": { "title": "Ouvrir Intégrations" }, + "ai-actions": { "title": "Ouvrir Actions IA" }, + "about": { "title": "Ouvrir À propos" }, + "users": { "title": "Ouvrir l’administration des utilisateurs" }, + "sso": { "title": "Ouvrir les paramètres SSO" }, + "ai": { "title": "Ouvrir les paramètres IA" } } }, "shortcuts": { diff --git a/frontend/src/locales/i18n.test.js b/frontend/src/locales/i18n.test.js index 5186a09f..6671d632 100644 --- a/frontend/src/locales/i18n.test.js +++ b/frontend/src/locales/i18n.test.js @@ -302,6 +302,14 @@ const SAME_VALUE_ALLOWED = { // "GTD" — acronym (Getting Things Done), same in every locale 'gtd.title': 'any', + // ── Command palette ─────────────────────────────────────────────────────── + // These labels are spelled identically in English and French. + 'commandPalette.target.application': [['en', 'fr']], + 'commandPalette.target.conversation': [['en', 'fr']], + // Folder names come from the account, so every locale intentionally renders + // the same interpolation token without adding surrounding copy. + 'commands.navigation.folder.title': 'any', + // ── Keyboard shortcuts ───────────────────────────────────────────────────── // "GTD" — acronym group heading, same in every locale (like admin.categories.gtdReveal) 'shortcuts.groups.gtd': 'any', @@ -357,6 +365,26 @@ const DYNAMIC_KEYS = new Set([ // appear as literals; the other three do via the tab pills). 'gtd.state.watch', 'gtd.state.delegated', + // Command definitions generate these keys from the fixed GTD-section and + // settings-tab allowlists in appCommands.js. + 'commands.navigation.gtd.todo.title', + 'commands.navigation.gtd.watch.title', + 'commands.navigation.gtd.delegated.title', + 'commands.navigation.gtd.reference.title', + 'commands.navigation.gtd.someday.title', + 'commands.settings.accounts.title', + 'commands.settings.notifications.title', + 'commands.settings.rules.title', + 'commands.settings.categories.title', + 'commands.settings.appearance.title', + 'commands.settings.shortcuts.title', + 'commands.settings.security.title', + 'commands.settings.integrations.title', + 'commands.settings.ai-actions.title', + 'commands.settings.about.title', + 'commands.settings.users.title', + 'commands.settings.sso.title', + 'commands.settings.ai.title', ]); // JSX attribute names whose values must never be plain strings — always t(). diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 0e785726..e3fbaef0 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -447,20 +447,58 @@ } }, "commandPalette": { - "placeholder": "Cerca azioni…", - "noResults": "Nessuna azione trovata", + "title": "Tavolozza dei comandi", + "placeholder": "Cerca comandi…", + "noResults": "Nessun comando trovato", + "matchedAlias": "Corrispondenza: {{alias}}", + "announcement": "{{count}} comandi. Destinazione: {{target}}.", + "target": { + "application": "Applicazione", + "conversation": "Conversazione", + "selected": "{{count}} conversazioni selezionate" + }, + "outcome": { + "failedTitle": "Comando non riuscito", + "partialTitle": "Comando completato parzialmente", + "partialBody": "{{succeeded}} riusciti; {{failed}} non riusciti." + }, "hint": { "navigate": "naviga", "select": "seleziona", "close": "chiudi" + } + }, + "commands": { + "compose": { "new": { "title": "Scrivi", "alias": { "write": "Componi" } } }, + "navigation": { + "search": { "title": "Cerca nella posta" }, + "contacts": { "title": "Apri Contatti" }, + "unifiedInbox": { "title": "Vai alla posta in arrivo unificata" }, + "accountInbox": { "title": "{{name}} — Posta in arrivo" }, + "folder": { "title": "{{name}}" }, + "gtd": { + "todo": { "title": "Vai a Da fare" }, + "watch": { "title": "Vai a Osservare" }, + "delegated": { "title": "Vai a Delegato" }, + "reference": { "title": "Vai a Riferimento" }, + "someday": { "title": "Vai a Un giorno" } + } }, - "actions": { - "compose": "Scrivi nuovo messaggio", - "inbox": "Vai alla Posta in arrivo", - "settings": "Apri Impostazioni", - "themes": "Apri Temi", - "switchTheme": "Passa al tema {{theme}}", - "accountInbox": "{{name}} — Posta in arrivo" + "appearance": { "theme": { "title": "Passa al tema {{name}}" } }, + "settings": { + "accounts": { "title": "Apri impostazioni account" }, + "notifications": { "title": "Apri impostazioni notifiche" }, + "rules": { "title": "Apri Regole" }, + "categories": { "title": "Apri Categorie" }, + "appearance": { "title": "Apri impostazioni aspetto" }, + "shortcuts": { "title": "Apri impostazioni scorciatoie" }, + "security": { "title": "Apri impostazioni sicurezza" }, + "integrations": { "title": "Apri Integrazioni" }, + "ai-actions": { "title": "Apri Azioni IA" }, + "about": { "title": "Apri Informazioni" }, + "users": { "title": "Apri amministrazione utenti" }, + "sso": { "title": "Apri impostazioni SSO" }, + "ai": { "title": "Apri impostazioni IA" } } }, "shortcuts": { diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 237b417e..b2d7014d 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -447,20 +447,58 @@ } }, "commandPalette": { - "placeholder": "Поиск действий…", - "noResults": "Действия не найдены", + "title": "Палитра команд", + "placeholder": "Поиск команд…", + "noResults": "Команды не найдены", + "matchedAlias": "Совпадение: {{alias}}", + "announcement": "Команд: {{count}}. Цель: {{target}}.", + "target": { + "application": "Приложение", + "conversation": "Переписка", + "selected": "Выбрано переписок: {{count}}" + }, + "outcome": { + "failedTitle": "Не удалось выполнить команду", + "partialTitle": "Команда выполнена частично", + "partialBody": "Успешно: {{succeeded}}; с ошибкой: {{failed}}." + }, "hint": { "navigate": "навигация", "select": "выбор", "close": "закрыть" + } + }, + "commands": { + "compose": { "new": { "title": "Написать", "alias": { "write": "Создать письмо" } } }, + "navigation": { + "search": { "title": "Поиск по почте" }, + "contacts": { "title": "Открыть Контакты" }, + "unifiedInbox": { "title": "Перейти в объединённые входящие" }, + "accountInbox": { "title": "{{name}} — Входящие" }, + "folder": { "title": "{{name}}" }, + "gtd": { + "todo": { "title": "Перейти в «Задачи»" }, + "watch": { "title": "Перейти в «Наблюдение»" }, + "delegated": { "title": "Перейти в «Делегировано»" }, + "reference": { "title": "Перейти в «Справочные»" }, + "someday": { "title": "Перейти в «Когда-нибудь»" } + } }, - "actions": { - "compose": "Написать новое сообщение", - "inbox": "Перейти во входящие", - "settings": "Открыть настройки", - "themes": "Открыть темы", - "switchTheme": "Переключиться на тему {{theme}}", - "accountInbox": "{{name}} — Входящие" + "appearance": { "theme": { "title": "Переключиться на тему {{name}}" } }, + "settings": { + "accounts": { "title": "Открыть настройки аккаунтов" }, + "notifications": { "title": "Открыть настройки уведомлений" }, + "rules": { "title": "Открыть Правила" }, + "categories": { "title": "Открыть Категории" }, + "appearance": { "title": "Открыть настройки внешнего вида" }, + "shortcuts": { "title": "Открыть настройки горячих клавиш" }, + "security": { "title": "Открыть настройки безопасности" }, + "integrations": { "title": "Открыть Интеграции" }, + "ai-actions": { "title": "Открыть ИИ-действия" }, + "about": { "title": "Открыть О приложении" }, + "users": { "title": "Открыть управление пользователями" }, + "sso": { "title": "Открыть настройки SSO" }, + "ai": { "title": "Открыть настройки ИИ" } } }, "shortcuts": { diff --git a/frontend/src/locales/zhCN.json b/frontend/src/locales/zhCN.json index ecfa7827..053d979d 100644 --- a/frontend/src/locales/zhCN.json +++ b/frontend/src/locales/zhCN.json @@ -447,20 +447,58 @@ } }, "commandPalette": { - "placeholder": "搜索操作…", - "noResults": "未找到任何操作", + "title": "命令面板", + "placeholder": "搜索命令…", + "noResults": "未找到命令", + "matchedAlias": "匹配项:{{alias}}", + "announcement": "{{count}} 个命令。目标:{{target}}。", + "target": { + "application": "应用", + "conversation": "会话", + "selected": "已选择 {{count}} 个会话" + }, + "outcome": { + "failedTitle": "命令执行失败", + "partialTitle": "命令部分完成", + "partialBody": "{{succeeded}} 个成功;{{failed}} 个失败。" + }, "hint": { "navigate": "导航", "select": "选择", "close": "关闭" + } + }, + "commands": { + "compose": { "new": { "title": "撰写", "alias": { "write": "写邮件" } } }, + "navigation": { + "search": { "title": "搜索邮件" }, + "contacts": { "title": "打开联系人" }, + "unifiedInbox": { "title": "前往统一收件箱" }, + "accountInbox": { "title": "{{name}} — 收件箱" }, + "folder": { "title": "{{name}}" }, + "gtd": { + "todo": { "title": "前往待办" }, + "watch": { "title": "前往关注" }, + "delegated": { "title": "前往已委派" }, + "reference": { "title": "前往参考" }, + "someday": { "title": "前往将来" } + } }, - "actions": { - "compose": "撰写新邮件", - "inbox": "前往收件箱", - "settings": "打开设置", - "themes": "打开主题", - "switchTheme": "切换至「{{theme}}」主题", - "accountInbox": "{{name}} — 收件箱" + "appearance": { "theme": { "title": "切换至「{{name}}」主题" } }, + "settings": { + "accounts": { "title": "打开账户设置" }, + "notifications": { "title": "打开通知设置" }, + "rules": { "title": "打开规则" }, + "categories": { "title": "打开分类" }, + "appearance": { "title": "打开外观设置" }, + "shortcuts": { "title": "打开快捷键设置" }, + "security": { "title": "打开安全设置" }, + "integrations": { "title": "打开集成" }, + "ai-actions": { "title": "打开 AI 操作" }, + "about": { "title": "打开关于" }, + "users": { "title": "打开用户管理" }, + "sso": { "title": "打开单点登录设置" }, + "ai": { "title": "打开 AI 设置" } } }, "shortcuts": { diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js index b3eeb168..702c9dbd 100644 --- a/frontend/src/store/index.js +++ b/frontend/src/store/index.js @@ -13,6 +13,7 @@ import { } from '../utils/gtd.js'; import { applyGtdRemovalGuard } from '../utils/pendingGtdRemovals.js'; import { clampRightSidebarWidth } from '../utils/rightSidebar.js'; +import { nextSelection } from '../commands/selection.js'; import { cacheFolderOrderFromPreferences, mergeFolderOrder, @@ -81,7 +82,7 @@ export const useStore = create((set, get) => ({ isLocked: true, messages: [], searchResults: [], searchQuery: '', accounts: [], accountsReady: false, - folders: {}, selectedMessageId: null, + folders: {}, selectedMessageIds: new Set(), selectedMessageId: null, unreadCounts: { total: 0, byAccount: {} }, notifications: [], threadMessages: {}, expandedThreadId: null, backfillProgress: {}, @@ -135,7 +136,9 @@ export const useStore = create((set, get) => ({ return { selectedAccountId: accountId, selectedFolder: folder, + selectedMessageIds: new Set(), selectedMessageId: null, + activeGtdTab: null, messages: [], messagesOffset: 0, hasMoreMessages: true, @@ -228,6 +231,14 @@ export const useStore = create((set, get) => ({ hasMoreMessages: true, setHasMoreMessages: (v) => set({ hasMoreMessages: v }), + // Shared checkbox selection: row UUIDs for existing bulk APIs. Command context + // converts these to account-scoped account_id:(message_id || id) identities. + selectedMessageIds: new Set(), + setSelectedMessageIds: (nextOrUpdater) => set(state => ({ + selectedMessageIds: nextSelection(state.selectedMessageIds, nextOrUpdater), + })), + clearSelectedMessageIds: () => set({ selectedMessageIds: new Set() }), + // Selected message selectedMessageId: null, lastViewedMessageId: null,