diff --git a/ui-web/README.md b/ui-web/README.md index 1d74a74c7..8869bc39f 100644 --- a/ui-web/README.md +++ b/ui-web/README.md @@ -133,7 +133,7 @@ file, the compass for the start page. from where the displayed list ends. - **A file** opens as its own tab, drawn by the viewer its path picks — Markdown rendered as prose, code highlighted behind a line gutter, an HTML - document in a frame of its own, an image at its own size, a PDF through the + document in a frame of its own, an image fitted to the panel, a PDF through the browser's viewer, anything else as bare text — with the text viewers one pick away in the header's chooser where they still make sense (an HTML document or an SVG is also its source; a raster image or a PDF is only @@ -398,6 +398,10 @@ unchanged. | Grouped work details | `015a9b202c`, `1bcb633c3f`, `a0a9aef5ec` | Compact, Standard, Detailed and Verbose; independent completion/next-input collapse timing; whole-turn history paging | | Model search | `6773e10645`, `989b93d86f`, `c87959bc44` | Fuzzy matching within provider groups, sticky headings, keyboard selection, pending indication and viewport placement | | Font preferences | `849e9ebd8d` | Independent conversation, code and terminal-output fonts and sizes, persisted per browser | +| Structured composer drafts | `e400349e3a`, `98d93db18f` | Session-scoped text and attachment metadata across navigation/reload; argument-taking commands become removable atomic chips | +| Session list folding and IDs | `7df3c41ec9`, `38c700f08e` | Five-row pages, a growing visible prefix for active sessions, search across folded rows and IDs, copyable session IDs | +| Document previews | `0cad3a0137`, `f65c15a5cf` | YAML frontmatter property cards, source switching, per-tab zoom and fit-width image previews | +| Image inspection | `7fe9c06fa6` | Expand inline and sent images, fit/actual-size controls, keyboard dismissal and lazy image-link hover previews | **Settings → General** owns reading preferences. Detailed mode shows individual work rows during the current turn and folds completed groups; Verbose retains @@ -407,6 +411,19 @@ on collapsed groups. Assistant messages and notices retain their chronological positions. Long conversations initially render the latest 20 turns; **Show earlier messages** adds 20 without moving the first visible turn. +Drafts are private to the browser tab and stored in `sessionStorage`. Accepted +uploads remain attached while the same agent runtime is available. When a saved +conversation needs a replacement runtime, its text is restored and stale upload +markers are removed with a notice to attach the files again. Large image previews +may be omitted from persisted metadata when browser storage is full. Command +chips retain the existing slash-command protocol when submitted. + +File previews keep independent zoom levels (25–400%) while their tabs are open. +Images initially fit the panel; HTML and PDF retain their native viewer controls. +Leading YAML frontmatter renders as properties without expanding aliases; malformed +metadata remains visible as source. Session ID popovers support copying, outside +click dismissal and Escape, including near the bottom of a scrolled sidebar. + The upstream account, plugin marketplace, native voice input, remote operators, translation, hosted search, scheduling service and interactive terminal depend on upstream host services absent from ClawCodex's gateway. They are not copied diff --git a/ui-web/e2e/conversation.spec.ts b/ui-web/e2e/conversation.spec.ts index 8ba2f00bf..f0b32fb7d 100644 --- a/ui-web/e2e/conversation.spec.ts +++ b/ui-web/e2e/conversation.spec.ts @@ -49,3 +49,39 @@ test('real gateway: model search selects a provider/model and keeps the menu in await expect(search).toHaveCount(0) expect(app.workspace).toContain('project') }) + +test('real gateway: drafts and accepted files survive navigation and reload; command chips stay atomic', async ({ page, app }) => { + const composer = page.getByRole('textbox', { name: 'Message ClawCodex' }) + await composer.fill('Start a session for draft persistence') + await composer.press('Enter') + await expect(page.getByText('Hello from the mock inference server! The full boot chain is working.', { exact: true })).toBeVisible({ timeout: 90_000 }) + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + const title = page.getByRole('textbox', { name: 'Session title' }) + await expect(title).toHaveValue(/Hello from the mock inference server/) + await title.fill('Draft one') + await title.press('Enter') + await expect(page.getByRole('button', { name: /^Draft one/ })).toBeVisible() + await composer.fill('/plan fix the project') + await expect(page.getByRole('button', { name: 'Remove /plan command' })).toBeVisible() + await expect(composer).toHaveValue('fix the project') + await page.locator('input[type=file]').last().setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('A persisted attachment.') }) + await expect(composer).toHaveValue(/\[File #\d+\]/) + const draft = await composer.inputValue() + await page.reload() + await expect(composer).toHaveValue(draft) + await expect(page.getByRole('button', { name: 'Remove notes.txt' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Remove /plan command' })).toBeVisible() + + await page.getByRole('button', { name: 'New session', exact: true }).first().click() + await page.getByRole('button', { name: 'Create', exact: true }).click() + await expect(composer).toHaveValue('') + await composer.fill('A separate unsent draft') + await page.getByRole('button', { name: /^Draft one/ }).click() + await expect(composer).toHaveValue(draft) + await page.getByRole('button', { name: 'Remove /plan command' }).click() + await expect(composer).toHaveValue(draft) + await composer.press('Enter') + await expect(page.locator('[data-file-card]').getByText('notes.txt')).toBeVisible() + await expect.poll(() => app.received.join('')).toContain('A persisted attachment.') + expect(app.workspace).toContain('project') +}) diff --git a/ui-web/e2e/fixtures.ts b/ui-web/e2e/fixtures.ts index 5d454701b..5114c5d98 100644 --- a/ui-web/e2e/fixtures.ts +++ b/ui-web/e2e/fixtures.ts @@ -4,13 +4,13 @@ import { existsSync, appendFileSync } from 'node:fs' import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' -import { startMockServer } from './model-server' +import { startMockServer, PREVIEW_SVG } from './model-server' const repo = path.resolve(import.meta.dirname, '../..') const python = process.env.CLAWCODEX_TEST_PYTHON ?? (existsSync(path.join(repo, '.venv/bin/python')) ? path.join(repo, '.venv/bin/python') : 'python3') /** Real Python gateway and agent loop; only model inference is replaced. */ -export const test = base.extend<{ app: { url: string; workspace: string } }>({ +export const test = base.extend<{ app: { url: string; workspace: string; received: string[] } }>({ app: async ({ page }, use, testInfo) => { const temp = await mkdtemp(path.join(tmpdir(), 'clawcodex-web-e2e-')) const config = path.join(temp, 'config') @@ -26,6 +26,7 @@ export const test = base.extend<{ app: { url: string; workspace: string } }>({ })) await writeFile(path.join(workspace, 'README.md'), '---\ntitle: Preview document\ntags: [web, port]\n---\n# Preview body\n\nA document served by the ClawCodex gateway.\n') await writeFile(path.join(workspace, 'example.txt'), 'A file from the real workspace.\n') + await writeFile(path.join(workspace, 'preview.svg'), PREVIEW_SVG) const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, SystemRoot: process.env.SystemRoot, TMPDIR: process.env.TMPDIR, PYTHONPATH: repo, PYTHONUNBUFFERED: '1', CLAWCODEX_DISABLE_LEGACY_MIGRATION: '1', @@ -52,7 +53,8 @@ export const test = base.extend<{ app: { url: string; workspace: string } }>({ const url = `http://127.0.0.1:${port}` await page.goto(url) await expect(page.getByRole('textbox', { name: 'Message ClawCodex' })).toBeVisible() - await use({ url, workspace }) + await expect(page.getByText('Connected', { exact: true })).toBeVisible() + await use({ url, workspace, received: mock.received }) expect(errors).toEqual([]) } finally { await testInfo.attach('backend.log', { body: logs, contentType: 'text/plain' }) diff --git a/ui-web/e2e/model-server.ts b/ui-web/e2e/model-server.ts index 3c0a0cb71..65d93332c 100644 --- a/ui-web/e2e/model-server.ts +++ b/ui-web/e2e/model-server.ts @@ -2,11 +2,18 @@ import http from 'node:http' export const FINAL_REPLY = 'All done! Here is the complete summary of what I found.' export const SIMPLE_REPLY = 'Hello from the mock inference server! The full boot chain is working.' +export const PREVIEW_SVG = 'ClawCodex image preview' /** Deterministic OpenAI-compatible inference, with real Read tool execution by the agent. */ export async function startMockServer() { let toolIssued = false + const received: string[] = [] const server = http.createServer(async (request, response) => { + if (request.url === '/preview.svg') { + response.setHeader('Content-Type', 'image/svg+xml') + response.end(PREVIEW_SVG) + return + } if (request.url === '/v1/models') { response.setHeader('Content-Type', 'application/json') response.end(JSON.stringify({ data: [{ id: 'mock-model' }] })) @@ -15,11 +22,15 @@ export async function startMockServer() { let raw = '' for await (const chunk of request) raw += String(chunk) const body = JSON.parse(raw || '{}') as { stream?: boolean; tools?: { function: { name: string } }[]; messages?: { role: string; content: unknown }[] } + received.push(JSON.stringify(body.messages ?? [])) const scripted = JSON.stringify(body.messages ?? []).includes('E2E_INTERIM_TRIGGER') const readTool = body.tools?.find(entry => ['Read', 'read_file'].includes(entry.function.name)) const tool = scripted && !toolIssued && readTool !== undefined if (tool) toolIssued = true - const text = tool ? 'I will read the project documentation.' : scripted ? FINAL_REPLY : SIMPLE_REPLY + const imageUrl = `http://${request.headers.host}/preview.svg` + const text = tool ? 'I will read the project documentation.' : scripted ? FINAL_REPLY : + JSON.stringify(body.messages ?? []).includes('E2E_IMAGE_TRIGGER') ? + `![Workspace image](${imageUrl})\n\n[Inspect linked image](${imageUrl})` : SIMPLE_REPLY const calls = tool ? [{ id: 'read-project', type: 'function', function: { name: readTool?.function.name, arguments: JSON.stringify({ file_path: 'README.md' }) } }] : undefined const finish = tool ? 'tool_calls' : 'stop' const usage = { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } @@ -41,7 +52,7 @@ export async function startMockServer() { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (!address || typeof address === 'string') throw new Error('No model server port') - return { url: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolve, reject) => { + return { url: `http://127.0.0.1:${address.port}`, received, close: () => new Promise((resolve, reject) => { server.closeAllConnections() server.close(error => error ? reject(error) : resolve()) }) } diff --git a/ui-web/e2e/previews.spec.ts b/ui-web/e2e/previews.spec.ts new file mode 100644 index 000000000..df29a6478 --- /dev/null +++ b/ui-web/e2e/previews.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from './fixtures' + +test('real gateway: document metadata and per-tab zoom, source switching, and image fit', async ({ page, app }, testInfo) => { + const composer = page.getByRole('textbox', { name: 'Message ClawCodex' }) + await composer.fill('Open the workspace') + await composer.press('Enter') + await expect(page.getByText('Hello from the mock inference server! The full boot chain is working.', { exact: true })).toBeVisible({ timeout: 90_000 }) + await page.getByRole('button', { name: 'Open workspace in Files' }).click() + await page.getByRole('button', { name: 'README.md', exact: true }).click() + await expect(page.getByRole('heading', { name: 'Preview body' })).toBeVisible() + await expect(page.getByLabel('Document properties').getByText('Preview document')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('document-preview.png') }) + await page.getByRole('button', { name: 'Zoom in', exact: true }).click() + await expect(page.getByLabel('Preview zoom level')).toHaveValue('1.25') + await page.getByRole('tab', { name: 'Files', exact: true }).click() + await page.getByRole('button', { name: 'example.txt', exact: true }).click() + await expect(page.getByText('A file from the real workspace.', { exact: true })).toBeVisible() + await expect(page.getByLabel('Preview zoom level')).toHaveValue('1') + await page.getByRole('tab', { name: 'README.md', exact: true }).click() + await expect(page.getByLabel('Preview zoom level')).toHaveValue('1.25') + await page.getByRole('button', { name: 'Viewer', exact: true }).click() + await page.getByRole('menuitem', { name: 'Plain text', exact: true }).click() + await expect(page.locator('[data-preview-line="2"]')).toContainText('title: Preview document') + await page.getByRole('tab', { name: 'Files', exact: true }).click() + await page.getByRole('button', { name: 'preview.svg', exact: true }).click() + const image = page.getByRole('img', { name: 'Preview of preview.svg' }) + await expect(image).toBeVisible() + await expect(page.getByLabel('Preview zoom level')).toHaveValue('fit') + expect((await image.boundingBox())!.width).toBeLessThan(1000) + await page.getByLabel('Preview zoom level').selectOption('1') + await expect.poll(async () => (await image.boundingBox())!.width).toBe(2000) + await page.getByLabel('Preview zoom level').selectOption('fit') + await page.screenshot({ path: testInfo.outputPath('image-file-preview.png') }) + expect(app.workspace).toContain('project') +}) + +test('real gateway: Markdown images expand, fit, close with Escape, and preview on hover', async ({ page, app }, testInfo) => { + await page.getByRole('textbox', { name: 'Message ClawCodex' }).fill('E2E_IMAGE_TRIGGER show the image') + await page.getByRole('textbox', { name: 'Message ClawCodex' }).press('Enter') + const thumbnail = page.getByRole('button', { name: 'Expand image: Workspace image' }) + await expect(thumbnail).toBeVisible({ timeout: 90_000 }) + await thumbnail.click() + const dialog = page.getByRole('dialog', { name: 'Image preview: Workspace image' }) + await expect(dialog).toBeVisible() + await page.getByRole('button', { name: 'Actual size', exact: true }).click() + await expect.poll(async () => (await dialog.getByRole('img').boundingBox())!.width).toBe(2000) + await page.getByRole('button', { name: 'Fit image', exact: true }).click() + expect((await dialog.getByRole('img').boundingBox())!.width).toBeLessThan(1440) + await page.screenshot({ path: testInfo.outputPath('expanded-image.png') }) + await page.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + await expect(thumbnail).toBeFocused() + await page.getByRole('link', { name: 'Inspect linked image' }).hover() + await expect(page.getByRole('img', { name: 'Linked image preview' })).toBeVisible() + expect(app.received.join('')).toContain('E2E_IMAGE_TRIGGER') +}) diff --git a/ui-web/e2e/sessions.spec.ts b/ui-web/e2e/sessions.spec.ts new file mode 100644 index 000000000..4cb306bce --- /dev/null +++ b/ui-web/e2e/sessions.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from './fixtures' + +test('real gateway: session lists fold, search hidden IDs, copy IDs, and keep the selected row visible', async ({ page, context, app }, testInfo) => { + const composer = page.getByRole('textbox', { name: 'Message ClawCodex' }) + for (let index = 0; index < 6; index++) { + if (index > 0) { + await page.getByRole('button', { name: 'New session', exact: true }).first().click() + await page.getByRole('button', { name: 'Create', exact: true }).click() + await expect(page.getByRole('dialog')).toHaveCount(0) + } + await composer.fill(`Start saved conversation ${index}`) + await composer.press('Enter') + await expect(page.getByText('Hello from the mock inference server! The full boot chain is working.', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + const title = page.getByRole('textbox', { name: 'Session title' }) + await expect(title).toHaveValue(/Hello from the mock inference server/) + await title.fill(`Saved conversation ${index}`) + await title.press('Enter') + await expect(page.getByRole('button', { name: new RegExp(`^Saved conversation ${index}`) })).toBeVisible() + } + await page.reload() + await expect(page.getByRole('textbox', { name: 'Session title' })).toHaveValue('Saved conversation 5') + await expect(page.getByRole('button', { name: /^Saved conversation 0/ })).toHaveCount(0) + await page.getByRole('button', { name: 'Show more (1)', exact: true }).click() + await page.getByRole('button', { name: 'Session actions: Saved conversation 0', exact: true }).click() + const details = page.getByRole('dialog', { name: 'Session details' }) + const id = await details.locator('code').innerText() + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: app.url }) + await details.getByRole('button', { name: 'Copy session ID' }).click() + await expect(details.getByRole('button', { name: 'Copied' })).toBeVisible() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(id) + await page.keyboard.press('Escape') + await expect(details).toHaveCount(0) + await page.getByRole('button', { name: 'Show less', exact: true }).click() + await page.getByRole('textbox', { name: 'Filter sessions' }).fill(id) + await page.getByRole('button', { name: /^Saved conversation 0/ }).click() + await page.getByRole('button', { name: 'Clear the filter' }).click() + await expect(page.getByRole('textbox', { name: 'Session title' })).toHaveValue('Saved conversation 0') + await expect(page.getByRole('button', { name: /^Saved conversation 0/ })).toBeVisible() + await page.emulateMedia({ colorScheme: 'dark' }) + await page.screenshot({ path: testInfo.outputPath('sessions-dark.png') }) +}) diff --git a/ui-web/package-lock.json b/ui-web/package-lock.json index ca8f5ec3c..4dcc2ae91 100644 --- a/ui-web/package-lock.json +++ b/ui-web/package-lock.json @@ -16,7 +16,8 @@ "nanostores": "1.4.0", "react": "19.2.7", "react-dom": "19.2.7", - "shiki": "4.3.1" + "shiki": "4.3.1", + "yaml": "2.9.0" }, "devDependencies": { "@playwright/test": "1.58.2", @@ -2686,6 +2687,21 @@ "dev": true, "license": "MIT" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/ui-web/package.json b/ui-web/package.json index 131c2d634..a00f7e174 100644 --- a/ui-web/package.json +++ b/ui-web/package.json @@ -28,7 +28,8 @@ "nanostores": "1.4.0", "react": "19.2.7", "react-dom": "19.2.7", - "shiki": "4.3.1" + "shiki": "4.3.1", + "yaml": "2.9.0" }, "devDependencies": { "@playwright/test": "1.58.2", diff --git a/ui-web/src/conversation/ConversationRoot.tsx b/ui-web/src/conversation/ConversationRoot.tsx index df838e463..98a4553d4 100644 --- a/ui-web/src/conversation/ConversationRoot.tsx +++ b/ui-web/src/conversation/ConversationRoot.tsx @@ -33,11 +33,13 @@ import { $sessionId, $sessionLoading, $sessionTitle, + $storedSessionId, $subagentView, $trajectory, $transcript, $workspace, } from '../state/store.ts' +import { $drafts, setDraftText } from '../state/drafts.ts' import { subagentCatalog } from '../state/subagents.ts' import { currentTodos } from '../state/todo-progress.ts' import { trajectoryStats } from '../state/trajectory.ts' @@ -108,7 +110,12 @@ export function ConversationRoot() { // backend restart, and the session's word wins. const nano = transcript.info.nano ?? backendNano - const [draft, setDraft] = useState('') + const storedId = useStore($storedSessionId) + const drafts = useStore($drafts) + const titleBeforeEdit = useRef('') + const draftScope = storedId ?? sessionId ?? 'welcome' + const draft = drafts[draftScope]?.text ?? '' + const setDraft = useCallback((text: string) => setDraftText(draftScope, text), [draftScope]) // null while in flight, so the panel can say "loading" rather than "no plan". const [plan, setPlan] = useState(null) const [atBottom, setAtBottom] = useState(true) @@ -227,6 +234,8 @@ export function ConversationRoot() { const composer = ( { titleBeforeEdit.current = event.currentTarget.value }} onBlur={event => { const value = event.target.value.trim() - if (value !== '' && value !== sessionTitle) void renameSession(value) + if (value !== '' && value !== titleBeforeEdit.current) void renameSession(value) }} onChange={event => { $sessionTitle.set(event.target.value) diff --git a/ui-web/src/conversation/InputBar.module.css b/ui-web/src/conversation/InputBar.module.css index 185785eaf..623d5bb47 100644 --- a/ui-web/src/conversation/InputBar.module.css +++ b/ui-web/src/conversation/InputBar.module.css @@ -521,3 +521,8 @@ .hiddenPicker { display: none; } +.commandChip { + display: inline-flex; align-items: center; gap: 6px; margin: 12px 16px 0; padding: 4px 8px; + border-radius: 8px; background: var(--cc-alias-interactive-bg-hover); font: var(--cc-font-xs-13); +} +.commandChip button { display: flex; border: 0; padding: 2px; background: transparent; color: inherit; cursor: pointer; } diff --git a/ui-web/src/conversation/InputBar.test.tsx b/ui-web/src/conversation/InputBar.test.tsx index 3695f9f2c..c7881a52c 100644 --- a/ui-web/src/conversation/InputBar.test.tsx +++ b/ui-web/src/conversation/InputBar.test.tsx @@ -275,3 +275,20 @@ describe('InputBar files from the clipboard and folders', () => { expect(screen.getByRole('status').textContent).toContain('Folders and empty files are skipped') }) }) + +it('edits command arguments independently and removes the command atomically', () => { + function Composer() { + const [draft, change] = useState('/plan fix the bug') + return + } + render() + const input = screen.getByRole('textbox', { name: 'Message ClawCodex' }) as HTMLTextAreaElement + expect(input.value).toBe('fix the bug') + expect(screen.getByRole('button', { name: 'Remove /plan command' })).toBeTruthy() + input.setSelectionRange(0, 0) + fireEvent.keyDown(input, { key: 'Backspace' }) + expect(input.value).toBe('fix the bug') + expect(screen.queryByRole('button', { name: 'Remove /plan command' })).toBeNull() +}) diff --git a/ui-web/src/conversation/InputBar.tsx b/ui-web/src/conversation/InputBar.tsx index 74c6e4cf0..cd56c1659 100644 --- a/ui-web/src/conversation/InputBar.tsx +++ b/ui-web/src/conversation/InputBar.tsx @@ -20,6 +20,8 @@ import { attachFile, attachImage, searchFiles } from '../state/actions.ts' import { $commands, $notice, $sessionAttaching } from '../state/store.ts' import { ArrowUpIcon, PlusIcon, SlashSquareIcon, StopIcon, XIcon } from '../ui/icons.tsx' import { FileTypeIcon } from '../ui/primitives/FileTypeIcon.tsx' +import { $drafts } from '../state/drafts.ts' +import { commandDraft } from './command-draft.ts' import { ContextMeter } from './ContextMeter.tsx' import { insertPlaceholder, @@ -41,6 +43,7 @@ import pickerCss from './Pickers.module.css' export interface InputBarProps { approvalMode?: ApprovalMode draft: string + draftScope?: string effort: EffortOptionsResult hero?: boolean /** Nano mode (docs/nano.md) — renders a chip beside the model, like the TUI's. */ @@ -98,6 +101,7 @@ function keepFocus(event: MouseEvent): void { export function InputBar({ approvalMode, draft, + draftScope, effort, hero = false, models, @@ -137,6 +141,10 @@ export function InputBar({ // pick, on Escape, on a pointer outside the card, and on the next keystroke. const [launcher, setLauncher] = useState(false) const rows = useMemo(() => menuRows(commands, vision), [commands, vision]) + const claimed = commandDraft(draft, rows) + const prefix = claimed?.prefix ?? '' + const editorText = claimed?.body ?? draft + const documents = useStore($drafts) const typed = useMemo(() => typedCommand(draft), [draft]) const menu = useMemo(() => { @@ -212,8 +220,8 @@ export function InputBar({ const syncMention = useCallback((element: HTMLTextAreaElement | null) => { if (element === null) return - setMention(mentionAt(element.value, element.selectionStart)) - }, []) + setMention(mentionAt(prefix + element.value, prefix.length + element.selectionStart)) + }, [prefix]) // Debounced: a mention is typed one keystroke at a time, and every query // costs an `rg --files` on the backend. 120ms is under the gap between @@ -263,7 +271,7 @@ export function InputBar({ const picker = useRef(null) const filePicker = useRef(null) - const shown = useMemo(() => liveAttachments(draft, attachments), [attachments, draft]) + const shown = useMemo(() => liveAttachments(draft, draftScope ? documents[draftScope]?.attachments ?? [] : attachments), [attachments, draft, draftScope, documents]) // Object URLs outlive the component unless revoked, and a long session can // paste a lot of screenshots. @@ -303,7 +311,8 @@ export function InputBar({ const current = draftRef.current const element = textarea.current - const caret = element === null ? current.length : element.selectionStart + const activePrefix = commandDraft(current, rows)?.prefix.length ?? 0 + const caret = element === null ? current.length : element.selectionStart + activePrefix const next = insertPlaceholder(current, caret, id, kind) if (kind === 'image') { @@ -321,10 +330,10 @@ export function InputBar({ if (live === null) return live.focus() - live.setSelectionRange(next.caret, next.caret) + live.setSelectionRange(next.caret - activePrefix, next.caret - activePrefix) }) }, - [onDraftChange], + [onDraftChange, rows], ) /** @@ -400,7 +409,7 @@ export function InputBar({ } if (row.hint !== undefined) { - const rest = typing ? '' : draft.trim() + const rest = typing ? '' : editorText.trim() onDraftChange(rest === '' ? `${row.name} ` : `${row.name} ${rest}`) textarea.current?.focus() @@ -413,7 +422,7 @@ export function InputBar({ onSubmit(row.name) textarea.current?.focus() }, - [draft, onDraftChange, onSubmit, typed], + [editorText, onDraftChange, onSubmit, typed], ) const acceptFile = useCallback( @@ -434,10 +443,10 @@ export function InputBar({ if (element === null) return element.focus() - element.setSelectionRange(next.caret, next.caret) + element.setSelectionRange(next.caret - prefix.length, next.caret - prefix.length) }) }, - [draft, mention, onDraftChange], + [draft, mention, onDraftChange, prefix], ) const submit = useCallback(() => { @@ -457,6 +466,11 @@ export function InputBar({ const onKeyDown = useCallback( (event: KeyboardEvent) => { + if (event.key === 'Backspace' && prefix !== '' && event.currentTarget.selectionStart === 0 && event.currentTarget.selectionEnd === 0 && !event.nativeEvent.isComposing) { + event.preventDefault() + onDraftChange(editorText) + return + } // The file menu is checked first: it is anchored at the caret, so it is // the one the user is looking at when both could be open. if (files.length > 0 && mention !== null) { @@ -542,7 +556,7 @@ export function InputBar({ submit() } }, - [accept, acceptFile, files, highlight, launcher, mention, menu, onDraftChange, submit, typed], + [accept, acceptFile, files, highlight, launcher, mention, menu, onDraftChange, submit, typed, prefix, editorText], ) return ( @@ -701,6 +715,12 @@ export function InputBar({ )} )} + {claimed &&
+ {claimed.row.label ?? bareName(claimed.row.name)} + +
}