From c7a9a8024cff83985c8639e36c4be0d856824cee Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Mon, 27 Jul 2026 14:38:05 +0100 Subject: [PATCH] feat(console): composer system-prompt picker + slash-command prompts Adds in-chat prompt selection surfaces that read the directory prompt store (directory::prompts::*, kind system|command): - a system-prompt picker beside the composer model/memory controls: pick a kind:system entry to override the system prompt for the conversation. - the slash-command menu surfaces kind:command prompts, injecting the body. Depends on the directory prompt-library backend (save/delete, kind, source). --- console/web/src/components/chat/ChatView.tsx | 13 +++ console/web/src/components/chat/Composer.tsx | 16 +++ .../web/src/components/chat/PromptPicker.tsx | 99 +++++++++++++++++++ .../chat/lexical/SlashCommandsPlugin.tsx | 65 ++++++++++-- console/web/src/hooks/use-prompts-status.ts | 44 +++++++++ console/web/src/lib/backend/harness-send.ts | 2 + console/web/src/lib/backend/real.ts | 6 ++ console/web/src/lib/backend/types.ts | 5 + console/web/src/lib/conversations-context.tsx | 14 +++ console/web/src/lib/prompts.ts | 66 +++++++++++++ console/web/src/lib/slash-commands.ts | 50 ++++++++-- 11 files changed, 366 insertions(+), 14 deletions(-) create mode 100644 console/web/src/components/chat/PromptPicker.tsx create mode 100644 console/web/src/hooks/use-prompts-status.ts create mode 100644 console/web/src/lib/prompts.ts diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index e68b7a4ec..199961897 100644 --- a/console/web/src/components/chat/ChatView.tsx +++ b/console/web/src/components/chat/ChatView.tsx @@ -74,6 +74,7 @@ import { Composer, type ComposerSubmitPayload } from './Composer' import { ContextUsage } from './ContextUsage' import { ExportSessionButton } from './ExportSessionButton' import { MessageList } from './MessageList' +import type { SessionPrompt } from './PromptPicker' import { SessionTriggers } from './SessionTriggers' import { WorktreeBadge } from './WorktreeBadge' @@ -157,6 +158,9 @@ export function ChatView({ const [thinkingLevel, setThinkingLevel] = useState( DEFAULT_THINKING_LEVEL, ) + // System prompt override for this conversation (prompt store, kind: + // system). Applied to every send until switched back to default. + const [sessionPrompt, setSessionPrompt] = useState(null) const abortRef = useRef(null) const [copied, setCopied] = useState(false) const { functionEntries } = useFunctionsCatalog(backend.id) @@ -1010,6 +1014,7 @@ export function ChatView({ sessionId, messageId, thinkingLevel, + ...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}), workingDir: conversation.workingDir, approvalGateAvailable: approvalEnabled, ...(attachedBlocks && attachedBlocks.length > 0 @@ -1055,6 +1060,7 @@ export function ChatView({ sessionId, messageId, thinkingLevel, + ...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}), workingDir: conversation.workingDir, approvalGateAvailable: approvalEnabled, approvalSessionMatcher, @@ -1334,6 +1340,7 @@ export function ChatView({ conversation.workingDir, effectiveModel, thinkingLevel, + sessionPrompt, sessionId, contextWindow, backend, @@ -1799,6 +1806,12 @@ export function ChatView({ backend.id === 'real' && (conversationsCtx?.memoryAvailable ?? false) } + showPromptPicker={ + backend.id === 'real' && + (conversationsCtx?.promptsAvailable ?? false) + } + sessionPrompt={sessionPrompt} + onSessionPromptChange={setSessionPrompt} memoryBank={conversation.memoryBank ?? null} onMemoryBankChange={(next) => conversationsCtx?.setMemoryBank(conversation.id, next) diff --git a/console/web/src/components/chat/Composer.tsx b/console/web/src/components/chat/Composer.tsx index 194fef0a5..a45bfc3ee 100644 --- a/console/web/src/components/chat/Composer.tsx +++ b/console/web/src/components/chat/Composer.tsx @@ -24,6 +24,7 @@ import { DirectoryPicker, type WorktreePickerOptions } from './DirectoryPicker' import { LexicalShell } from './LexicalShell' import { ModelPicker } from './ModelPicker' import { ModePicker } from './ModePicker' +import { PromptPicker, type SessionPrompt } from './PromptPicker' import { nextHistoryTarget } from './queue-history' export interface ComposerSubmitPayload { @@ -64,6 +65,11 @@ interface ComposerProps { workingDir?: string | null /** Show the memory bank picker (memory worker present, real backend). */ showMemoryBank?: boolean + /** Show the system prompt picker (directory worker present, real backend). */ + showPromptPicker?: boolean + /** This chat's system prompt override; null = the harness default chain. */ + sessionPrompt?: SessionPrompt | null + onSessionPromptChange?: (next: SessionPrompt | null) => void /** This chat's memory bank; null = the worker's default bank. */ memoryBank?: string | null onMemoryBankChange?: (next: string | null) => void @@ -150,6 +156,9 @@ export function Composer({ workingDir, showMemoryBank, memoryBank, + showPromptPicker, + sessionPrompt, + onSessionPromptChange, onMemoryBankChange, workingDirLocked, workingDirError, @@ -358,6 +367,13 @@ export function Composer({ disabled={optionsDisabled} /> ) : null} + {showPromptPicker && onSessionPromptChange ? ( + + ) : null} {showPermissionMode ? ( void + disabled?: boolean +} + +const DEFAULT = '(default)' + +export function PromptPicker({ value, onChange, disabled }: PromptPickerProps) { + const [prompts, setPrompts] = useState([]) + // Monotonic token so a slow getPrompt from an earlier selection can't + // land after a newer one and apply a stale prompt. + const selectionRef = useRef(0) + + useEffect(() => { + let cancelled = false + void listPrompts() + .then((next) => { + if (!cancelled) { + setPrompts(next.filter((p) => p.kind === 'system')) + } + }) + .catch(() => {}) + return () => { + cancelled = true + } + }, []) + + const known = prompts.some((p) => p.name === value?.name) + const options = [ + { + value: DEFAULT, + label: 'prompt: default', + title: + 'the harness identity chain (router override, provider prompt, fallback)', + }, + ...prompts.map((p) => ({ + value: p.name, + label: `prompt: ${p.name}`, + title: p.description, + })), + ...(value && !known + ? [ + { + value: value.name, + label: `prompt: ${value.name}`, + title: 'no longer in the prompt store; still applied to this chat', + }, + ] + : []), + ] + + return ( + + value={value?.name ?? DEFAULT} + onChange={(next) => { + // Every selection (including DEFAULT) supersedes any in-flight read. + const token = ++selectionRef.current + if (next === DEFAULT) { + onChange(null) + return + } + void getPrompt(next) + .then((detail) => { + if (token !== selectionRef.current) return + if (detail) onChange({ name: detail.name, body: detail.body }) + }) + .catch(() => { + // Failed read: leave the current selection untouched. + }) + }} + disabled={disabled} + aria-label="system prompt" + className="min-w-[104px] max-w-[180px]" + options={options} + /> + ) +} diff --git a/console/web/src/components/chat/lexical/SlashCommandsPlugin.tsx b/console/web/src/components/chat/lexical/SlashCommandsPlugin.tsx index 5479d6f58..4387951b5 100644 --- a/console/web/src/components/chat/lexical/SlashCommandsPlugin.tsx +++ b/console/web/src/components/chat/lexical/SlashCommandsPlugin.tsx @@ -1,16 +1,25 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { LexicalTypeaheadMenuPlugin, MenuOption, } from '@lexical/react/LexicalTypeaheadMenuPlugin' import { $createTextNode, + $getNodeByKey, + $isTextNode, COMMAND_PRIORITY_NORMAL, type LexicalEditor, type TextNode, } from 'lexical' import { useCallback, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { fuzzyFilterSlash, type SlashCommand } from '@/lib/slash-commands' +import { getPrompt } from '@/lib/prompts' +import { + fuzzyFilterSlash, + loadPromptSlashCommands, + SLASH_COMMANDS, + type SlashCommand, +} from '@/lib/slash-commands' import { FlipMenu } from './FlipMenu' class SlashCommandOption extends MenuOption { @@ -27,7 +36,8 @@ interface SlashCommandsPluginProps { } // Anchored at column 0 so `/` mid-sentence ("either/or") doesn't fire. -const SLASH_PATTERN = /^\/(\w*)$/ +// `-` is allowed because prompt names are kebab-case (`/blog-writer`). +const SLASH_PATTERN = /^\/([\w-]*)$/ function slashTriggerFn(text: string, _editor: LexicalEditor) { const match = text.match(SLASH_PATTERN) @@ -42,14 +52,22 @@ function slashTriggerFn(text: string, _editor: LexicalEditor) { export function SlashCommandsPlugin({ menuOpenRef, }: SlashCommandsPluginProps = {}) { + const [editor] = useLexicalComposerContext() const [query, setQuery] = useState(null) + const [promptCommands, setPromptCommands] = useState([]) + + // The prompt store changes rarely; re-read each time the menu opens so a + // save in the prompts library shows up without a reload. + const refreshPromptCommands = useCallback(() => { + void loadPromptSlashCommands().then(setPromptCommands) + }, []) const options = useMemo( () => - fuzzyFilterSlash(query ?? '').map( + fuzzyFilterSlash(query ?? '', [...SLASH_COMMANDS, ...promptCommands]).map( (entry) => new SlashCommandOption(entry), ), - [query], + [query, promptCommands], ) const onSelectOption = useCallback( @@ -58,14 +76,46 @@ export function SlashCommandsPlugin({ textNodeContainingQuery: TextNode | null, closeMenu: () => void, ) => { - if (textNodeContainingQuery) { - const replacement = $createTextNode(`${option.entry.command} `) + const { entry } = option + if (!textNodeContainingQuery) { + closeMenu() + return + } + if (!entry.promptName) { + const replacement = $createTextNode(`${entry.command} `) textNodeContainingQuery.replace(replacement) replacement.selectEnd() + closeMenu() + return } + // Prompt entry: inject the prompt BODY into the message (claude-code + // style context injection). The body is fetched async, so drop a + // placeholder now and swap it once the read lands. + const placeholder = $createTextNode(`${entry.command} `) + textNodeContainingQuery.replace(placeholder) + placeholder.selectEnd() + const key = placeholder.getKey() + const name = entry.promptName closeMenu() + void getPrompt(name).then((detail) => { + editor.update(() => { + const node = $getNodeByKey(key) + if (!node || !detail) return + // Swap only while the node still holds the untouched placeholder. + // If the user kept typing into it while the read was pending, leave + // their text alone rather than clobbering it. + if ( + !$isTextNode(node) || + node.getTextContent() !== `${entry.command} ` + ) + return + const body = $createTextNode(`${detail.body.trim()}\n`) + node.replace(body) + body.selectEnd() + }) + }) }, - [], + [editor], ) return ( @@ -75,6 +125,7 @@ export function SlashCommandsPlugin({ onSelectOption={onSelectOption} onOpen={() => { if (menuOpenRef) menuOpenRef.current = true + refreshPromptCommands() }} onClose={() => { if (menuOpenRef) menuOpenRef.current = false diff --git a/console/web/src/hooks/use-prompts-status.ts b/console/web/src/hooks/use-prompts-status.ts new file mode 100644 index 000000000..21079944c --- /dev/null +++ b/console/web/src/hooks/use-prompts-status.ts @@ -0,0 +1,44 @@ +import { + isWorkerPresent, + useWorkerPresence, + type WorkerPresence, +} from './use-worker-presence' + +/** + * Presence probe for the directory worker, which serves the prompt store + * (`directory::prompts::*`): worker-shipped slash templates plus the user + * prompt library. OPTIONAL, so the console gates the Prompts page on its + * presence rather than rendering controls that would call functions that + * don't exist. Thin wrapper over the generic worker-presence probe. + */ + +/** Engine worker name for the directory worker. */ +const PROMPTS_WORKER_NAME = 'iii-directory' +/** Base id for the browser-local handler bound to the `worker` trigger. */ +const PROMPTS_WATCH_FN = 'console::prompts-watch' + +export type PromptsStatus = WorkerPresence + +/** + * @param enabled - only run against the real backend; pass `false` for the + * mock/Storybook backend (treats the worker as present so its UI shows + * in isolation). + */ +export function usePromptsStatus(enabled: boolean): PromptsStatus { + return useWorkerPresence({ + workerName: PROMPTS_WORKER_NAME, + watchFnId: PROMPTS_WATCH_FN, + enabled, + }) +} + +/** + * Whether the directory worker's prompt functions are registered and safe + * to trigger. False during the initial presence probe and while the worker + * is absent. + */ +export function isPromptsAvailable(status: PromptsStatus): boolean { + return isWorkerPresent(status) +} + +export { PROMPTS_WATCH_FN, PROMPTS_WORKER_NAME } diff --git a/console/web/src/lib/backend/harness-send.ts b/console/web/src/lib/backend/harness-send.ts index e18b5af6d..b855ed384 100644 --- a/console/web/src/lib/backend/harness-send.ts +++ b/console/web/src/lib/backend/harness-send.ts @@ -45,6 +45,8 @@ export type HarnessSendMode = 'ask' | 'agent' /** Per-send options frozen onto the turn record. */ export interface HarnessSendOptions { system_prompt?: string + /** How a caller prompt combines with the built-in identity: override replaces it verbatim. */ + system_prompt_strategy?: 'override' | 'enrich' mode?: HarnessSendMode max_turns?: number thinking_level?: HarnessThinkingLevel diff --git a/console/web/src/lib/backend/real.ts b/console/web/src/lib/backend/real.ts index 44a440a47..921e5f0d7 100644 --- a/console/web/src/lib/backend/real.ts +++ b/console/web/src/lib/backend/real.ts @@ -188,6 +188,12 @@ async function buildSendRequest( options: { mode, functions: functionPolicy, + ...(opts?.systemPrompt + ? { + system_prompt: opts.systemPrompt, + system_prompt_strategy: 'override' as const, + } + : {}), ...(thinkingLevel ? { thinking_level: thinkingLevel } : {}), ...(providerOptions ? { provider_options: providerOptions } : {}), metadata: buildTurnMetadata(sessionId, messageId, opts?.workingDir), diff --git a/console/web/src/lib/backend/types.ts b/console/web/src/lib/backend/types.ts index 4ff6254fd..c87875524 100644 --- a/console/web/src/lib/backend/types.ts +++ b/console/web/src/lib/backend/types.ts @@ -117,6 +117,11 @@ export type StreamEvent = export interface ChatStreamOptions { signal?: AbortSignal + /** + * Full system prompt override for this send (a kind: system entry from + * the prompt store). Omitted = the harness identity chain. + */ + systemPrompt?: string /** * Reasoning effort for the turn. `default` omits the override; exact native * values also travel through namespaced provider options. diff --git a/console/web/src/lib/conversations-context.tsx b/console/web/src/lib/conversations-context.tsx index 1e3f722ae..8bce162c3 100644 --- a/console/web/src/lib/conversations-context.tsx +++ b/console/web/src/lib/conversations-context.tsx @@ -25,6 +25,10 @@ import { } from '@/hooks/use-harness-status' import { isMemoryAvailable, useMemoryStatus } from '@/hooks/use-memory-status' import { useModelPickerSource } from '@/hooks/use-model-picker-source' +import { + isPromptsAvailable, + usePromptsStatus, +} from '@/hooks/use-prompts-status' import { isShellAvailable, useShellStatus } from '@/hooks/use-shell-status' import { isWorktreeAvailable, @@ -99,6 +103,12 @@ interface ConversationsContextValue extends ConversationsApi { * backend. */ githubAvailable: boolean + /** + * Whether the directory worker (the prompt store) is connected - gates + * the Prompts page nav entry and its directory::prompts::* RPC. Only + * meaningful on the real backend. + */ + promptsAvailable: boolean } const ConversationsContext = createContext( @@ -136,6 +146,9 @@ export function ConversationsProvider({ const githubAvailable = isGithubAvailable( useGithubStatus(backend.id === 'real'), ) + const promptsAvailable = isPromptsAvailable( + usePromptsStatus(backend.id === 'real'), + ) const { modelOptions, catalogKeys, @@ -186,6 +199,7 @@ export function ConversationsProvider({ browserAvailable, memoryAvailable, githubAvailable, + promptsAvailable, } return ( diff --git a/console/web/src/lib/prompts.ts b/console/web/src/lib/prompts.ts new file mode 100644 index 000000000..d98459176 --- /dev/null +++ b/console/web/src/lib/prompts.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import { getIiiClient } from '@/lib/iii-client' + +/** + * Typed wrappers over the directory worker's `directory::prompts::*` + * functions. Same conventions as `memory.ts`: zod schemas with defaults + * for cross-version tolerance, `safeParse` + drop unparseable rows on + * reads, plain `trigger` on mutations (errors propagate to the page). + * + * Two kinds flow through one store: `command` (slash-style templates + * injected into the message context) and `system` (full system prompts + * applied via the router override or the send/spawn `system_prompt` + * options). `source` separates worker-shipped templates (read-only from + * the console's point of view) from user library entries. + */ + +const promptRowSchema = z.object({ + name: z.string(), + description: z.string().default(''), + kind: z.string().default('command'), + source: z.string().default('worker'), + modified_at: z.string().default(''), +}) +export type PromptRow = z.infer + +const promptListSchema = z.object({ + prompts: z.array(z.unknown()).default([]), +}) + +const promptDetailSchema = promptRowSchema.extend({ + body: z.string().default(''), +}) +export type PromptDetail = z.infer + +export async function listPrompts(): Promise { + const client = await getIiiClient() + const res = await client.trigger('directory::prompts::list', {}) + const parsed = promptListSchema.safeParse(res) + if (!parsed.success) return [] + return parsed.data.prompts + .map((p) => promptRowSchema.safeParse(p)) + .filter((p): p is { success: true; data: PromptRow } => p.success) + .map((p) => p.data) +} + +export async function getPrompt(name: string): Promise { + const client = await getIiiClient() + const res = await client.trigger('directory::prompts::get', { name }) + const parsed = promptDetailSchema.safeParse(res) + return parsed.success ? parsed.data : null +} + +export async function savePrompt(input: { + name: string + description: string + body: string + kind: string +}): Promise { + const client = await getIiiClient() + await client.trigger('directory::prompts::save', input) +} + +export async function deletePrompt(name: string): Promise { + const client = await getIiiClient() + await client.trigger('directory::prompts::delete', { name, yes: true }) +} diff --git a/console/web/src/lib/slash-commands.ts b/console/web/src/lib/slash-commands.ts index 0dd7430cb..52ab43c66 100644 --- a/console/web/src/lib/slash-commands.ts +++ b/console/web/src/lib/slash-commands.ts @@ -1,6 +1,14 @@ +import { listPrompts } from '@/lib/prompts' + export interface SlashCommand { command: string description: string + /** + * Directory prompt name backing this entry. Builtins leave it unset; + * prompt entries insert their BODY into the composer on select + * (claude-code-style context injection, never the system prompt). + */ + promptName?: string } export const SLASH_COMMANDS: SlashCommand[] = [ @@ -10,12 +18,40 @@ export const SLASH_COMMANDS: SlashCommand[] = [ }, ] -export function fuzzyFilterSlash(query: string, limit = 8): SlashCommand[] { +/** + * Directory prompts as slash entries: worker-shipped templates and user + * library entries alike, labeled by kind. An absent directory worker (or + * an older one) degrades to the builtins only. + */ +export async function loadPromptSlashCommands(): Promise { + try { + const prompts = await listPrompts() + // Only command-kind templates inject into the message; system prompts + // apply through the composer's prompt picker instead. + return prompts + .filter((p) => p.kind === 'command') + .map((p) => ({ + command: `/${p.name}`, + description: `${p.kind} prompt - ${p.description}`, + promptName: p.name, + })) + } catch { + return [] + } +} + +export function fuzzyFilterSlash( + query: string, + commands: SlashCommand[] = SLASH_COMMANDS, + limit = 8, +): SlashCommand[] { const q = query.trim().toLowerCase() - if (!q) return SLASH_COMMANDS.slice(0, limit) - return SLASH_COMMANDS.filter( - (c) => - c.command.toLowerCase().includes(q) || - c.description.toLowerCase().includes(q), - ).slice(0, limit) + if (!q) return commands.slice(0, limit) + return commands + .filter( + (c) => + c.command.toLowerCase().includes(q) || + c.description.toLowerCase().includes(q), + ) + .slice(0, limit) }