diff --git a/console/web/src/App.tsx b/console/web/src/App.tsx index d1c88e6f6..e8b161e38 100644 --- a/console/web/src/App.tsx +++ b/console/web/src/App.tsx @@ -24,6 +24,7 @@ import { Browser } from '@/pages/Browser' import { Configuration } from '@/pages/Configuration' import { Github } from '@/pages/Github' import { Memory } from '@/pages/Memory' +import { Prompts } from '@/pages/Prompts' import { TracesV2 } from '@/pages/TracesV2' import { Workers } from '@/pages/Workers' import { Worktrees } from '@/pages/Worktrees' @@ -101,6 +102,8 @@ export function App() { ) : view === 'memory' ? ( + ) : view === 'prompts' ? ( + ) : view === 'github' ? ( ) : ( @@ -137,11 +140,13 @@ function Header({ browserAvailable, memoryAvailable, githubAvailable, + promptsAvailable, } = useConversationsCtx() const viewOptions = buildViewOptions( worktreeAvailable, browserAvailable, memoryAvailable, + promptsAvailable, githubAvailable, ) const onConsoleSettings = view === 'configuration' diff --git a/console/web/src/components/chat/ChatView.tsx b/console/web/src/components/chat/ChatView.tsx index 069669778..0ec387cb9 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) @@ -1003,6 +1007,7 @@ export function ChatView({ sessionId, messageId, thinkingLevel, + ...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}), workingDir: conversation.workingDir, approvalGateAvailable: approvalEnabled, ...(attachedBlocks && attachedBlocks.length > 0 @@ -1048,6 +1053,7 @@ export function ChatView({ sessionId, messageId, thinkingLevel, + ...(sessionPrompt ? { systemPrompt: sessionPrompt.body } : {}), workingDir: conversation.workingDir, approvalGateAvailable: approvalEnabled, approvalSessionMatcher, @@ -1327,6 +1333,7 @@ export function ChatView({ conversation.workingDir, effectiveModel, thinkingLevel, + sessionPrompt, sessionId, contextWindow, backend, @@ -1788,6 +1795,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..8b0b743e7 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 { useCallback, useEffect, 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,29 @@ 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 page shows up without a reload. + const [openCount, setOpenCount] = useState(0) + useEffect(() => { + let cancelled = false + loadPromptSlashCommands().then((commands) => { + if (!cancelled) setPromptCommands(commands) + }) + return () => { + cancelled = true + } + }, [openCount]) const options = useMemo( () => - fuzzyFilterSlash(query ?? '').map( + fuzzyFilterSlash(query ?? '', [...SLASH_COMMANDS, ...promptCommands]).map( (entry) => new SlashCommandOption(entry), ), - [query], + [query, promptCommands], ) const onSelectOption = useCallback( @@ -58,14 +83,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 +132,7 @@ export function SlashCommandsPlugin({ onSelectOption={onSelectOption} onOpen={() => { if (menuOpenRef) menuOpenRef.current = true + setOpenCount((n) => n + 1) }} onClose={() => { if (menuOpenRef) menuOpenRef.current = false diff --git a/console/web/src/hooks/use-hash-route.ts b/console/web/src/hooks/use-hash-route.ts index 07c57fdbd..bddef7a69 100644 --- a/console/web/src/hooks/use-hash-route.ts +++ b/console/web/src/hooks/use-hash-route.ts @@ -12,6 +12,7 @@ export type View = | 'worktrees' | 'browser' | 'memory' + | 'prompts' | 'github' export interface WorkersConfigurationRoute { @@ -98,6 +99,9 @@ function routeFromHash(hash: string): View | null { if (hash === '#/memory') { return 'memory' } + if (hash === '#/prompts') { + return 'prompts' + } if (hash === '#/github') { return 'github' } @@ -134,6 +138,8 @@ function hashFor(view: View): string { return '#/browser' case 'memory': return '#/memory' + case 'prompts': + return '#/prompts' case 'github': return '#/github' case 'configuration': 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 02d360e61..0ed9f0bf6 100644 --- a/console/web/src/lib/backend/harness-send.ts +++ b/console/web/src/lib/backend/harness-send.ts @@ -43,6 +43,8 @@ export type HarnessSendMode = 'plan' | '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 e19f351a4..8826efc34 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 bb629d916..a0ff65d32 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/nav-options.test.ts b/console/web/src/lib/nav-options.test.ts index cbe8430a2..49356c697 100644 --- a/console/web/src/lib/nav-options.test.ts +++ b/console/web/src/lib/nav-options.test.ts @@ -4,37 +4,51 @@ import { buildViewOptions } from './nav-options' describe('buildViewOptions', () => { it('hides the optional-worker entries while their workers are absent', () => { expect( - buildViewOptions(false, false, false, false).map((o) => o.value), + buildViewOptions(false, false, false, false, false).map((o) => o.value), ).toEqual(['traces', 'workers']) }) it('appends the worktrees entry when the worker is present', () => { expect( - buildViewOptions(true, false, false, false).map((o) => o.value), + buildViewOptions(true, false, false, false, false).map((o) => o.value), ).toEqual(['traces', 'workers', 'worktrees']) }) it('appends the browser entry when the worker is present', () => { expect( - buildViewOptions(false, true, false, false).map((o) => o.value), + buildViewOptions(false, true, false, false, false).map((o) => o.value), ).toEqual(['traces', 'workers', 'browser']) }) it('appends the memory entry when the worker is present', () => { expect( - buildViewOptions(false, false, true, false).map((o) => o.value), + buildViewOptions(false, false, true, false, false).map((o) => o.value), ).toEqual(['traces', 'workers', 'memory']) }) + it('appends the prompts entry when the directory worker is present', () => { + expect( + buildViewOptions(false, false, false, true, false).map((o) => o.value), + ).toEqual(['traces', 'workers', 'prompts']) + }) + it('appends the github entry when the worker is present', () => { expect( - buildViewOptions(false, false, false, true).map((o) => o.value), + buildViewOptions(false, false, false, false, true).map((o) => o.value), ).toEqual(['traces', 'workers', 'github']) }) it('appends every entry when all optional workers are present', () => { expect( - buildViewOptions(true, true, true, true).map((o) => o.value), - ).toEqual(['traces', 'workers', 'worktrees', 'browser', 'memory', 'github']) + buildViewOptions(true, true, true, true, true).map((o) => o.value), + ).toEqual([ + 'traces', + 'workers', + 'worktrees', + 'browser', + 'memory', + 'prompts', + 'github', + ]) }) }) diff --git a/console/web/src/lib/nav-options.ts b/console/web/src/lib/nav-options.ts index fac2aecf2..3672f7594 100644 --- a/console/web/src/lib/nav-options.ts +++ b/console/web/src/lib/nav-options.ts @@ -10,6 +10,7 @@ export function buildViewOptions( worktreeAvailable: boolean, browserAvailable: boolean, memoryAvailable: boolean, + promptsAvailable: boolean, githubAvailable: boolean, ): { value: View; label: string }[] { const options: { value: View; label: string }[] = [ @@ -25,6 +26,9 @@ export function buildViewOptions( if (memoryAvailable) { options.push({ value: 'memory', label: 'memory' }) } + if (promptsAvailable) { + options.push({ value: 'prompts', label: 'prompts' }) + } if (githubAvailable) { options.push({ value: 'github', label: 'github' }) } 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) } diff --git a/console/web/src/pages/Prompts/index.tsx b/console/web/src/pages/Prompts/index.tsx new file mode 100644 index 000000000..5618574d3 --- /dev/null +++ b/console/web/src/pages/Prompts/index.tsx @@ -0,0 +1,350 @@ +import { AlertCircle, Plus, RefreshCw } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/Button' +import { StatusPanel } from '@/components/ui/StatusPanel' +import { + isPromptsAvailable, + usePromptsStatus, +} from '@/hooks/use-prompts-status' +import { useConversationsCtx } from '@/lib/conversations-context' +import { + deletePrompt, + getPrompt, + listPrompts, + type PromptRow, + savePrompt, +} from '@/lib/prompts' +import { cn } from '@/lib/utils' + +/** + * The prompt store's visible-and-editable surface: every prompt the + * directory worker serves in a left rail (worker-shipped slash templates + * and user library entries, `command` and `system` kinds), the selected + * prompt's body in an editor on the right. User library entries save, + * fork (save under a new name), and delete in place; worker-shipped + * templates are read-only here (they ship with their worker's bundle). + */ + +interface Draft { + name: string + description: string + kind: string + body: string + /** Existing library entry (save overwrites) vs a new/forked one. */ + existing: boolean + /** Worker-shipped rows render read-only. */ + readonly: boolean +} + +const EMPTY_DRAFT: Draft = { + name: '', + description: '', + kind: 'system', + body: '', + existing: false, + readonly: false, +} + +export function Prompts() { + const { backend } = useConversationsCtx() + const status = usePromptsStatus(backend.id === 'real') + const available = isPromptsAvailable(status) + + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [draft, setDraft] = useState(null) + const [busy, setBusy] = useState(false) + + const refresh = useCallback(() => { + if (!available) return + setLoading(true) + listPrompts() + .then((next) => { + setRows(next) + setError(null) + }) + .catch((err) => + setError(err instanceof Error ? err.message : String(err)), + ) + .finally(() => setLoading(false)) + }, [available]) + + useEffect(() => { + refresh() + }, [refresh]) + + const open = useCallback(async (row: PromptRow) => { + setError(null) + let detail: Awaited> + try { + detail = await getPrompt(row.name) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + return + } + if (!detail) { + setError(`could not load prompt "${row.name}"`) + return + } + setDraft({ + name: detail.name, + description: detail.description, + kind: detail.kind, + body: detail.body, + existing: detail.source === 'user', + readonly: detail.source !== 'user', + }) + }, []) + + const act = useCallback( + async (action: () => Promise) => { + setBusy(true) + setError(null) + try { + await action() + refresh() + return true + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + return false + } finally { + setBusy(false) + } + }, + [refresh], + ) + + const onSave = useCallback(async () => { + if (!draft) return + const ok = await act(() => + savePrompt({ + name: draft.name.trim(), + description: draft.description.trim(), + body: draft.body, + kind: draft.kind, + }), + ) + if (ok) setDraft({ ...draft, existing: true }) + }, [act, draft]) + + const onFork = useCallback(() => { + if (!draft) return + setDraft({ + ...draft, + name: `${draft.name}-fork`, + existing: false, + readonly: false, + }) + }, [draft]) + + const onDelete = useCallback(async () => { + if (!draft) return + if ( + !window.confirm(`Delete prompt "${draft.name}"? This cannot be undone.`) + ) + return + const ok = await act(() => deletePrompt(draft.name)) + if (ok) setDraft(null) + }, [act, draft]) + + const label = loading ? '...' : `${rows.length} prompts` + + return ( +
+
+
+

+ prompts +

+

+ {available ? label : 'worker not connected'} +

+
+ {available ? ( +
+ + +
+ ) : null} +
+ + {!available ? ( +
+ {status.loading ? ( +

+ checking for the directory worker... +

+ ) : ( + } + headline="directory worker not installed" + detail="this page needs the directory worker (prompt store). run: iii worker add iii-directory" + /> + )} +
+ ) : ( +
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} + {!draft ? ( +

+ select a prompt to view or edit it, or create a new one +

+ ) : ( + <> +
+ + setDraft({ ...draft, name: e.target.value }) + } + disabled={draft.existing || draft.readonly} + placeholder="prompt-name" + aria-label="prompt name" + className="font-mono text-[13px] bg-surface border border-rule rounded px-2 py-1 text-ink w-56 disabled:opacity-60" + /> + + {draft.readonly ? ( + worker-shipped, read-only + ) : null} + + {!draft.readonly ? ( + + ) : null} + + {draft.existing && !draft.readonly ? ( + + ) : null} +
+ + setDraft({ ...draft, description: e.target.value }) + } + disabled={draft.readonly} + placeholder="one-line description (shown in list and pickers)" + aria-label="prompt description" + className="font-mono text-[12px] bg-surface border border-rule rounded px-2 py-1 text-ink disabled:opacity-60" + /> +