From 867a0bb3dbd95dfab2f9c01476f07c24f24d1108 Mon Sep 17 00:00:00 2001 From: jackwener Date: Tue, 14 Jul 2026 16:52:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(composer):=20@=20=E6=96=87=E4=BB=B6=20/=20?= =?UTF-8?q?=E6=8A=80=E8=83=BD=20=E6=8F=90=E5=8F=8A=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add composer mention popups: typing @ (at a word boundary) opens a workspace-file reference popup, / opens a skill reference popup. v1 inserts plain-text tokens into the uncontrolled textarea — '@ ' for files, '使用 技能:' for skills (human-in-the-loop, never auto-send). See notes/composer-mentions-spec-2026-07-14.md. - packages/ui/src/chat-input-behavior.ts: detectMentionTrigger + mentionQueryMatches (pure, unit-pinned). Boundary-anchored nearest trigger so a path-internal slash never hijacks an @ file query. - packages/ui/src/composer-mention-popup.tsx: presentational listbox overlay (a11y: role=listbox/option, aria-activedescendant). - packages/ui/src/composer.tsx: trigger detection on input/keyup/ selectionchange; keyboard branch (arrows/Enter/Tab/Esc) before the Esc-drag + send branches; splice insertion. New optional props mentionSkills + onSearchMentionFiles, inert when absent (SSR-safe). - apps/desktop workspace:searchFiles IPC (git ls-files + bounded walk, path-contained), preload + global.d.ts typing, use-composer-mentions hook, app-shell wiring. - styles/composer-mention.css (absolute overlay; no cursor:pointer per native-cursor convention). Tests: trigger-detection boundary matrix + matcher; workspace file search (git/walk/containment/cap/no_project); popup contract (Enter-intercept precedes send, Esc closes popup only, SSR inert). --- apps/desktop/src/global.d.ts | 9 + .../composer-mention-contract.test.ts | 81 +++++++ .../__tests__/workspace-file-search.test.ts | 144 ++++++++++++ apps/desktop/src/main/main.ts | 13 ++ .../desktop/src/main/workspace-file-search.ts | 127 ++++++++++ apps/desktop/src/preload/preload.ts | 12 + apps/desktop/src/renderer/app-shell.tsx | 8 + apps/desktop/src/renderer/styles.css | 1 + .../src/renderer/styles/composer-mention.css | 85 +++++++ .../src/renderer/use-composer-mentions.ts | 43 ++++ notes/composer-mentions-spec-2026-07-14.md | 87 +++++++ .../src/__tests__/chat-input-behavior.test.ts | 80 +++++++ packages/ui/src/chat-input-behavior.ts | 69 ++++++ packages/ui/src/composer-mention-popup.tsx | 121 ++++++++++ packages/ui/src/composer.tsx | 220 +++++++++++++++++- 15 files changed, 1099 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/main/__tests__/composer-mention-contract.test.ts create mode 100644 apps/desktop/src/main/__tests__/workspace-file-search.test.ts create mode 100644 apps/desktop/src/main/workspace-file-search.ts create mode 100644 apps/desktop/src/renderer/styles/composer-mention.css create mode 100644 apps/desktop/src/renderer/use-composer-mentions.ts create mode 100644 notes/composer-mentions-spec-2026-07-14.md create mode 100644 packages/ui/src/composer-mention-popup.tsx diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 8dbf1faf6f..ebc142085a 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -541,6 +541,15 @@ declare global { >; saveArtifactAs(artifactId: string): Promise; }; + workspace: { + searchFiles( + query: string, + limit?: number, + ): Promise< + | { ok: true; files: Array<{ relativePath: string }> } + | { ok: false; reason: 'no_project' | 'search_failed' } + >; + }; visualSmoke: { getState(): Promise; capture(input: { scenario: string; variant: string }): Promise< diff --git a/apps/desktop/src/main/__tests__/composer-mention-contract.test.ts b/apps/desktop/src/main/__tests__/composer-mention-contract.test.ts new file mode 100644 index 0000000000..0a40372795 --- /dev/null +++ b/apps/desktop/src/main/__tests__/composer-mention-contract.test.ts @@ -0,0 +1,81 @@ +/** + * Contract for the composer `@`/`/` mention popups + * (feat/composer-mentions, notes/composer-mentions-spec-2026-07-14.md). + * + * Pins the fragile ordering + SSR-safety guarantees: + * 1. The mention-popup keyboard branch runs BEFORE the Esc/drag branch and + * BEFORE the send fall-through, so Enter selects a mention (never sends). + * 2. The mention Escape branch closes ONLY the popup — it must not call + * setDragActive or props.onStop (Esc-with-popup keeps the drag highlight + * and never stops the stream). + * 3. Composer rendered with minimal props (no mention props) stays green and + * renders no listbox — the mention feature is fully inert without wiring. + */ + +import { strict as assert } from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Composer } from '@maka/ui'; + +const COMPOSER_TSX = join(process.cwd(), '../../packages/ui/src/composer.tsx'); + +function keydownBody(source: string): string { + return source.match(/function onTextareaKeyDown\(event: KeyboardEvent\) \{[\s\S]*?\n \}/)?.[0] ?? ''; +} + +describe('composer mention popup contract', () => { + it('runs the mention keyboard branch before the Esc/drag and send branches', async () => { + const source = await readFile(COMPOSER_TSX, 'utf8'); + const keydown = keydownBody(source); + assert.notEqual(keydown, '', 'onTextareaKeyDown body must be found'); + + const mentionAt = keydown.indexOf('if (mentionPopupOpen) {'); + const escDragAt = keydown.indexOf("event.key === 'Escape' && dragActive"); + const streamingAt = keydown.indexOf("event.key === 'Escape' && props.streaming"); + const sendAt = keydown.indexOf("if (event.key !== 'Enter') return;"); + + assert.ok(mentionAt >= 0, 'mention popup branch must exist in onTextareaKeyDown'); + assert.ok(escDragAt >= 0 && streamingAt >= 0 && sendAt >= 0, 'anchor branches must exist'); + assert.ok(mentionAt < escDragAt, 'mention branch must precede the Esc/drag branch'); + assert.ok(mentionAt < streamingAt, 'mention branch must precede the streaming Esc branch'); + assert.ok(mentionAt < sendAt, 'mention branch must precede the Enter→send fall-through'); + }); + + it('Enter/Tab select a mention with preventDefault (so Enter cannot send)', async () => { + const source = await readFile(COMPOSER_TSX, 'utf8'); + const keydown = keydownBody(source); + const mentionBlock = keydown.slice(keydown.indexOf('if (mentionPopupOpen) {')); + assert.match( + mentionBlock, + /if \(event\.key === 'Enter' \|\| event\.key === 'Tab'\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?selectMention\(mentionActiveIndex\);/, + 'Enter/Tab with items must preventDefault and select the active mention', + ); + }); + + it('the mention Escape branch closes only the popup (no drag clear, no stop)', async () => { + const source = await readFile(COMPOSER_TSX, 'utf8'); + const keydown = keydownBody(source); + const mentionStart = keydown.indexOf('if (mentionPopupOpen) {'); + const escDragAt = keydown.indexOf("event.key === 'Escape' && dragActive"); + // The slice of the mention branch that precedes the drag branch. + const mentionBlock = keydown.slice(mentionStart, escDragAt); + assert.match( + mentionBlock, + /if \(event\.key === 'Escape'\) \{[\s\S]*?event\.preventDefault\(\);[\s\S]*?closeMention\(\);[\s\S]*?return;/, + 'popup-open Escape must preventDefault + closeMention + return', + ); + assert.doesNotMatch(mentionBlock, /setDragActive/, 'mention branch must not touch drag state'); + assert.doesNotMatch(mentionBlock, /onStop/, 'mention branch must not stop the stream'); + }); + + it('renders inert (no listbox) when mention props are absent (SSR minimal props)', () => { + const markup = renderToStaticMarkup( + createElement(Composer, { onSend: () => {}, onStop: () => {} }), + ); + assert.doesNotMatch(markup, /role="listbox"/, 'no popup without mention props'); + assert.doesNotMatch(markup, /maka-composer-mention-popup/, 'popup markup must be absent'); + }); +}); diff --git a/apps/desktop/src/main/__tests__/workspace-file-search.test.ts b/apps/desktop/src/main/__tests__/workspace-file-search.test.ts new file mode 100644 index 0000000000..1605905104 --- /dev/null +++ b/apps/desktop/src/main/__tests__/workspace-file-search.test.ts @@ -0,0 +1,144 @@ +import { strict as assert } from 'node:assert'; +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { ExecFileException } from 'node:child_process'; +import { searchWorkspaceFiles } from '../workspace-file-search.js'; + +type ExecFileCallback = ( + file: string, + args: readonly string[], + options: { cwd: string; timeout: number; windowsHide: boolean; maxBuffer: number }, + cb: (error: ExecFileException | null, stdout: string, stderr: string) => void, +) => void; + +/** Fake `git ls-files` returning a fixed newline-joined path list. */ +function fakeGit(stdout: string, error: ExecFileException | null = null): ExecFileCallback { + return (_file, args, _options, cb) => { + assert.deepEqual(args, ['ls-files', '--cached', '--others', '--exclude-standard']); + cb(error, stdout, ''); + }; +} + +async function withGitRepo(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-wfs-git-')); + await mkdir(join(root, '.git'), { recursive: true }); + await writeFile(join(root, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8'); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function withPlainDir(run: (root: string) => Promise): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-wfs-plain-')); + try { + await run(root); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +describe('searchWorkspaceFiles', () => { + it('lists git-tracked/untracked files honoring the ls-files output', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeGit('src/app.tsx\nsrc/main.tsx\nREADME.md\n'); + const result = await searchWorkspaceFiles(root, { query: '', execFileImpl }); + assert.equal(result.ok, true); + assert.ok(result.ok && result.files.some((f) => f.relativePath === 'src/app.tsx')); + assert.ok(result.ok && result.files.some((f) => f.relativePath === 'README.md')); + }); + }); + + it('filters with AND-of-substring tokens, case-insensitively', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeGit('src/app.tsx\nsrc/main.tsx\ndocs/app.md\n'); + const result = await searchWorkspaceFiles(root, { query: 'SRC app', execFileImpl }); + assert.ok(result.ok); + const paths = result.ok ? result.files.map((f) => f.relativePath) : []; + assert.deepEqual(paths, ['src/app.tsx']); + }); + }); + + it('ranks shorter paths first, then lexicographically', async () => { + await withGitRepo(async (root) => { + const execFileImpl = fakeGit('a/b/c/app.tsx\napp.tsx\nlib/app.tsx\n'); + const result = await searchWorkspaceFiles(root, { query: 'app', execFileImpl }); + assert.ok(result.ok); + const paths = result.ok ? result.files.map((f) => f.relativePath) : []; + assert.deepEqual(paths, ['app.tsx', 'lib/app.tsx', 'a/b/c/app.tsx']); + }); + }); + + it('caps the result count at the requested limit', async () => { + await withGitRepo(async (root) => { + const many = Array.from({ length: 200 }, (_v, i) => `file-${i}.ts`).join('\n'); + const execFileImpl = fakeGit(many); + const result = await searchWorkspaceFiles(root, { query: '', limit: 5, execFileImpl }); + assert.ok(result.ok); + assert.equal(result.ok ? result.files.length : -1, 5); + }); + }); + + it('falls back to a readdir walk when the tree is not a git repo', async () => { + await withPlainDir(async (root) => { + await mkdir(join(root, 'src'), { recursive: true }); + await writeFile(join(root, 'src', 'index.ts'), '', 'utf8'); + await writeFile(join(root, 'top.md'), '', 'utf8'); + // Should be skipped by the walk. + await mkdir(join(root, 'node_modules', 'pkg'), { recursive: true }); + await writeFile(join(root, 'node_modules', 'pkg', 'ignored.js'), '', 'utf8'); + + const result = await searchWorkspaceFiles(root, { query: '' }); + assert.ok(result.ok); + const paths = result.ok ? result.files.map((f) => f.relativePath) : []; + assert.ok(paths.includes('src/index.ts')); + assert.ok(paths.includes('top.md')); + assert.ok(!paths.some((p) => p.includes('node_modules')), 'node_modules must be skipped'); + }); + }); + + it('never returns paths outside the root and does not follow symlinked dirs', async () => { + await withPlainDir(async (root) => { + const outside = await mkdtemp(join(tmpdir(), 'maka-wfs-outside-')); + try { + await writeFile(join(outside, 'secret.txt'), '', 'utf8'); + await writeFile(join(root, 'inside.txt'), '', 'utf8'); + try { + await symlink(outside, join(root, 'link'), 'dir'); + } catch { + // Some sandboxes disallow symlinks — the containment assertion below + // still holds for the real files. + } + const result = await searchWorkspaceFiles(root, { query: '' }); + assert.ok(result.ok); + const paths = result.ok ? result.files.map((f) => f.relativePath) : []; + assert.ok(paths.includes('inside.txt')); + assert.ok(!paths.some((p) => p.includes('secret') || p.startsWith('..')), 'must not escape root via symlink'); + } finally { + await rm(outside, { recursive: true, force: true }); + } + }); + }); + + it('falls back to the walk when git ls-files errors', async () => { + await withGitRepo(async (root) => { + await writeFile(join(root, 'walked.ts'), '', 'utf8'); + const failing: ExecFileCallback = (_f, _a, _o, cb) => { + const err = new Error('boom') as ExecFileException; + cb(err, '', 'fatal'); + }; + const result = await searchWorkspaceFiles(root, { query: 'walked', execFileImpl: failing }); + assert.ok(result.ok); + assert.ok(result.ok && result.files.some((f) => f.relativePath === 'walked.ts')); + }); + }); + + it('returns no_project when the root is empty', async () => { + const result = await searchWorkspaceFiles('', { query: 'x' }); + assert.equal(result.ok, false); + assert.equal(!result.ok && result.reason, 'no_project'); + }); +}); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 40f93ee783..a1997b435a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -134,6 +134,7 @@ import { probeOfficeCli } from './officecli-probe.js'; import { resolveOpenPath, type OpenPathResult } from './open-path-guard.js'; import { resolveProjectGitInfo, resolveProjectRoot } from '@maka/runtime'; import { listLocalBranches, checkoutBranch } from './git-branch.js'; +import { searchWorkspaceFiles } from './workspace-file-search.js'; import { createDailyReviewArchiveStore } from './daily-review-archive-store.js'; import { botTestErrorMessage, buildSettingsUpdateResult, maskAppSettings, preserveSensitivePlaceholders, toSettingsTestResult } from './settings-ipc-helpers.js'; import { @@ -1249,6 +1250,18 @@ function registerIpc(): void { return checkoutBranch(projectPath, branch); }, ); + // Composer `@` mention popup: list workspace files under the same project + // root that app:info reports. Git repos honor .gitignore + untracked via + // `git ls-files`; other trees fall back to a bounded walk. See + // workspace-file-search.ts. + ipcMain.handle( + 'workspace:searchFiles', + async (_event, input: unknown) => { + const request = (input ?? {}) as { query?: unknown; limit?: unknown }; + const projectPath = await currentProjectRoot(); + return searchWorkspaceFiles(projectPath, { query: request.query, limit: request.limit }); + }, + ); registerMemoryIpc({ localMemory }); registerConfigIpc({ connectionStore, settingsStore, credentialStore, workspaceRoot }); registerNotificationsIpc({ settingsStore, mainWindowController, e2e: isE2e }); diff --git a/apps/desktop/src/main/workspace-file-search.ts b/apps/desktop/src/main/workspace-file-search.ts new file mode 100644 index 0000000000..2c5bf99f4a --- /dev/null +++ b/apps/desktop/src/main/workspace-file-search.ts @@ -0,0 +1,127 @@ +import { execFile, type ExecFileException } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import { join, relative, sep } from 'node:path'; +import { resolveProjectGitInfo } from '@maka/runtime'; + +/** + * workspace-file-search.ts — local-only workspace file listing for the composer + * `@` mention popup. Mirrors git-branch.ts: we shell out to `git ls-files` when + * the project root is a git repo (so .gitignore + untracked files are honored + * exactly as the user expects) and fall back to a bounded recursive readdir + * walk otherwise. `execFileImpl` is injectable so unit tests can fake git. + * + * Returned `relativePath`s are always POSIX-style (forward slashes) and always + * inside the project root — the git path list is repo-relative by construction, + * and the walk never follows symlinked directories or escapes the root. + */ + +export type WorkspaceFileSearchResult = + | { ok: true; files: Array<{ relativePath: string }> } + | { ok: false; reason: 'no_project' | 'search_failed' }; + +const LS_TIMEOUT_MS = 3_000; +const DEFAULT_LIMIT = 50; +/** Cap the fallback walk so a huge non-git tree can't stall the popup. */ +const MAX_WALK_ENTRIES = 5_000; +const SKIP_DIRS = new Set(['.git', 'node_modules']); + +type ExecFileCallback = ( + file: string, + args: readonly string[], + options: { cwd: string; timeout: number; windowsHide: boolean; maxBuffer: number }, + cb: (error: ExecFileException | null, stdout: string, stderr: string) => void, +) => void; + +/** AND-of-substring token match, case-insensitive — the same rule the composer + * uses client-side (kept local so the main process doesn't import @maka/ui). */ +function matchesAllTokens(tokens: readonly string[], text: string): boolean { + const haystack = text.toLowerCase(); + return tokens.every((token) => haystack.includes(token)); +} + +function toPosix(path: string): string { + return sep === '/' ? path : path.split(sep).join('/'); +} + +function runGitLsFiles( + cwd: string, + execFileImpl: ExecFileCallback, +): Promise<{ ok: true; files: string[] } | { ok: false }> { + return new Promise((resolve) => { + execFileImpl( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard'], + { cwd, timeout: LS_TIMEOUT_MS, windowsHide: true, maxBuffer: 32 * 1024 * 1024 }, + (error, stdout) => { + if (error) { + resolve({ ok: false }); + return; + } + const files = stdout.split('\n').map((line) => line.trim()).filter(Boolean); + resolve({ ok: true, files }); + }, + ); + }); +} + +/** Bounded recursive walk. Skips node_modules/.git, never recurses into + * symlinked directories (dirent.isDirectory() is false for a symlink), and + * stops once MAX_WALK_ENTRIES files are collected. */ +async function walkFiles(root: string): Promise { + const out: string[] = []; + const stack: string[] = [root]; + while (stack.length > 0 && out.length < MAX_WALK_ENTRIES) { + const dir = stack.pop()!; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + continue; // unreadable dir — skip rather than fail the whole walk + } + for (const entry of entries) { + if (out.length >= MAX_WALK_ENTRIES) break; + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + stack.push(join(dir, entry.name)); + } else if (entry.isFile()) { + out.push(toPosix(relative(root, join(dir, entry.name)))); + } + // Symlinks (isDirectory()/isFile() both false) are intentionally ignored. + } + } + return out; +} + +export async function searchWorkspaceFiles( + projectRoot: string, + input: { query?: unknown; limit?: unknown; execFileImpl?: ExecFileCallback } = {}, +): Promise { + if (typeof projectRoot !== 'string' || !projectRoot) { + return { ok: false, reason: 'no_project' }; + } + const query = typeof input.query === 'string' ? input.query : ''; + const limit = typeof input.limit === 'number' && input.limit > 0 ? Math.floor(input.limit) : DEFAULT_LIMIT; + const execFileImpl = input.execFileImpl ?? (execFile as unknown as ExecFileCallback); + + try { + let candidates: string[]; + const info = await resolveProjectGitInfo(projectRoot); + if (info.isGitRepo) { + const listed = await runGitLsFiles(projectRoot, execFileImpl); + candidates = listed.ok ? listed.files : await walkFiles(projectRoot); + } else { + candidates = await walkFiles(projectRoot); + } + + const tokens = query.toLowerCase().split(/\s+/).filter(Boolean); + const filtered = tokens.length === 0 + ? candidates + : candidates.filter((path) => matchesAllTokens(tokens, path)); + // Rank shorter paths first (usually the closest / most relevant match), + // then lexicographically for a stable order. + filtered.sort((a, b) => a.length - b.length || a.localeCompare(b)); + return { ok: true, files: filtered.slice(0, limit).map((relativePath) => ({ relativePath })) }; + } catch { + return { ok: false, reason: 'search_failed' }; + } +} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 90b9842f63..c525f4c183 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -921,6 +921,18 @@ contextBridge.exposeInMainWorld('maka', { return ipcRenderer.invoke('app:saveArtifactAs', artifactId); }, }, + workspace: { + /** Composer `@` mention popup: list workspace files matching `query`. */ + searchFiles( + query: string, + limit?: number, + ): Promise< + | { ok: true; files: Array<{ relativePath: string }> } + | { ok: false; reason: 'no_project' | 'search_failed' } + > { + return ipcRenderer.invoke('workspace:searchFiles', { query, limit }); + }, + }, visualSmoke: { getState(): Promise { return ipcRenderer.invoke('visualSmoke:getState'); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 7b677bf50f..cb4d806be0 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -103,6 +103,7 @@ import { import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; import { useKeyedPendingRegistry } from './use-pending-action-registry'; import { useAppShellComposerAttachments } from './use-app-shell-composer-attachments'; +import { useComposerMentions } from './use-composer-mentions'; import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; import { useShellConnections } from './use-shell-connections'; import { useShellChatModel } from './use-shell-chat-model'; @@ -743,6 +744,11 @@ export function AppShell({ toastApi, }); + // Composer mention popups: `/` skills (enabled only) + `@` workspace file + // search. The hook owns the window.maka IPC wrapper so app-shell keeps no + // inline mention state. + const { mentionSkills, searchMentionFiles } = useComposerMentions({ skills }); + const { appInfo, branchList, @@ -1473,6 +1479,8 @@ export function AppShell({ onSend={sendWithAttachments} onStop={stop} stopPending={activeId ? stopPendingBySession[activeId] === true : false} + mentionSkills={mentionSkills} + onSearchMentionFiles={searchMentionFiles} pendingAttachments={pendingAttachments} onRemoveAttachment={removeAttachment} onPickAttachments={pickAttachments} diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index f8b3be180f..03f437c7ca 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -30,6 +30,7 @@ @import "./styles/chat-message.css" layer(components); @import "./styles/prose.css" layer(components); @import "./styles/composer.css"; +@import "./styles/composer-mention.css"; @import "./styles/chat-detail.css"; @import "./styles/settings.css"; @import "./styles/markdown-link.css" layer(components); diff --git a/apps/desktop/src/renderer/styles/composer-mention.css b/apps/desktop/src/renderer/styles/composer-mention.css new file mode 100644 index 0000000000..ae7003e5f6 --- /dev/null +++ b/apps/desktop/src/renderer/styles/composer-mention.css @@ -0,0 +1,85 @@ +/* Composer mention popup — the @ file / skill overlay rendered by + ComposerMentionPopup (@maka/ui). The popover surface (rounded-md bg-popover + shadow-maka-panel z-overlay) is set via Tailwind utilities on the element; + this file owns layout + row chrome only. + + The popup is an ABSOLUTE overlay anchored to `.maka-composer-inner` (which is + already position:relative at rest), bottom-anchored so it floats above the + textarea and never grows the composer box (composer-constant-footprint- + contract). No `.maka-composer-inner` rest rule is added here — the + composer-container-single-source contract keeps that selector's single + definition in composer.css. */ +.maka-composer-mention-popup { + position: absolute; + left: 0; + bottom: calc(100% + var(--space-2)); + width: min(360px, 100%); + max-height: 280px; + overflow-y: auto; + padding: var(--space-1); +} + +.maka-composer-mention-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-0-5); +} + +.maka-composer-mention-option { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1-5) var(--space-2); + border-radius: var(--radius-control); + color: var(--foreground); + /* No cursor:pointer — native macOS reserves the hand for links; menu-like + rows keep the default arrow (native-cursor-convention contract). */ + transition: background var(--duration-base) var(--ease-out-strong); +} + +.maka-composer-mention-option[data-active="true"] { + background: var(--muted); +} + +.maka-composer-mention-icon { + flex: 0 0 auto; + color: var(--muted-foreground); +} + +.maka-composer-mention-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.maka-composer-mention-name { + min-width: 0; + font-size: var(--font-size-ui); + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.maka-composer-mention-secondary { + min-width: 0; + font-size: var(--font-size-caption); + color: var(--muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.maka-composer-mention-secondary:empty { + display: none; +} + +.maka-composer-mention-status { + padding: var(--space-2) var(--space-2); + font-size: var(--font-size-caption); + color: var(--muted-foreground); + text-align: center; +} diff --git a/apps/desktop/src/renderer/use-composer-mentions.ts b/apps/desktop/src/renderer/use-composer-mentions.ts new file mode 100644 index 0000000000..2d63601c96 --- /dev/null +++ b/apps/desktop/src/renderer/use-composer-mentions.ts @@ -0,0 +1,43 @@ +import { useCallback, useMemo } from 'react'; +import type { SkillEntry } from '@maka/ui'; + +/** + * Owns the composer mention popup wiring so app-shell.tsx keeps no inline + * `window.maka` state (app-shell-composer-attachment-owner-contract). Derives + * the `/` popup's skill list (enabled only) from the shell's skills list, and + * exposes a fail-soft file-search callback backed by the `workspace:searchFiles` + * IPC. Both return values are memoized so the Composer props keep stable + * identities across renders. + */ +export function useComposerMentions(options: { skills: readonly SkillEntry[] }): { + mentionSkills: ReadonlyArray<{ id: string; name: string; description?: string }>; + searchMentionFiles(query: string): Promise>; +} { + const { skills } = options; + + const mentionSkills = useMemo( + () => + skills + // Only skills the runtime will actually honor — mirrors how the skills + // panel treats enabled + runtimeStatus. + .filter((skill) => skill.enabled && skill.runtimeStatus === 'enabled') + .map((skill) => ({ id: skill.id, name: skill.name, description: skill.description })), + [skills], + ); + + const searchMentionFiles = useCallback( + async (query: string): Promise> => { + try { + const result = await window.maka.workspace.searchFiles(query); + return result.ok ? result.files : []; + } catch { + // Fail soft: a failed search just yields an empty list, so the popup + // shows 未找到文件 rather than surfacing an error into the composer. + return []; + } + }, + [], + ); + + return { mentionSkills, searchMentionFiles }; +} diff --git a/notes/composer-mentions-spec-2026-07-14.md b/notes/composer-mentions-spec-2026-07-14.md new file mode 100644 index 0000000000..fb8354d373 --- /dev/null +++ b/notes/composer-mentions-spec-2026-07-14.md @@ -0,0 +1,87 @@ +# Composer mention popups (`@` file / `/` skill) — v1 spec + +Date: 2026-07-14 +Branch: `feat/composer-mentions` + +## What shipped + +Typing `@` or `/` in the chat composer (at a word boundary) opens a popup: + +- `@` → workspace file reference. Filtered live against `workspace:searchFiles` + (git `ls-files` in a repo, bounded readdir walk otherwise). Selecting a file + inserts the plain-text token `@ ` (with the `@` and a trailing + space). +- `/` → skill reference. Filtered client-side against the enabled skills list. + Selecting a skill replaces the `/query` token with `使用 技能:` — + the exact house convention from `useSkillInChat` (app-shell.tsx). This is + human-in-the-loop: it fills the composer, it NEVER auto-sends. + +## Competitor model vs. our v1 model + +**Competitors (QoderWork / WorkBuddy, from decompiled bundles):** the composer +is a `contenteditable` surface. A mention becomes an *atomic chip* — a +non-editable inline node carrying a typed wire token (`@[file:/abs/path]`, +`@[skill:id]`) that the runtime parses out of the message. Backspace deletes the +whole chip; the rendered label and the wire token are decoupled. + +**Our v1:** the composer is an *uncontrolled native `