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
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:
@@ -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}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user