From 6b51428ea7ec1b412c648f86988828e835d07633 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Sat, 5 Sep 2026 12:41:05 +0000 Subject: [PATCH 01/22] feat: add prompt shortcut storage and settings [risk:high] Add scoped private/shared shortcut catalogs, lazy LoroDoc bodies, durable local publication recovery, settings authoring and Storybook prototypes. Slash invocation and composer send integration remain follow-up work. Model: gpt-6-astra --- locales/en.json | 35 + locales/zh_CN.json | 35 + packages/cloud-api/src/index.ts | 45 ++ .../components/src/components/main-layout.tsx | 31 +- .../src/components/mentions/AGENTS.md | 7 + .../mentions/combined-mention-textarea.tsx | 85 +- .../components/mentions/mention-registry.ts | 30 +- .../mentions/mention-shortcut-template.ts | 89 +++ .../mentions/mention-skill-source.tsx | 2 +- .../mentions/mention-two-level-menu.tsx | 21 +- .../mentions/shortcut-template-ranges.ts | 33 + .../src/components/prototypes/AGENTS.md | 50 ++ .../src/components/prototypes/CLAUDE.md | 1 + .../prompt-shortcut-composer-prototype.tsx | 724 ++++++++++++++++++ .../prompt-shortcut-desktop-shell.tsx | 104 +++ .../prompt-shortcut-editor-prototype.tsx | 523 +++++++++++++ .../prompt-shortcut-fixtures.ts | 290 +++++++ .../prompt-shortcuts/prompt-shortcut-model.ts | 445 +++++++++++ .../prompt-shortcut-visuals.tsx | 292 +++++++ .../prompt-shortcuts-setting-prototype.tsx | 202 +++++ .../src/components/settings/AGENTS.md | 22 + .../src/components/settings/CLAUDE.md | 1 + .../settings/desktop-settings-modal.tsx | 3 + .../settings/prompt-shortcut-form.tsx | 402 ++++++++++ .../settings/prompt-shortcuts-setting.tsx | 389 ++++++++++ .../src/components/settings/settings-tabs.tsx | 12 + .../components/src/lib/clear-local-cache.ts | 15 +- .../src/lib/cloud-api-operations.ts | 18 + .../src/lib/prompt-shortcut-storage.ts | 6 + packages/components/src/providers/AGENTS.md | 19 + .../providers/prompt-shortcut-provider.tsx | 253 ++++++ packages/components/src/routeTree.gen.ts | 23 + .../_auth/settings/prompt-shortcuts.tsx | 6 + .../stories/MentionTwoLevelMenu.stories.tsx | 19 + .../stories/PromptShortcutForm.stories.tsx | 84 ++ .../PromptShortcutsPrototype.stories.tsx | 226 ++++++ .../tests/clear-local-cache.test.ts | 7 + ...bined-mention-textarea-activation.test.tsx | 22 + .../components/tests/mention-registry.test.ts | 20 + .../tests/mention-shortcut-template.test.ts | 85 ++ .../tests/prompt-shortcut-form.test.tsx | 243 ++++++ .../prompt-shortcut-prototype-model.test.ts | 231 ++++++ .../tests/prompt-shortcuts-setting.test.tsx | 125 +++ packages/shared/package.json | 8 + .../shared/src/prompt-shortcuts/AGENTS.md | 62 ++ .../shared/src/prompt-shortcuts/CLAUDE.md | 1 + .../shared/src/prompt-shortcuts/access.ts | 37 + .../shared/src/prompt-shortcuts/catalog.ts | 138 ++++ .../shared/src/prompt-shortcuts/compiler.ts | 359 +++++++++ .../shared/src/prompt-shortcuts/document.ts | 169 ++++ packages/shared/src/prompt-shortcuts/index.ts | 8 + .../src/prompt-shortcuts/local-store.ts | 249 ++++++ packages/shared/src/prompt-shortcuts/model.ts | 264 +++++++ .../shared/src/prompt-shortcuts/runtime.ts | 428 +++++++++++ packages/shared/src/prompt-shortcuts/sync.ts | 218 ++++++ .../tests/prompt-shortcut-runtime.test.ts | 446 +++++++++++ .../shared/tests/prompt-shortcut-sync.test.ts | 93 +++ .../shared/tests/prompt-shortcuts.test.ts | 458 +++++++++++ 58 files changed, 8163 insertions(+), 50 deletions(-) create mode 100644 packages/components/src/components/mentions/mention-shortcut-template.ts create mode 100644 packages/components/src/components/mentions/shortcut-template-ranges.ts create mode 100644 packages/components/src/components/prototypes/AGENTS.md create mode 120000 packages/components/src/components/prototypes/CLAUDE.md create mode 100644 packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-composer-prototype.tsx create mode 100644 packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-desktop-shell.tsx create mode 100644 packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-editor-prototype.tsx create mode 100644 packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-fixtures.ts create mode 100644 packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-model.ts create mode 100644 packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-visuals.tsx create mode 100644 packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcuts-setting-prototype.tsx create mode 100644 packages/components/src/components/settings/AGENTS.md create mode 120000 packages/components/src/components/settings/CLAUDE.md create mode 100644 packages/components/src/components/settings/prompt-shortcut-form.tsx create mode 100644 packages/components/src/components/settings/prompt-shortcuts-setting.tsx create mode 100644 packages/components/src/lib/prompt-shortcut-storage.ts create mode 100644 packages/components/src/providers/prompt-shortcut-provider.tsx create mode 100644 packages/components/src/routes/$workspaceName/_auth/settings/prompt-shortcuts.tsx create mode 100644 packages/components/src/stories/PromptShortcutForm.stories.tsx create mode 100644 packages/components/src/stories/PromptShortcutsPrototype.stories.tsx create mode 100644 packages/components/tests/mention-shortcut-template.test.ts create mode 100644 packages/components/tests/prompt-shortcut-form.test.tsx create mode 100644 packages/components/tests/prompt-shortcut-prototype-model.test.ts create mode 100644 packages/components/tests/prompt-shortcuts-setting.test.tsx create mode 100644 packages/shared/src/prompt-shortcuts/AGENTS.md create mode 120000 packages/shared/src/prompt-shortcuts/CLAUDE.md create mode 100644 packages/shared/src/prompt-shortcuts/access.ts create mode 100644 packages/shared/src/prompt-shortcuts/catalog.ts create mode 100644 packages/shared/src/prompt-shortcuts/compiler.ts create mode 100644 packages/shared/src/prompt-shortcuts/document.ts create mode 100644 packages/shared/src/prompt-shortcuts/index.ts create mode 100644 packages/shared/src/prompt-shortcuts/local-store.ts create mode 100644 packages/shared/src/prompt-shortcuts/model.ts create mode 100644 packages/shared/src/prompt-shortcuts/runtime.ts create mode 100644 packages/shared/src/prompt-shortcuts/sync.ts create mode 100644 packages/shared/tests/prompt-shortcut-runtime.test.ts create mode 100644 packages/shared/tests/prompt-shortcut-sync.test.ts create mode 100644 packages/shared/tests/prompt-shortcuts.test.ts diff --git a/locales/en.json b/locales/en.json index d239dbfc1..8a1c2f410 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1,4 +1,39 @@ { + "settings.promptShortcuts.workspaceScope": "Workspace", + "settings.promptShortcuts.repairScope": "Restore the matching scope or remove these mentions before saving.", + "settings.promptShortcuts.requiredAxes": "Requires matching {{axes}}", + "settings.promptShortcuts.thisMachine": "Limit to this machine", + "settings.promptShortcuts.waitPublication": "This revision is awaiting publication. Retry the pending publication before saving another revision.", + "settings.tabs.promptShortcuts": "Prompt Shortcuts", + "settings.categories.promptShortcuts.description": "Reusable Prompts, mentions and variables", + "settings.promptShortcuts.name": "Name", + "settings.promptShortcuts.command": "Slash command", + "settings.promptShortcuts.description": "Description (optional)", + "settings.promptShortcuts.scope": "Scope", + "settings.promptShortcuts.scopeHelp": "No scope means available throughout this workspace. Select scope before adding restricted mentions.", + "settings.promptShortcuts.project": "Project", + "settings.promptShortcuts.machine": "Machine", + "settings.promptShortcuts.agent": "Agent", + "settings.promptShortcuts.none": "None", + "settings.promptShortcuts.prompt": "Prompt", + "settings.promptShortcuts.variablesHelp": "Use !{name} for a required variable. Defaults are optional; values are inserted as literal text.", + "settings.promptShortcuts.defaults": "Variable defaults", + "settings.promptShortcuts.share": "Share with workspace", + "settings.promptShortcuts.shareWarning": "Workspace members can read and copy this Prompt. Making it private later cannot remove copies they already received.", + "settings.promptShortcuts.invalid": "Could not save. Check the name, command, mention scope and size limits, then try again.", + "settings.promptShortcuts.intro": "Save reusable Prompts with mentions and variables. Private by default.", + "settings.promptShortcuts.new": "New shortcut", + "settings.promptShortcuts.empty": "No Prompt Shortcuts yet.", + "settings.promptShortcuts.private": "Private", + "settings.promptShortcuts.shared": "Workspace", + "settings.promptShortcuts.machineScoped": "Machine scoped", + "settings.promptShortcuts.pending": "Saved locally · publication pending", + "settings.promptShortcuts.delete": "Delete shortcut", + "settings.promptShortcuts.retryHelp": "Some changes could not be loaded or published. Your local saves are retained.", + "settings.promptShortcuts.edit": "Prompt Shortcut", + "settings.promptShortcuts.editorHelp": "Save a Prompt as reusable content. Scope controls where it can be used.", + "settings.promptShortcuts.deleteHelp": "Delete this shortcut? Prompts already inserted into drafts or sent messages are unchanged.", + "settings.promptShortcuts.mentionScope": "Select the required Project, Machine and Agent scope first.", "agents.acpCapabilities.refreshError": "Refresh failed", "agents.acpCapabilities.refreshModelsAndModes": "Refresh models and modes", "agents.acpCapabilities.refreshSuccess": "Capabilities refreshed: {{modelCount}} models, {{modeCount}} modes", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8c0e272a0..34ca3759c 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1,4 +1,39 @@ { + "settings.promptShortcuts.workspaceScope": "整个工作区", + "settings.promptShortcuts.repairScope": "请恢复匹配的适用范围或移除以下引用后再保存。", + "settings.promptShortcuts.requiredAxes": "需要匹配的{{axes}}范围", + "settings.promptShortcuts.thisMachine": "仅限本机", + "settings.promptShortcuts.waitPublication": "当前版本正在等待发布,请先重试待发布操作,再保存新版本。", + "settings.tabs.promptShortcuts": "Prompt Shortcuts", + "settings.categories.promptShortcuts.description": "可复用的 Prompt、mention 和变量", + "settings.promptShortcuts.name": "名称", + "settings.promptShortcuts.command": "Slash 命令", + "settings.promptShortcuts.description": "说明(可选)", + "settings.promptShortcuts.scope": "适用范围", + "settings.promptShortcuts.scopeHelp": "未限制时适用于整个工作区。请先选择范围,再添加有相应限制的 mention。", + "settings.promptShortcuts.project": "项目", + "settings.promptShortcuts.machine": "机器", + "settings.promptShortcuts.agent": "Agent", + "settings.promptShortcuts.none": "不限制", + "settings.promptShortcuts.prompt": "Prompt", + "settings.promptShortcuts.variablesHelp": "使用 !{name} 声明必填变量,可设置默认值。变量值按原文插入。", + "settings.promptShortcuts.defaults": "变量默认值", + "settings.promptShortcuts.share": "共享给工作区", + "settings.promptShortcuts.shareWarning": "工作区成员可以读取、复制此 Prompt。之后改为私有,无法收回他们已获取的副本。", + "settings.promptShortcuts.invalid": "无法保存,请检查名称、命令格式、mention 范围和长度限制后重试。", + "settings.promptShortcuts.intro": "保存可复用的 Prompt,支持 mention 和变量,默认私有。", + "settings.promptShortcuts.new": "新建 Shortcut", + "settings.promptShortcuts.empty": "还没有 Prompt Shortcut。", + "settings.promptShortcuts.private": "私有", + "settings.promptShortcuts.shared": "工作区共享", + "settings.promptShortcuts.machineScoped": "限制机器", + "settings.promptShortcuts.pending": "已保存在本地 · 待发布", + "settings.promptShortcuts.delete": "删除 Shortcut", + "settings.promptShortcuts.retryHelp": "部分内容无法加载或发布,本地保存的内容已保留。", + "settings.promptShortcuts.edit": "Prompt Shortcut", + "settings.promptShortcuts.editorHelp": "将 Prompt 保存为可复用内容,使用范围决定它在哪里可用。", + "settings.promptShortcuts.deleteHelp": "确定删除此 Shortcut?已插入草稿或已发送的 Prompt 不受影响。", + "settings.promptShortcuts.mentionScope": "请先选择所需的项目、机器或 Agent 范围。", "agents.acpCapabilities.refreshError": "刷新失败", "agents.acpCapabilities.refreshModelsAndModes": "刷新模型和模式", "agents.acpCapabilities.refreshSuccess": "能力已刷新:{{modelCount}} 个模型,{{modeCount}} 个模式", diff --git a/packages/cloud-api/src/index.ts b/packages/cloud-api/src/index.ts index 356e50b46..c1a45bdb9 100644 --- a/packages/cloud-api/src/index.ts +++ b/packages/cloud-api/src/index.ts @@ -274,6 +274,51 @@ type SeatInvitePreview = }; export type CloudApi = { + promptShortcuts: { + stageDocument: Mutation< + { + workspaceId: string; + ownerUserId: string; + shortcutId: string; + bodyDocId: string; + visibility: 'private' | 'workspace'; + }, + { bodyDocId: string; status: 'staged' | 'active' } + >; + activateDocument: Mutation< + { + workspaceId: string; + bodyDocId: string; + previousBodyDocId: string | null; + previousRevision: string | null; + revision: string; + slug: string; + indexBytes: number; + }, + null + >; + revokeShortcut: Mutation<{ workspaceId: string; shortcutId: string }, null>; + listAccessibleDocuments: Query< + { workspaceId: string }, + Array<{ + shortcutId: string; + bodyDocId: string; + ownerUserId: string; + visibility: 'private' | 'workspace'; + revision: string | null; + }> + >; + getStreamToken: Action< + { + workspaceId: string; + target: + | { kind: 'index'; ownerUserId: string; visibility: 'private' | 'workspace' } + | { kind: 'body'; bodyDocId: string }; + write: boolean; + }, + { token: string; expiresIn: number; gatewayBaseUrl: string; streamId: string } + >; + }; activity: { recordMyWorkspaceDailyActiveUser: Mutation< { workspaceId: string }, diff --git a/packages/components/src/components/main-layout.tsx b/packages/components/src/components/main-layout.tsx index f351baefb..134343667 100644 --- a/packages/components/src/components/main-layout.tsx +++ b/packages/components/src/components/main-layout.tsx @@ -11,6 +11,7 @@ import { StuckConnectionBannerContainer } from './stuck-connection-banner'; import { DesktopSettingsModal } from './settings/desktop-settings-modal'; import { TaskQuickAddDialogContainer } from './tasks/task-quick-add-dialog-container'; import { TaskStatusWatcher } from './tasks/task-status-watcher'; +import { PromptShortcutProvider } from '../providers/prompt-shortcut-provider'; export { getMobileMainLayoutContentClassName, getMobileMainLayoutRootClassName, @@ -55,19 +56,21 @@ export function MainLayout({ const tasksEnabled = useAtomValue(tasksFeatureEnabledAtom); return ( - - {children} - {tasksEnabled && workspaceReady ? ( - <> - - - - - ) : null} - {workspaceReady ? : null} - - - {workspaceReady ? : null} - + + + {children} + {tasksEnabled && workspaceReady ? ( + <> + + + + + ) : null} + {workspaceReady ? : null} + + + {workspaceReady ? : null} + + ); } diff --git a/packages/components/src/components/mentions/AGENTS.md b/packages/components/src/components/mentions/AGENTS.md index 561077859..7916ae54c 100644 --- a/packages/components/src/components/mentions/AGENTS.md +++ b/packages/components/src/components/mentions/AGENTS.md @@ -4,6 +4,13 @@ Product-level mention sources built on `src/ui/mention`. ## Invariants +- A `disabled` category remains discoverable, with a source-supplied localized + reason, but cannot navigate, rank candidates or activate its lazy source. + This is distinct from `enabled: false` (absent) and `loading` (eligible but + pending). Enforce it for typed namespaces, direct triggers and aggregate + search as well as pointer/keyboard selection; Shortcut editors use this for + explicit scope requirements, never silently adopting the current composer. + - `@` reaches every mention type through the two-level menu. Skills also retain their direct `$` menu for compatibility, and `/` still opens commands directly because a slash command must own the whole prompt. `#` does not open diff --git a/packages/components/src/components/mentions/combined-mention-textarea.tsx b/packages/components/src/components/mentions/combined-mention-textarea.tsx index 8f4ab3ea2..37a5ca2e3 100644 --- a/packages/components/src/components/mentions/combined-mention-textarea.tsx +++ b/packages/components/src/components/mentions/combined-mention-textarea.tsx @@ -68,6 +68,8 @@ import type { Mention as MentionRange, MentionChipResolver } from '@/ui/mention/ import { Textarea, type TextareaProps } from '@/ui/textarea'; import { parseMentionNamespaceSearch } from '@/ui/mention/mention-trigger'; import { getCommandKeybindings, useCommand } from '@/lib/commands'; +import type { PromptShortcutScope } from '@lody/shared/prompt-shortcuts'; +import { shortcutTemplateCategories } from './mention-shortcut-template'; // ============================================================================ // Two-level `@` menu @@ -109,6 +111,7 @@ function TwoLevelMentionMenu({ enableAgentRoleMentions, agentRoleItems, surface, + templateScope, }: { fileData: MentionFileDataState; fileSourceKind: MentionFileSourceKind; @@ -132,6 +135,7 @@ function TwoLevelMentionMenu({ enableAgentRoleMentions: boolean; agentRoleItems: readonly AgentRoleMentionItem[]; surface: MentionSurface; + templateScope?: PromptShortcutScope; }) { const context = useMentionContext('TwoLevelMentionMenu'); const { t } = useTranslation(); @@ -199,7 +203,7 @@ function TwoLevelMentionMenu({ [issuePrFuseCtor] ); - const fileSource = React.useMemo( + const fileSource = React.useMemo>( () => ({ enabled: enableFileMentions, status: @@ -237,7 +241,7 @@ function TwoLevelMentionMenu({ void refreshIssuePr(); }, [refreshIssuePr]); - const issuePrSource = React.useMemo( + const issuePrSource = React.useMemo>( () => ({ enabled: enableIssueMentions, status: @@ -266,7 +270,7 @@ function TwoLevelMentionMenu({ ] ); - const skillSource = React.useMemo( + const skillSource = React.useMemo>( () => ({ enabled: enableSkillMentions, status: @@ -349,7 +353,7 @@ function TwoLevelMentionMenu({ ] ); - const agentRoleSource = React.useMemo( + const agentRoleSource = React.useMemo>( () => ({ enabled: enableAgentRoleMentions, items: agentRoleItems }), [agentRoleItems, enableAgentRoleMentions] ); @@ -359,19 +363,43 @@ function TwoLevelMentionMenu({ [availableCommands, enableCommandMentions] ); - const categories = useMentionCategories( + const baseCategories = useMentionCategories( React.useMemo( () => ({ - file: fileSource, - issuePr: issuePrSource, - skill: skillSource, + file: templateScope ? { ...fileSource, enabled: true } : fileSource, + issuePr: templateScope ? { ...issuePrSource, enabled: true } : issuePrSource, + skill: templateScope ? { ...skillSource, enabled: true } : skillSource, session: sessionSource, - agentRole: agentRoleSource, + agentRole: templateScope ? { ...agentRoleSource, enabled: true } : agentRoleSource, command: commandSource, }), - [agentRoleSource, commandSource, fileSource, issuePrSource, sessionSource, skillSource] + [ + agentRoleSource, + commandSource, + fileSource, + issuePrSource, + sessionSource, + skillSource, + templateScope, + ] ) ); + const categories = React.useMemo( + () => + templateScope + ? shortcutTemplateCategories({ + categories: baseCategories, + scope: templateScope, + skills: skillItems, + allowedDirs: allowedSkillDirs, + disabledReason: t( + 'settings.promptShortcuts.mentionScope', + 'Select the required Project, Machine and Agent scope first.' + ), + }) + : baseCategories, + [templateScope, baseCategories, skillItems, allowedSkillDirs, t] + ); // Ask the provider to list a directory the user has drilled into but that was // never expanded, so the second level fills in instead of showing nothing. @@ -589,6 +617,8 @@ export interface CombinedMentionTextareaProps extends Omit< TextareaProps, 'value' | 'defaultValue' | 'onChange' > { + /** Explicit template scope. Disables token hydration and context-dependent session/command mentions. */ + templateScope?: PromptShortcutScope; mentionSource?: MentionProjectSource; availableCommands?: AcpCommandSummary[]; /** The selected ACP provider. When set, the `$` skill menu only offers @@ -656,6 +686,7 @@ export const CombinedMentionTextarea = React.forwardRef< ( { mentionSource, + templateScope, availableCommands, skillAgent, mentionSurface = 'unknown', @@ -726,7 +757,9 @@ export const CombinedMentionTextarea = React.forwardRef< : false; // Enable `$` when there are project skills OR a known machine whose global // skills we can list (so GitHub / plain-agent chats still offer skills). - const enableSkillMentions = hasProjectSkillSource || Boolean(skillGlobalMachineId); + const enableSkillMentions = + (hasProjectSkillSource || Boolean(skillGlobalMachineId)) && + (!templateScope || (!!templateScope.providerKey && !!skillAgent)); // Only scan/fetch skills once they are actually asked for, so the composer // doesn't kick a skills RPC on every mount. Two things ask: the menu, when a // query reaches the Skills category (`onActivate` below), and a draft that @@ -736,7 +769,8 @@ export const CombinedMentionTextarea = React.forwardRef< const [skillsRequested, setSkillsRequested] = React.useState(false); const activateSkills = React.useCallback(() => setSkillsRequested(true), []); const skillsActive = - enableSkillMentions && (skillsRequested || value.includes(SKILL_MENTION_TRIGGER)); + enableSkillMentions && + (skillsRequested || (!templateScope && value.includes(SKILL_MENTION_TRIGGER))); const { fileData, initializeLazyDirectory, getKnownFileTokens } = useMentionProjectFiles(mentionSource); @@ -757,11 +791,13 @@ export const CombinedMentionTextarea = React.forwardRef< ); const agentRoleContext = React.useMemo( () => - buildAgentRoleMentionContext({ - mentionSource, - currentMachineId: skillAgent?.machineId, - }), - [mentionSource, skillAgent?.machineId] + templateScope + ? { kind: 'github' as const } + : buildAgentRoleMentionContext({ + mentionSource, + currentMachineId: skillAgent?.machineId, + }), + [mentionSource, skillAgent?.machineId, templateScope] ); const agentRoleItems = useAgentRoleMentionItems(agentRoleContext); // A committed range carries only the Role id, so the caller's chip resolver @@ -893,18 +929,20 @@ export const CombinedMentionTextarea = React.forwardRef< textarea?.focus(); }, [instanceKey, ref]); - const enableCommandMentions = Boolean(availableCommands && availableCommands.length > 0); + const enableCommandMentions = + !templateScope && Boolean(availableCommands && availableCommands.length > 0); const hasExternalMentionSupport = externalMentions.length > 0 || Boolean(onExternalMentionsChange) || Boolean(onMentionClick); // One list of what `@` can reach, so registering the trigger and mounting // the mention tree can never disagree about a type. They drifted once // already: a composer with only issues rendered a plain textarea. - const enableSessionMentions = sessionItems.length > 0; + const enableSessionMentions = !templateScope && sessionItems.length > 0; // Having any mentionable Role IS the enablement rule: the list is already // filtered by visibility, executability, and work context, so an empty one // means there is nothing this composer could offer. const enableAgentRoleMentions = agentRoleItems.length > 0; const enableAtMentions = + !!templateScope || enableFileMentions || enableIssueMentions || enableSkillMentions || @@ -964,7 +1002,7 @@ export const CombinedMentionTextarea = React.forwardRef< {persistedMentions && persistedMentions.length > 0 ? ( @@ -979,7 +1017,7 @@ export const CombinedMentionTextarea = React.forwardRef< getKnownFileTokens={getKnownFileTokens} text={value} items={agentRoleItems} - enabled={enableAgentRoleMentions} + enabled={enableAgentRoleMentions && !templateScope} /> {mentionActionsRef ? ( @@ -988,7 +1026,7 @@ export const CombinedMentionTextarea = React.forwardRef< ) : null} {enableIssueMentions ? ( @@ -996,7 +1034,7 @@ export const CombinedMentionTextarea = React.forwardRef< (); for (const category of queried) { + if (category.status === 'disabled') continue; if (category.activation) bySource.set(category.activation.sourceKey, category.activation); } return [...bySource.values()]; @@ -246,7 +247,12 @@ export function selectMentionMenuView( const category = categories.find((entry) => entry.namespace === namespaced.namespace); if (category) { const { term } = namespaced; - return { level: 'category', category, term, candidates: category.getCandidates(term) }; + return { + level: 'category', + category, + term, + candidates: category.status === 'disabled' ? [] : category.getCandidates(term), + }; } } @@ -257,6 +263,7 @@ export function selectMentionMenuView( const limit = options?.aggregateLimitPerCategory ?? AGGREGATE_LIMIT_PER_CATEGORY; const groups: MentionCandidateGroup[] = []; for (const category of categories) { + if (category.status === 'disabled') continue; // `limit` is passed down so a source can stop early, and enforced here so // the cap holds whether or not it did. const candidates = category.getCandidates(search, limit).slice(0, limit); @@ -290,7 +297,7 @@ export function selectMentionMenuViewForTrigger( level: 'category', category: direct, term: search, - candidates: direct.getCandidates(search), + candidates: direct.status === 'disabled' ? [] : direct.getCandidates(search), }; } @@ -578,7 +585,10 @@ function sourceCategoryFields(sourceKey: MentionSourceKey, source: SourceState) return { status: source.status ?? 'ready', message: source.message, - activation: source.onActivate ? { sourceKey, activate: source.onActivate } : undefined, + activation: + source.status !== 'disabled' && source.onActivate + ? { sourceKey, activate: source.onActivate } + : undefined, }; } @@ -628,14 +638,20 @@ export function useMentionCategories(sources: MentionCategorySources): MentionCa // types, and re-splitting it inside `getCandidates` walked the whole list // twice on every keystroke. const issueSuggestions = React.useMemo( - () => (issuePr?.enabled ? issuePr.suggestions.filter((item) => item.type === 'issue') : []), + () => + issuePr?.enabled && issuePr.status !== 'disabled' + ? issuePr.suggestions.filter((item) => item.type === 'issue') + : [], [issuePr] ); const prSuggestions = React.useMemo( - () => (issuePr?.enabled ? issuePr.suggestions.filter((item) => item.type === 'pr') : []), + () => + issuePr?.enabled && issuePr.status !== 'disabled' + ? issuePr.suggestions.filter((item) => item.type === 'pr') + : [], [issuePr] ); - const createIssuePrFuse = issuePr?.createFuse; + const createIssuePrFuse = issuePr?.status === 'disabled' ? undefined : issuePr?.createFuse; const issueFuse = React.useMemo( () => createIssuePrFuse?.(issueSuggestions) ?? null, [createIssuePrFuse, issueSuggestions] diff --git a/packages/components/src/components/mentions/mention-shortcut-template.ts b/packages/components/src/components/mentions/mention-shortcut-template.ts new file mode 100644 index 000000000..87d940ac5 --- /dev/null +++ b/packages/components/src/components/mentions/mention-shortcut-template.ts @@ -0,0 +1,89 @@ +import { + getShortcutMentionGate, + getShortcutMentionScopeIssues, + type PromptShortcutScope, + type PromptShortcutTarget, +} from '@lody/shared/prompt-shortcuts/model'; +import type { MentionCategory, MentionCandidate } from './mention-registry'; +import { + getSkillMentionReferencePath, + selectSkillMentionCandidates, + type SkillMentionItem, +} from './mention-skill-source'; + +/** Freeze the semantic target at selection, not by reparsing a label on save. */ +export function shortcutTemplateCategories(input: { + categories: readonly MentionCategory[]; + scope: PromptShortcutScope; + skills: readonly SkillMentionItem[]; + allowedDirs: ReadonlySet | null; + disabledReason: string; +}): MentionCategory[] { + const { scope } = input; + const skills = new Map( + selectSkillMentionCandidates(input.skills, '', input.allowedDirs).map((item) => [ + item.token, + item, + ]) + ); + const targetFor = (candidate: MentionCandidate): PromptShortcutTarget | null => { + if (candidate.kind === 'agent_role') + return { kind: 'agent_role', agentRoleId: candidate.value }; + if ((candidate.kind === 'file' || candidate.kind === 'dir') && scope.project) + return { + kind: 'file', + project: scope.project, + path: candidate.value.replace(/\/+$/, ''), + ...(candidate.kind === 'dir' ? { directory: true } : {}), + }; + if ((candidate.kind === 'issue' || candidate.kind === 'pr') && scope.project?.kind === 'github') + return { + kind: candidate.kind === 'pr' ? 'pull_request' : 'issue', + repository: scope.project.repository, + number: Number(candidate.value.replace(/^#/, '')), + }; + const skill = skills.get(candidate.value); + if (candidate.kind === 'skill' && skill && scope.providerKey) + return { + kind: 'skill', + source: skill.scope, + path: getSkillMentionReferencePath(skill), + compatibleProviders: [scope.providerKey], + ...(skill.scope === 'project' + ? { project: scope.project } + : { machineId: scope.machineId }), + }; + return null; + }; + return input.categories + .filter((category) => category.id !== 'session' && category.id !== 'command') + .map((category) => { + const enabled = + category.id === 'skill' + ? getShortcutMentionGate('project_skill', scope).enabled || + getShortcutMentionGate('global_skill', scope).enabled + : getShortcutMentionGate( + category.id === 'pr' + ? 'pull_request' + : (category.id as 'file' | 'issue' | 'agent_role'), + scope + ).enabled; + if (!enabled) + return { + ...category, + status: 'disabled', + message: input.disabledReason, + activation: undefined, + getCandidates: () => [], + }; + return { + ...category, + getCandidates: (term, limit) => + category.getCandidates(term, limit).flatMap((candidate) => { + const target = targetFor(candidate); + if (!target || getShortcutMentionScopeIssues(scope, target).length > 0) return []; + return [{ ...candidate, value: JSON.stringify(target) }]; + }), + }; + }); +} diff --git a/packages/components/src/components/mentions/mention-skill-source.tsx b/packages/components/src/components/mentions/mention-skill-source.tsx index a74c881ac..3d28ca769 100644 --- a/packages/components/src/components/mentions/mention-skill-source.tsx +++ b/packages/components/src/components/mentions/mention-skill-source.tsx @@ -183,7 +183,7 @@ export function getAllowedSkillMentionDirs( ]); } -function getSkillMentionReferencePath(item: SkillMentionItem): string { +export function getSkillMentionReferencePath(item: SkillMentionItem): string { // Home-scoped skills (global + system) expand to their absolute SKILL.md path; // project skills use the project-relative path. if (item.scope !== 'project') { diff --git a/packages/components/src/components/mentions/mention-two-level-menu.tsx b/packages/components/src/components/mentions/mention-two-level-menu.tsx index e59e0cfbb..2cd08f6eb 100644 --- a/packages/components/src/components/mentions/mention-two-level-menu.tsx +++ b/packages/components/src/components/mentions/mention-two-level-menu.tsx @@ -56,7 +56,13 @@ export function useMentionCategoryActivation( const activateCategory = React.useCallback( (category: MentionCategory) => { const activation = category.activation; - if (!open || !activation || !shouldActivateSource(activation.sourceKey)) return; + if ( + !open || + category.status === 'disabled' || + !activation || + !shouldActivateSource(activation.sourceKey) + ) + return; activation.activate(); }, [open, shouldActivateSource] @@ -129,6 +135,8 @@ function CategoryRow({ return ( onNavigate(category) : undefined} > - {category.label} - + + {category.label} + {category.status === 'disabled' && (category.message?.length ?? 0) > 0 ? ( + {category.message} + ) : null} + + {category.status !== 'disabled' ? ( + + ) : null} ); } diff --git a/packages/components/src/components/mentions/shortcut-template-ranges.ts b/packages/components/src/components/mentions/shortcut-template-ranges.ts new file mode 100644 index 000000000..36d53844c --- /dev/null +++ b/packages/components/src/components/mentions/shortcut-template-ranges.ts @@ -0,0 +1,33 @@ +import { + PromptShortcutTargetSchema, + type PromptShortcutMention, +} from '@lody/shared/prompt-shortcuts/model'; +import type { PersistedMentionRange } from './mention-persistence'; + +/** Pure editor serialization. Importing this must not activate any mention source. */ +export function shortcutMentionRanges( + mentions: readonly PromptShortcutMention[] +): PersistedMentionRange[] { + return mentions.map((mention) => ({ + start: mention.start, + end: mention.end, + value: JSON.stringify(mention.target), + kind: + mention.target.kind === 'pull_request' + ? 'pr' + : mention.target.kind === 'file' && mention.target.directory + ? 'dir' + : mention.target.kind, + })); +} +export function shortcutTemplateMentions( + text: string, + ranges: readonly PersistedMentionRange[] +): PromptShortcutMention[] { + return ranges.map((range) => ({ + start: range.start, + end: range.end, + label: text.slice(range.start, range.end), + target: PromptShortcutTargetSchema.parse(JSON.parse(range.value)), + })); +} diff --git a/packages/components/src/components/prototypes/AGENTS.md b/packages/components/src/components/prototypes/AGENTS.md new file mode 100644 index 000000000..038f21740 --- /dev/null +++ b/packages/components/src/components/prototypes/AGENTS.md @@ -0,0 +1,50 @@ +# `src/components/prototypes` + +Interaction prototypes for features that have a design document but no +implementation. They exist to be looked at in Storybook and argued about, not +to ship. + +Parent `AGENTS.md` files also apply, with the deviations recorded below. + +## Invariants + +- **Nothing here is imported by product code.** No route, no atom, no hook that + touches Flock, Convex, Machine RPC, or the network. A prototype that acquires + a production consumer is no longer a prototype and must move out of this + directory and take on the full rules of its destination. +- Data comes from a closed, deterministic fixture module in the prototype's own + folder — no clock, no randomness, no fetch — so a story renders the same + pixels on every run and can be diffed in review. +- Reuse the real visual language: `src/ui` primitives, `ui/menu-styles.ts`, + `settings/form-primitives.tsx`, and the composer's own chip table in + `components/mentions/mention-chips.tsx`. A prototype that invents its own + spacing and colour is answering a question nobody asked. +- Prefer computing a state over drawing it. The point of a runnable prototype + over a mockup is that the derived states — requirement pills, availability, + send gates — react to input, which is exactly where a design is wrong or + right. + +## Deviation: i18n + +Copy in this directory is inline English, not `t()`. The package rule is that +user-visible copy goes through i18n; a prototype is exempt because its strings +are proposals under review and would otherwise land ~60 speculative keys, plus +their translations, in the shipped locale files before anyone has agreed to the +feature. **Moving a prototype toward production means moving its copy to +`locales/*.json` first.** + +## Current prototypes + +- `prompt-shortcuts/` — `docs/prompt-shortcuts.md` (private repo). Settings + catalog, editor, `/` menu, invocation chip + variable tray, expand-and-edit. + Stories: `src/stories/PromptShortcutsPrototype.stories.tsx`. + + Two places where the prototype deliberately disagrees with that document, on + product-owner review: the template is ONE field rather than an ordered block + list (blocks were joined into one message anyway, so they were a second way to + press Enter), and the derived requirements are one "Runs in" pill row under + the field rather than their own section (the references are already visible in + the prompt above it; a table restated them). The editor also carries a + "Browse from" project/machine pair, which is §3.3's source-first mention menu + as a persistent control — it scopes what `@` offers and never sets a + requirement itself. diff --git a/packages/components/src/components/prototypes/CLAUDE.md b/packages/components/src/components/prototypes/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/components/src/components/prototypes/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-composer-prototype.tsx b/packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-composer-prototype.tsx new file mode 100644 index 000000000..07f74f8c4 --- /dev/null +++ b/packages/components/src/components/prototypes/prompt-shortcuts/prompt-shortcut-composer-prototype.tsx @@ -0,0 +1,724 @@ +/** + * PROTOTYPE — calling a Prompt Shortcut from the composer (§5.3, §6). + * + * The whole interaction is here and it is real: the `/` menu filters on the + * same eligibility resolver Settings uses, the chip is compact and owns the + * prompt, the `!` badge counts missing required values, the send button is + * gated on them, and "Expand and edit" compiles the snapshot into plain text + * through the same segment pipeline that would produce the sent message. + * + * What is deliberately NOT here: persistence, an ACP capability cache, and any + * attempt to nest an input inside the textarea (§2.4). + */ + +import * as React from 'react'; +import { ArrowUp, ChevronDown, Terminal, TriangleAlert, X, Zap } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/ui/button'; +import { Input } from '@/ui/input'; +import { Mention, MentionInput, MentionLabel } from '@/ui/mention'; +import { + menuGroupLabelClassName, + menuItemClassName, + menuItemExtraClassName, + menuSeparatorClassName, + menuSeparatorStyle, + menuSurfaceClassName, + menuSurfaceStyle, +} from '@/ui/menu-styles'; +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/ui/sheet'; +import { Textarea } from '@/ui/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/ui/tooltip'; +import { + agentLabel, + machineLabel, + projectLabel, + PROTOTYPE_AGENT_COMMANDS, + PROTOTYPE_LABELS, + PROTOTYPE_MENTION_CATALOG, + PROTOTYPE_SHORTCUTS, + PROTOTYPE_WORK_CONTEXT, +} from './prompt-shortcut-fixtures'; +import { + compileShortcut, + findMissingVariables, + resolveEffectiveValue, + resolveEligibility, + resolveVariableRows, + type PrototypeEligibility, + type PrototypeShortcut, + type PrototypeVariable, + type PrototypeWorkContext, +} from './prompt-shortcut-model'; +import { + derivePrototypeRanges, + describeEligibility, + prototypeChipResolver, + ScopePills, +} from './prompt-shortcut-visuals'; + +const COMPOSER_SHELL_CLASS_NAME = cn( + 'flex flex-col gap-2 rounded-2xl border border-foreground/[0.10] bg-background px-3 py-2.5 transition-shadow focus-within:ring-1 focus-within:ring-ring/30', + 'dark:border-input-border/70 dark:bg-input/90', + '[--mention-chip-surface:hsl(var(--background))] dark:[--mention-chip-surface:color-mix(in_srgb,hsl(var(--input))_90%,hsl(var(--background)))]' +); + +type ComposerMode = 'text' | 'invocation' | 'expanded'; + +export type PromptShortcutComposerPrototypeProps = { + context?: PrototypeWorkContext; + shortcuts?: readonly PrototypeShortcut[]; + /** Story entry point: start with this Shortcut already invoked. */ + initialSlug?: string; + /** Story entry point: values already typed into the tray. */ + initialValues?: Record; + /** Story entry point: open the `/` menu with this query typed. */ + initialQuery?: string; + /** Story entry point: start in the expanded plain-prompt state. */ + initialExpanded?: boolean; + /** Story entry point: open the parameter surface immediately. */ + initialTrayOpen?: boolean; + /** Touch surfaces get a bottom sheet instead of an inline tray (§5.3). */ + surface?: 'desktop' | 'mobile'; + className?: string; +}; + +export function PromptShortcutComposerPrototype({ + context = PROTOTYPE_WORK_CONTEXT, + shortcuts = PROTOTYPE_SHORTCUTS, + initialSlug, + initialValues, + initialQuery, + initialExpanded = false, + initialTrayOpen, + surface = 'desktop', + className, +}: PromptShortcutComposerPrototypeProps) { + const initialShortcut = initialSlug + ? (shortcuts.find((entry) => entry.slug === initialSlug) ?? null) + : null; + + const [mode, setMode] = React.useState( + initialExpanded ? 'expanded' : initialShortcut ? 'invocation' : 'text' + ); + const [text, setText] = React.useState(initialQuery === undefined ? '' : `/${initialQuery}`); + const [invoked, setInvoked] = React.useState(initialShortcut); + const [values, setValues] = React.useState>(initialValues ?? {}); + const [menuOpen, setMenuOpen] = React.useState(initialQuery !== undefined); + const [trayOpen, setTrayOpen] = React.useState(initialTrayOpen ?? false); + /** + * §7 — what landed in history. `slug` is provenance captured AT SEND, not read + * back off the live invocation: sending clears the invocation, so reading it + * at render time lost the marker entirely. + */ + const [sent, setSent] = React.useState<{ text: string; slug?: string } | null>(null); + // A story may start expanded; compile the snapshot once so the box is not empty. + const [expandedText, setExpandedText] = React.useState(() => + initialExpanded && initialShortcut + ? compileShortcut(initialShortcut, PROTOTYPE_MENTION_CATALOG, initialValues ?? {}).text + : '' + ); + + const fieldRefs = React.useRef>([]); + + const entries = React.useMemo( + () => + shortcuts.map((shortcut) => ({ + shortcut, + eligibility: resolveEligibility( + shortcut, + PROTOTYPE_MENTION_CATALOG, + context, + PROTOTYPE_LABELS + ), + })), + [context, shortcuts] + ); + + const query = text.startsWith('/') ? text.slice(1) : ''; + const matches = (value: string) => value.toLowerCase().includes(query.toLowerCase()); + + // §2.3 — the menu shows what can run here. `unknown` is not optimistically + // available, and an unavailable Shortcut only appears on an exact search, as + // an unselectable line that says why. + const availableEntries = entries.filter( + (entry) => + entry.eligibility.kind === 'available' && + (matches(entry.shortcut.slug) || matches(entry.shortcut.name)) + ); + const diagnosticEntries = + query.length >= 3 + ? entries.filter( + (entry) => entry.eligibility.kind !== 'available' && entry.shortcut.slug.startsWith(query) + ) + : []; + const commandMatches = PROTOTYPE_AGENT_COMMANDS.filter((command) => matches(command.name)); + + const variableRows = React.useMemo( + () => (invoked ? resolveVariableRows(invoked, PROTOTYPE_MENTION_CATALOG) : []), + [invoked] + ); + const missing = React.useMemo( + () => findMissingVariables(variableRows, values), + [values, variableRows] + ); + + const invokedEntry = invoked ? entries.find((entry) => entry.shortcut.id === invoked.id) : null; + // Re-checked here rather than trusted from selection time: switching project, + // machine, or agent after insertion must block the send, not silently retarget. + const invokedEligibility: PrototypeEligibility = invokedEntry?.eligibility ?? { + kind: 'available', + }; + + const expandedMissing = React.useMemo(() => { + if (mode !== 'expanded') return []; + return variableRows.filter((row) => row.required && expandedText.includes(`!{${row.name}}`)); + }, [expandedText, mode, variableRows]); + + const blockedReason = (() => { + if (mode === 'text') return text.trim().length === 0 ? 'Nothing to send' : null; + if (invokedEligibility.kind !== 'available') return describeEligibility(invokedEligibility); + if (mode === 'expanded') { + return expandedMissing.length > 0 + ? `Still unfilled: ${expandedMissing.map((row) => `!{${row.name}}`).join(', ')}` + : null; + } + if (missing.length > 0) { + return `Fill ${missing.length} ${missing.length === 1 ? 'value' : 'values'} to send: ${missing + .map((row) => row.name) + .join(', ')}`; + } + return null; + })(); + + const selectShortcut = (shortcut: PrototypeShortcut) => { + const rows = resolveVariableRows(shortcut, PROTOTYPE_MENTION_CATALOG); + setInvoked(shortcut); + setMode('invocation'); + setText(''); + setMenuOpen(false); + // §5.3 — the parameter surface opens immediately and focuses the first + // missing value, so nothing has to be discovered. + const opens = rows.some((row) => row.required && !resolveEffectiveValue(row, values)); + setTrayOpen(opens); + if (opens) { + window.requestAnimationFrame(() => fieldRefs.current[0]?.focus()); + } + }; + + const clearInvocation = () => { + setInvoked(null); + setMode('text'); + setTrayOpen(false); + setValues({}); + }; + + const expandAndEdit = () => { + if (!invoked) return; + const compiled = compileShortcut(invoked, PROTOTYPE_MENTION_CATALOG, values); + setExpandedText(compiled.text); + setMode('expanded'); + setTrayOpen(false); + }; + + const send = () => { + if (blockedReason) return; + if (mode === 'expanded') { + // Expanded text is plain text now, so it carries no Shortcut provenance. + setSent({ text: expandedText }); + return; + } + if (mode === 'invocation' && invoked) { + setSent({ + text: compileShortcut(invoked, PROTOTYPE_MENTION_CATALOG, values).text, + slug: invoked.slug, + }); + setInvoked(null); + setValues({}); + setMode('text'); + setTrayOpen(false); + return; + } + setSent({ text }); + setText(''); + }; + + const focusNextField = (index: number) => { + const next = fieldRefs.current[index + 1]; + if (next) next.focus(); + else setTrayOpen(false); + }; + + const expandedRanges = React.useMemo(() => derivePrototypeRanges(expandedText), [expandedText]); + + const tray = invoked ? ( + setValues((current) => ({ ...current, [name]: value }))} + onSubmitField={focusNextField} + registerField={(index, node) => { + fieldRefs.current[index] = node; + }} + onClose={() => setTrayOpen(false)} + /> + ) : null; + + return ( +
+ {sent !== null ? ( + setSent(null)} /> + ) : null} + +
+ {menuOpen && mode === 'text' ? ( + { + setText(`/${name}`); + setMenuOpen(false); + }} + /> + ) : null} + +
+ {mode === 'invocation' && invoked ? ( +
+ { + setTrayOpen(true); + window.requestAnimationFrame(() => fieldRefs.current[0]?.focus()); + }} + onRemove={clearInvocation} + /> + {/* §6.3 — a Shortcut chip owns the whole prompt; there is no free + text after it. Extra input is a variable or an expansion. */} + + This Shortcut is the whole prompt + +
+ ) : mode === 'expanded' ? ( + + Expanded prompt + + + ) : ( +