fix(snippets): close the recall-surface gaps found reviewing the Drafter
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 19s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m7s

Four defects from the 2026-07-25 review of the recall (#227) and merge (#231)
milestones. The theme: a snippet could be recorded but not fully corrected, and
the agent and web surfaces had drifted apart.

- #2076 language was mis-derived from the first caller tag, so a snippet created
  with tags and no language read that tag back as its language — corrupting the
  tag set and the code fence on the next update. Only the FIRST tag can carry
  the language, since compose_tags emits [language, "snippet", *caller].
- #2077 MCP update_snippet mapped "" to "unchanged", so no field could ever be
  cleared and no snippet detached from its project. Now an omitted field is left
  alone, an empty string clears, and project_id follows the -1 = detach
  convention. A service-level UNSET sentinel keeps None available as the clear.
- #2078 surface parity: adds delete_snippet (MCP had none, so a wrong snippet
  could not be retired by the agent that recorded it), locations on MCP create
  and update, system_ids through the REST routes and the editor, and the
  near-duplicate gate on REST create with a "record it anyway" escape.
- #2079 project scoping: list_snippets takes project_id through the service, the
  MCP tool and the REST route, defaulting to every project — reaching across
  projects is the point when the helper you need was written elsewhere.

Sharing the list across owners is deliberately NOT in here: query_knowledge is
shared with the Knowledge browse surface, so widening it changes behaviour well
beyond snippets. Left open on #2079 for a scope decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLwAaV4DQEmVyn496HnEvt
This commit is contained in:
2026-07-25 19:00:05 -04:00
parent 7a81b7333e
commit b33e2a79c6
12 changed files with 492 additions and 43 deletions
+6 -1
View File
@@ -36,6 +36,7 @@ export interface Snippet {
created_at: string;
updated_at: string;
snippet: SnippetFields;
systems?: { id: number; name: string }[];
}
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
@@ -63,14 +64,18 @@ export interface SnippetInput {
locations?: SnippetLocation[];
tags?: string[];
project_id?: number | null;
system_ids?: number[];
/** Create only: record it even though a near-duplicate already exists. */
force?: boolean;
}
export async function listSnippets(
params: { q?: string; tag?: string } = {},
params: { q?: string; tag?: string; projectId?: number | null } = {},
): Promise<{ snippets: SnippetListItem[]; total: number }> {
const qs = new URLSearchParams();
if (params.q) qs.set("q", params.q);
if (params.tag) qs.set("tag", params.tag);
if (params.projectId) qs.set("project_id", String(params.projectId));
const query = qs.toString();
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
}
+165 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from "vue";
import { ref, computed, onMounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
getSnippet,
@@ -8,11 +8,15 @@ import {
type SnippetInput,
type SnippetLocation,
} from "@/api/snippets";
import { ApiError } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
import { useSystemsStore } from "@/stores/systems";
import { useToastStore } from "@/stores/toast";
const route = useRoute();
const router = useRouter();
const toast = useToastStore();
const systemsStore = useSystemsStore();
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
const isEditing = computed(() => editId.value !== null);
@@ -47,17 +51,66 @@ function removeLocation(i: number) {
locations.value.splice(i, 1);
}
const tagsText = ref("");
const projectId = ref<number | null>(null);
const systemIds = ref<number[]>([]);
const loading = ref(false);
const saving = ref(false);
const loadError = ref<string | null>(null);
const nameRef = ref<HTMLInputElement | null>(null);
// Systems belong to a project, so the picker only has anything to offer once
// one is chosen — and it repopulates when the project changes.
const projectSystems = computed(() =>
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
);
async function loadSystems() {
if (!projectId.value) return;
try {
await systemsStore.fetchSystems(projectId.value);
} catch {
/* non-fatal — the systems picker just won't populate */
}
}
watch(projectId, (next, prev) => {
// Moving to another project invalidates system ids from the old one.
if (prev !== null && next !== prev) systemIds.value = [];
loadSystems();
});
const canSave = computed(
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
);
// A near-duplicate blocked on create: the same reusable thing is already
// recorded. Recording it twice is what merge then has to undo, so the editor
// offers the existing record before it offers to save anyway.
interface DuplicateHit {
existing_id: number;
existing_title: string;
}
const duplicate = ref<DuplicateHit | null>(null);
const forceCreate = ref(false);
function duplicateFrom(err: unknown): DuplicateHit | null {
if (!(err instanceof ApiError) || err.status !== 409) return null;
const body = err.body as Record<string, unknown>;
if (!body.duplicate) return null;
return {
existing_id: Number(body.existing_id),
existing_title: String(body.existing_title ?? "an existing snippet"),
};
}
async function load() {
if (!isEditing.value) {
// Recording from a project page carries the project through, so the snippet
// lands where the work is instead of unfiled.
if (route.query.projectId) {
projectId.value = Number(route.query.projectId);
await loadSystems();
}
await nextTick();
nameRef.value?.focus();
return;
@@ -82,6 +135,9 @@ async function load() {
tagsText.value = s.tags
.filter((t) => t !== "snippet" && t !== f.language)
.join(", ");
projectId.value = s.project_id ?? null;
systemIds.value = (s.systems ?? []).map((sys) => sys.id);
await loadSystems();
} catch {
loadError.value = "Couldn't load this snippet to edit.";
} finally {
@@ -115,20 +171,37 @@ async function save() {
when_to_use: form.value.when_to_use.trim(),
locations: cleanLocations(),
tags: parseTags(),
project_id: projectId.value,
system_ids: systemIds.value,
};
if (forceCreate.value) payload.force = true;
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 {
} catch (err) {
// A blocked near-duplicate isn't a failure — it's the record telling you
// this already exists. Offer the existing one rather than a red toast.
const dup = duplicateFrom(err);
if (dup) {
duplicate.value = dup;
saving.value = false;
return;
}
toast.show("Failed to save snippet", "error");
} finally {
saving.value = false;
}
}
async function saveAnyway() {
duplicate.value = null;
forceCreate.value = true;
await save();
}
function cancel() {
if (isEditing.value) router.push(`/snippets/${editId.value}`);
else router.push("/snippets");
@@ -243,6 +316,41 @@ function cancel() {
<p class="hint">Extra tags. Language and snippet are added automatically.</p>
</div>
<div class="field">
<label for="sn-project">Project</label>
<ProjectSelector id="sn-project" v-model="projectId" />
<p class="hint">
Snippets surface proactively within their project; search reaches across all of them.
</p>
</div>
<div v-if="projectId" class="field">
<span class="field-label">Systems</span>
<div v-if="projectSystems.length" class="systems">
<label v-for="s in projectSystems" :key="s.id" class="system-opt">
<input type="checkbox" :value="s.id" v-model="systemIds" />
<span>{{ s.name }}</span>
</label>
</div>
<p v-else class="hint">No systems in this project yet.</p>
</div>
<div v-if="duplicate" class="duplicate" role="alert">
<p class="duplicate-title">This may already be recorded</p>
<p class="duplicate-body">
A similar snippet exists
<router-link :to="`/snippets/${duplicate.existing_id}`">
{{ duplicate.existing_title }}
</router-link
>. Reuse or edit that one rather than keeping two copies of the same thing.
</p>
<div class="duplicate-actions">
<button type="button" class="btn-secondary" @click="saveAnyway">
Record it anyway
</button>
</div>
</div>
<div class="actions">
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn-primary" :disabled="!canSave">
@@ -403,6 +511,61 @@ function cancel() {
}
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text);
}
.systems {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 160px;
overflow-y: auto;
}
.system-opt {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
color: var(--color-text);
cursor: pointer;
}
.system-opt input {
accent-color: var(--color-primary);
cursor: pointer;
}
.duplicate {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.85rem 1rem;
border: 1px solid var(--color-border);
border-left: 3px solid var(--color-warning, var(--color-primary));
border-radius: 8px;
background: var(--color-bg-secondary);
}
.duplicate-title {
margin: 0;
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text);
}
.duplicate-body {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.duplicate-body a {
color: var(--color-primary);
}
.duplicate-actions {
display: flex;
justify-content: flex-end;
}
.actions {
display: flex;
justify-content: flex-end;