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
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:
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { listSnippets, type SnippetListItem } from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
|
||||
const snippets = ref<SnippetListItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const search = ref("");
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
async function loadSnippets() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const data = await listSnippets({ q: search.value.trim() || undefined });
|
||||
snippets.value = data.snippets;
|
||||
} catch {
|
||||
error.value = "Couldn't load your snippets.";
|
||||
toast.show("Failed to load snippets", "error");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(loadSnippets, 300);
|
||||
}
|
||||
|
||||
onMounted(loadSnippets);
|
||||
|
||||
/** Titles are stored as "name — when to reach for it"; split for display. */
|
||||
function splitTitle(title: string): { name: string; when: string } {
|
||||
const idx = title.indexOf(" — ");
|
||||
if (idx === -1) return { name: title, when: "" };
|
||||
return { name: title.slice(0, idx), when: title.slice(idx + 3) };
|
||||
}
|
||||
|
||||
function languageOf(tags: string[]): string {
|
||||
return tags.find((t) => t && t !== "snippet") ?? "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippets-list">
|
||||
<div class="page-header">
|
||||
<h1>Snippets</h1>
|
||||
<button class="btn-primary" @click="router.push('/snippets/new')">
|
||||
+ New snippet
|
||||
</button>
|
||||
</div>
|
||||
<p class="page-sub">
|
||||
Reusable functions and components, recorded once so they surface before
|
||||
they're rewritten as a one-off.
|
||||
</p>
|
||||
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="search"
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search snippets…"
|
||||
aria-label="Search snippets"
|
||||
@input="onSearchInput"
|
||||
@keydown.enter="loadSnippets"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="skeleton-grid">
|
||||
<div class="skeleton-card" v-for="i in 6" :key="i"></div>
|
||||
</div>
|
||||
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
||||
<div class="empty-icon">❭_</div>
|
||||
<p class="empty-title">
|
||||
{{ search.trim() ? "No snippets match your search" : "No snippets kept yet" }}
|
||||
</p>
|
||||
<p class="empty-sub">
|
||||
{{ search.trim()
|
||||
? "Try a different term, or clear the search."
|
||||
: "Record a reusable function or component and it will be offered back to you later." }}
|
||||
</p>
|
||||
<button
|
||||
v-if="!search.trim()"
|
||||
class="empty-action"
|
||||
@click="router.push('/snippets/new')"
|
||||
>
|
||||
New snippet →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="snippets-grid">
|
||||
<div
|
||||
v-for="s in snippets"
|
||||
:key="s.id"
|
||||
class="snippet-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="router.push(`/snippets/${s.id}`)"
|
||||
@keydown.enter="router.push(`/snippets/${s.id}`)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="snippet-name">{{ splitTitle(s.title).name }}</span>
|
||||
<span v-if="languageOf(s.tags)" class="lang-pill">{{ languageOf(s.tags) }}</span>
|
||||
</div>
|
||||
<p v-if="splitTitle(s.title).when" class="snippet-when">
|
||||
{{ splitTitle(s.title).when }}
|
||||
</p>
|
||||
<div class="card-footer">
|
||||
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snippets-list {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.page-sub {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 0.5rem 0.8rem;
|
||||
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;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.empty-state-rich {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.empty-icon {
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
.empty-sub {
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 1rem;
|
||||
max-width: 44ch;
|
||||
margin-inline: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty-action {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.empty-action:hover {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.skeleton-grid,
|
||||
.snippets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.skeleton-card {
|
||||
height: 96px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.snippet-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.9rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.snippet-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.snippet-card:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.snippet-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
||||
}
|
||||
|
||||
/* Language tag — accent pill per the design system's tag treatment. */
|
||||
.lang-pill {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.snippet-when {
|
||||
font-size: 0.83rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
.meta-date {
|
||||
font-size: 0.73rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.snippets-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user