diff --git a/locales/en.json b/locales/en.json index 42e145984..c306c6c93 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3851,7 +3851,7 @@ "mention.category.issue.label": "Issues", "mention.category.pr.label": "Pull Requests", "mention.category.skill.label": "Skills", - "mention.category.command.label": "Commands", + "mention.category.command.label": "Agent Commands", "mention.menu.back": "Back", "mention.menu.noResults": "No results", "mention.menu.loading": "Loading…", @@ -3957,11 +3957,9 @@ "settings.agentRoles.issues.modelUnsupported": "model “{{value}}”", "settings.agentRoles.issues.optionUnsupported": "option “{{option}}”", "settings.agentRoles.issues.optionValueUnsupported": "option “{{option}}” value “{{value}}”", - "settings.agentRoles.form.emoji": "Emoji", "settings.agentRoles.errors.nameTaken": "Another role already uses this name.", "emojiPicker.empty": "No emoji found.", "emojiPicker.placeholder": "Select an emoji…", - "settings.agentRoles.form.emojiReset": "Reset to {{emoji}}", "settings.agentRoles.form.memory": "Memory", "settings.agentRoles.form.memoryHint": "Not supported yet. Every session this role creates starts fresh — it remembers nothing from earlier ones.", "sessions.fileViewer.pathCopyFailed": "Failed to copy file path", @@ -4042,5 +4040,66 @@ "sharing.reloadPage": "Reload page", "sessions.copyContextMarkdown": "Copy context as Markdown", "sessions.copyContextMarkdownHint": "Copy the conversation through this message", - "sessions.copyContextIncomplete": "The last response was still generating when copied." + "sessions.copyContextIncomplete": "The last response was still generating when copied.", + "settings.promptShortcuts.intro": "Saved Prompts you can call with a slash command. Each one says where it applies; a Shortcut with nothing set works anywhere in this workspace. Private until you share it.", + "settings.promptShortcuts.new": "New Prompt Shortcut", + "settings.promptShortcuts.empty": "No Prompt Shortcuts yet. Save a Prompt you retype often and call it with /.", + "settings.promptShortcuts.private": "Private", + "settings.promptShortcuts.shared": "Shared", + "settings.promptShortcuts.readOnly": "Read-only", + "settings.promptShortcuts.workspaceScope": "Workspace", + "settings.promptShortcuts.needsAttention": "A reference is outside this scope", + "settings.promptShortcuts.delete": "Delete shortcut", + "settings.promptShortcuts.deleteTitle": "Delete Prompt Shortcut", + "settings.promptShortcuts.deleteHelp": "Delete “{{name}}”? Prompts already inserted into drafts or sent messages are unchanged.", + "settings.promptShortcuts.edit": "Edit Prompt Shortcut", + "settings.promptShortcuts.view": "Prompt Shortcut", + "settings.promptShortcuts.editorHelp": "Saved to this workspace and sent as one message.", + "settings.promptShortcuts.readOnlyHelp": "Shared by another member. Only its author can change it.", + "settings.promptShortcuts.name": "Name", + "settings.promptShortcuts.command": "Slash command", + "settings.promptShortcuts.commandPlaceholder": "review-pr", + "settings.promptShortcuts.description": "Description (optional)", + "settings.promptShortcuts.descriptionPlaceholder": "Description — shown in the / menu", + "settings.promptShortcuts.scope": "Applies to", + "settings.promptShortcuts.project": "Project", + "settings.promptShortcuts.machine": "Machine", + "settings.promptShortcuts.agent": "Agent", + "settings.promptShortcuts.none": "None", + "settings.promptShortcuts.thisMachine": "Limit to this machine", + "settings.promptShortcuts.mentionScope": "Not available for the scope set in “Applies to”.", + "settings.promptShortcuts.mentionScopeMissing": "Set {{axes}} in “Applies to” first.", + "settings.promptShortcuts.prompt": "Prompt", + "settings.promptShortcuts.promptHelp": "One message. @ mentions a file or Role, $ a skill, # an issue. The selectors below say where this Shortcut can be called and what @ completes against — None on every axis means anywhere in this workspace.", + "settings.promptShortcuts.promptPlaceholder": "Write the prompt you would otherwise retype.", + "settings.promptShortcuts.repairScope": "Restore the matching scope or remove these mentions before saving.", + "settings.promptShortcuts.requiredAxes": "Requires matching {{axes}}", + "settings.promptShortcuts.share": "Share with workspace", + "settings.promptShortcuts.shareHint": "Off by default. Sharing is separate from where the Shortcut applies.", + "settings.promptShortcuts.shareWarning": "Workspace members can read and copy this Prompt and its reference labels. Making it private later cannot remove copies they already received.", + "settings.promptShortcuts.create": "Create", + "settings.promptShortcuts.invalid": "Could not save. Check the name, command, mention scope and size limits, then try again.", + "settings.tabs.promptShortcuts": "Prompt Shortcuts", + "settings.categories.promptShortcuts.description": "Reusable Prompts and mentions", + "settings.emoji.label": "Emoji", + "settings.emoji.reset": "Reset to {{emoji}}", + "mention.category.promptShortcut.label": "Prompt Shortcuts", + "promptShortcut.unverified": "Dependencies cannot be verified yet.", + "promptShortcut.checking": "Checking availability…", + "promptShortcut.projectMismatch": "Requires a different project.", + "promptShortcut.machineMismatch": "Requires a different machine.", + "promptShortcut.providerMismatch": "Requires a different agent.", + "promptShortcut.machineOffline": "Required machine is offline.", + "promptShortcut.missingScope": "A reference requires an explicit scope.", + "promptShortcut.scopeMismatch": "A reference does not match the saved scope.", + "promptShortcut.permissionDenied": "A required reference is no longer accessible.", + "promptShortcut.dependencyMissing": "A required reference is unavailable.", + "promptShortcut.private": "Private", + "promptShortcut.shared": "Shared", + "promptShortcut.loadingBody": "Loading Shortcut…", + "promptShortcut.versionChanged": "Shortcut changed. Select it again to retry.", + "promptShortcut.bodyOffline": "Body is not available offline. Reconnect and select again.", + "promptShortcut.loadFailed": "Could not load Shortcut. Select it again to retry.", + "settings.beta.promptShortcutsHelper": "Create reusable prompts and insert them with /. In development — expect rough edges.", + "settings.promptShortcuts.disabled": "Enable Prompt Shortcuts under Developer mode in Settings → About to use this feature." } diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 288a2c435..e41bb2dd3 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -3851,7 +3851,7 @@ "mention.category.issue.label": "Issues", "mention.category.pr.label": "Pull Requests", "mention.category.skill.label": "Skills", - "mention.category.command.label": "命令", + "mention.category.command.label": "Agent 命令", "mention.menu.back": "返回", "mention.menu.noResults": "没有结果", "mention.menu.loading": "加载中…", @@ -3957,11 +3957,9 @@ "settings.agentRoles.issues.modelUnsupported": "模型“{{value}}”", "settings.agentRoles.issues.optionUnsupported": "选项“{{option}}”", "settings.agentRoles.issues.optionValueUnsupported": "选项“{{option}}”的值“{{value}}”", - "settings.agentRoles.form.emoji": "Emoji", "settings.agentRoles.errors.nameTaken": "另一个角色已经使用了这个名称。", "emojiPicker.empty": "没有找到 Emoji。", "emojiPicker.placeholder": "选择一个 Emoji…", - "settings.agentRoles.form.emojiReset": "恢复为 {{emoji}}", "settings.agentRoles.form.memory": "记忆", "settings.agentRoles.form.memoryHint": "暂不支持。该角色创建的每个会话都从零开始,不会记住此前会话中的任何内容。", "sessions.fileViewer.pathCopyFailed": "复制文件路径失败", @@ -4042,5 +4040,66 @@ "sharing.reloadPage": "重新加载页面", "sessions.copyContextMarkdown": "复制上下文为 Markdown", "sessions.copyContextMarkdownHint": "复制截至此条的对话", - "sessions.copyContextIncomplete": "复制时,最后一条回复仍在生成。" + "sessions.copyContextIncomplete": "复制时,最后一条回复仍在生成。", + "settings.promptShortcuts.intro": "保存可以用斜杠命令调用的 Prompt。每条都会写明适用范围;三项都不设置就表示在本工作区内任何地方都能用。默认私有,除非你选择共享。", + "settings.promptShortcuts.new": "新建 Prompt Shortcut", + "settings.promptShortcuts.empty": "还没有 Prompt Shortcut。把常写的 Prompt 存下来,用 / 调用。", + "settings.promptShortcuts.private": "私有", + "settings.promptShortcuts.shared": "已共享", + "settings.promptShortcuts.readOnly": "只读", + "settings.promptShortcuts.workspaceScope": "整个工作区", + "settings.promptShortcuts.needsAttention": "有引用超出适用范围", + "settings.promptShortcuts.delete": "删除 Shortcut", + "settings.promptShortcuts.deleteTitle": "删除 Prompt Shortcut", + "settings.promptShortcuts.deleteHelp": "确定删除“{{name}}”吗?已经插入草稿或已发送消息中的内容不会改变。", + "settings.promptShortcuts.edit": "编辑 Prompt Shortcut", + "settings.promptShortcuts.view": "Prompt Shortcut", + "settings.promptShortcuts.editorHelp": "保存在本工作区,调用时作为一条消息发送。", + "settings.promptShortcuts.readOnlyHelp": "由其他成员共享,只有作者本人可以修改。", + "settings.promptShortcuts.name": "名称", + "settings.promptShortcuts.command": "斜杠命令", + "settings.promptShortcuts.commandPlaceholder": "review-pr", + "settings.promptShortcuts.description": "描述(可选)", + "settings.promptShortcuts.descriptionPlaceholder": "描述 - 显示在 / 菜单中", + "settings.promptShortcuts.scope": "适用范围", + "settings.promptShortcuts.project": "项目", + "settings.promptShortcuts.machine": "机器", + "settings.promptShortcuts.agent": "Agent", + "settings.promptShortcuts.none": "None", + "settings.promptShortcuts.thisMachine": "仅限本机", + "settings.promptShortcuts.mentionScope": "当前“适用范围”下无法使用这种引用。", + "settings.promptShortcuts.mentionScopeMissing": "请先在“适用范围”里设置{{axes}}。", + "settings.promptShortcuts.prompt": "Prompt", + "settings.promptShortcuts.promptHelp": "只发送一条消息。@ 引用文件或 Role,$ 引用 Skill,# 引用 Issue。下面三个选择器决定它能在哪里调用,也决定 @ 从哪里补全;三项都为 None 表示本工作区内任意位置。", + "settings.promptShortcuts.promptPlaceholder": "写下你平时会反复输入的 Prompt。", + "settings.promptShortcuts.repairScope": "保存前请恢复匹配的适用范围,或移除以下引用。", + "settings.promptShortcuts.requiredAxes": "需要匹配的{{axes}}范围", + "settings.promptShortcuts.share": "共享给工作区", + "settings.promptShortcuts.shareHint": "默认关闭。共享与适用范围是两件独立的事。", + "settings.promptShortcuts.shareWarning": "工作区成员可以阅读和复制此 Prompt 及其引用标签。之后改为私有也无法撤回他们已经收到的副本。", + "settings.promptShortcuts.create": "创建", + "settings.promptShortcuts.invalid": "无法保存。请检查名称、命令、引用范围和大小限制后重试。", + "settings.tabs.promptShortcuts": "Prompt Shortcuts", + "settings.categories.promptShortcuts.description": "可复用的 Prompt 与提及", + "settings.emoji.label": "Emoji", + "settings.emoji.reset": "恢复为 {{emoji}}", + "mention.category.promptShortcut.label": "提示词快捷方式", + "promptShortcut.unverified": "暂时无法验证依赖。", + "promptShortcut.checking": "正在检查可用性…", + "promptShortcut.projectMismatch": "需要切换到指定项目。", + "promptShortcut.machineMismatch": "需要切换到指定机器。", + "promptShortcut.providerMismatch": "需要切换到指定 Agent。", + "promptShortcut.machineOffline": "所需机器已离线。", + "promptShortcut.missingScope": "引用缺少明确的适用范围。", + "promptShortcut.scopeMismatch": "引用与已保存的适用范围不匹配。", + "promptShortcut.permissionDenied": "已无权访问所需引用。", + "promptShortcut.dependencyMissing": "所需引用不可用。", + "promptShortcut.private": "私有", + "promptShortcut.shared": "共享", + "promptShortcut.loadingBody": "正在加载快捷方式…", + "promptShortcut.versionChanged": "快捷方式版本已变化,请重新选择以重试。", + "promptShortcut.bodyOffline": "离线时无法读取正文,请联网后重新选择。", + "promptShortcut.loadFailed": "无法加载快捷方式,请重新选择以重试。", + "settings.beta.promptShortcutsHelper": "创建可复用的提示词,通过 / 插入。功能开发中,体验可能不完善。", + "settings.promptShortcuts.disabled": "请在设置 → 关于的开发者模式下开启 Prompt Shortcuts。" } diff --git a/packages/cloud-api/src/index.ts b/packages/cloud-api/src/index.ts index 7c00deb8b..cbeacea06 100644 --- a/packages/cloud-api/src/index.ts +++ b/packages/cloud-api/src/index.ts @@ -281,6 +281,69 @@ 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 + >; + settleDocument: Mutation< + { + workspaceId: string; + shortcutId: string; + bodyDocId: string; + visibility: 'private' | 'workspace'; + }, + 'active' | 'cancelled' + >; + revokeShortcut: Mutation< + { + workspaceId: string; + shortcutId: string; + bodyDocId: string; + visibility: 'private' | 'workspace'; + }, + null + >; + listAccessibleDocuments: Query< + { workspaceId: string }, + Array<{ + shortcutId: string; + bodyDocId: string; + ownerUserId: string; + visibility: 'private' | 'workspace'; + revision: string | null; + deleted?: boolean; + }> + >; + 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 } + >; + }; sessionSharing: { requestVerification: Mutation< { workspaceId: string; sessionIds: string[] }, diff --git a/packages/components/src/atoms/settings.ts b/packages/components/src/atoms/settings.ts index da8db2006..bee65d517 100644 --- a/packages/components/src/atoms/settings.ts +++ b/packages/components/src/atoms/settings.ts @@ -3,10 +3,7 @@ import { atomWithStorage } from 'jotai/utils'; import type { MachineId, SupportedLanguage } from '@lody/shared'; import type { MobileKeyboardAction } from '@/lib/mobile-keyboard-action'; import { isSymbolFontFamily } from '@/lib/local-fonts'; -import { - SETTINGS_DEFAULT_TAB, - type SettingsTabId, -} from '@/components/settings/settings-tabs'; +import { SETTINGS_DEFAULT_TAB, type SettingsTabId } from '@/components/settings/settings-tabs'; export const languageAtom = atomWithStorage('lody-language', 'en'); @@ -131,10 +128,7 @@ export const electronSessionCompletionNotificationsEnabledAtom = atomWithStorage // (e.g. an unwrapped Markdown paragraph) stays readable without horizontal // scrolling — especially on mobile. Shared by every SessionMonacoTextViewer // mount via the viewer reading this atom directly. -export const fileViewerWordWrapAtom = atomWithStorage( - 'lody-file-viewer-word-wrap', - true -); +export const fileViewerWordWrapAtom = atomWithStorage('lody-file-viewer-word-wrap', true); // Mobile composer keyboard return key behavior. export const mobileKeyboardActionAtom = atomWithStorage( @@ -201,9 +195,7 @@ export function readTasksFeatureEnabledFromStorage(): boolean { return false; } try { - const developerMode = JSON.parse( - localStorage.getItem(DEVELOPER_MODE_STORAGE_KEY) ?? 'false' - ); + const developerMode = JSON.parse(localStorage.getItem(DEVELOPER_MODE_STORAGE_KEY) ?? 'false'); const tasksBeta = JSON.parse(localStorage.getItem(TASKS_BETA_STORAGE_KEY) ?? 'false'); return developerMode === true && tasksBeta === true; } catch { @@ -257,6 +249,19 @@ export const inboxFeatureEnabledAtom = atom( (get) => get(developerModeEnabledAtom) && get(inboxBetaEnabledAtom) ); +// Developer-only opt-in. Turning Developer mode off retains the local choice. +export const promptShortcutsBetaEnabledAtom = atomWithStorage( + 'lody-prompt-shortcuts-beta-enabled', + false, + undefined, + { getOnInit: true } +); + +/** Shared gate for Shortcut settings, discovery and the workspace runtime. */ +export const promptShortcutsFeatureEnabledAtom = atom( + (get) => get(developerModeEnabledAtom) && get(promptShortcutsBetaEnabledAtom) +); + /** localStorage keys for the experimental features gate. */ export const EXPERIMENTAL_FEATURES_STORAGE_KEY = 'lody-experimental-features-enabled'; export const REVIEW_AGENT_EXPERIMENT_STORAGE_KEY = 'lody-review-agent-enabled'; diff --git a/packages/components/src/components/chat/chat-composer.tsx b/packages/components/src/components/chat/chat-composer.tsx index 37a098c62..ee88529a5 100644 --- a/packages/components/src/components/chat/chat-composer.tsx +++ b/packages/components/src/components/chat/chat-composer.tsx @@ -289,6 +289,7 @@ export function ChatComposer({ focusOnContainerClick = false, }: ChatComposerProps) { const { t, i18n } = useTranslation(); + const shortcutsEnabled = variant !== 'dialog'; const intlLocale = useMemo( () => toIntlLocale(i18n.resolvedLanguage ?? i18n.language), [i18n.language, i18n.resolvedLanguage] @@ -883,6 +884,7 @@ export function ChatComposer({ ) : null} - - {children} - {owner && workspaceReady ? : null} - {owner && workspaceReady ? : null} - {tasksEnabled && workspaceReady ? ( - <> - - {owner ? : null} - - - ) : null} - {workspaceReady ? : null} - - - {workspaceReady ? : null} - + + + {children} + {owner && workspaceReady ? : null} + {owner && workspaceReady ? : null} + {tasksEnabled && workspaceReady ? ( + <> + + {owner ? : null} + + + ) : null} + {workspaceReady ? : null} + + + {workspaceReady ? : null} + + ); } diff --git a/packages/components/src/components/mentions/combined-mention-textarea.tsx b/packages/components/src/components/mentions/combined-mention-textarea.tsx index b0fb01e75..dbf500362 100644 --- a/packages/components/src/components/mentions/combined-mention-textarea.tsx +++ b/packages/components/src/components/mentions/combined-mention-textarea.tsx @@ -1,3 +1,5 @@ +import { useShortcutMentionSource } from './use-shortcut-mention-source'; +import { shortcutComposerScope } from './shortcut-composer-state'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; @@ -63,6 +65,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 @@ -103,6 +107,8 @@ function TwoLevelMentionMenu({ enableAgentRoleMentions, agentRoleItems, surface, + templateScope, + promptShortcutSource, }: { fileData: MentionFileDataState; fileSourceKind: MentionFileSourceKind; @@ -126,6 +132,8 @@ function TwoLevelMentionMenu({ enableAgentRoleMentions: boolean; agentRoleItems: readonly AgentRoleMentionItem[]; surface: MentionSurface; + templateScope?: PromptShortcutScope; + promptShortcutSource?: MentionCategorySources['promptShortcut']; }) { const context = useMentionContext('TwoLevelMentionMenu'); const { t } = useTranslation(); @@ -168,7 +176,7 @@ function TwoLevelMentionMenu({ enableIssueMentions && issuePrData.entry ? buildItemSuggestions(issuePrData.entry.items) : [], [enableIssueMentions, issuePrData.entry] ); - const fileSource = React.useMemo( + const fileSource = React.useMemo>( () => ({ enabled: enableFileMentions, status: @@ -205,7 +213,7 @@ function TwoLevelMentionMenu({ void refreshIssuePr(); }, [refreshIssuePr]); - const issuePrSource = React.useMemo( + const issuePrSource = React.useMemo>( () => ({ enabled: enableIssueMentions, status: @@ -225,7 +233,7 @@ function TwoLevelMentionMenu({ [activateIssuePr, enableIssueMentions, issuePrData, issuePrSuggestions, repoFullName, t] ); - const skillSource = React.useMemo( + const skillSource = React.useMemo>( () => ({ enabled: enableSkillMentions, status: @@ -308,7 +316,7 @@ function TwoLevelMentionMenu({ ] ); - const agentRoleSource = React.useMemo( + const agentRoleSource = React.useMemo>( () => ({ enabled: enableAgentRoleMentions, items: agentRoleItems }), [agentRoleItems, enableAgentRoleMentions] ); @@ -318,19 +326,64 @@ 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, - command: commandSource, + agentRole: templateScope ? { ...agentRoleSource, enabled: true } : agentRoleSource, + command: + context.trigger === '/' && !/^\/\S*$/.test(context.inputValue) + ? undefined + : commandSource, + promptShortcut: templateScope ? undefined : promptShortcutSource, }), - [agentRoleSource, commandSource, fileSource, issuePrSource, sessionSource, skillSource] + [ + agentRoleSource, + context.trigger, + context.inputValue, + promptShortcutSource, + commandSource, + fileSource, + issuePrSource, + sessionSource, + skillSource, + templateScope, + ] ) ); + const categories = React.useMemo( + () => + templateScope + ? shortcutTemplateCategories({ + categories: baseCategories, + scope: templateScope, + skills: skillItems, + allowedDirs: allowedSkillDirs, + disabledReason: (missing) => + missing.length === 0 + ? t( + 'settings.promptShortcuts.mentionScope', + 'Not available for the scope set in “Applies to”.' + ) + : t('settings.promptShortcuts.mentionScopeMissing', { + defaultValue: 'Set {{axes}} in “Applies to” first.', + axes: missing + .map((axis) => + axis === 'project' + ? t('settings.promptShortcuts.project', 'Project') + : axis === 'machineId' + ? t('settings.promptShortcuts.machine', 'Machine') + : t('settings.promptShortcuts.agent', 'Agent') + ) + .join(' + '), + }), + }) + : 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. @@ -573,6 +626,10 @@ export interface CombinedMentionTextareaProps extends Omit< TextareaProps, 'value' | 'defaultValue' | 'onChange' > { + /** Explicit template scope. Disables token hydration and context-dependent session/command mentions. */ + templateScope?: PromptShortcutScope; + promptShortcutSource?: MentionCategorySources['promptShortcut']; + enablePromptShortcuts?: boolean; mentionSource?: MentionProjectSource; availableCommands?: AcpCommandSummary[]; /** The selected ACP provider. When set, the `$` skill menu only offers @@ -640,6 +697,9 @@ export const CombinedMentionTextarea = React.forwardRef< ( { mentionSource, + templateScope, + promptShortcutSource: promptShortcutSourceProp, + enablePromptShortcuts = false, availableCommands, skillAgent, mentionSurface = 'unknown', @@ -665,6 +725,13 @@ export const CombinedMentionTextarea = React.forwardRef< }, ref ) => { + const liveShortcutSource = useShortcutMentionSource( + enablePromptShortcuts && !templateScope + ? shortcutComposerScope(mentionSource, skillAgent) + : null, + draftKey + ); + const promptShortcutSource = promptShortcutSourceProp ?? liveShortcutSource; const githubRepoFullName = mentionSource?.kind === 'github' ? mentionSource.repoFullName @@ -710,7 +777,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 @@ -720,7 +789,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); @@ -740,8 +810,11 @@ export const CombinedMentionTextarea = React.forwardRef< [mentionSource] ); const agentRoleContext = React.useMemo( - () => buildAgentRoleMentionContext({ mentionSource }), - [mentionSource] + () => + templateScope + ? { kind: 'authorized_machines' as const } + : buildAgentRoleMentionContext({ mentionSource }), + [mentionSource, templateScope] ); const agentRoleItems = useAgentRoleMentionItems(agentRoleContext); // A committed range carries only the Role id, so the caller's chip resolver @@ -863,37 +936,50 @@ export const CombinedMentionTextarea = React.forwardRef< value, ]); - 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 || enableSessionMentions || enableAgentRoleMentions; - const enableMentions = enableAtMentions || enableCommandMentions || hasExternalMentionSupport; + const enableShortcutMentions = !templateScope && Boolean(promptShortcutSource?.enabled); + const enableMentions = + enableAtMentions || + enableCommandMentions || + enableShortcutMentions || + hasExternalMentionSupport; // `/` trigger is only active when the entire input is a slash command (e.g. "" or "/review") const isSlashOnly = !value || /^\/\S*$/.test(value); const triggers = React.useMemo(() => { - const t: string[] = []; + const nextTriggers: string[] = []; // Every mention type is reachable through `@`; skills also retain their // direct `$` entry point, and slash commands retain `/` because they must // own the whole prompt. - if (enableAtMentions) t.push('@'); - if (enableSkillMentions) t.push(SKILL_MENTION_TRIGGER); - if (enableCommandMentions && isSlashOnly) t.push('/'); - return t; - }, [enableAtMentions, enableCommandMentions, enableSkillMentions, isSlashOnly]); + if (enableAtMentions) nextTriggers.push('@'); + if (enableSkillMentions) nextTriggers.push(SKILL_MENTION_TRIGGER); + if (enableShortcutMentions || (enableCommandMentions && isSlashOnly)) nextTriggers.push('/'); + return nextTriggers; + }, [ + enableAtMentions, + enableCommandMentions, + enableShortcutMentions, + enableSkillMentions, + isSlashOnly, + ]); if (!enableMentions) { const textarea = ( @@ -903,7 +989,9 @@ export const CombinedMentionTextarea = React.forwardRef< // itself to the composer and not hijack reverse-Tab elsewhere. data-lody-composer-input="" value={value} - onChange={(event) => onValueChange(event.target.value)} + onChange={(event) => { + onValueChange(event.target.value); + }} className={cn('resize-none', className)} aria-label={props['aria-label'] ?? label} {...props} @@ -916,12 +1004,16 @@ export const CombinedMentionTextarea = React.forwardRef< return ( { + onValueChange(next); + }} mentions={mergedMentions} onMentionsChange={handleMentionsChange} onMentionClick={onMentionClick} @@ -937,7 +1029,7 @@ export const CombinedMentionTextarea = React.forwardRef< {persistedMentions && persistedMentions.length > 0 ? ( @@ -952,7 +1044,7 @@ export const CombinedMentionTextarea = React.forwardRef< getKnownFileTokens={getKnownFileTokens} text={value} items={agentRoleItems} - enabled={enableAgentRoleMentions} + enabled={enableAgentRoleMentions && !templateScope} /> {mentionActionsRef ? ( @@ -961,7 +1053,7 @@ export const CombinedMentionTextarea = React.forwardRef< ) : null} {enableIssueMentions ? ( @@ -969,12 +1061,12 @@ export const CombinedMentionTextarea = React.forwardRef< ) : null} @@ -990,6 +1082,8 @@ export const CombinedMentionTextarea = React.forwardRef< {...props} /> = new Set([ 'skill', 'session', 'command', + 'prompt_shortcut', 'agent_role', 'issue', 'pr', diff --git a/packages/components/src/components/mentions/mention-prompt-shortcut-source.tsx b/packages/components/src/components/mentions/mention-prompt-shortcut-source.tsx new file mode 100644 index 000000000..9466d2a67 --- /dev/null +++ b/packages/components/src/components/mentions/mention-prompt-shortcut-source.tsx @@ -0,0 +1,137 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { + getShortcutEmoji, + resolveShortcutAvailability, + type PromptShortcutIndexEntry, + type ShortcutAvailability, +} from '@lody/shared/prompt-shortcuts'; +import type { MentionCandidate, MentionCategorySources } from './mention-registry'; + +export type ShortcutMentionContext = Parameters[0]['context']; +export type ShortcutDependencyResolver = Parameters< + typeof resolveShortcutAvailability +>[0]['resolveDependency']; +export const unverifiedShortcutDependency: ShortcutDependencyResolver = () => ({ + kind: 'unknown', + reason: 'dependencies_unverified', +}); + +export function shortcutAvailabilityMessage( + availability: ShortcutAvailability, + t: TFunction +): string { + if (availability.kind === 'available') return ''; + if (availability.kind === 'unknown') { + return availability.reason === 'dependencies_unverified' + ? t('promptShortcut.unverified', 'Dependencies cannot be verified yet.') + : t('promptShortcut.checking', 'Checking availability…'); + } + switch (availability.reason) { + case 'project_mismatch': + return t('promptShortcut.projectMismatch', 'Requires a different project.'); + case 'machine_mismatch': + return t('promptShortcut.machineMismatch', 'Requires a different machine.'); + case 'provider_mismatch': + return t('promptShortcut.providerMismatch', 'Requires a different agent.'); + case 'machine_offline': + return t('promptShortcut.machineOffline', 'Required machine is offline.'); + case 'missing_scope': + return t('promptShortcut.missingScope', 'A reference requires an explicit scope.'); + case 'reference_out_of_scope': + case 'scope_mismatch': + return t('promptShortcut.scopeMismatch', 'A reference does not match the saved scope.'); + case 'permission_denied': + return t('promptShortcut.permissionDenied', 'A required reference is no longer accessible.'); + default: + return t('promptShortcut.dependencyMissing', 'A required reference is unavailable.'); + } +} + +export type PromptShortcutSourceInput = { + entries: readonly PromptShortcutIndexEntry[]; + context: ShortcutMentionContext | null; + loading?: boolean; + resolveDependency?: ShortcutDependencyResolver; +}; + +/** Index-only discovery. This boundary deliberately has no body loader or runtime. */ +export function selectPromptShortcutCandidates( + input: PromptShortcutSourceInput, + term: string, + t: TFunction, + limit = 50 +): MentionCandidate[] { + const context = input.context; + if (!context) return []; + const query = term.trim().toLowerCase(); + return input.entries + .flatMap((entry): MentionCandidate[] => { + // Never turn a private/foreign entry into a diagnostic disclosure. + if ( + entry.workspaceId !== context.workspaceId || + (entry.visibility === 'private' && entry.ownerUserId !== context.userId) + ) + return []; + const exact = query !== '' && (entry.slug === query || entry.name.toLowerCase() === query); + if ( + query && + !exact && + ![entry.slug, entry.name, entry.description ?? ''].some((field) => + field.toLowerCase().includes(query) + ) + ) + return []; + const availability = input.loading + ? { kind: 'unknown' as const, reason: 'context_loading' } + : resolveShortcutAvailability({ + shortcut: entry, + dependencies: entry.dependencySummary, + context, + canRead: true, + resolveDependency: input.resolveDependency ?? unverifiedShortcutDependency, + }); + if (availability.kind !== 'available' && !exact) return []; + const visibility = + entry.visibility === 'private' + ? t('promptShortcut.private', 'Private') + : t('promptShortcut.shared', 'Shared'); + return [ + { + value: `prompt-shortcut:${entry.id}`, + label: entry.slug, + insertText: `/${entry.slug}`, + kind: 'prompt_shortcut', + icon: 'prompt_shortcut', + // Replaces the category glyph on the row, the way a Role's does: the + // header already says these are Prompt Shortcuts. + iconEmoji: getShortcutEmoji(entry), + title: `/${entry.slug}`, + subtitle: entry.description, + trailing: visibility, + disabled: availability.kind !== 'available', + disabledReason: shortcutAvailabilityMessage(availability, t) || undefined, + }, + ]; + }) + .sort( + (a, b) => + Number(b.label === query) - Number(a.label === query) || a.label.localeCompare(b.label) + ) + .slice(0, limit); +} + +export function usePromptShortcutMentionSource( + input: PromptShortcutSourceInput +): NonNullable { + const { t } = useTranslation(); + return useMemo( + () => ({ + enabled: true, + status: input.loading || !input.context ? 'loading' : 'ready', + getCandidates: (term, limit) => selectPromptShortcutCandidates(input, term, t, limit), + }), + [input, t] + ); +} diff --git a/packages/components/src/components/mentions/mention-registry.ts b/packages/components/src/components/mentions/mention-registry.ts index 147bfc08d..a5e6b67e2 100644 --- a/packages/components/src/components/mentions/mention-registry.ts +++ b/packages/components/src/components/mentions/mention-registry.ts @@ -1,3 +1,4 @@ +import type { MentionPrepare } from '@/ui/mention/index'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { getAgentRoleEmoji, type AcpCommandSummary } from '@lody/shared'; @@ -42,6 +43,7 @@ export type MentionCategoryId = | 'pr' | 'skill' | 'command' + | 'prompt_shortcut' | 'session' | 'agent_role'; @@ -52,10 +54,11 @@ export type MentionIcon = | 'pr' | 'skill' | 'command' + | 'prompt_shortcut' | 'session' | 'agent_role'; -export type MentionCategoryStatus = 'ready' | 'loading' | 'error'; +export type MentionCategoryStatus = 'ready' | 'loading' | 'error' | 'disabled'; /** * Side-panel content for a highlighted candidate. Deliberately neutral: the @@ -81,6 +84,10 @@ export type MentionCandidateDetail = { export type MentionCandidate = { /** Payload recorded on the mention range; also the row key. */ value: string; + /** Diagnostic rows stay visible but cannot commit by pointer or keyboard. */ + disabled?: boolean; + disabledReason?: string; + onPrepare?: MentionPrepare; /** What the user can type to match exactly, driving Enter-on-exact-match. */ label: string; /** @@ -94,7 +101,6 @@ export type MentionCandidate = { icon: MentionIcon; title: string; subtitle?: string; - disabled?: boolean; trailing?: string; /** Render the title in the monospace face (paths, tokens). */ mono?: boolean; @@ -187,6 +193,8 @@ export type MentionMenuView = /** Categories whose own name matches, offered above the results. */ categories: MentionCategory[]; groups: MentionCandidateGroup[]; + /** Direct grouped triggers activate only their own sources, even while empty. */ + queriedCategories?: readonly MentionCategory[]; } /** `@issue:foo` — second level, scoped to one category. */ | { @@ -219,9 +227,14 @@ export function selectMentionViewActivations( categories: readonly MentionCategory[] ): MentionCategoryActivation[] { const queried = - view?.level === 'category' ? [view.category] : view?.level === 'aggregate' ? categories : []; + view?.level === 'category' + ? [view.category] + : view?.level === 'aggregate' + ? (view.queriedCategories ?? categories) + : []; const bySource = new Map(); for (const category of queried) { + if (category.status === 'disabled') continue; if (category.activation) bySource.set(category.activation.sourceKey, category.activation); } return [...bySource.values()]; @@ -247,7 +260,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), + }; } } @@ -258,6 +276,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); @@ -285,13 +304,26 @@ export function selectMentionMenuViewForTrigger( if (trigger === MENTION_TRIGGER) { return selectMentionMenuView(categories, search, options); } + if (trigger === '/') { + const directCategories = categories.filter((category) => category.directTrigger === '/'); + return { + level: 'aggregate', + term: search, + categories: [], + queriedCategories: directCategories, + groups: directCategories.map((category) => ({ + category, + candidates: category.status === 'disabled' ? [] : category.getCandidates(search), + })), + }; + } const direct = categories.find((entry) => entry.directTrigger === trigger); if (!direct) return null; return { level: 'category', category: direct, term: search, - candidates: direct.getCandidates(search), + candidates: direct.status === 'disabled' ? [] : direct.getCandidates(search), }; } @@ -543,7 +575,7 @@ export function buildAgentRoleCandidates( export function toCommandCandidate(command: AcpCommandSummary): MentionCandidate { return { - value: command.name, + value: `acp-command:${command.name}`, label: command.name, // A slash command already owns the whole prompt: its `/` trigger only fires // on a slash-only composer, so the trigger span *is* the prompt. @@ -584,7 +616,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, }; } @@ -603,6 +638,9 @@ export type MentionCategorySources = { command?: SourceState & { commands: readonly AcpCommandSummary[]; }; + promptShortcut?: SourceState & { + getCandidates: MentionCategory['getCandidates']; + }; session?: SourceState & { items: readonly SessionMentionItem[]; header?: MentionCategoryHeader; @@ -621,16 +659,22 @@ export type MentionSourceKey = keyof MentionCategorySources; */ export function useMentionCategories(sources: MentionCategorySources): MentionCategory[] { const { t } = useTranslation(); - const { file, issuePr, skill, command, session, agentRole } = sources; + const { file, issuePr, skill, command, promptShortcut, session, agentRole } = sources; // Partitioned once: the cache holds both types, and re-splitting it inside // `getCandidates` would walk 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] ); return React.useMemo(() => { @@ -738,12 +782,24 @@ export function useMentionCategories(sources: MentionCategorySources): MentionCa }); } + if (promptShortcut?.enabled) { + categories.push({ + id: 'prompt_shortcut', + namespace: 'shortcut', + directTrigger: '/', + label: t('mention.category.promptShortcut.label', 'Prompt Shortcuts'), + icon: 'prompt_shortcut', + ...sourceCategoryFields('promptShortcut', promptShortcut), + getCandidates: promptShortcut.getCandidates, + }); + } + if (command?.enabled) { categories.push({ id: 'command', namespace: 'cmd', directTrigger: '/', - label: t('mention.category.command.label', 'Commands'), + label: t('mention.category.command.label', 'Agent Commands'), icon: 'command', ...sourceCategoryFields('command', command), getCandidates: (term, limit) => buildCommandCandidates(command.commands, term, limit), @@ -751,5 +807,16 @@ export function useMentionCategories(sources: MentionCategorySources): MentionCa } return categories; - }, [agentRole, command, file, issuePr, issueSuggestions, prSuggestions, session, skill, t]); + }, [ + agentRole, + command, + promptShortcut, + file, + issuePr, + issueSuggestions, + prSuggestions, + session, + skill, + t, + ]); } 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..f08157cc0 --- /dev/null +++ b/packages/components/src/components/mentions/mention-shortcut-template.ts @@ -0,0 +1,106 @@ +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'; + +/** The scope axes a disabled mention category is still waiting for. */ +export type ShortcutScopeAxis = keyof PromptShortcutScope; + +/** 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: (missing: readonly ShortcutScopeAxis[]) => 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) => !['session', 'command', 'prompt_shortcut'].includes(category.id)) + .map((category) => { + // Skills come from two sources; either one being satisfiable enables the + // category, so the missing axes are the ones the closer source still needs. + const gates = + category.id === 'skill' + ? [ + getShortcutMentionGate('project_skill', scope), + getShortcutMentionGate('global_skill', scope), + ] + : [ + getShortcutMentionGate( + category.id === 'pr' + ? 'pull_request' + : (category.id as 'file' | 'issue' | 'agent_role'), + scope + ), + ]; + const enabled = gates.some((gate) => gate.enabled); + if (!enabled) { + const missing = gates.reduce( + (fewest, gate) => (gate.missing.length < fewest.length ? [...gate.missing] : fewest), + [...(gates[0]?.missing ?? [])] + ); + return { + ...category, + status: 'disabled', + // Name the axes THIS kind of reference needs. One sentence repeated + // under every disabled entry says only "something is missing". + message: input.disabledReason(missing), + 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..7615b692a 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') { @@ -196,6 +196,11 @@ function formatSkillPathMarkdownDestination(path: string): string { return path.replace(/\\/g, '\\\\').replace(/\)/g, '\\)'); } +/** Stable-target form, shared by ordinary skill mentions and frozen Shortcut semantics. */ +export function formatSkillMentionPrompt(token: string, path: string): string { + return `use ${SKILL_MENTION_PROMPT_PREFIX}${token} [${SKILL_MENTION_PATH_LABEL}](${formatSkillPathMarkdownDestination(path)})`; +} + function buildSkillMentionPathByToken( items: readonly SkillMentionItem[], allowedDirs: ReadonlySet | null @@ -269,7 +274,7 @@ export function buildSkillMentionRewrites( rewrites.push({ start, end: tokenEnd, - replacement: `use ${SKILL_MENTION_PROMPT_PREFIX}${token} [${SKILL_MENTION_PATH_LABEL}](${formatSkillPathMarkdownDestination(path)})`, + replacement: formatSkillMentionPrompt(token, path), span: { kind: 'skill', label: `${SKILL_MENTION_TRIGGER}${token}`, target: token }, }); return true; 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 fca97312e..d3079d162 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] @@ -107,6 +113,7 @@ function CandidateIcon({ case 'skill': return ; case 'command': + case 'prompt_shortcut': return ; case 'session': return ; @@ -129,6 +136,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} ); } @@ -153,9 +169,10 @@ function CandidateRow({ return ( ) : null} + {candidate.disabledReason ? ( + {candidate.disabledReason} + ) : null} {candidate.trailing ? ( @@ -401,6 +422,14 @@ export function MentionTwoLevelMenuBody({ {view.groups.map((group) => ( {group.category.label} + {group.candidates.length === 0 ? ( + + {group.category.message ?? + (group.category.status === 'loading' + ? t('mention.menu.loading', 'Loading…') + : t('mention.menu.noResults', 'No results'))} + + ) : null} {group.candidates.map((candidate, rank) => ( ; +export class ShortcutSelectionUnavailable extends Error { + constructor(readonly availability: ShortcutAvailability) { + super(availability.kind); + } +} + +/** A selection reads precisely one version, then checks the live index again. */ +export async function prepareShortcutSelection(input: { + runtime: ShortcutSelectionRuntime; + entry: PromptShortcutIndexEntry; + context: ShortcutMentionContext; + request: Parameters[0]; + isCurrent: () => boolean; + resolveDependency?: ShortcutDependencyResolver; +}): Promise { + const { runtime, entry, request, context } = input; + const current = () => !request.signal.aborted && input.isCurrent(); + const checkIndex = () => { + if (runtime.workspaceId !== context.workspaceId || runtime.userId !== context.userId) + throw new PromptShortcutError('forbidden', 'Selection identity changed'); + const latest = runtime.getSnapshot().entries.find((item) => item.id === entry.id); + if (!latest) throw new PromptShortcutError('not_found', 'Shortcut is no longer listed'); + if (latest.revision !== entry.revision || latest.bodyDocId !== entry.bodyDocId) + throw new PromptShortcutError('revision_pending', 'Shortcut changed during selection'); + }; + if (!current()) return null; + checkIndex(); + const snapshot = await runtime.read(entry); + if (!current()) return null; + checkIndex(); + if ( + snapshot.id !== entry.id || + snapshot.revision !== entry.revision || + snapshot.workspaceId !== entry.workspaceId || + snapshot.ownerUserId !== entry.ownerUserId || + snapshot.visibility !== entry.visibility + ) + throw new PromptShortcutError('revision_pending', 'Index and body do not agree'); + const availability = resolveShortcutAvailability({ + shortcut: snapshot, + dependencies: snapshot.mentions.map((mention) => mention.target), + context, + canRead: true, + resolveDependency: input.resolveDependency ?? unverifiedShortcutDependency, + }); + if (availability.kind !== 'available') throw new ShortcutSelectionUnavailable(availability); + return { + text: snapshot.prompt, + mentions: snapshot.mentions.map(({ start, end, label, target }) => ({ + start, + end, + kind: + target.kind === 'pull_request' + ? 'pr' + : target.kind === 'file' && target.directory + ? 'dir' + : target.kind, + value: + target.kind === 'agent_role' + ? target.agentRoleId + : target.kind === 'file' + ? target.path + : target.kind === 'skill' + ? label.replace(/^\$/, '') + : `#${target.number}`, + })), + }; +} 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/mentions/use-shortcut-mention-source.ts b/packages/components/src/components/mentions/use-shortcut-mention-source.ts new file mode 100644 index 000000000..1c6d07230 --- /dev/null +++ b/packages/components/src/components/mentions/use-shortcut-mention-source.ts @@ -0,0 +1,120 @@ +import { useAtomValue } from 'jotai'; +import { promptShortcutsFeatureEnabledAtom } from '@/atoms/settings'; +import { useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PromptShortcutError, type PromptShortcutScope } from '@lody/shared/prompt-shortcuts'; +import { usePromptShortcuts } from '../../providers/prompt-shortcut-provider'; +import type { MentionCategorySources } from './mention-registry'; +import { + selectPromptShortcutCandidates, + shortcutAvailabilityMessage, + type ShortcutMentionContext, +} from './mention-prompt-shortcut-source'; +import { prepareShortcutSelection, ShortcutSelectionUnavailable } from './shortcut-selection'; + +/** Workspace provider owns the runtime. Each composer owns only a cancellable selection. */ +export function useShortcutMentionSource( + scope: PromptShortcutScope | null, + draftKey?: string +): MentionCategorySources['promptShortcut'] { + const featureEnabled = useAtomValue(promptShortcutsFeatureEnabledAtom); + const { t } = useTranslation(); + const { runtime, entries, loading } = usePromptShortcuts(); + const context = useMemo( + () => + featureEnabled && runtime && scope + ? { + workspaceId: runtime.workspaceId, + userId: runtime.userId, + scope, + } + : null, + [featureEnabled, runtime, scope] + ); + const scopeKey = JSON.stringify([context, draftKey]); + const current = useRef({ runtime, scopeKey }); + current.current = { runtime, scopeKey }; + const [selection, setSelection] = useState<{ + runtime: typeof runtime; + scopeKey: string; + id: string; + token: object; + status: 'loading' | 'error'; + message?: string; + } | null>(null); + const activeSelection = + selection && selection.runtime === runtime && selection.scopeKey === scopeKey + ? selection + : null; + return useMemo( + () => ({ + enabled: context !== null, + status: loading || !runtime ? 'loading' : 'ready', + getCandidates: (term, limit) => + selectPromptShortcutCandidates({ entries, context, loading }, term, t, limit).map( + (candidate) => { + const entry = entries.find((item) => `prompt-shortcut:${item.id}` === candidate.value)!; + const state = activeSelection?.id === entry.id ? activeSelection : null; + return { + ...candidate, + disabled: candidate.disabled || !runtime || state?.status === 'loading', + disabledReason: + state?.status === 'loading' + ? t('promptShortcut.loadingBody', 'Loading Shortcut…') + : (state?.message ?? candidate.disabledReason), + onPrepare: async (request) => { + if (!runtime || !context) return null; + const token = {}; + const isCurrent = () => + current.current.runtime === runtime && current.current.scopeKey === scopeKey; + const clear = () => + setSelection((previous) => (previous?.token === token ? null : previous)); + request.signal.addEventListener('abort', clear, { once: true }); + setSelection({ runtime, scopeKey, id: entry.id, token, status: 'loading' }); + try { + const result = await prepareShortcutSelection({ + runtime, + entry, + context, + request, + isCurrent, + }); + clear(); + return result; + } catch (error) { + if (!request.signal.aborted && isCurrent()) { + const message = + error instanceof ShortcutSelectionUnavailable + ? shortcutAvailabilityMessage(error.availability, t) + : error instanceof PromptShortcutError && error.code === 'revision_pending' + ? t( + 'promptShortcut.versionChanged', + 'Shortcut changed. Select it again to retry.' + ) + : typeof navigator !== 'undefined' && navigator.onLine === false + ? t( + 'promptShortcut.bodyOffline', + 'Body is not available offline. Reconnect and select again.' + ) + : t( + 'promptShortcut.loadFailed', + 'Could not load Shortcut. Select it again to retry.' + ); + setSelection((previous) => + previous?.token === token + ? { ...previous, status: 'error', message } + : previous + ); + } + return null; + } finally { + request.signal.removeEventListener('abort', clear); + } + }, + }; + } + ), + }), + [activeSelection, context, entries, loading, runtime, scopeKey, t] + ); +} diff --git a/packages/components/src/components/settings/agent-role-form.tsx b/packages/components/src/components/settings/agent-role-form.tsx index 400ee6c44..1d8ddad36 100644 --- a/packages/components/src/components/settings/agent-role-form.tsx +++ b/packages/components/src/components/settings/agent-role-form.tsx @@ -1,5 +1,5 @@ -import { lazy, Suspense, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'; -import { AlertTriangle, Loader2 } from 'lucide-react'; +import { useId, type FormEvent } from 'react'; +import { Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { AGENT_ROLE_NAME_MAX_LENGTH, @@ -21,13 +21,11 @@ import { cn } from '@/lib/utils'; import { Button } from '@/ui/button'; import { Input } from '@/ui/input'; import { Label } from '@/ui/label'; -import { Popover, PopoverContent, PopoverTrigger } from '@/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/ui/select'; import { Switch } from '@/ui/switch'; import { Textarea } from '@/ui/textarea'; -import { Field, Section } from './form-primitives'; - -const AgentRoleEmojiPicker = lazy(() => import('./agent-role-emoji-picker')); +import { EmojiField } from './emoji-field'; +import { Field, FormMessage, Section } from './form-primitives'; export type AgentRoleMachineOption = { machineId: MachineId; @@ -110,7 +108,11 @@ export function AgentRoleForm({ card: an emoji and a name need no section heading to be read. */}
- update({ emoji })} /> + update({ emoji })} + /> void; -}) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const triggerRef = useRef(null); - - // The settings editor is a Dialog, whose scroll lock swallows wheel events in - // a body-level portal — and this popover's whole content is a scrolling list. - // Same rule as `option-selector.tsx`. - const portalContainer = open - ? triggerRef.current?.closest('[data-lody-dialog-content]') - : null; - - return ( - - - - - event.preventDefault()} - > - -
- } - > - { - onChange(emoji); - setOpen(false); - }} - /> - - {value ? ( -
- -
- ) : null} - - - ); -} - function RunConfigIssueText({ issue }: { issue: AgentRoleRunConfigIssue }) { const { t } = useTranslation(); const describe = (): string => { @@ -511,26 +438,3 @@ function ConfigOptionField({ ); } - -function FormMessage({ tone, children }: { tone: 'error' | 'warning'; children: ReactNode }) { - return ( -

-

- ); -} diff --git a/packages/components/src/components/settings/beta-features-setting.tsx b/packages/components/src/components/settings/beta-features-setting.tsx index aad902a85..fb529448b 100644 --- a/packages/components/src/components/settings/beta-features-setting.tsx +++ b/packages/components/src/components/settings/beta-features-setting.tsx @@ -4,6 +4,7 @@ import { Switch } from '@/ui/switch'; import { developerModeEnabledAtom, inboxBetaEnabledAtom, + promptShortcutsBetaEnabledAtom, tasksBetaEnabledAtom, } from '@/atoms/settings'; import { CompactRow, CompactSection } from './compact-layout'; @@ -24,6 +25,10 @@ export function BetaFeaturesSection() { const [tasksBetaEnabled, setTasksBetaEnabled] = useAtom(tasksBetaEnabledAtom); const [inboxBetaEnabled, setInboxBetaEnabled] = useAtom(inboxBetaEnabledAtom); + const [promptShortcutsBetaEnabled, setPromptShortcutsBetaEnabled] = useAtom( + promptShortcutsBetaEnabledAtom + ); + if (!developerModeEnabled) return null; return ( @@ -54,6 +59,19 @@ export function BetaFeaturesSection() { aria-label={t('settings.beta.inbox', 'Inbox')} /> + + + ); } diff --git a/packages/components/src/components/settings/desktop-settings-modal.tsx b/packages/components/src/components/settings/desktop-settings-modal.tsx index d3b8e8996..b752cfe53 100644 --- a/packages/components/src/components/settings/desktop-settings-modal.tsx +++ b/packages/components/src/components/settings/desktop-settings-modal.tsx @@ -35,6 +35,7 @@ import { IntegrationsSettingsComponent } from './integrations-setting'; import { KeyboardShortcutsSetting } from './keyboard-shortcuts-setting'; import { AboutSettingsComponent } from './about-setting'; import { AgentRolesSetting } from './agent-roles-setting'; +import { PromptShortcutsSetting } from './prompt-shortcuts-setting'; import { McpSetting } from './mcp-setting'; import { FocusScope, useListKeyboardNavigation } from '@/ui/focus-scope'; @@ -284,6 +285,8 @@ function SettingsTabContent({ tabId }: { tabId: SettingsTabId }) { ); case 'agent-roles': return ; + case 'prompt-shortcuts': + return ; case 'mcp': return ; case 'machines': diff --git a/packages/components/src/components/settings/emoji-field.tsx b/packages/components/src/components/settings/emoji-field.tsx new file mode 100644 index 000000000..0226b9f32 --- /dev/null +++ b/packages/components/src/components/settings/emoji-field.tsx @@ -0,0 +1,97 @@ +import { lazy, Suspense, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/ui/button'; +import { Popover, PopoverContent, PopoverTrigger } from '@/ui/popover'; + +const EmojiPickerPanel = lazy(() => import('./emoji-picker-panel')); + +/** + * A catalog entry's glyph: the current emoji, and a picker behind it. + * + * An always-filled button rather than a text field. Typing an emoji means + * knowing the OS shortcut, and an empty slot makes "no emoji" look like an + * unfinished form — so the button shows the default glyph and clicking it is a + * change, the way a Notion page icon works. + * + * Shared by every settings editor that has one, so an Agent Role and a Prompt + * Shortcut cannot end up with two different emoji controls. + */ +export function EmojiField({ + value, + defaultEmoji, + onChange, +}: { + value: string; + /** Shown when nothing is set, and offered as the reset. */ + defaultEmoji: string; + onChange: (emoji: string) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + // The settings editor is a Dialog, whose scroll lock swallows wheel events in + // a body-level portal — and this popover's whole content is a scrolling list. + // Same rule as `option-selector.tsx`. + const portalContainer = open + ? triggerRef.current?.closest('[data-lody-dialog-content]') + : null; + + return ( + + + + + event.preventDefault()} + > + +
+ } + > + { + onChange(emoji); + setOpen(false); + }} + /> + + {value ? ( +
+ +
+ ) : null} + + + ); +} diff --git a/packages/components/src/components/settings/agent-role-emoji-picker.tsx b/packages/components/src/components/settings/emoji-picker-panel.tsx similarity index 91% rename from packages/components/src/components/settings/agent-role-emoji-picker.tsx rename to packages/components/src/components/settings/emoji-picker-panel.tsx index a16ec388c..55935ac39 100644 --- a/packages/components/src/components/settings/agent-role-emoji-picker.tsx +++ b/packages/components/src/components/settings/emoji-picker-panel.tsx @@ -16,11 +16,7 @@ import { * the Agent Role editor. The popover unmounts its content when closed, so the * lazy boundary is exactly the mount. */ -export default function AgentRoleEmojiPicker({ - onSelect, -}: { - onSelect: (emoji: string) => void; -}) { +export default function AgentRoleEmojiPicker({ onSelect }: { onSelect: (emoji: string) => void }) { const { i18n } = useTranslation(); return ( ); } + +/** + * An inline message inside a settings editor. + * + * `error` blocks the save that is about to be attempted; `warning` states a + * consequence the author should read before saving. Both carry the icon, so a + * reader who cannot see the tint still gets the signal. + */ +export function FormMessage({ + tone, + children, + className, +}: { + tone: 'error' | 'warning'; + children: ReactNode; + className?: string; +}) { + return ( +
+
+ ); +} + +/** + * A one-line field that becomes as tall as the text put in it. + * + * Multi-line values are allowed but rare — a variable default is usually a few + * words — so the field starts at a single row and grows with the content + * instead of reserving space for lines nobody wrote. It scrolls once it reaches + * `maxRows`, and never shows a resize handle: the height is the content's. + */ +export function AutoGrowTextarea({ + value, + maxRows = 8, + className, + ...props +}: Omit & { value: string; maxRows?: number }) { + const ref = useRef(null); + const resize = useCallback(() => { + const element = ref.current; + if (!element) return; + element.style.height = 'auto'; + const styles = window.getComputedStyle(element); + // A layout-less environment (jsdom) reports '' for these; a NaN height would + // be written to the style attribute and silently dropped. + const px = (style: string) => (Number.isFinite(parseFloat(style)) ? parseFloat(style) : 0); + const lineHeight = px(styles.lineHeight) || 16; + const paddingY = px(styles.paddingTop) + px(styles.paddingBottom); + const borderY = px(styles.borderTopWidth) + px(styles.borderBottomWidth); + const maxHeight = lineHeight * maxRows + paddingY + borderY; + const content = element.scrollHeight + borderY; + if (content <= 0) return; + element.style.height = `${Math.min(content, maxHeight)}px`; + element.style.overflowY = content > maxHeight ? 'auto' : 'hidden'; + }, [maxRows]); + + // Layout effect: a saved multi-line value must render at its full height, + // not flash one row and then jump. + useLayoutEffect(resize, [resize, value]); + // Re-measure on width changes; wrapping is what decides the row count, and a + // field inside a dialog can be measured before the dialog has its width. + const lastWidth = useRef(-1); + useLayoutEffect(() => { + const element = ref.current; + if (!element) return undefined; + lastWidth.current = element.clientWidth; + return observeResizeOnAnimationFrame(element, () => { + if (element.clientWidth === lastWidth.current) return; + lastWidth.current = element.clientWidth; + resize(); + }); + }, [resize]); + + return ( +