diff --git a/apps/web/e2e/composer-skill-suggest.spec.ts b/apps/web/e2e/composer-skill-suggest.spec.ts new file mode 100644 index 000000000..b0216b660 --- /dev/null +++ b/apps/web/e2e/composer-skill-suggest.spec.ts @@ -0,0 +1,222 @@ +// Natural-language skill auto-suggest (issue #823): the ghost hint bar. +// The wire matches plain prose against the first-batch trigger table and, +// when a team skill fits, shows `.skill-suggest` — Tab / click accepts the +// skill token at the caret, Esc / close single-ignores it. The strip never +// co-opens with the `/` or `@` menus (they own their keys). +// +// Failure modes pinned here (live detail face, stubBoot discipline borrowed +// from composer-slash.spec): +// 1. trigger prose shows the strip; Tab accepts the token, prose intact; +// 2. Esc dismisses and further typing never resurrects the same nudge; +// 3. the close button dismisses the same way; +// 4. an open `/` or `@` menu suppresses the strip (no key clash); +// 5. chatter, slash lines and skill-less teams stay silent. + +import { expect, type Page, test } from '@playwright/test'; +import { evidenceShot } from './evidence'; + +const TEAM_ID = 'team-1'; +const USER = { id: 'user-1', displayName: '我', avatarUrl: null }; +const TEAM = { id: TEAM_ID, name: 'Team', createdAt: 0, plan: 'free', avatarStyle: null }; + +const CARD_ID = 'todo-1'; +const BUILD_ID = 'build-1'; +const PROJECT_ID = 'proj-1'; + +const WIRE_CARD = { + id: CARD_ID, + teamId: TEAM_ID, + projectId: PROJECT_ID, + title: '自然话 skill 提示探针', + spec: '', + phase: 'building', + phaseAt: 0, + seqNum: 9, + orderIndex: 0, + tagIds: [], + assignment: null, + agent: null, + latestBuildId: BUILD_ID, + lastRunAt: 0, + hasChanges: false, + hasPlan: true, + buildHistory: [], + sourceTodo: null, + v: 1, +}; + +const PROJECT = { + id: PROJECT_ID, + name: 'pacman', + teamId: TEAM_ID, + repoKind: 'hosted', + repoName: 'pacman', + githubRepo: null, + localPath: null, +}; + +const WIRE_BUILD = { + id: BUILD_ID, + todoId: CARD_ID, + withPlan: true, + prevPhase: null, + triggerSource: 'user', + pinnedMachineId: null, + planDocId: null, + errorMessage: null, + prUrl: null, + prNumber: null, + diffHash: null, + createdAt: 0, +}; + +const EMPTY_CONVERSATION = { + messages: [], + chips: null, + historyEpoch: 0, + steerPending: [], + activeRun: null, + nextCursor: null, +}; + +const AGENTS = [{ id: 'agent-1', displayName: 'builder', description: 'Builds features' }]; + +const SKILLS = [ + { + id: 'remind', + teamId: TEAM_ID, + name: 'remind', + description: 'schedule reminders and notifications', + }, + { id: 'translate', teamId: TEAM_ID, name: 'translate', description: '翻译文本' }, +]; + +async function stubBoot(page: Page) { + await page.route('**/api/**', (route, request) => { + if (request.method() !== 'GET') return route.fallback(); + return route.fulfill({ status: 500, json: { error: 'e2e stub: not the surface under test' } }); + }); + await page.route('**/api/teams', (route) => route.fulfill({ json: [TEAM] })); + await page.route('**/api/user/me', (route) => route.fulfill({ json: USER })); + await page.route(`**/api/teams/${TEAM_ID}/notifications`, (route) => + route.fulfill({ json: { unreadThreadIds: [] } }), + ); +} + +async function openDetail(page: Page, skills: unknown[] = SKILLS) { + await stubBoot(page); + const members = [ + { id: 'member-user', teamId: TEAM_ID, actorId: USER.id, memberType: 'user', actor: USER }, + ...AGENTS.map((a, i) => ({ + id: `member-agent-${i}`, + teamId: TEAM_ID, + actorId: a.id, + memberType: 'agent', + actor: a, + })), + ]; + await page.route(`**/api/teams/${TEAM_ID}/members`, (route) => route.fulfill({ json: members })); + await page.route('**/api/skills*', (route) => route.fulfill({ json: skills })); + await page.route('**/api/todos?*', (route) => route.fulfill({ json: [WIRE_CARD] })); + await page.route(`**/api/todos/${CARD_ID}`, (route) => route.fulfill({ json: WIRE_CARD })); + await page.route('**/api/projects', (route) => route.fulfill({ json: [PROJECT] })); + await page.route(`**/api/builds/${BUILD_ID}`, (route) => route.fulfill({ json: WIRE_BUILD })); + await page.route(`**/api/builds/${BUILD_ID}/steps`, (route) => route.fulfill({ json: [] })); + await page.route(`**/api/builds/${BUILD_ID}/plans`, (route) => route.fulfill({ json: [] })); + await page.route(`**/api/builds/${BUILD_ID}/usage`, (route) => route.fulfill({ json: [] })); + await page.route(`**/api/builds/${BUILD_ID}/changes`, (route) => + route.fulfill({ json: { files: [] } }), + ); + await page.route(`**/api/conversations/${BUILD_ID}/messages`, (route) => + route.fulfill({ json: EMPTY_CONVERSATION }), + ); + const skillsLoaded = page.waitForResponse( + (r) => r.url().includes('/api/skills') && r.status() === 200, + ); + await page.goto(`/app/todo/${CARD_ID}`); + await skillsLoaded; + const input = page.locator('.composer-input'); + await expect(input).toBeVisible(); + await expect(input).toBeEditable(); + return { input, strip: page.locator('.skill-suggest') }; +} + +test('trigger prose shows the strip; Tab accepts the token, prose intact', async ({ page }) => { + const { input, strip } = await openDetail(page); + + await input.fill('明早 9 点提醒我开会'); + await expect(strip).toBeVisible(); + await expect(strip).toContainText('remind'); + await evidenceShot(page, 'suggest-strip.png'); + + await input.press('Tab'); + // The mention-token spacing discipline puts one space before the token. + await expect(input).toHaveValue('明早 9 点提醒我开会 [remind](skill:remind) '); + // The draft now references the skill — the nudge goes quiet on its own. + await expect(strip).toBeHidden(); + await evidenceShot(page, 'suggest-accepted.png'); +}); + +test('Esc dismisses; further typing never resurrects the same nudge', async ({ page }) => { + const { input, strip } = await openDetail(page); + + await input.fill('明早 9 点提醒我开会'); + await expect(strip).toBeVisible(); + await input.press('Escape'); + await expect(strip).toBeHidden(); + + await input.pressSequentially(',别忘了'); + await expect(strip).toBeHidden(); + // A different intent still nudges (the ignore is per-suggestion, not a gag). + await input.pressSequentially(',再翻译成英文'); + await expect(strip).toBeVisible(); + await expect(strip).toContainText('translate'); +}); + +test('the close button dismisses the same way', async ({ page }) => { + const { input, strip } = await openDetail(page); + + await input.fill('翻译这段话'); + await expect(strip).toBeVisible(); + await strip.locator('.skill-suggest-close').click(); + await expect(strip).toBeHidden(); + await expect(input).toHaveValue('翻译这段话'); +}); + +test('open slash or at menus suppress the strip', async ({ page }) => { + const { input, strip } = await openDetail(page); + const slashMenu = page.locator('.slash-menu'); + const atList = page.locator('.mention-inline'); + + await input.fill('明早提醒我 /'); + await expect(slashMenu).toBeVisible(); + await expect(strip).toBeHidden(); + await input.press('Escape'); + await expect(slashMenu).toBeHidden(); + // The slash token is gone with Esc — retype prose, the strip returns. + await input.fill('明早提醒我开会'); + await expect(strip).toBeVisible(); + + await input.fill('hi @b'); + await expect(atList).toBeVisible(); + await expect(strip).toBeHidden(); +}); + +test('chatter, slash lines and skill-less teams stay silent', async ({ page }) => { + const { input, strip } = await openDetail(page); + + await input.fill('今天天气不错'); + await expect(strip).toBeHidden(); + + await input.fill('/remind'); + await expect(strip).toBeHidden(); +}); + +test('a team without a matching skill stays silent', async ({ page }) => { + const { input, strip } = await openDetail(page, [ + { id: 'spell', teamId: TEAM_ID, name: 'spell-check', description: 'Checks spelling' }, + ]); + + await input.fill('明早 9 点提醒我开会'); + await expect(strip).toBeHidden(); +}); diff --git a/apps/web/src/chief/chief-drawer.tsx b/apps/web/src/chief/chief-drawer.tsx index 0951f3f01..f9157ec0f 100644 --- a/apps/web/src/chief/chief-drawer.tsx +++ b/apps/web/src/chief/chief-drawer.tsx @@ -76,6 +76,7 @@ import { attachmentFailureTitle } from '../overlay/attachment-paste.js'; import { AttachmentStrip } from '../overlay/attachment-strip.js'; import { useComposerWire } from '../overlay/composer-wire.js'; import { type MentionGroups, MentionInline, MentionPicker } from '../overlay/mention-picker.js'; +import { SkillSuggestStrip } from '../overlay/skill-suggest-strip.js'; import { useChiefNewThreadHotkey } from '../overlays/hotkeys.js'; import './chief.css'; import { ChiefIdentity } from './chief-identity.js'; @@ -345,6 +346,9 @@ export function ChiefDrawer({ insertTokens, insertFile, groups, + suggestion, + acceptSuggestion, + dismissSuggestion, } = wire; // #773:抽屉收起联动收弹层——模型 popover / 切换器 / 提及 picker / // 内联补全 / 恢复确认的 open 态都自持在抽屉内部,抽屉只收容器时它们跟 @@ -976,6 +980,13 @@ export function ChiefDrawer({ listboxRef={inlineListboxRef} listboxId={inlineListboxId} /> + {/* #823 自然话 skill 提示条(detail composer 同款;两弹层都关 + 时才挂载,wire 保证)。 */} + {/* #757 附件 strip(detail composer 同件:在途占位 + 落定 chip, 可点预览)。chief composer 卡是 in-flow 布局,strip 走流式、 diff --git a/apps/web/src/detail/composer.tsx b/apps/web/src/detail/composer.tsx index eb098da2b..10486ea27 100644 --- a/apps/web/src/detail/composer.tsx +++ b/apps/web/src/detail/composer.tsx @@ -38,6 +38,7 @@ import { AttachmentStrip } from '../overlay/attachment-strip.js'; import { useComposerWire } from '../overlay/composer-wire.js'; import { type MentionGroups, MentionInline, MentionPicker } from '../overlay/mention-picker.js'; import type { FileMentionEntry } from '../overlay/mention-token.js'; +import { SkillSuggestStrip } from '../overlay/skill-suggest-strip.js'; import { SlashHelp, SlashMenu } from '../overlay/slash-menu.js'; import { ComposerChips } from './composer-chips.js'; @@ -143,6 +144,9 @@ export function Composer({ closeHelp, helpRows, helpSkillCount, + suggestion, + acceptSuggestion, + dismissSuggestion, } = useComposerWire({ editable, draft: draftProp, @@ -243,6 +247,13 @@ export function Composer({
+ {/* #823 自然话 skill 提示条:同浮列叠放(两弹层都关时才挂载, + wire 保证),Tab / 点击接受,Esc / × 单条忽略。 */} +
= { 查看命令说明: 'Show command help', 命令说明: 'Command help', '团队技能来自技能页面(共 {count} 个)': 'Team skills come from the Skills page ({count} total)', + // #823 natural-language skill auto-suggest vocabulary. + '检测到 skill「{name}」,Tab 接受': 'Skill detected: {name} — Tab to accept', + '接受 skill 建议:{name}': 'Accept skill suggestion: {name}', + '忽略 skill 建议': 'Dismiss skill suggestion', '停止当前这一轮?': 'Stop this round?', '丢弃本轮修改——方案和代码回到上一个版本': 'Discard this round’s changes — plan and code revert to the previous version', diff --git a/apps/web/src/overlay/composer-wire.ts b/apps/web/src/overlay/composer-wire.ts index ca27a0852..0bb726ce1 100644 --- a/apps/web/src/overlay/composer-wire.ts +++ b/apps/web/src/overlay/composer-wire.ts @@ -44,6 +44,7 @@ // precedent (chat-page and chat-window share the conversation logic while // each keeps its own shell). +import { type SkillSuggestion, suggestSkill } from '@pacman/shared'; import type { ChangeEvent, ClipboardEvent, @@ -234,6 +235,17 @@ export interface ComposerWire { /** mentionGroups with the empty-groups fallback applied (the picker opens * on empty groups so the user still sees the zero counts, r9 §2.2). */ groups: MentionGroups; + /** Natural-language skill suggestion (#823): the matched team skill for + * the current draft, or null. Live only while both completion popups are + * closed (the `/` and `@` faces own their keys — this face never steals + * Tab/Enter/Esc from them) and never on static faces. */ + suggestion: SkillSuggestion | null; + /** Accept the suggestion the way Tab-with-hint would: inserts the skill + * token at the caret without consuming any typed text. */ + acceptSuggestion: () => void; + /** Dismiss this suggestion instance (single-ignore: Esc or the strip's + * close button; the ignore lasts until the draft is sent). */ + dismissSuggestion: () => void; } const EMPTY_GROUPS: MentionGroups = { @@ -319,6 +331,12 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire { const slashListboxRef = useRef(null); const slashEnabled = editable && slashOptions !== undefined; + // Natural-language skill suggestion (#823): the skill dismissed by an + // explicit Esc / close-button stays dismissed until the draft is sent — + // typing more text never resurrects the same nudge, a different matched + // skill still shows. + const [dismissedSkillId, setDismissedSkillId] = useState(null); + // Mirror of the freshest committed draft (#729): an upload resolving // seconds after the paste must insert into the text as it stands now — // the render-scoped `draft` closure would be stale and eat everything @@ -472,6 +490,34 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire { const slashRows = useMemo(() => slashSections.flatMap((s) => s.rows), [slashSections]); const slashOpen = slashRange !== null && slashEnabled; const slashQuery = slashRange?.query ?? ''; + + // Skill suggestion (#823): draft-derived, over the same skill vocab that + // feeds the mention picker and the `/` menu (mentionGroups.skill — the + // useSkills projection, never a side table). Suppressed while either + // completion popup is open so Tab/Enter/Esc keep their menu meaning, and + // off entirely on static faces. A dismissed skill is excluded from the + // retry so a multi-intent draft falls through to the next live nudge + // instead of going quiet entirely. + const suggestion = useMemo(() => { + if (!editable) return null; + if (slashOpen || inlineOpen) return null; + const skills = (mentionGroups?.skill ?? []).map((s) => ({ + id: s.id, + name: s.label, + ...(s.subtitle !== undefined ? { description: s.subtitle } : {}), + })); + const hit = suggestSkill(draft, skills); + if (hit === null) return null; + if (hit.skillId !== dismissedSkillId) return hit; + return suggestSkill( + draft, + skills.filter((s) => s.id !== dismissedSkillId), + ); + }, [draft, mentionGroups, editable, slashOpen, inlineOpen, dismissedSkillId]); + // Synchronous mirror for event handlers (same React-state-lag rationale + // as the inline range ref above). + const suggestionRef = useRef(null); + suggestionRef.current = suggestion; const helpSkillCount = mentionGroups?.skill.length ?? 0; const helpRows = useMemo( () => @@ -491,6 +537,9 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire { // never leave without the tokens still being uploaded (#729 failure // mode 3). The draft survives untouched; Enter sends once landed. if (attachInFlightRef.current > 0) return; + // A sent draft retires the single-ignore (#823): the next draft judges + // its suggestion fresh. + setDismissedSkillId(null); const text = draft.trim(); if (text === '' && !editable) { // Static capture face: the fixture send stays callable with the empty @@ -686,6 +735,25 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire { insertTokensAt(tokens, inlineOpen ? inlineRangeRef.current : null); }; + /** Accept the live suggestion: the skill token lands at the caret, the + * typed prose stays untouched (range null = pure caret insert, nothing + * consumed). The new draft references the skill, so the matcher goes + * quiet on its own — no extra state to clear. Plain function (not + * useCallback): it must close over the current render's insertTokensAt, + * whose controlled-mode setDraft carries the fresh draft — a frozen + * first-render closure would insert into the empty initial draft. */ + const acceptSuggestion = () => { + const hit = suggestionRef.current; + if (hit === null) return; + insertTokensAt([{ kind: 'skill', id: hit.skillId, label: hit.skillName }], null); + }; + + const dismissSuggestion = () => { + const hit = suggestionRef.current; + if (hit === null) return; + setDismissedSkillId(hit.skillId); + }; + const insertToken = (token: MentionToken) => insertTokens([token]); /** File path insert (#760): bare path text + trailing space (CC rule 26), @@ -831,6 +899,25 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire { } return; } + // intent === 'ignore': the skill-suggestion face (#823) runs here — + // reachable only with both completion popups closed (their open-state + // branches above all return first), so Tab/Esc can never steal a menu + // key. IME composition never triggers either layer (#728 failure + // mode 8 carries over). + if (!slashOpen && !inlineOpen && suggestionRef.current !== null && !isComposing) { + if (event.key === 'Tab') { + event.preventDefault(); + acceptSuggestion(); + return; + } + if (event.key === 'Escape') { + // preventDefault is load-bearing for the #634 Esc ladder (see the + // inline dismiss above): closing the hint consumes the key. + event.preventDefault(); + dismissSuggestion(); + return; + } + } // intent === 'ignore': the composer's own keys. Enter sends — but never // mid-IME-composition (the composing Enter belongs to the candidate // window; #728 failure mode 8) and never with shift (newline). A plain @@ -918,6 +1005,9 @@ export function useComposerWire(options: ComposerWireOptions): ComposerWire { insertTokens, insertFile, groups, + suggestion, + acceptSuggestion, + dismissSuggestion, slashOpen, slashQuery, slashCaret: slashRange?.start ?? null, diff --git a/apps/web/src/overlay/skill-suggest-strip.tsx b/apps/web/src/overlay/skill-suggest-strip.tsx new file mode 100644 index 000000000..50e1c38a9 --- /dev/null +++ b/apps/web/src/overlay/skill-suggest-strip.tsx @@ -0,0 +1,56 @@ +// Skill-suggestion hint bar (issue #823): the ghost-style strip shown when +// the wire matches plain prose to a team skill. Tab / click accepts (the +// skill token lands at the caret, the typed prose stays), the close button +// or Esc single-ignores. Rows are non-focusable and mousedown is swallowed +// so the textarea keeps DOM focus the whole time (the MentionInline combobox +// contract — a focus jump would re-judge the token and flicker the strip). +// +// The strip only ever renders while both completion popups are closed (the +// wire guarantees it), so it can sit in-flow under the textarea without +// covering any listbox. + +import type { SkillSuggestion } from '@pacman/shared'; +import { useI18n } from '../i18n/provider.js'; +import { Puzzle, X } from '../icons/index.js'; +import './skill-suggest.css'; + +interface SkillSuggestStripProps { + suggestion: SkillSuggestion | null; + onAccept: () => void; + onDismiss: () => void; +} + +export function SkillSuggestStrip({ suggestion, onAccept, onDismiss }: SkillSuggestStripProps) { + const { t } = useI18n(); + if (suggestion === null) return null; + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/overlay/skill-suggest.css b/apps/web/src/overlay/skill-suggest.css new file mode 100644 index 000000000..b153ee1b1 --- /dev/null +++ b/apps/web/src/overlay/skill-suggest.css @@ -0,0 +1,86 @@ +/* Skill-suggestion hint bar (issue #823): a ghost-style strip — visible + enough to notice, quiet enough to ignore. In-flow under the textarea (it + only renders while both completion popups are closed, so it never covers + a listbox); the kbd chip mirrors the Tab-accept key. */ + +.skill-suggest { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + color: var(--muted-foreground); + font-size: 12px; + line-height: 20px; +} + +/* Detail composer float column (#757 contract): the column never + intercepts — interactive strips re-enable. The mention strip stays + inert (display-only); this strip has accept/dismiss buttons, so it + opts back in like the attachment strip. */ +.composer-float > .skill-suggest { + margin-top: 0; + pointer-events: auto; +} + +.skill-suggest-body { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 2px 8px; + border: 1px dashed var(--border); + border-radius: 6px; + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + overflow: hidden; + white-space: nowrap; +} + +.skill-suggest-body:hover { + border-style: solid; + color: var(--foreground); +} + +.skill-suggest-icon { + display: inline-flex; + flex: none; +} + +.skill-suggest-text { + overflow: hidden; + text-overflow: ellipsis; +} + +.skill-suggest-key { + flex: none; + padding: 0 5px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 4px; + font-family: inherit; + font-size: 11px; + line-height: 16px; + background: var(--muted); +} + +.skill-suggest-close { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 6px; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.6; +} + +.skill-suggest-close:hover { + opacity: 1; + background: var(--muted); +} diff --git a/apps/web/test/skill-suggest.test.ts b/apps/web/test/skill-suggest.test.ts new file mode 100644 index 000000000..209fa85e7 --- /dev/null +++ b/apps/web/test/skill-suggest.test.ts @@ -0,0 +1,72 @@ +// Skill auto-suggest matcher (issue #823) — tests first per repo discipline. +// Pins the failure-mode list decided before implementation: +// F1 each first-batch intent fires on its trigger phrasing (ZH + EN) +// F2 no trigger phrasing -> null (chatter never suggests) +// F3 team has no matching skill -> null (never invents a candidate; +// the vocab is useSkills-only, no side table) +// F4 skill already referenced in the draft -> null (no duplicate nudge) +// F5 empty / whitespace draft -> null +// F6 case-insensitive on both faces (draft + skill name/description) +// F7 multi-intent draft follows table order (first intent wins) +// F8 slash-command line (`/…` at message start) -> null (the `/` face +// owns that line; no key/intent clash by construction) +import { describe, expect, test } from 'vitest'; +import { suggestSkill } from '@pacman/shared'; + +const SKILLS = [ + { id: 's-remind', name: 'remind', description: 'schedule reminders and cron notifications' }, + { id: 's-trans', name: 'translate', description: '翻译文本' }, + { id: 's-sum', name: 'summarize', description: '总结长文档' }, + { id: 's-review', name: 'code-review', description: '代码审查' }, +]; + +describe('suggestSkill first-batch intents', () => { + test('F1 reminder fires on time expressions', () => { + expect(suggestSkill('明早 9 点提醒我开会', SKILLS)?.skillId).toBe('s-remind'); + expect(suggestSkill('remind me tomorrow at 9am', SKILLS)?.skillId).toBe('s-remind'); + }); + test('F1 translate fires', () => { + expect(suggestSkill('把这段翻译成英文', SKILLS)?.skillId).toBe('s-trans'); + expect(suggestSkill('translate this paragraph', SKILLS)?.skillId).toBe('s-trans'); + }); + test('F1 summarize fires', () => { + expect(suggestSkill('帮我总结一下这篇文档', SKILLS)?.skillId).toBe('s-sum'); + expect(suggestSkill('summarize this doc', SKILLS)?.skillId).toBe('s-sum'); + }); + test('F1 review fires', () => { + expect(suggestSkill('帮我 review 一下这段代码', SKILLS)?.skillId).toBe('s-review'); + expect(suggestSkill('做一次代码审查', SKILLS)?.skillId).toBe('s-review'); + }); + test('F2 chatter without triggers stays silent', () => { + expect(suggestSkill('今天天气不错', SKILLS)).toBeNull(); + expect(suggestSkill('hello world', SKILLS)).toBeNull(); + }); + test('F3 no matching skill in vocab stays silent', () => { + expect(suggestSkill('明早 9 点提醒我开会', [])).toBeNull(); + expect( + suggestSkill('明早 9 点提醒我开会', [{ id: 'x', name: 'translate', description: '翻译' }]), + ).toBeNull(); + }); + test('F4 already-referenced skill is not re-suggested', () => { + expect(suggestSkill('/remind 明早 9 点提醒我', SKILLS)).toBeNull(); + expect(suggestSkill('明早提醒我 [remind](skill:s-remind)', SKILLS)).toBeNull(); + }); + test('F5 empty draft stays silent', () => { + expect(suggestSkill('', SKILLS)).toBeNull(); + expect(suggestSkill(' ', SKILLS)).toBeNull(); + }); + test('F6 matching is case-insensitive', () => { + expect(suggestSkill('REMIND me tomorrow', SKILLS)?.skillId).toBe('s-remind'); + expect( + suggestSkill('明早提醒我', [{ id: 'r', name: 'Remind', description: 'SCHEDULE alerts' }]) + ?.skillId, + ).toBe('r'); + }); + test('F7 first table intent wins on multi-intent drafts', () => { + expect(suggestSkill('明早提醒我,顺便翻译成英文', SKILLS)?.skillId).toBe('s-remind'); + }); + test('F8 slash-command line belongs to the slash face', () => { + expect(suggestSkill('/remind', SKILLS)).toBeNull(); + expect(suggestSkill(' /translate hello', SKILLS)).toBeNull(); + }); +}); diff --git a/docs/verify/823/suggest-accepted.png b/docs/verify/823/suggest-accepted.png new file mode 100644 index 000000000..1cb4af80c Binary files /dev/null and b/docs/verify/823/suggest-accepted.png differ diff --git a/docs/verify/823/suggest-flow.gif b/docs/verify/823/suggest-flow.gif new file mode 100644 index 000000000..a8640b40e Binary files /dev/null and b/docs/verify/823/suggest-flow.gif differ diff --git a/docs/verify/823/suggest-strip.png b/docs/verify/823/suggest-strip.png new file mode 100644 index 000000000..716fa0dbf Binary files /dev/null and b/docs/verify/823/suggest-strip.png differ diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3782a3884..f1470fc3b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -15,6 +15,7 @@ export * from './protocol/index.js'; export * from './records/index.js'; export * from './scheduler.js'; export * from './secret-box.js'; +export * from './skill-suggest.js'; export * from './tables.js'; // spec 15 #394:占位标题派生 + 固定标签词表(ADR 0002 自觉背离原站的面)。 export * from './task-meta.js'; diff --git a/packages/shared/src/skill-suggest.ts b/packages/shared/src/skill-suggest.ts new file mode 100644 index 000000000..f13061c8f --- /dev/null +++ b/packages/shared/src/skill-suggest.ts @@ -0,0 +1,121 @@ +// Natural-language skill auto-suggest (issue #823). +// +// When the user types plain prose ("明早 9 点提醒我"), pacman matches the +// draft against a first-batch trigger table and suggests the best team +// skill as a ghost hint bar (Tab accepts). The candidate pool is ALWAYS +// the caller's skill vocab (the same `useSkills` projection that feeds the +// mention picker and the `/` menu) — this module builds no side table, so +// a team without a matching skill simply gets no suggestion. +// +// Home is shared (not apps/web/src) on purpose: the trigger phrases are +// matcher data, not UI copy, so they stay out of the web i18n-coverage +// gate (every CJK literal under apps/web/src must be an en-dict key). +// Pure logic, no React — both the web composer wire and future server +// consumers import it from here. +// +// Deliberately NOT here: key handling and rendering. The composer wire +// suppresses the suggestion while the `/` or `@` menus are open and owns +// the Tab-accept key, so the three faces never steal each other's keys; +// the skin lives with the composer faces. + +export interface SuggestSkillEntry { + id: string; + name: string; + description?: string; +} + +export interface SkillSuggestion { + skillId: string; + skillName: string; + intentId: string; +} + +interface TriggerIntent { + id: string; + /** Draft-side trigger phrases (matched case-insensitively, substring). */ + triggers: string[]; + /** Skill-side keywords: a skill matches when its name or description + * contains any of these (case-insensitive). */ + skillKeywords: string[]; +} + +/** First-batch trigger table (#823:首批映射,不许全量). Order = priority: + * a draft hitting several intents suggests the earliest one. */ +const TRIGGER_INTENTS: TriggerIntent[] = [ + { + id: 'reminder', + triggers: [ + '提醒', + '闹钟', + '定时', + '明早', + '今晚', + '明天', + '后天', + '每天', + '每周', + 'deadline', + 'remind', + 'schedule', + 'cron', + 'alarm', + 'notify me', + '点提醒', + '点叫我', + ], + skillKeywords: ['remind', 'schedule', 'cron', 'notif', 'alarm', '提醒', '定时', '日程'], + }, + { + id: 'translate', + triggers: ['翻译', '译成', '翻成', 'translate', 'translation'], + skillKeywords: ['translat', '翻译'], + }, + { + id: 'summarize', + triggers: ['总结', '摘要', '归纳', 'summar', 'tl;dr'], + skillKeywords: ['summar', '总结', '摘要'], + }, + { + id: 'review', + triggers: ['代码审查', 'review', 'code review', '审核一下', '帮我看一下代码', '检查一下代码'], + skillKeywords: ['review', '审查', 'lint'], + }, +]; + +/** True when the draft already references the skill — either as a slash + * token (`/name`) or as an inserted mention (`[label](skill:id)`). */ +function alreadyReferenced(draft: string, skill: SuggestSkillEntry): boolean { + const lowered = draft.toLowerCase(); + if (lowered.includes(`/${skill.name.toLowerCase()}`)) return true; + return draft.includes(`(skill:${skill.id})`); +} + +/** A message-start slash line belongs to the `/` face. */ +function isSlashLine(draft: string): boolean { + return draft.trimStart().startsWith('/'); +} + +/** First matching suggestion, or null when nothing should be shown. */ +export function suggestSkill( + draft: string, + skills: readonly SuggestSkillEntry[], +): SkillSuggestion | null { + if (draft.trim() === '') return null; + if (isSlashLine(draft)) return null; + if (skills.length === 0) return null; + const loweredDraft = draft.toLowerCase(); + for (const intent of TRIGGER_INTENTS) { + if (!intent.triggers.some((t) => loweredDraft.includes(t.toLowerCase()))) continue; + const hit = skills.find( + (s) => + !alreadyReferenced(draft, s) && + intent.skillKeywords.some( + (k) => + s.name.toLowerCase().includes(k.toLowerCase()) || + (s.description ?? '').toLowerCase().includes(k.toLowerCase()), + ), + ); + if (hit !== undefined) return { skillId: hit.id, skillName: hit.name, intentId: intent.id }; + } + return null; +}