feat(scribe): snippet management UI + REST routes; embed snippets on create
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 23s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m4s

Step 6 of the Drafter recall milestone (#227): a human-facing surface for
the reusable-code snippets that agents record via MCP, plus the REST API
behind it. Backend embeds snippets inline on create/update so they're
recallable immediately, not only after a restart.

Backend:
- routes/snippets.py: GET/POST /api/snippets, GET/PATCH/DELETE
  /api/snippets/<id>. Share-aware per rule #78 (get_note_for_user +
  can_write_note), writes performed as the owner; list owner-scoped —
  mirrors routes/notes.py. Registered in app.py.
- services/snippets.py: embed on create/update via a _embed_snippet
  fire-and-forget helper, covering BOTH the MCP tool and the REST route
  by construction. A snippet's value is immediate recall, so it can't wait
  for the startup-only backfill (see issue: MCP create path doesn't embed
  inline for notes/tasks generally).
- tests/test_routes_snippets.py: structural registration + handler/service
  contract + PATCH-field ↔ update_snippet-kwarg parity (rule #33).

Frontend (Vue 3 + TS):
- api/snippets.ts: typed client, modeled on api/systems.ts.
- views: SnippetListView (search, skeleton/empty/error states),
  SnippetDetailView (read + copy-to-clipboard, ConfirmDialog delete),
  SnippetEditorView (create/edit all fields, Ctrl/Cmd+S, Esc, autofocus,
  validation). v1 quality per rules #24/#27.
- router: /snippets, /snippets/new, /snippets/:id, /snippets/:id/edit.
- NoteType union widened to include 'snippet'; Snippets nav link added to
  AppHeader (desktop pill bar + mobile menu).
- Design system: Moss --color-action-primary for action buttons, accent
  --color-primary reserved for tags/brand (Hybrid rule); focus rings;
  JetBrains Mono for code/name/signature/location.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pa2EsuB54BuWQ8GfJq9c7t
This commit is contained in:
2026-07-25 14:16:02 -04:00
parent 0ea3bff797
commit d257c0fd67
11 changed files with 1355 additions and 4 deletions
+394
View File
@@ -0,0 +1,394 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getSnippet,
createSnippet,
updateSnippet,
type SnippetInput,
} from "@/api/snippets";
import { useToastStore } from "@/stores/toast";
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
const isEditing = computed(() => editId.value !== null);
// All-string form state (tags are edited separately as text) so v-model and
// .trim() never meet an undefined.
interface FormState {
name: string;
code: string;
language: string;
signature: string;
when_to_use: string;
repo: string;
path: string;
symbol: string;
}
const form = ref<FormState>({
name: "",
code: "",
language: "",
signature: "",
when_to_use: "",
repo: "",
path: "",
symbol: "",
});
const tagsText = ref("");
const loading = ref(false);
const saving = ref(false);
const loadError = ref<string | null>(null);
const nameRef = ref<HTMLInputElement | null>(null);
const canSave = computed(
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
);
async function load() {
if (!isEditing.value) {
await nextTick();
nameRef.value?.focus();
return;
}
loading.value = true;
loadError.value = null;
try {
const s = await getSnippet(editId.value!);
const f = s.snippet;
form.value = {
name: f.name,
code: f.code,
language: f.language,
signature: f.signature,
when_to_use: f.when_to_use,
repo: f.repo,
path: f.path,
symbol: f.symbol,
};
// The stored tag list carries language + "snippet" markers, which the
// backend re-derives on save — show only the caller's extra tags here.
tagsText.value = s.tags
.filter((t) => t !== "snippet" && t !== f.language)
.join(", ");
} catch {
loadError.value = "Couldn't load this snippet to edit.";
} finally {
loading.value = false;
}
}
onMounted(load);
function parseTags(): string[] {
return tagsText.value
.split(",")
.map((t) => t.trim())
.filter(Boolean);
}
async function save() {
if (!canSave.value) return;
saving.value = true;
const payload: SnippetInput = {
name: form.value.name.trim(),
code: form.value.code,
language: form.value.language.trim(),
signature: form.value.signature.trim(),
when_to_use: form.value.when_to_use.trim(),
repo: form.value.repo.trim(),
path: form.value.path.trim(),
symbol: form.value.symbol.trim(),
tags: parseTags(),
};
try {
const result = isEditing.value
? await updateSnippet(editId.value!, payload)
: await createSnippet(payload);
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
router.push(`/snippets/${result.id}`);
} catch {
toast.show("Failed to save snippet", "error");
} finally {
saving.value = false;
}
}
function cancel() {
if (isEditing.value) router.push(`/snippets/${editId.value}`);
else router.push("/snippets");
}
</script>
<template>
<main class="snippet-editor" @keydown.ctrl.s.prevent="save" @keydown.meta.s.prevent="save">
<router-link :to="isEditing ? `/snippets/${editId}` : '/snippets'" class="back-link">
{{ isEditing ? "Back to snippet" : "Snippets" }}
</router-link>
<h1>{{ isEditing ? "Edit snippet" : "New snippet" }}</h1>
<div v-if="loading" class="state-msg">Loading</div>
<p v-else-if="loadError" class="error-msg">{{ loadError }}</p>
<form v-else class="form" @submit.prevent="save">
<div class="field">
<label for="sn-name">Name <span class="required">*</span></label>
<input
id="sn-name"
ref="nameRef"
v-model="form.name"
type="text"
class="input mono"
placeholder="useDebouncedRef"
@keydown.escape="cancel"
/>
</div>
<div class="field">
<label for="sn-when">When to reach for it</label>
<input
id="sn-when"
v-model="form.when_to_use"
type="text"
class="input"
placeholder="Debounce a reactive ref that updates too often"
@keydown.escape="cancel"
/>
<p class="hint">Shown in the recall menu keep it sharp.</p>
</div>
<div class="field-row">
<div class="field">
<label for="sn-lang">Language</label>
<input
id="sn-lang"
v-model="form.language"
type="text"
class="input"
placeholder="typescript"
@keydown.escape="cancel"
/>
</div>
<div class="field">
<label for="sn-sig">Signature</label>
<input
id="sn-sig"
v-model="form.signature"
type="text"
class="input mono"
placeholder="useDebouncedRef(value, ms)"
@keydown.escape="cancel"
/>
</div>
</div>
<fieldset class="location-set">
<legend>Location <span class="hint-inline"> where the reference implementation lives</span></legend>
<div class="field-row three">
<div class="field">
<label for="sn-repo">Repo</label>
<input id="sn-repo" v-model="form.repo" type="text" class="input mono" placeholder="scribe" @keydown.escape="cancel" />
</div>
<div class="field">
<label for="sn-path">Path</label>
<input id="sn-path" v-model="form.path" type="text" class="input mono" placeholder="src/composables/x.ts" @keydown.escape="cancel" />
</div>
<div class="field">
<label for="sn-symbol">Symbol</label>
<input id="sn-symbol" v-model="form.symbol" type="text" class="input mono" placeholder="useDebouncedRef" @keydown.escape="cancel" />
</div>
</div>
</fieldset>
<div class="field">
<label for="sn-code">Code <span class="required">*</span></label>
<textarea
id="sn-code"
v-model="form.code"
class="input mono code-area"
rows="14"
spellcheck="false"
placeholder="Paste the reusable implementation…"
></textarea>
</div>
<div class="field">
<label for="sn-tags">Tags</label>
<input
id="sn-tags"
v-model="tagsText"
type="text"
class="input"
placeholder="composable, ui (comma-separated)"
@keydown.escape="cancel"
/>
<p class="hint">Extra tags. Language and snippet are added automatically.</p>
</div>
<div class="actions">
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn-primary" :disabled="!canSave">
{{ saving ? "Saving…" : isEditing ? "Save changes" : "Create snippet" }}
</button>
</div>
</form>
</main>
</template>
<style scoped>
.snippet-editor {
max-width: 760px;
margin: 2rem auto;
padding: 0 var(--page-padding-x);
overflow-x: clip;
}
.snippet-editor h1 {
margin: 0 0 1.25rem;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--color-primary);
}
.state-msg {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.error-msg {
color: var(--color-danger);
font-size: 0.9rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.field-row.three {
grid-template-columns: 1fr 1.4fr 1fr;
}
.field label,
.location-set legend {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
}
.required {
color: var(--color-danger);
}
.hint {
font-size: 0.75rem;
color: var(--color-text-muted);
margin: 0.1rem 0 0;
}
.hint-inline {
font-weight: 400;
color: var(--color-text-muted);
}
.input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.mono {
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
}
.code-area {
resize: vertical;
line-height: 1.6;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
tab-size: 2;
}
.location-set {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.85rem 1rem 1rem;
margin: 0;
}
.location-set legend {
padding: 0 0.4rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.25rem;
}
.btn-primary {
padding: 0.5rem 1.1rem;
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-action-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-secondary {
padding: 0.5rem 1.1rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.btn-secondary:hover {
background: var(--color-bg);
}
@media (max-width: 600px) {
.field-row,
.field-row.three {
grid-template-columns: 1fr;
}
}
</style>