A diff is ordered by file path, which is almost never the order in which a change makes sense. This adds a Walkthrough surface that reorders it: the model groups related hunks into stops, explains what each group changes about behavior, and orders the stops so each builds on the last. It explains and orders; judging code stays with the existing Review action. Reviews uncommitted work (all, staged, unstaged), a branch against its base, or a pull request. Generation is always user-initiated — nothing runs on a timer, on a file change, or as a side effect of opening a panel. Invariants worth preserving: - Hunk identity is derived on the server and only there. Ids are content hashes, so an anchor that no longer resolves is proof the code it described changed, and staleness needs no heuristics. The client matches ids to ids and never recomputes them; two implementations would have to agree forever. - The digest is never truncated. A diff that does not fit the model's context is refused with an actionable reason, because a walkthrough written against half a diff reads as confident and is wrong. - Nothing disappears. Lockfiles and other generated output are excluded from the model's input by name — never by size — and everything no stop covers is listed at the end, so "have I seen all of it" stays answerable. - Cost is explicit. Results are content-addressed, so returning the working tree to an earlier state costs nothing; generation outlives its request, so a refresh detaches the client rather than discarding paid-for work, and only an explicit cancel stops it. Supporting changes to shared modules: - git: expose the existing getRangeDiff as GET /api/git listUntrackedPaths and getUntrackedDiffs. The latter resolve the repository once for a batch instead of per file, taking a panel ~340ms on an 80-file working tree. - small-model: structured output across four wire forma and abort signal, and an onOverflow policy so an oversized prompt fails loudly instead of being silently clipped. A provider remembered so the prompt-side fallback goes first next time. - models.dev metadata: surface structured_output as tri false blocks a model, a missing field does not, because the catalog omits it for roughly half of all models. Desktop and tablet only: VS Code serves Git through its these routes, and the mobile shell does not consume the surface registry. Docs: packages/docs walkthrough page in English and all eight locales.
176 lines
5.8 KiB
JavaScript
176 lines
5.8 KiB
JavaScript
// Shape of the walkthrough the model must produce, plus normalization of what
|
|
// it actually produced. The model is only ever trusted for prose and grouping —
|
|
// every anchor it returns is re-resolved against the digest here, and anything
|
|
// that does not resolve is dropped rather than rendered as a broken stop.
|
|
|
|
export const WALKTHROUGH_VERSION = 1;
|
|
|
|
// Bumping this invalidates every cached walkthrough, which is the point: a
|
|
// changed prompt produces different output and old entries would misrepresent
|
|
// what the current code would say.
|
|
export const PROMPT_VERSION = 2;
|
|
|
|
export const MAX_CHAPTERS = 6;
|
|
export const MAX_STOPS = 16;
|
|
export const MAX_HUNKS_PER_STOP = 14;
|
|
export const MAX_CHAPTER_TITLE_CHARS = 24;
|
|
|
|
const CHAPTER_ICONS = ['bug', 'wrench', 'path', 'flask', 'doc', 'gear'];
|
|
const STOP_IMPORTANCE = ['critical', 'normal', 'context'];
|
|
|
|
export const responseSchema = {
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string' },
|
|
focus: { type: 'string' },
|
|
chapters: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string' },
|
|
icon: { type: 'string', enum: CHAPTER_ICONS },
|
|
blurb: { type: 'string' },
|
|
stops: {
|
|
type: 'array',
|
|
items: {
|
|
type: 'object',
|
|
properties: {
|
|
title: { type: 'string' },
|
|
hunks: { type: 'array', items: { type: 'string' } },
|
|
importance: { type: 'string', enum: STOP_IMPORTANCE },
|
|
prose: { type: 'string' },
|
|
},
|
|
required: ['title', 'hunks', 'importance', 'prose'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
required: ['title', 'icon', 'blurb', 'stops'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
required: ['title', 'focus', 'chapters'],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const asString = (value, max) => {
|
|
if (typeof value !== 'string') return '';
|
|
const trimmed = value.trim();
|
|
return max && trimmed.length > max ? trimmed.slice(0, max) : trimmed;
|
|
};
|
|
|
|
/**
|
|
* Turn a raw model response into a walkthrough anchored to real hunk ids.
|
|
*
|
|
* @param {object} raw parsed model JSON
|
|
* @param {Map<string,string>} idByAlias alias → real hunk id, from the digest
|
|
* @returns {{title: string, focus: string, chapters: Array<object>, droppedAnchors: number}}
|
|
*/
|
|
export function normalizeWalkthrough(raw, idByAlias) {
|
|
if (!raw || typeof raw !== 'object') {
|
|
throw Object.assign(new Error('Model returned no walkthrough object'), { code: 'invalid-walkthrough' });
|
|
}
|
|
|
|
const usedIds = new Set();
|
|
let droppedAnchors = 0;
|
|
let stopCount = 0;
|
|
|
|
const chapters = [];
|
|
for (const [chapterIndex, rawChapter] of (Array.isArray(raw.chapters) ? raw.chapters : []).entries()) {
|
|
if (chapters.length >= MAX_CHAPTERS) break;
|
|
if (!rawChapter || typeof rawChapter !== 'object') continue;
|
|
|
|
const stops = [];
|
|
for (const rawStop of Array.isArray(rawChapter.stops) ? rawChapter.stops : []) {
|
|
if (stopCount >= MAX_STOPS) break;
|
|
if (!rawStop || typeof rawStop !== 'object') continue;
|
|
|
|
const hunkIds = [];
|
|
for (const alias of Array.isArray(rawStop.hunks) ? rawStop.hunks : []) {
|
|
const id = idByAlias.get(typeof alias === 'string' ? alias.trim() : '');
|
|
if (!id) {
|
|
droppedAnchors += 1;
|
|
continue;
|
|
}
|
|
// One hunk belongs to exactly one stop; a model that anchors the same
|
|
// code twice would otherwise render it twice in the stream.
|
|
if (usedIds.has(id)) continue;
|
|
if (hunkIds.length >= MAX_HUNKS_PER_STOP) break;
|
|
usedIds.add(id);
|
|
hunkIds.push(id);
|
|
}
|
|
|
|
const prose = asString(rawStop.prose);
|
|
if (hunkIds.length === 0 || !prose) continue;
|
|
|
|
stopCount += 1;
|
|
stops.push({
|
|
id: `stop-${chapterIndex + 1}-${stops.length + 1}`,
|
|
title: asString(rawStop.title) || `Step ${stopCount}`,
|
|
hunkIds,
|
|
importance: STOP_IMPORTANCE.includes(rawStop.importance) ? rawStop.importance : 'normal',
|
|
prose,
|
|
});
|
|
}
|
|
|
|
if (stops.length === 0) continue;
|
|
|
|
chapters.push({
|
|
id: `chapter-${chapters.length + 1}`,
|
|
title: asString(rawChapter.title, MAX_CHAPTER_TITLE_CHARS) || `Part ${chapters.length + 1}`,
|
|
icon: CHAPTER_ICONS.includes(rawChapter.icon) ? rawChapter.icon : 'doc',
|
|
blurb: asString(rawChapter.blurb),
|
|
stops,
|
|
});
|
|
}
|
|
|
|
if (chapters.length === 0) {
|
|
throw Object.assign(
|
|
new Error('Model returned no usable stops for this diff'),
|
|
{ code: 'invalid-walkthrough' },
|
|
);
|
|
}
|
|
|
|
return {
|
|
title: asString(raw.title) || 'Change walkthrough',
|
|
focus: asString(raw.focus),
|
|
chapters,
|
|
droppedAnchors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract a JSON object from a model response that may or may not honour the
|
|
* schema — some providers wrap it in prose or a fenced block.
|
|
*/
|
|
export function parseModelJson(text) {
|
|
if (typeof text !== 'string' || !text.trim()) {
|
|
throw Object.assign(new Error('Model returned an empty response'), { code: 'invalid-walkthrough' });
|
|
}
|
|
|
|
const withoutFence = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
|
|
try {
|
|
return JSON.parse(withoutFence);
|
|
} catch {
|
|
// Fall through to a bounded scan for the outermost object.
|
|
}
|
|
|
|
const start = withoutFence.indexOf('{');
|
|
if (start === -1) {
|
|
throw Object.assign(new Error('Model response contained no JSON object'), { code: 'invalid-walkthrough' });
|
|
}
|
|
|
|
for (let end = withoutFence.lastIndexOf('}'); end > start; end = withoutFence.lastIndexOf('}', end - 1)) {
|
|
try {
|
|
return JSON.parse(withoutFence.slice(start, end + 1));
|
|
} catch {
|
|
// Keep shrinking from the right.
|
|
}
|
|
}
|
|
|
|
throw Object.assign(new Error('Model response was not valid JSON'), { code: 'invalid-walkthrough' });
|
|
}
|