diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
| commit | 38db3827870960f466be89afbc49f91238d46144 (patch) | |
| tree | 24cb1b896dfadc31e72552dbe67f00530881242e /src/features/system-prompt/ui | |
| parent | 17ce47987e673b6618454d033885b17b2a01912e (diff) | |
| download | dispatch-web-38db3827870960f466be89afbc49f91238d46144.tar.gz dispatch-web-38db3827870960f466be89afbc49f91238d46144.zip | |
feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring
- workspaces: URL-driven conversation grouping (home listing at /, routing,
store, http adapter, WorkspaceCard) wired into the App.svelte shell
- rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature)
- new features: mcp (status view), settings (chat-limit field), system-prompt
(prompt builder), all rendered via the generic surface host
- chat: store + ChatView updates
- tabs: tabs-store updates
- app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte
This commit makes HEAD typecheck clean for the first time: the prior HEAD
(c95cc77) imported features/settings from app/App.svelte but never committed
the feature, so only the full working tree was green.
Diffstat (limited to 'src/features/system-prompt/ui')
| -rw-r--r-- | src/features/system-prompt/ui/SystemPromptBuilder.svelte | 242 |
1 files changed, 242 insertions, 0 deletions
diff --git a/src/features/system-prompt/ui/SystemPromptBuilder.svelte b/src/features/system-prompt/ui/SystemPromptBuilder.svelte new file mode 100644 index 0000000..4e07317 --- /dev/null +++ b/src/features/system-prompt/ui/SystemPromptBuilder.svelte @@ -0,0 +1,242 @@ +<script lang="ts"> + import type { SystemPromptVariable } from "@dispatch/transport-contract"; + import { tick, untrack } from "svelte"; + import { + buildTag, + groupVariables, + insertTag, + isDynamicVariable, + type LoadSystemPrompt, + type LoadSystemPromptVariables, + type SaveSystemPrompt, + } from "../logic/view-model"; + + let { + loadPrompt, + savePrompt, + loadVariables, + onClose, + }: { + loadPrompt: LoadSystemPrompt; + savePrompt: SaveSystemPrompt; + loadVariables: LoadSystemPromptVariables; + onClose: () => void; + } = $props(); + + let value = $state(""); + let loadedTemplate = $state(""); + let loading = $state(false); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state(false); + let variables = $state<readonly SystemPromptVariable[]>([]); + let filePath = $state(""); + let textarea: HTMLTextAreaElement | null = null; + + const groups = $derived(groupVariables(variables)); + const hasChanges = $derived(value !== loadedTemplate); + + async function load() { + untrack(() => { + loading = true; + error = null; + }); + + const [templateResult, variablesResult] = await Promise.all([loadPrompt(), loadVariables()]); + + loading = false; + + if (!templateResult.ok || !variablesResult.ok) { + const parts: string[] = []; + if (!templateResult.ok) parts.push(templateResult.error); + if (!variablesResult.ok) parts.push(variablesResult.error); + error = parts.join("; "); + return; + } + + value = templateResult.template; + loadedTemplate = templateResult.template; + variables = variablesResult.variables; + } + + async function save() { + if (saving || loading) return; + saving = true; + error = null; + justSaved = false; + const result = await savePrompt(value); + saving = false; + if (!result.ok) { + error = result.error; + return; + } + loadedTemplate = result.template; + value = result.template; + justSaved = true; + } + + function reset() { + value = loadedTemplate; + error = null; + justSaved = false; + } + + async function insertTagAtCursor(tag: string) { + if (textarea === null) return; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const insertion = insertTag(value, tag, start, end); + value = insertion.template; + await tick(); + textarea.focus(); + textarea.setSelectionRange(insertion.cursor, insertion.cursor); + } + + async function insertFileVariable(type: string) { + const path = filePath.trim(); + if (path.length === 0) return; + await insertTagAtCursor(buildTag(type, path)); + filePath = ""; + } + + function onKeydown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + + // Load on mount once. + $effect(() => { + void load(); + }); +</script> + +<svelte:window onkeydown={onKeydown} /> + +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div + class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" + role="dialog" + aria-modal="true" + aria-label="System prompt builder" + tabindex="-1" + onclick={onClose} + onkeydown={onKeydown} +> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div + class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl" + onclick={(e) => e.stopPropagation()} + > + <!-- Header --> + <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3"> + <div class="flex items-center gap-2"> + <h2 class="text-sm font-semibold">System Prompt</h2> + {#if loading} + <span class="loading loading-spinner loading-xs"></span> + {/if} + </div> + <button + type="button" + class="btn btn-ghost btn-sm btn-square" + onclick={onClose} + aria-label="Close system prompt builder" + > + ✕ + </button> + </div> + + <!-- Body: half editor / half variables --> + <div class="flex min-h-0 flex-1"> + <!-- Left: template editor --> + <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4"> + <textarea + bind:this={textarea} + bind:value + class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs" + placeholder={loading ? "Loading template..." : "Edit the global system prompt template..."} + disabled={loading} + aria-label="System prompt template" + ></textarea> + + <div class="flex shrink-0 items-center gap-2"> + <button + type="button" + class="btn btn-primary btn-sm" + disabled={loading || saving || !hasChanges} + onclick={save} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-sm" + disabled={loading || !hasChanges} + onclick={reset} + > + Reset + </button> + {#if justSaved && !hasChanges} + <span class="text-xs text-success">Saved.</span> + {:else if hasChanges} + <span class="text-xs opacity-60">Unsaved changes</span> + {/if} + </div> + + {#if error} + <p class="shrink-0 text-xs text-error">{error}</p> + {/if} + </div> + + <!-- Right: variable palette --> + <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4"> + <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3> + {#if groups.length === 0 && !loading} + <p class="text-xs opacity-60">No variables available.</p> + {/if} + <div class="flex flex-col gap-3"> + {#each groups as group (group.type)} + <div class="rounded-box bg-base-200 p-3"> + <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span> + <div class="mt-2 flex flex-wrap gap-1"> + {#each group.variables as variable (variable.type + variable.name)} + {#if isDynamicVariable(variable)} + <div class="flex items-center gap-1"> + <input + type="text" + class="input input-bordered input-xs w-32 font-mono" + bind:value={filePath} + placeholder={variable.name} + onkeydown={(e) => { + if (e.key === "Enter") void insertFileVariable(variable.type); + }} + /> + <button + type="button" + class="btn btn-xs" + onclick={() => void insertFileVariable(variable.type)} + > + Insert + </button> + </div> + {:else} + <button + type="button" + class="btn btn-xs" + title={variable.description} + onclick={() => void insertTagAtCursor(buildTag(variable.type, variable.name))} + > + {variable.name} + </button> + {/if} + {/each} + </div> + </div> + {/each} + </div> + </div> + </div> + </div> +</div> |
