feat(scribe): snippet merge UI — multi-select merge, repeatable locations
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 29s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 1m2s

Step 3 of the snippet-merge milestone (#231): the human surfaces for
merge + multi-location, at v1 quality.

Frontend:
- SnippetListView: a Select mode (checkbox on each card) → a sticky action
  bar → a merge modal that lets you pick which selected snippet is the
  canonical (the others fold in and go to trash). Accent border on selected
  cards, Moss action buttons (Hybrid rule).
- SnippetEditorView: the single Location fieldset becomes a repeatable
  locations list (add/remove rows), so editing a merged snippet no longer
  collapses its call sites — no data loss. Sends `locations`.
- SnippetDetailView: renders every location (Location vs Locations label).
- api/snippets.ts: SnippetLocation type, `locations` on fields/input,
  mergeSnippets().

Backend (editor enablement):
- create_snippet service + POST route accept an optional `locations` list;
  PATCH route forwards `locations` — so the editor's location list works
  uniformly on create and edit. Single repo/path/symbol remain the
  one-location shorthand (MCP create contract unchanged).

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 17:22:01 -04:00
parent 85625de394
commit 7a81b7333e
6 changed files with 414 additions and 48 deletions
+16 -9
View File
@@ -21,11 +21,11 @@ const canWrite = computed(() => {
return p === undefined || p === "owner" || p === "edit" || p === "admin";
});
const locationParts = computed(() => {
const s = snippet.value?.snippet;
if (!s) return [];
return [s.repo, s.path, s.symbol].filter((p) => p && p.trim());
});
const locations = computed(() => snippet.value?.snippet.locations ?? []);
function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
}
async function load() {
loading.value = true;
@@ -92,10 +92,12 @@ async function confirmDelete() {
<dt>Signature</dt>
<dd><code>{{ snippet.snippet.signature }}</code></dd>
</template>
<template v-if="locationParts.length">
<dt>Location</dt>
<dd class="location">
<code v-for="(p, i) in locationParts" :key="i">{{ p }}</code>
<template v-if="locations.length">
<dt>{{ locations.length > 1 ? "Locations" : "Location" }}</dt>
<dd class="location-list">
<div v-for="(loc, i) in locations" :key="i" class="location">
<code v-for="(p, j) in locParts(loc)" :key="j">{{ p }}</code>
</div>
</dd>
</template>
<template v-if="snippet.snippet.language">
@@ -242,6 +244,11 @@ async function confirmDelete() {
border-radius: var(--radius-sm);
word-break: break-all;
}
.location-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.location {
display: flex;
flex-wrap: wrap;