feat(walkthrough): guided AI walkthrough for diffs, branches, and PRs (#2572)
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.
This commit is contained in:
committed by
GitHub
parent
b1ec34162e
commit
34d0ff7383
@@ -0,0 +1,621 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { buildWalkthroughView } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { deriveBaseBranch } from '@/components/views/git/baseBranch';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useGitBranches, useGitStatus } from '@/stores/useGitStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import {
|
||||
getFreshestPrStatusForBranch,
|
||||
getGitHubPrStatusKey,
|
||||
useGitHubPrStatusStore,
|
||||
} from '@/stores/useGitHubPrStatusStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WalkthroughBlocker } from './WalkthroughBlocker';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from './walkthroughAction';
|
||||
import { WalkthroughStages } from './WalkthroughStages';
|
||||
import { useWalkthroughStageProgress } from './useWalkthroughStageProgress';
|
||||
import { WalkthroughStream } from './WalkthroughStream';
|
||||
import { WalkthroughToc } from './WalkthroughToc';
|
||||
|
||||
interface WalkthroughViewProps {
|
||||
directory: string;
|
||||
}
|
||||
|
||||
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
|
||||
|
||||
// DropdownMenuLabel defaults to the same size and weight as its items, which
|
||||
// makes a heading read as another choice. This matches SelectLabel, the
|
||||
// treatment used by the worktree picker.
|
||||
const SCOPE_GROUP_LABEL_CLASS = 'typography-meta font-normal text-muted-foreground';
|
||||
|
||||
// Below this the table of contents would squeeze the diff into uselessness, so
|
||||
// the stream takes the whole panel and the header arrows carry navigation.
|
||||
const TOC_MIN_PANEL_WIDTH = 720;
|
||||
const TOC_MIN_WIDTH = 180;
|
||||
// The diff is the point of the surface; the contents column may never take more
|
||||
// than half the panel no matter how far the user drags.
|
||||
const TOC_MAX_FRACTION = 0.5;
|
||||
|
||||
export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(0);
|
||||
|
||||
// Panel width, not viewport width: this surface is resizable independently of
|
||||
// the window.
|
||||
useEffect(() => {
|
||||
const element = rootRef.current;
|
||||
if (!element || typeof ResizeObserver === 'undefined') return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
setPanelWidth(entries[0]?.contentRect.width ?? 0);
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const storedTocWidth = useUIStore((state) => state.walkthroughTocWidth);
|
||||
const setStoredTocWidth = useUIStore((state) => state.setWalkthroughTocWidth);
|
||||
const [draggingToc, setDraggingToc] = useState(false);
|
||||
|
||||
const showToc = panelWidth === 0 || panelWidth >= TOC_MIN_PANEL_WIDTH;
|
||||
// Clamped on read rather than on write: the panel can be resized after the
|
||||
// width was stored, and a remembered 400px column must not swallow a narrow
|
||||
// panel.
|
||||
const tocWidth = Math.min(
|
||||
Math.max(storedTocWidth, TOC_MIN_WIDTH),
|
||||
Math.max(TOC_MIN_WIDTH, (panelWidth || TOC_MIN_PANEL_WIDTH) * TOC_MAX_FRACTION)
|
||||
);
|
||||
|
||||
const handleTocResizeStart = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const startX = event.clientX;
|
||||
const startWidth = tocWidth;
|
||||
const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION);
|
||||
setDraggingToc(true);
|
||||
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
const next = Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, startWidth + moveEvent.clientX - startX));
|
||||
setStoredTocWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
setDraggingToc(false);
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
},
|
||||
[setStoredTocWidth, tocWidth]
|
||||
);
|
||||
|
||||
const handleTocResizeKey = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const step = event.shiftKey ? 40 : 10;
|
||||
const delta = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
|
||||
if (delta === 0) return;
|
||||
event.preventDefault();
|
||||
const maxWidth = Math.max(TOC_MIN_WIDTH, (rootRef.current?.clientWidth ?? 0) * TOC_MAX_FRACTION);
|
||||
setStoredTocWidth(Math.min(maxWidth, Math.max(TOC_MIN_WIDTH, tocWidth + delta)));
|
||||
},
|
||||
[setStoredTocWidth, tocWidth]
|
||||
);
|
||||
const [scope, setScope] = useState<WalkthroughWorkingTreeScope>('all');
|
||||
const [activeStopId, setActiveStopId] = useState<string | null>(null);
|
||||
const [scrollToStopId, setScrollToStopId] = useState<string | null>(null);
|
||||
const [visitedStopIds, setVisitedStopIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
|
||||
const wrapLines = useUIStore((state) => state.diffWrapLines);
|
||||
// The walkthrough column is narrower than the diff surface and stops are read
|
||||
// top-to-bottom, so `dynamic` resolves to inline here rather than guessing
|
||||
// from the window width.
|
||||
const renderSideBySide = diffLayoutPreference === 'side-by-side';
|
||||
|
||||
const requestedSource = useWalkthroughStore((state) => state.requestedSource[directory]);
|
||||
const clearRequestedSource = useWalkthroughStore((state) => state.clearRequestedSource);
|
||||
|
||||
const status = useGitStatus(directory || null);
|
||||
const branches = useGitBranches(directory || null);
|
||||
|
||||
// The branch source reviews everything on this branch that is not on its
|
||||
// base. Three-dot semantics server-side mean merges from the base are
|
||||
// already excluded.
|
||||
const currentBranch = status?.current ?? null;
|
||||
const branchSource = useMemo<WalkthroughSource | null>(() => {
|
||||
const headRef = currentBranch;
|
||||
if (!headRef) return null;
|
||||
const all = branches?.all ?? [];
|
||||
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
|
||||
const remoteNames = new Set(
|
||||
all
|
||||
.filter((name) => name.startsWith('remotes/'))
|
||||
.map((name) => name.slice('remotes/'.length).split('/')[0])
|
||||
.filter(Boolean)
|
||||
);
|
||||
const baseRef = deriveBaseBranch({ remoteNames, localBranches });
|
||||
if (!baseRef || baseRef === headRef) return null;
|
||||
return { kind: 'branch', baseRef, headRef };
|
||||
}, [branches, currentBranch]);
|
||||
|
||||
// The pull request for this branch used to appear only after visiting the PR
|
||||
// panel, because nothing else asked GitHub about it. Ask here too: the status
|
||||
// store already dedupes by signature and throttles by TTL, so several panels
|
||||
// wanting the same answer produce one request.
|
||||
const { github } = useRuntimeAPIs();
|
||||
const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const refreshPrStatusTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||
|
||||
useEffect(() => {
|
||||
if (!directory || !currentBranch || !githubAuthChecked || !githubConnected) return;
|
||||
const key = getGitHubPrStatusKey(directory, currentBranch);
|
||||
ensurePrStatusEntry(key);
|
||||
setPrStatusParams(key, {
|
||||
directory,
|
||||
branch: currentBranch,
|
||||
remoteName: null,
|
||||
canShow: true,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected,
|
||||
});
|
||||
void refreshPrStatusTargets([{ directory, branch: currentBranch, remoteName: null }]);
|
||||
}, [
|
||||
currentBranch,
|
||||
directory,
|
||||
ensurePrStatusEntry,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected,
|
||||
refreshPrStatusTargets,
|
||||
setPrStatusParams,
|
||||
]);
|
||||
|
||||
// Selecting the number rather than the entry map: a primitive keeps this
|
||||
// panel out of every unrelated PR status update.
|
||||
const branchPrNumber = useGitHubPrStatusStore((state) => (
|
||||
directory && currentBranch
|
||||
? getFreshestPrStatusForBranch(state.entries, directory, currentBranch)?.pr?.number ?? null
|
||||
: null
|
||||
));
|
||||
|
||||
const source = useMemo<WalkthroughSource>(
|
||||
() => requestedSource ?? { kind: 'working-tree', scope },
|
||||
[requestedSource, scope]
|
||||
);
|
||||
|
||||
// Offer whichever pull request we know about: the one already selected, or
|
||||
// the one this branch has.
|
||||
const prSource = useMemo<Extract<WalkthroughSource, { kind: 'pr' }> | null>(() => {
|
||||
if (source.kind === 'pr') return source;
|
||||
return branchPrNumber ? { kind: 'pr', number: branchPrNumber } : null;
|
||||
}, [branchPrNumber, source]);
|
||||
|
||||
const selectWorkingTree = useCallback(
|
||||
(value: WalkthroughWorkingTreeScope) => {
|
||||
clearRequestedSource(directory);
|
||||
setScope(value);
|
||||
},
|
||||
[clearRequestedSource, directory]
|
||||
);
|
||||
const entry = useWalkthroughStore((state) => state.getEntry(directory, source));
|
||||
const load = useWalkthroughStore((state) => state.load);
|
||||
const generate = useWalkthroughStore((state) => state.generate);
|
||||
const cancel = useWalkthroughStore((state) => state.cancel);
|
||||
const requestSource = useWalkthroughStore((state) => state.requestSource);
|
||||
const selectModel = useWalkthroughStore((state) => state.selectModel);
|
||||
const selectedModel = useWalkthroughStore((state) => state.getSelectedModel(directory, source));
|
||||
|
||||
// Reloads on a model change too: whether this diff fits, and whether the
|
||||
// model can produce structured output, are answers about a specific model.
|
||||
useEffect(() => {
|
||||
void load(directory, source);
|
||||
}, [directory, load, source, selectedModel]);
|
||||
|
||||
const view = useMemo(() => buildWalkthroughView(entry.result), [entry.result]);
|
||||
|
||||
// A new walkthrough is a new reading path: keeping the old progress would
|
||||
// mark stops as visited that the user has never seen.
|
||||
const generatedAt = entry.result?.generatedAt;
|
||||
const lastGeneratedAt = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (lastGeneratedAt.current === generatedAt) return;
|
||||
lastGeneratedAt.current = generatedAt;
|
||||
setVisitedStopIds(new Set());
|
||||
setActiveStopId(view?.stops[0]?.stop.id ?? null);
|
||||
}, [generatedAt, view]);
|
||||
|
||||
const handleActiveStopChange = useCallback((stopId: string) => {
|
||||
setActiveStopId(stopId);
|
||||
setVisitedStopIds((visited) => {
|
||||
if (visited.has(stopId)) return visited;
|
||||
const next = new Set(visited);
|
||||
next.add(stopId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSelectStop = useCallback(
|
||||
(stopId: string) => {
|
||||
handleActiveStopChange(stopId);
|
||||
setScrollToStopId(stopId);
|
||||
},
|
||||
[handleActiveStopChange]
|
||||
);
|
||||
|
||||
const step = useCallback(
|
||||
(delta: number) => {
|
||||
if (!view || view.stops.length === 0) return;
|
||||
const currentIndex = view.stops.findIndex((stop) => stop.stop.id === activeStopId);
|
||||
const nextIndex = Math.min(view.stops.length - 1, Math.max(0, (currentIndex < 0 ? 0 : currentIndex) + delta));
|
||||
handleSelectStop(view.stops[nextIndex].stop.id);
|
||||
},
|
||||
[activeStopId, handleSelectStop, view]
|
||||
);
|
||||
|
||||
const [sourceMenuOpen, setSourceMenuOpen] = useState(false);
|
||||
const sourceValue = source.kind === 'working-tree' ? source.scope : source.kind;
|
||||
const sourceLabel = source.kind === 'branch'
|
||||
? t('walkthrough.scope.branch')
|
||||
: source.kind === 'pr'
|
||||
? t('walkthrough.scope.pullRequest', { number: source.number })
|
||||
: scope === 'all'
|
||||
? t('walkthrough.scope.all')
|
||||
: scope === 'staged'
|
||||
? t('walkthrough.scope.staged')
|
||||
: t('walkthrough.scope.working');
|
||||
|
||||
// Explicit pick first, then the model that actually produced what is on
|
||||
// screen, then whatever settings resolve to. The middle step is what makes
|
||||
// reopening a review show the model behind it rather than the default.
|
||||
const activeModel = selectedModel
|
||||
?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined)
|
||||
?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined);
|
||||
const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/');
|
||||
const activeModelId = activeModelParts.join('/');
|
||||
|
||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||
const [modelProviders, setModelProviders] = useState<string[] | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (modelProviders !== undefined) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await runtimeFetch('/api/small-model', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const payload = (await response.json().catch(() => null)) as { authenticatedProviders?: unknown } | null;
|
||||
if (!cancelled && Array.isArray(payload?.authenticatedProviders)) {
|
||||
setModelProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
|
||||
}
|
||||
} catch {
|
||||
// Leave undefined: the picker then offers every provider, which is
|
||||
// worse but not broken.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [modelProviders]);
|
||||
|
||||
const isStructuredOutputCapable = useCallback(
|
||||
(providerId: string, modelId: string) =>
|
||||
modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false,
|
||||
[modelsMetadata]
|
||||
);
|
||||
|
||||
const isBusy = entry.status === 'loading' || entry.status === 'generating';
|
||||
|
||||
// The stage list outlives the work by a beat. Assembling takes milliseconds,
|
||||
// so without this the result replaces the list before the last step is ever
|
||||
// seen finishing — the user is told about a step they never observe.
|
||||
const stageProgress = useWalkthroughStageProgress(entry.stage, entry.status === 'generating');
|
||||
|
||||
// Only a generation that started from an empty panel gets held: regenerating
|
||||
// over an existing walkthrough keeps the stream on screen with a banner, and
|
||||
// hiding readable content to show a progress list would be a downgrade.
|
||||
const startedFromEmptyRef = useRef(false);
|
||||
const previousStatusRef = useRef(entry.status);
|
||||
useEffect(() => {
|
||||
if (previousStatusRef.current !== 'generating' && entry.status === 'generating') {
|
||||
startedFromEmptyRef.current = !view;
|
||||
}
|
||||
previousStatusRef.current = entry.status;
|
||||
}, [entry.status, view]);
|
||||
|
||||
const showStages = startedFromEmptyRef.current
|
||||
&& (entry.status === 'generating' || stageProgress.holding);
|
||||
const blockedReason = entry.error?.code === 'context-too-small'
|
||||
|| entry.error?.code === 'structured-output-unsupported'
|
||||
|| entry.error?.code === 'no-model'
|
||||
|| entry.error?.code === 'empty-diff'
|
||||
|| entry.error?.code === 'only-generated'
|
||||
|| entry.error?.code === 'output-exhausted'
|
||||
? entry.error.code
|
||||
: entry.readiness && !entry.readiness.ready && !view
|
||||
? entry.readiness.reason
|
||||
: undefined;
|
||||
|
||||
// Both sources carry the model that was tried; the error is the more specific
|
||||
// one when generation actually ran.
|
||||
const blockedModel = entry.error?.model ?? entry.readiness?.model;
|
||||
const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars;
|
||||
const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars;
|
||||
|
||||
const handleGenerate = useCallback(
|
||||
(force: boolean) => {
|
||||
void generate(directory, source, { force });
|
||||
},
|
||||
[directory, generate, source]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<DropdownMenu open={sourceMenuOpen} onOpenChange={setSourceMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 flex-shrink-0 items-center gap-1.5 rounded-md px-2 typography-ui-label font-semibold text-foreground outline-none hover:bg-interactive-hover focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t('walkthrough.scope.selectorAria')}
|
||||
>
|
||||
<span className="whitespace-nowrap">{sourceLabel}</span>
|
||||
<Icon name="arrow-down-s" className="size-4 flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceValue}
|
||||
onValueChange={(value) => {
|
||||
setSourceMenuOpen(false);
|
||||
if (value === 'branch') {
|
||||
if (branchSource) requestSource(directory, branchSource);
|
||||
return;
|
||||
}
|
||||
if (value === 'pr') {
|
||||
if (prSource) requestSource(directory, prSource);
|
||||
return;
|
||||
}
|
||||
selectWorkingTree(value as WalkthroughWorkingTreeScope);
|
||||
}}
|
||||
>
|
||||
{/* Grouped so "everything" is visibly scoped to uncommitted work:
|
||||
on its own next to "This branch" it read as "all changes that
|
||||
exist", which is the opposite of what it selects. */}
|
||||
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
|
||||
{t('walkthrough.scope.group.workingTree')}
|
||||
</DropdownMenuLabel>
|
||||
{SCOPES.map((value) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
{value === 'all'
|
||||
? t('walkthrough.scope.all')
|
||||
: value === 'staged'
|
||||
? t('walkthrough.scope.staged')
|
||||
: t('walkthrough.scope.working')}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{(branchSource || prSource) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className={SCOPE_GROUP_LABEL_CLASS}>
|
||||
{t('walkthrough.scope.group.committed')}
|
||||
</DropdownMenuLabel>
|
||||
</>
|
||||
)}
|
||||
{branchSource && (
|
||||
<DropdownMenuRadioItem value="branch">
|
||||
{t('walkthrough.scope.branch')}
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
{prSource && (
|
||||
<DropdownMenuRadioItem value="pr">
|
||||
{t('walkthrough.scope.pullRequest', { number: prSource.number })}
|
||||
</DropdownMenuRadioItem>
|
||||
)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="ml-auto flex min-w-0 items-center gap-1">
|
||||
{/* Choosing a roomier model for a risky change is a per-review call,
|
||||
so this is panel state rather than a settings edit. */}
|
||||
<ModelSelector
|
||||
providerId={activeProviderId ?? ''}
|
||||
modelId={activeModelId}
|
||||
onChange={(providerId, modelId) => {
|
||||
selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null);
|
||||
}}
|
||||
allowedProviderIds={modelProviders}
|
||||
isModelAllowed={isStructuredOutputCapable}
|
||||
tooltipsEnabled={false}
|
||||
dropdownPortalToBody
|
||||
className="h-7 min-w-0 max-w-48"
|
||||
/>
|
||||
{view && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('walkthrough.action.previous')}
|
||||
onClick={() => step(-1)}
|
||||
>
|
||||
<Icon name="arrow-right-s" className="size-4 rotate-180" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('walkthrough.action.next')}
|
||||
onClick={() => step(1)}
|
||||
>
|
||||
<Icon name="arrow-right-s" className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isBusy ? (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => cancel(directory, source)}>
|
||||
{t('walkthrough.action.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={WALKTHROUGH_ACTION_CLASS}
|
||||
onClick={() => handleGenerate(Boolean(view))}
|
||||
>
|
||||
<Icon name={view ? 'refresh' : 'route'} className="size-3.5" />
|
||||
{view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* While regenerating over an existing walkthrough the stream keeps showing
|
||||
the old content, so the only other signal would be the button swapping
|
||||
to Cancel — far too quiet for something that runs for tens of seconds. */}
|
||||
{entry.status === 'generating' && view && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-[var(--status-info-background)] px-3 py-2">
|
||||
<Icon name="loader-4" className="size-4 shrink-0 animate-spin text-[var(--status-info)]" />
|
||||
<span className="typography-meta text-foreground">
|
||||
{entry.stage === 'collecting'
|
||||
? t('walkthrough.stage.collecting')
|
||||
: entry.stage === 'assembling'
|
||||
? t('walkthrough.stage.assembling')
|
||||
: t('walkthrough.stage.asking')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view?.isStale && entry.status !== 'generating' && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-status-warning/10 px-3 py-2">
|
||||
<Icon name="error-warning" className="size-4 shrink-0 text-status-warning" />
|
||||
<span className="typography-meta text-foreground">
|
||||
{t('walkthrough.stale.banner', { count: view.staleStopCount })}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="ml-auto"
|
||||
// Clicking this again mid-flight would abort the running generation
|
||||
// and start another — paying for the same answer twice.
|
||||
disabled={isBusy}
|
||||
onClick={() => handleGenerate(true)}
|
||||
>
|
||||
{t('walkthrough.action.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && !blockedReason && (
|
||||
<div className="flex shrink-0 items-start gap-2 border-b border-border/60 bg-status-error/10 px-3 py-2">
|
||||
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-status-error" />
|
||||
{/* Provider errors arrive as raw JSON bodies. Show a readable amount
|
||||
and keep the rest reachable rather than filling the panel. */}
|
||||
<span className="typography-meta line-clamp-2 text-foreground" title={entry.error.message}>
|
||||
{entry.error.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('flex min-h-0 flex-1', showToc ? 'flex-row' : 'flex-col')}>
|
||||
{blockedReason ? (
|
||||
<WalkthroughBlocker
|
||||
reason={blockedReason}
|
||||
model={blockedModel}
|
||||
requiredChars={blockedRequiredChars}
|
||||
availableChars={blockedAvailableChars}
|
||||
onRetry={() => void load(directory, source)}
|
||||
/>
|
||||
) : showStages ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8">
|
||||
<WalkthroughStages progress={stageProgress} />
|
||||
</div>
|
||||
) : view ? (
|
||||
<>
|
||||
{showToc && (
|
||||
<>
|
||||
<WalkthroughToc
|
||||
view={view}
|
||||
activeStopId={activeStopId}
|
||||
visitedStopIds={visitedStopIds}
|
||||
onSelectStop={handleSelectStop}
|
||||
width={tocWidth}
|
||||
/>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t('walkthrough.toc.resize')}
|
||||
tabIndex={0}
|
||||
onPointerDown={handleTocResizeStart}
|
||||
onKeyDown={handleTocResizeKey}
|
||||
className={cn(
|
||||
'group relative w-1 shrink-0 cursor-col-resize',
|
||||
'before:absolute before:inset-y-0 before:-left-1 before:-right-1 before:content-[\'\']',
|
||||
'hover:bg-interactive-selection focus-visible:bg-interactive-selection focus-visible:outline-none',
|
||||
draggingToc && 'bg-interactive-selection'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<WalkthroughStream
|
||||
view={view}
|
||||
activeStopId={activeStopId}
|
||||
scrollToStopId={scrollToStopId}
|
||||
onActiveStopChange={handleActiveStopChange}
|
||||
onScrollHandled={() => setScrollToStopId(null)}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
{entry.status === 'loading' ? (
|
||||
<Icon name="loader-4" className="size-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<Icon name="route" className="size-6 text-muted-foreground" />
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">
|
||||
{t('walkthrough.empty.title')}
|
||||
</h3>
|
||||
<p className="typography-meta max-w-md text-muted-foreground">
|
||||
{t('walkthrough.empty.description')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user