diff --git a/packages/ui/src/app/drafts.test.ts b/packages/ui/src/app/drafts.test.ts index 232fe871c..f0b6f21a7 100644 --- a/packages/ui/src/app/drafts.test.ts +++ b/packages/ui/src/app/drafts.test.ts @@ -191,6 +191,11 @@ test('a draft names its project in the heading and the dropdown moves it to anot typeQuery('zzz'); expect(shownValues()).toEqual([]); expect(query('[data-testid=draft-project-empty]').textContent).toContain('zzz'); + // Enter on a query nothing answers takes nothing: the folder row is not its fallback. + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + flushSync(); + expect(store.projectPickerOpen).toBe(false); + expect(document.querySelector('[data-testid=draft-project-menu]')).not.toBeNull(); typeQuery('BOI'); expect(shownValues().map((value) => JSON.parse(value!)[1])).toEqual(['p-boite']); @@ -210,6 +215,28 @@ test('a draft names its project in the heading and the dropdown moves it to anot expect(query('[data-testid=project][data-project-id=p-notes]').querySelector('[data-testid=draft-row]')).toBeNull(); await waitFor(() => document.activeElement === document.querySelector('[data-testid=composer-input]')); + // The find chord opens the list from the composer, one word per field + // finds the row, and Escape twice gives the keyboard back to the composer. + const composer = query('[data-testid=composer-input]'); + composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', code: 'KeyF', ctrlKey: true, bubbles: true, cancelable: true })); + await waitFor(() => document.activeElement === document.querySelector('[data-testid=draft-project-search]')); + const again = query('[data-testid=draft-project-search]'); + // The list is modal: Tab stays in its search instead of reaching the page behind the scrim. + const tab = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }); + again.dispatchEvent(tab); + expect(tab.defaultPrevented).toBe(true); + expect(document.activeElement).toBe(again); + again.value = 'note src'; + again.dispatchEvent(new Event('input', { bubbles: true })); + flushSync(); + expect(shownValues().map((value) => JSON.parse(value!)[1])).toEqual(['p-notes']); + again.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + flushSync(); + expect(again.value).toBe(''); + again.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await waitFor(() => document.querySelector('[data-testid=draft-project-menu]') === null); + expect(document.activeElement).toBe(composer); + // Focus back on the trigger, Escape still closes the list. const trigger = query('[data-testid=draft-project]'); trigger.click(); diff --git a/packages/ui/src/components/ChatView.svelte b/packages/ui/src/components/ChatView.svelte index 423d96fbd..dcd6c3aa8 100644 --- a/packages/ui/src/components/ChatView.svelte +++ b/packages/ui/src/components/ChatView.svelte @@ -109,7 +109,7 @@

{strings.mobile.draftTitle}

{strings.mobile.draftHint}

- {#if project}{:else}{/if}{project ? projectName(project) : strings.drafts.name} + {#if project}{:else}{/if}{project ? projectName(project) : strings.drafts.name} {#if draftChoice}{modePhrase(draftChoice.permissionMode, draftProvider)}{/if} {#if store.draft?.worktree}{strings.thread.inWorktree}{/if} diff --git a/packages/ui/src/components/CommandPalette.svelte b/packages/ui/src/components/CommandPalette.svelte index a10dc551f..ee66dc927 100644 --- a/packages/ui/src/components/CommandPalette.svelte +++ b/packages/ui/src/components/CommandPalette.svelte @@ -1,15 +1,20 @@ @@ -182,32 +241,51 @@ Esc
- {#each rows as item, index (item.id)} - {@const thread = threadOf(item)} - {@const title = heading(index)} - {#if title} - - {/if} - + {#each section.items as item, n (item.id)} + {@const index = first + n} + {@const place = placeOf(item.id)} + + {/each} +
{/each} {#if rows.length === 0}

{strings.palette.empty}

@@ -291,10 +369,42 @@ padding: 6px; } + .section + .section { + margin-top: 4px; + } + .section-label { padding: 8px 10px 4px; } + /* A project heading reads like the sidebar's: tile, name, then its machine. */ + .project-label { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + text-transform: none; + letter-spacing: normal; + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-foreground); + } + + .project-label .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .machine { + display: inline-flex; + align-items: center; + gap: 4px; + flex: none; + font-weight: 400; + color: var(--color-muted-foreground); + } + .row { width: 100%; display: flex; @@ -314,8 +424,22 @@ background: transparent; } + /* The row Enter takes: the accent's wash, a bar on its edge and the Enter glyph, readable in both themes. */ .row.selected { - background: var(--color-active); + background: var(--color-accent-soft); + box-shadow: inset 2px 0 0 var(--color-accent); + } + + .enter { + display: inline-flex; + flex: none; + width: 13px; + color: var(--color-accent); + opacity: 0; + } + + .row.selected .enter { + opacity: 1; } .row:active:not(:disabled) { @@ -324,7 +448,7 @@ .mark-slot { display: inline-flex; - width: 10px; + width: 16px; justify-content: center; flex: none; } @@ -338,12 +462,20 @@ font-weight: 400; } - .hint { + .state, + .meta { flex: none; + display: inline-flex; + align-items: center; + gap: 4px; font-size: var(--text-sm); color: var(--color-muted-foreground); } + .kbd { + flex: none; + } + .empty { padding: 14px 10px; text-align: center; diff --git a/packages/ui/src/components/CommandPalette.test.ts b/packages/ui/src/components/CommandPalette.test.ts index e51d422bb..b25f03b5e 100644 --- a/packages/ui/src/components/CommandPalette.test.ts +++ b/packages/ui/src/components/CommandPalette.test.ts @@ -66,16 +66,52 @@ test('closed until asked, then the recents come first and the commands after the expect(document.querySelector('[data-testid=palette]')).not.toBeNull(); expect(document.activeElement?.getAttribute('data-testid')).toBe('palette-input'); const ids = rows(); - // Every seeded thread first, then the commands; nothing typed. + // Every seeded thread first, then the commands; nothing typed, no project rows. expect([...ids.slice(0, 4)].sort()).toEqual(['thread:t-bench', 'thread:t-descriptors', 'thread:t-scheduler', 'thread:t-trace']); expect(ids).toContain('new-thread'); expect(ids).toContain('settings'); + expect(ids.some((id) => id.startsWith('project:'))).toBe(false); expect(ids.at(-1)).toBe('archive'); expect(selectedId()).toBe(ids[0]); - expect(document.body.textContent).toContain('Threads'); + // Each project's threads sit together under its name, the commands after them. + const sections = Array.from(document.querySelectorAll('[data-testid=palette-section]')); + const of = (section: HTMLElement) => Array.from(section.querySelectorAll('[data-testid=palette-row]')).map((row) => row.getAttribute('data-palette-id')); + expect(sections.map((section) => section.querySelector('[data-testid=palette-project] .name')?.textContent ?? 'commands')).toEqual(['notes', 'boite', 'commands']); + expect(of(sections[0]!)).toEqual(['thread:t-descriptors']); + expect(of(sections[1]!).sort()).toEqual(['thread:t-bench', 'thread:t-scheduler', 'thread:t-trace']); + // A row says what its thread is doing, the way the sidebar does. + expect(document.querySelector('[data-palette-id="thread:t-bench"]')?.textContent).toContain('Needs you'); expect(document.body.textContent).toContain('Commands'); }); +test('a query finds a project by its name and folder, and Enter starts a thread there with the keyboard in the composer', async () => { + const composer = document.createElement('textarea'); + composer.dataset['testid'] = 'composer-input'; + document.body.append(composer); + store.paletteOpen = true; + flushSync(); + await tick(); + + // "boite src" lands one word on the name, the other on the folder. + await type('boite src'); + const project = rows().find((id) => id.startsWith('project:')); + expect(project).toBe('project:["local","p-boite"]'); + expect(rows()).not.toContain('project:["local","p-notes"]'); + while (selectedId() !== project) key('ArrowDown'); + // WebKit sends the Enter that confirms an IME candidate with keyCode 229. + const input = document.querySelector('[data-testid=palette-input]')!; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, bubbles: true, cancelable: true })); + flushSync(); + expect(store.paletteOpen).toBe(true); + key('Enter'); + await new Promise((resolve) => setTimeout(resolve, 5)); + await tick(); + expect(store.paletteOpen).toBe(false); + expect(store.openThread).toBeNull(); + expect(store.draft?.projectId).toBe('p-boite'); + expect(document.activeElement).toBe(composer); +}); + test('typing filters across threads and commands, the arrows move, Enter opens the thread', async () => { store.paletteOpen = true; flushSync(); diff --git a/packages/ui/src/components/ProjectChoice.svelte b/packages/ui/src/components/ProjectChoice.svelte index fd1bca812..ab192d855 100644 --- a/packages/ui/src/components/ProjectChoice.svelte +++ b/packages/ui/src/components/ProjectChoice.svelte @@ -1,112 +1,118 @@ - - -
+
{/each} {/each} @@ -303,19 +340,20 @@ {/if}
+
{/if} @@ -344,8 +382,26 @@ background: var(--color-hover); } + .scrim { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: min(14vh, 120px); + background: var(--color-scrim); + animation: fade var(--dur-2) var(--ease-out-quint); + } + + .scrim.closing { + animation-name: fade-out; + pointer-events: none; + } + .popover { - width: min(420px, calc(100vw - 24px)); + width: min(520px, calc(100vw - 24px)); + max-height: min(70vh, 520px); display: flex; flex-direction: column; padding: 6px; @@ -359,10 +415,8 @@ letter-spacing: normal; line-height: normal; text-align: left; - z-index: 40; overflow: hidden; animation: pop var(--dur-2) var(--ease-out-quint); - transform-origin: bottom left; } .popover.closing { @@ -419,20 +473,24 @@ } .group { + display: flex; + align-items: center; + gap: 6px; padding: 8px 8px 4px; color: var(--color-muted-foreground); font-size: var(--text-xs); font-weight: 500; } + /* One line a project: its tile, its name, its folder, then the machine and the mark. */ .row { display: flex; align-items: center; gap: 10px; width: 100%; height: auto; - min-height: 44px; - padding: 6px 8px; + min-height: var(--row); + padding: 4px 8px; border: none; border-radius: var(--radius-sm); background: transparent; @@ -441,8 +499,10 @@ cursor: pointer; } + /* The row Enter takes, as in the command palette: the accent's wash and a bar on its edge. */ .row.highlight { - background: var(--color-hover); + background: var(--color-accent-soft); + box-shadow: inset 2px 0 0 var(--color-accent); } .row:active { @@ -453,6 +513,16 @@ .check { flex: none; display: inline-flex; + width: 15px; + color: var(--color-accent); + } + + .machine { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); color: var(--color-muted-foreground); } @@ -466,15 +536,9 @@ color: var(--color-muted-foreground); } - .text { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 5px; - } - .name { + flex: none; + max-width: 55%; font-weight: 500; white-space: nowrap; overflow: hidden; @@ -483,6 +547,7 @@ .folder .name { flex: 1; + max-width: none; font-weight: 400; color: var(--color-muted-foreground); } @@ -491,8 +556,10 @@ color: var(--color-foreground); } - /* One line each: a long path ends in an ellipsis, the full one is in the title. */ + /* After the name, filling the line: a long path ends in an ellipsis, the full one is in the title. */ .hint { + flex: 1; + min-width: 0; font-size: var(--text-xs); color: var(--color-muted-foreground); white-space: nowrap; @@ -526,6 +593,7 @@ } @media (max-width: 720px) { + .scrim { padding-top: 12px; } .search { height: var(--control-touch); } .row { min-height: var(--touch-target); } } diff --git a/packages/ui/src/lib/command-table.ts b/packages/ui/src/lib/command-table.ts index 30e1d8ef4..0b9beb35f 100644 --- a/packages/ui/src/lib/command-table.ts +++ b/packages/ui/src/lib/command-table.ts @@ -64,7 +64,12 @@ export const commandTable: readonly CommandDefinition[] = [ { id: 'terminal-next-command', label: () => strings.keyboard.commands.terminalNextCommand, ownerOnly: true }, { id: 'reopen-thread', label: () => strings.keyboard.commands.reopenThread, run: (store) => { void reopenLastArchived(store); } }, { id: 'copy-answer', label: () => strings.keyboard.commands.copyAnswer, run: (store) => { const open = store.openThread; if (open) { const text = lastAnswer(open.messages); if (text) void store.copy(text); } } }, - { id: 'find', label: () => strings.keyboard.commands.find, run: (store) => { const open = store.openThread; if (open) { store.showChat(); store.findOpen = true; store.findRequest += 1; } } }, + // A new thread has nothing to find in yet: there the same chord picks its project. + { id: 'find', label: () => strings.keyboard.commands.find, run: (store) => { + const open = store.openThread; + if (open) { store.showChat(); store.findOpen = true; store.findRequest += 1; } + else if (store.draft && store.page === 'chat') store.projectChoiceRequest += 1; + } }, { id: 'subagents', run: (store) => { store.showChat(); store.panel.toggleKind('agents'); } }, { id: 'task-manager', run: (store) => { store.showSettings('task-manager'); } }, { id: 'archived', run: (store) => { store.showSettings('general', 'archived'); } }, diff --git a/packages/ui/src/lib/commands.svelte.ts b/packages/ui/src/lib/commands.svelte.ts index d57dfe877..501bb2017 100644 --- a/packages/ui/src/lib/commands.svelte.ts +++ b/packages/ui/src/lib/commands.svelte.ts @@ -45,7 +45,7 @@ export function appCommands(store: Store, inShell: boolean): PaletteItem[] { items.push(row('pin', open.pinned ? strings.palette.unpin : strings.palette.pin, 'favourite top')); items.push(row('rename', strings.palette.rename, 'title')); items.push(row('retitle', strings.palette.retitle, 'title agent name')); - items.push(row('find', strings.keyboard.commands.find, 'search text ctrl+f')); + items.push(row('find', strings.palette.find, 'search text ctrl+f')); items.push(row('copy-answer', strings.keyboard.commands.copyAnswer, 'clipboard reply response')); items.push(row('panel', strings.palette.panel, 'browser surface')); // Following subagents and workflow runs is open to a paired device. diff --git a/packages/ui/src/lib/format.ts b/packages/ui/src/lib/format.ts index 8d9cdad82..eae2991cf 100644 --- a/packages/ui/src/lib/format.ts +++ b/packages/ui/src/lib/format.ts @@ -255,6 +255,11 @@ export function titleFrom(prompt: string, max = 60): string { } /** A project's name as the user reads it: the drafts in the app's own language, any other as the core named it. */ +/** A folder with the user's home folded to `~`, the part that tells two projects apart. */ +export function homePath(path: string): string { + return path.replace(/^(\/home\/[^/]+|\/Users\/[^/]+|[A-Za-z]:\\Users\\[^\\]+)(?=[/\\]|$)/, '~'); +} + export function projectName(project: { name: string; kind?: 'drafts' } | null | undefined): string { if (!project) return ''; return project.kind === 'drafts' ? strings.drafts.name : project.name; diff --git a/packages/ui/src/lib/palette.test.ts b/packages/ui/src/lib/palette.test.ts index 4d4b4f5b5..5c5d265cd 100644 --- a/packages/ui/src/lib/palette.test.ts +++ b/packages/ui/src/lib/palette.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { rankItems, scoreMatch, type PaletteItem } from './palette'; +import { rankItems, scoreFields, scoreMatch, sectionRows, type PaletteItem } from './palette'; describe('scoreMatch', () => { test('a prefix beats a word start, which beats a substring, which beats scattered letters', () => { @@ -63,3 +63,36 @@ describe('rankItems', () => { expect(rankItems('boite', rows).map((item) => item.id)).toEqual(['b', 'a']); }); }); + +describe('scoreFields', () => { + const boite = [{ text: 'Boite' }, { text: 'm2', penalty: 5 }, { text: '/home/boite/projects/boite', penalty: 15 }]; + const other = [{ text: 'Boite' }, { text: 'PC', penalty: 5 }, { text: 'D:/Dev/boite', penalty: 15 }]; + + test('each word may land on its own field: the name, then the machine', () => { + expect(scoreFields('boite m2', boite)).not.toBeNull(); + expect(scoreFields('boite m2', other)).toBeNull(); + expect(scoreFields('m2 boi', boite)).not.toBeNull(); + }); + + test('the name outranks the machine and the folder, and every word has to land', () => { + expect(scoreFields('boite', boite)!).toBeGreaterThan(scoreFields('m2', boite)!); + expect(scoreFields('m2', boite)!).toBeGreaterThan(scoreFields('projects', boite)!); + expect(scoreFields('boite zzz', boite)).toBeNull(); + }); +}); + +describe('sectionRows', () => { + test('a section stands where its best row ranked and keeps its rows together', () => { + const ranked: PaletteItem[] = [ + { id: 'a', kind: 'thread', label: 'A', section: 'boite' }, + { id: 'cmd', kind: 'command', label: 'Command' }, + { id: 'b', kind: 'thread', label: 'B', section: 'notes' }, + { id: 'c', kind: 'thread', label: 'C', section: 'boite' } + ]; + expect(sectionRows(ranked).map((section) => [section.id, section.items.map((item) => item.id)])).toEqual([ + ['boite', ['a', 'c']], + ['command', ['cmd']], + ['notes', ['b']] + ]); + }); +}); diff --git a/packages/ui/src/lib/palette.ts b/packages/ui/src/lib/palette.ts index 2b3da620c..b83924086 100644 --- a/packages/ui/src/lib/palette.ts +++ b/packages/ui/src/lib/palette.ts @@ -4,7 +4,7 @@ * test and the component only draws. */ -export type PaletteKind = 'thread' | 'command'; +export type PaletteKind = 'thread' | 'project' | 'command'; export interface PaletteItem { id: string; @@ -16,6 +16,14 @@ export interface PaletteItem { keywords?: string; /** The second line a list may draw under the label. The palette draws none; the slash menu does. */ description?: string; + /** The heading a row sits under: a thread's project on its machine. Rows without one group by kind. */ + section?: string; +} + +/** A text a query word may land on, and what landing there costs against the label. */ +export interface SearchField { + text: string; + penalty?: number; } /** Threads shown before any typing: the most recent ones, whatever the project. */ @@ -80,6 +88,43 @@ export function scoreMatch(query: string, text: string): number | null { return Math.max(1, 30 - gaps * 3 - Math.min(last, 10)); } +/** + * How well a row with these fields answers `query`, null when it does not. The + * whole query may land on one field ("port the" on a title); otherwise every + * word lands on its own field, the weakest decides, so "boite m2" finds the + * project boite on the machine m2 and "trace boite" a thread of boite. + */ +export function scoreFields(query: string, fields: readonly SearchField[]): number | null { + const best = (needle: string): number | null => { + let top: number | null = null; + for (const field of fields) { + const score = scoreMatch(needle, field.text); + if (score !== null) top = Math.max(top ?? -Infinity, score - (field.penalty ?? 0)); + } + return top; + }; + const whole = best(query); + const words = fold(query.trim()).split(/\s+/).filter(Boolean); + if (words.length < 2) return whole; + let worst: number | null = Infinity; + for (const word of words) { + const score = best(word); + if (score === null) { worst = null; break; } + worst = Math.min(worst, score); + } + if (worst === null) return whole; + return Math.max(whole ?? -Infinity, worst); +} + +/** A row's fields: its label, then its keywords, then its hint, each a little weaker. */ +function itemFields(item: PaletteItem): SearchField[] { + return [ + { text: item.label }, + ...(item.keywords === undefined ? [] : [{ text: item.keywords, penalty: 5 }]), + ...(item.hint === undefined ? [] : [{ text: item.hint, penalty: 10 }]) + ]; +} + /** * The rows to draw for `query`: every item that matches, best first, ties in * the order given. With nothing typed, the items come back as given, cut at @@ -89,12 +134,33 @@ export function rankItems(query: string, items: PaletteItem[], limit = PALETTE_L if (query.trim().length === 0) return items.slice(0, limit); const scored: { item: PaletteItem; score: number; order: number }[] = []; items.forEach((item, order) => { - const own = scoreMatch(query, item.label); - const hint = item.hint === undefined ? null : scoreMatch(query, item.hint); - const words = item.keywords === undefined ? null : scoreMatch(query, item.keywords); - const best = Math.max(own ?? -1, hint === null ? -1 : hint - 10, words === null ? -1 : words - 5); - if (best >= 0) scored.push({ item, score: best, order }); + const score = scoreFields(query, itemFields(item)); + if (score !== null && score >= 0) scored.push({ item, score, order }); }); scored.sort((a, b) => b.score - a.score || a.order - b.order); return scored.slice(0, limit).map((entry) => entry.item); } + +export interface PaletteSection { + /** The rows' `section`, or their kind for rows without one. */ + id: string; + kind: PaletteKind; + items: PaletteItem[]; +} + +/** + * Ranked rows cut into the headings the palette draws: a project's threads + * together, then the projects, then the commands. A section stands where its + * best row ranked, its rows keep their order inside it, so typing still puts + * the best match first and the first row is always the best one. + */ +export function sectionRows(ranked: readonly PaletteItem[]): PaletteSection[] { + const sections = new Map(); + for (const item of ranked) { + const id = item.section ?? item.kind; + const section = sections.get(id); + if (section) section.items.push(item); + else sections.set(id, { id, kind: item.kind, items: [item] }); + } + return [...sections.values()]; +} diff --git a/packages/ui/src/lib/store.svelte.ts b/packages/ui/src/lib/store.svelte.ts index d961a2b8d..70abd453e 100644 --- a/packages/ui/src/lib/store.svelte.ts +++ b/packages/ui/src/lib/store.svelte.ts @@ -286,6 +286,8 @@ export class Store { set findOpen(value) { this.#ctx.layout.findOpen = value; } get findRequest() { return this.#ctx.layout.findRequest; } set findRequest(value) { this.#ctx.layout.findRequest = value; } + get projectChoiceRequest() { return this.#ctx.layout.projectChoiceRequest; } + set projectChoiceRequest(value) { this.#ctx.layout.projectChoiceRequest = value; } get page() { return this.#ctx.layout.page; } set page(value) { this.#ctx.layout.page = value; } get settingsTab() { return this.#ctx.layout.settingsTab; } diff --git a/packages/ui/src/lib/store/layout.svelte.ts b/packages/ui/src/lib/store/layout.svelte.ts index 1d68f6088..83312ff64 100644 --- a/packages/ui/src/lib/store/layout.svelte.ts +++ b/packages/ui/src/lib/store/layout.svelte.ts @@ -29,6 +29,8 @@ export class Layout { /** The find bar over the open thread, and a count each ask bumps so a second Ctrl+F refocuses it. */ findOpen = $state(false); findRequest = $state(0); + /** Bumped to open the draft's project dropdown from the keyboard. */ + projectChoiceRequest = $state(0); page = $state('chat'); settingsTab = $state('home'); /** A settings card requested before its lazy page exists, with a fresh key for repeated asks. */ diff --git a/packages/ui/src/lib/strings/en/keyboard.ts b/packages/ui/src/lib/strings/en/keyboard.ts index 42ea226bb..e08cffe12 100644 --- a/packages/ui/src/lib/strings/en/keyboard.ts +++ b/packages/ui/src/lib/strings/en/keyboard.ts @@ -38,7 +38,7 @@ export const strings = { pin: 'Pin or unpin this thread', reopenThread: 'Reopen the thread archived last', copyAnswer: 'Copy the last answer', - find: 'Find in this thread', + find: 'Find in this thread, or pick a new thread\'s project', terminalNew: 'New terminal', terminalSplit: 'Split the terminal right', terminalSplitVertical: 'Split the terminal down', diff --git a/packages/ui/src/lib/strings/en/palette.ts b/packages/ui/src/lib/strings/en/palette.ts index e7f6ee613..03eb558c5 100644 --- a/packages/ui/src/lib/strings/en/palette.ts +++ b/packages/ui/src/lib/strings/en/palette.ts @@ -1,9 +1,12 @@ export const strings = { palette: { - placeholder: 'Search threads, or type a command', + placeholder: 'Search threads, projects or commands', + projects: 'Projects', + /** Extra words a project row answers to: picking one starts a thread there. */ + newThreadWords: 'new thread start draft', + find: 'Find in this thread', terminal: 'Show or hide the terminal', - threads: 'Threads', commands: 'Commands', empty: 'Nothing matches.', hint: 'Enter to open, Esc to close', diff --git a/packages/ui/src/lib/strings/fr/keyboard.ts b/packages/ui/src/lib/strings/fr/keyboard.ts index 3ed704ba3..ffd1c6b67 100644 --- a/packages/ui/src/lib/strings/fr/keyboard.ts +++ b/packages/ui/src/lib/strings/fr/keyboard.ts @@ -39,7 +39,7 @@ export const fr: Translation = { pin: 'Épingler ou désépingler cette conversation', reopenThread: 'Rouvrir la dernière conversation archivée', copyAnswer: 'Copier la dernière réponse', - find: 'Chercher dans cette conversation', + find: 'Chercher dans cette conversation, ou choisir le projet d\'une nouvelle', terminalNew: 'Nouveau terminal', terminalSplit: 'Diviser le terminal à droite', terminalSplitVertical: 'Diviser le terminal en bas', diff --git a/packages/ui/src/lib/strings/fr/palette.ts b/packages/ui/src/lib/strings/fr/palette.ts index ac3e0f4ca..9688638d0 100644 --- a/packages/ui/src/lib/strings/fr/palette.ts +++ b/packages/ui/src/lib/strings/fr/palette.ts @@ -3,9 +3,12 @@ import type { Translation } from '../../i18n.svelte'; export const fr: Translation = { palette: { - placeholder: 'Cherchez une conversation, ou tapez une commande', + placeholder: 'Cherchez une conversation, un projet ou une commande', + projects: 'Projets', + /** Extra words a project row answers to: picking one starts a thread there. */ + newThreadWords: 'nouvelle conversation démarrer brouillon new thread', + find: 'Chercher dans cette conversation', terminal: 'Afficher ou masquer le terminal', - threads: 'Conversations', commands: 'Commandes', empty: 'Rien ne correspond.', hint: 'Entrée pour ouvrir, Échap pour fermer', diff --git a/tests/e2e/drafts-overlays.test.ts b/tests/e2e/drafts-overlays.test.ts index d5ed63bab..57dc28f10 100644 --- a/tests/e2e/drafts-overlays.test.ts +++ b/tests/e2e/drafts-overlays.test.ts @@ -30,11 +30,11 @@ test('height-capped desktop popovers can scroll to their last action', async () await escape(page); await page.waitFor(`!document.querySelector('[data-testid="${popup}"]')`); } - // Rows with a hint are taller than the minimum: capped, the list scrolls and none of them shrinks onto the next. + // Capped by a short window, the project list scrolls and none of its rows shrinks onto the next. // A label's ink room overflows its row by under 3px at rest; a squashed row lost 7px or more. await page.evaluate('globalThis.__boiteTest.workspace.active.startDraft(null)'); await page.waitFor('document.querySelector("[data-testid=draft-project]")'); - await page.send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 330, deviceScaleFactor: 1, mobile: false }); + await page.send('Emulation.setDeviceMetricsOverride', { width: 1100, height: 220, deviceScaleFactor: 1, mobile: false }); await page.click('[data-testid=draft-project]'); await page.waitFor('document.querySelector("[data-testid=draft-project-menu]")'); await page.settleAnimations(); diff --git a/tests/e2e/recent-attention.test.ts b/tests/e2e/recent-attention.test.ts index d5166def9..83be5437f 100644 --- a/tests/e2e/recent-attention.test.ts +++ b/tests/e2e/recent-attention.test.ts @@ -177,10 +177,36 @@ test('the draft project picker has its logo and dashed selection at desktop and expect(await page.evaluate(`getComputedStyle(document.querySelector('${id('mobile-draft-project')}').closest('.project-choice')).borderStyle`)).toBe('dashed'); expect(await page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')).toBe(true); await capture('recent-project-phone.png'); + // The find chord opens the phone's copy with the keys in its search, and Back closes it. + await page.evaluate(`document.querySelector('${id('composer-input')}').focus()`); + const find = { key: 'f', code: 'KeyF', modifiers: 2, windowsVirtualKeyCode: 70 }; + await page.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...find }); + await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...find }); + await page.waitFor(`document.activeElement === document.querySelector('${id('mobile-draft-project-search')}')`); + expect(await page.evaluate(`!!document.querySelector('${id('draft-project-menu')}')`)).toBe(false); + await page.evaluate('history.back()'); + await page.waitFor(`!document.querySelector('${id('mobile-draft-project-menu')}')`); await page.click(id('mobile-draft-project')); await page.evaluate(`Array.from(document.querySelectorAll('${id('mobile-draft-project-menu')} [data-value]')).find(e => e.dataset.value === ${JSON.stringify(JSON.stringify(['http://builder.test', 'p-notes']))}).click()`); await page.waitFor(`globalThis.__boiteTest.workspace.active.machineId === 'http://builder.test' && document.querySelector('${id('mobile-draft-project')} [data-tech=python]')`); expect(await page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')).toBe(true); + // From the keyboard alone: the find chord on a draft opens the list, one + // word lands on the project and one on the machine, Enter moves the draft + // to the other machine and its composer takes the next keys. + await page.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }); + await page.waitFor(`document.querySelector('${id('composer-input')}')?.getClientRects().length`); + await page.evaluate(`document.querySelector('${id('composer-input')}').focus()`); + const chord = { key: 'f', code: 'KeyF', modifiers: 2, windowsVirtualKeyCode: 70 }; + await page.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...chord }); + await page.send('Input.dispatchKeyEvent', { type: 'keyUp', ...chord }); + await page.waitFor(`document.activeElement === document.querySelector('${id('draft-project-search')}')`); + // The machine's own name, in whatever language the page speaks. + const here = await page.evaluate('globalThis.__boiteTest.workspace.machines[0].label'); + await page.send('Input.insertText', { text: `boite ${here}` }); + await page.waitFor(`Array.from(document.querySelectorAll('${id('draft-project-list')} [data-value]')).map(e => e.dataset.value).join('|') === ${JSON.stringify(JSON.stringify([main, 'p-boite']))}`); + await page.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', text: '\r', windowsVirtualKeyCode: 13 }); + await page.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 }); + await page.waitFor(`globalThis.__boiteTest.workspace.active === globalThis.__boiteTest.workspace.machines[0].store && globalThis.__boiteTest.workspace.active.draft?.projectId === 'p-boite' && document.activeElement === document.querySelector('${id('composer-input')}')`); expect(page.errors()).toEqual([]); console.log('RECENT_PROJECT_PICKER_OK desktop=1280x900 phone=390x844'); }, 20_000);