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.
270 lines
9.8 KiB
TypeScript
270 lines
9.8 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Icon } from '@/components/icon/Icon';
|
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { groupHunksByFile } from '@/lib/walkthrough/model';
|
|
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
|
|
import type { WalkthroughHunk, WalkthroughStopImportance } from '@/lib/walkthrough/types';
|
|
import { cn } from '@/lib/utils';
|
|
import { WalkthroughHunkRun } from './WalkthroughHunkRun';
|
|
import { stopElementId } from './stopElementId';
|
|
|
|
interface WalkthroughStreamProps {
|
|
view: WalkthroughView;
|
|
activeStopId: string | null;
|
|
scrollToStopId: string | null;
|
|
onActiveStopChange: (stopId: string) => void;
|
|
onScrollHandled: () => void;
|
|
renderSideBySide: boolean;
|
|
wrapLines: boolean;
|
|
}
|
|
|
|
const IMPORTANCE_CLASS: Record<WalkthroughStopImportance, string> = {
|
|
critical: 'bg-status-error/10 text-status-error',
|
|
normal: 'bg-surface-muted text-muted-foreground',
|
|
context: 'bg-surface-muted text-muted-foreground',
|
|
};
|
|
|
|
const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => {
|
|
const { t } = useI18n();
|
|
const { stop } = stopView;
|
|
|
|
return (
|
|
<header className="flex flex-col gap-2 px-4 pt-5 pb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="typography-micro flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-surface-muted px-1.5 tabular-nums text-muted-foreground">
|
|
{stopView.position}
|
|
</span>
|
|
<h3 className="typography-ui-label font-semibold text-foreground">{stop.title}</h3>
|
|
{/* Same height as the step badge, so a row with an importance pill is
|
|
exactly as tall as one without: vertical padding on a smaller type
|
|
size was pushing past the tallest element in the row. */}
|
|
{stop.importance !== 'normal' && (
|
|
<span className={cn('typography-micro flex h-5 items-center rounded px-1.5 leading-none', IMPORTANCE_CLASS[stop.importance])}>
|
|
{stop.importance === 'critical'
|
|
? t('walkthrough.importance.critical')
|
|
: t('walkthrough.importance.context')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="typography-body text-muted-foreground">{stop.prose}</p>
|
|
{stopView.isStale && (
|
|
<p className="typography-meta flex items-center gap-1.5 text-status-warning">
|
|
<Icon name="error-warning" className="size-3.5 shrink-0" />
|
|
{stopView.hunks.length === 0
|
|
? t('walkthrough.stop.staleAll')
|
|
: t('walkthrough.stop.stalePartial', { count: stopView.missingHunkIds.length })}
|
|
</p>
|
|
)}
|
|
</header>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Sticky so the file you are reading stays named while you scroll through its
|
|
* hunks — the path is the main orientation cue in a long stream, and as a plain
|
|
* caption it was easy to scroll straight past.
|
|
*/
|
|
const FileHeader = ({ path }: { path: string }) => (
|
|
<div className="sticky top-0 z-10 flex items-center gap-1.5 border-b border-[var(--interactive-border)]/35 bg-[var(--surface-elevated)]/90 px-4 py-1.5 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--surface-elevated)]/80">
|
|
<FileTypeIcon filePath={path} className="size-3.5 shrink-0" />
|
|
<span className="typography-meta truncate font-mono text-foreground">{path}</span>
|
|
</div>
|
|
);
|
|
|
|
const HunkRuns = ({
|
|
hunks,
|
|
renderSideBySide,
|
|
wrapLines,
|
|
}: {
|
|
hunks: WalkthroughHunk[];
|
|
renderSideBySide: boolean;
|
|
wrapLines: boolean;
|
|
}) => {
|
|
const runs = useMemo(() => groupHunksByFile(hunks), [hunks]);
|
|
|
|
return (
|
|
<>
|
|
{runs.map((run, index) => (
|
|
<div key={`${run.path}-${index}`}>
|
|
<FileHeader path={run.path} />
|
|
<WalkthroughHunkRun
|
|
path={run.path}
|
|
hunks={run.hunks}
|
|
renderSideBySide={renderSideBySide}
|
|
wrapLines={wrapLines}
|
|
/>
|
|
</div>
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Everything the walkthrough covers, plus everything it does not, in one
|
|
* continuous scroll: a stop's explanation sits directly above the code it
|
|
* explains.
|
|
*/
|
|
export const WalkthroughStream = memo(function WalkthroughStream({
|
|
view,
|
|
activeStopId,
|
|
scrollToStopId,
|
|
onActiveStopChange,
|
|
onScrollHandled,
|
|
renderSideBySide,
|
|
wrapLines,
|
|
}: WalkthroughStreamProps) {
|
|
const { t } = useI18n();
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const [uncoveredOpen, setUncoveredOpen] = useState(false);
|
|
|
|
// Set while a click-driven jump is in flight. Without it the observer reports
|
|
// every stop the viewport passes over on the way to the target and the
|
|
// highlight ends up on whichever one happened to be reported last — the
|
|
// sidebar showing step 5 while the stream shows step 6.
|
|
const navigatingRef = useRef<string | null>(null);
|
|
const navigationTimerRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => () => {
|
|
if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current);
|
|
}, []);
|
|
|
|
// Scrolling is driven by the DOM rather than a virtualizer: only the visible
|
|
// stops mount their diff viewers, and each stop is its own element, so there
|
|
// is nothing to translate between index space and pixel space.
|
|
useEffect(() => {
|
|
if (!scrollToStopId) return;
|
|
const element = document.getElementById(stopElementId(scrollToStopId));
|
|
if (element) {
|
|
navigatingRef.current = scrollToStopId;
|
|
// Instant, not smooth: picking a step is a jump to a known destination,
|
|
// and a long animation only creates a window for the highlight to drift
|
|
// through everything in between.
|
|
element.scrollIntoView({ behavior: 'auto', block: 'start' });
|
|
|
|
// The observer fires asynchronously after the jump, and an element that
|
|
// was already in view may not fire at all — so the mute is released on a
|
|
// timer as well as on arrival.
|
|
if (navigationTimerRef.current !== null) window.clearTimeout(navigationTimerRef.current);
|
|
navigationTimerRef.current = window.setTimeout(() => {
|
|
navigatingRef.current = null;
|
|
navigationTimerRef.current = null;
|
|
}, 250);
|
|
}
|
|
onScrollHandled();
|
|
}, [scrollToStopId, onScrollHandled]);
|
|
|
|
const handleIntersection = useCallback(
|
|
(entries: IntersectionObserverEntry[]) => {
|
|
const visible = entries
|
|
.filter((entry) => entry.isIntersecting)
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
|
|
if (!visible) return;
|
|
const stopId = visible.target.getAttribute('data-stop-id');
|
|
if (!stopId) return;
|
|
|
|
const navigatingTo = navigatingRef.current;
|
|
if (navigatingTo) {
|
|
// Arrived: hand control back to free scrolling.
|
|
if (stopId === navigatingTo) navigatingRef.current = null;
|
|
return;
|
|
}
|
|
|
|
onActiveStopChange(stopId);
|
|
},
|
|
[onActiveStopChange]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const root = scrollRef.current;
|
|
if (!root) return;
|
|
|
|
const observer = new IntersectionObserver(handleIntersection, {
|
|
root,
|
|
// Only count a stop as active once its header reaches the upper band of
|
|
// the viewport, so scrolling through a long diff does not flicker the
|
|
// active step back and forth.
|
|
rootMargin: '0px 0px -70% 0px',
|
|
threshold: 0,
|
|
});
|
|
|
|
for (const stopView of view.stops) {
|
|
const element = document.getElementById(stopElementId(stopView.stop.id));
|
|
if (element) observer.observe(element);
|
|
}
|
|
|
|
return () => observer.disconnect();
|
|
}, [handleIntersection, view.stops]);
|
|
|
|
const uncoveredRuns = useMemo(() => groupHunksByFile(view.uncoveredHunks), [view.uncoveredHunks]);
|
|
|
|
return (
|
|
<div
|
|
ref={scrollRef}
|
|
className="min-h-0 flex-1 overflow-y-auto"
|
|
data-diff-virtual-root
|
|
data-diff-virtual-content
|
|
>
|
|
{view.stops.map((stopView) => (
|
|
<section
|
|
key={stopView.stop.id}
|
|
id={stopElementId(stopView.stop.id)}
|
|
data-stop-id={stopView.stop.id}
|
|
className={cn(
|
|
'border-b border-border/60',
|
|
activeStopId === stopView.stop.id && 'bg-interactive-selection/5'
|
|
)}
|
|
>
|
|
<StopHeader stopView={stopView} />
|
|
{stopView.hunks.length > 0 ? (
|
|
<HunkRuns
|
|
hunks={stopView.hunks}
|
|
renderSideBySide={renderSideBySide}
|
|
wrapLines={wrapLines}
|
|
/>
|
|
) : (
|
|
<p className="typography-meta px-4 pb-4 text-muted-foreground">
|
|
{t('walkthrough.stop.noCode')}
|
|
</p>
|
|
)}
|
|
</section>
|
|
))}
|
|
|
|
{view.uncoveredHunks.length > 0 && (
|
|
<section className="border-b border-border/60">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="h-auto w-full justify-start gap-2 px-4 py-3"
|
|
onClick={() => setUncoveredOpen((open) => !open)}
|
|
aria-expanded={uncoveredOpen}
|
|
>
|
|
<Icon name={uncoveredOpen ? 'arrow-down-s' : 'arrow-right-s'} className="size-4 shrink-0" />
|
|
<span className="typography-ui-label text-muted-foreground">
|
|
{t('walkthrough.uncovered.title', { count: view.uncoveredHunks.length })}
|
|
</span>
|
|
</Button>
|
|
{!uncoveredOpen && (
|
|
<p className="typography-meta px-4 pb-3 pl-10 text-muted-foreground">
|
|
{t('walkthrough.uncovered.description')}
|
|
</p>
|
|
)}
|
|
{uncoveredOpen
|
|
&& uncoveredRuns.map((run, index) => (
|
|
<div key={`${run.path}-${index}`}>
|
|
<FileHeader path={run.path} />
|
|
<WalkthroughHunkRun
|
|
path={run.path}
|
|
hunks={run.hunks}
|
|
renderSideBySide={renderSideBySide}
|
|
wrapLines={wrapLines}
|
|
/>
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|