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
@@ -192,6 +192,7 @@ export const iconSpriteData = {
|
||||
"robot": `<path d="M13 4.05493C17.5 4.55237 21 8.36745 21 13V22H3V13C3 8.36745 6.50005 4.55237 11 4.05493V1H13V4.05493ZM19 20V13C19 9.13401 15.866 6 12 6C8.13401 6 5 9.13401 5 13V20H19ZM12 18C9.23858 18 7 15.7614 7 13C7 10.2386 9.23858 8 12 8C14.7614 8 17 10.2386 17 13C17 15.7614 14.7614 18 12 18ZM12 16C13.6569 16 15 14.6569 15 13C15 11.3431 13.6569 10 12 10C10.3431 10 9 11.3431 9 13C9 14.6569 10.3431 16 12 16ZM12 14C11.4477 14 11 13.5523 11 13C11 12.4477 11.4477 12 12 12C12.5523 12 13 12.4477 13 13C13 13.5523 12.5523 14 12 14Z" fill="currentColor"/>`,
|
||||
"robot-2": `<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z" fill="currentColor"/>`,
|
||||
"rocket": `<path d="M4.99958 12.9999C4.99958 7.91198 7.90222 3.5636 11.9996 1.81799C16.0969 3.5636 18.9996 7.91198 18.9996 12.9999C18.9996 13.8229 18.9236 14.6264 18.779 15.4027L20.7194 17.2353C20.8845 17.3913 20.9238 17.6389 20.815 17.8383L18.3196 22.4133C18.1873 22.6557 17.8836 22.7451 17.6412 22.6128C17.5993 22.59 17.5608 22.5612 17.5271 22.5274L15.2925 20.2928C15.1049 20.1053 14.8506 19.9999 14.5854 19.9999H9.41379C9.14857 19.9999 8.89422 20.1053 8.70668 20.2928L6.47209 22.5274C6.27683 22.7227 5.96025 22.7227 5.76498 22.5274C5.73122 22.4937 5.70246 22.4552 5.67959 22.4133L3.18412 17.8383C3.07537 17.6389 3.11464 17.3913 3.27975 17.2353L5.22014 15.4027C5.07551 14.6264 4.99958 13.8229 4.99958 12.9999ZM6.47542 19.6957L7.29247 18.8786C7.85508 18.316 8.61814 17.9999 9.41379 17.9999H14.5854C15.381 17.9999 16.1441 18.316 16.7067 18.8786L17.5237 19.6957L18.5056 17.8955L17.4058 16.8568C16.9117 16.3901 16.6884 15.7045 16.8128 15.0364C16.9366 14.3722 16.9996 13.6911 16.9996 12.9999C16.9996 9.13037 15.0045 5.69965 11.9996 4.04033C8.99462 5.69965 6.99958 9.13037 6.99958 12.9999C6.99958 13.6911 7.06255 14.3722 7.18631 15.0364C7.31078 15.7045 7.08746 16.3901 6.59338 16.8568L5.49353 17.8955L6.47542 19.6957ZM11.9996 12.9999C10.895 12.9999 9.99958 12.1045 9.99958 10.9999C9.99958 9.89537 10.895 8.99994 11.9996 8.99994C13.1041 8.99994 13.9996 9.89537 13.9996 10.9999C13.9996 12.1045 13.1041 12.9999 11.9996 12.9999Z" fill="currentColor"/>`,
|
||||
"route": `<path d="M4 15V8.5C4 6.01472 6.01472 4 8.5 4C10.9853 4 13 6.01472 13 8.5V15.5C13 16.8807 14.1193 18 15.5 18C16.8807 18 18 16.8807 18 15.5V8.82929C16.8348 8.41746 16 7.30622 16 6C16 4.34315 17.3431 3 19 3C20.6569 3 22 4.34315 22 6C22 7.30622 21.1652 8.41746 20 8.82929V15.5C20 17.9853 17.9853 20 15.5 20C13.0147 20 11 17.9853 11 15.5V8.5C11 7.11929 9.88071 6 8.5 6C7.11929 6 6 7.11929 6 8.5V15H9L5 20L1 15H4ZM19 7C19.5523 7 20 6.55228 20 6C20 5.44772 19.5523 5 19 5C18.4477 5 18 5.44772 18 6C18 6.55228 18.4477 7 19 7Z" fill="currentColor"/>`,
|
||||
"save-3": `<path d="M18 19H19V6.82843L17.1716 5H16V9H7V5H5V19H6V12H18V19ZM4 3H18L20.7071 5.70711C20.8946 5.89464 21 6.149 21 6.41421V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3ZM8 14V19H16V14H8Z" fill="currentColor"/>`,
|
||||
"scales-3": `<path d="M12.9985 2L12.9979 3.278L17.9985 4.94591L21.631 3.73509L22.2634 5.63246L19.2319 6.643L22.3272 15.1549C21.2353 16.2921 19.6996 17 17.9985 17C16.2975 17 14.7618 16.2921 13.6699 15.1549L16.7639 6.643L12.9979 5.387V19H16.9985V21H6.99854V19H10.9979V5.387L7.23192 6.643L10.3272 15.1549C9.23528 16.2921 7.69957 17 5.99854 17C4.2975 17 2.76179 16.2921 1.66992 15.1549L4.76392 6.643L1.73363 5.63246L2.36608 3.73509L5.99854 4.94591L10.9979 3.278L10.9985 2H12.9985ZM17.9985 9.10267L16.04 14.4892C16.628 14.8201 17.2979 15 17.9985 15C18.6992 15 19.3691 14.8201 19.957 14.4892L17.9985 9.10267ZM5.99854 9.10267L4.04004 14.4892C4.62795 14.8201 5.29792 15 5.99854 15C6.69916 15 7.36912 14.8201 7.95703 14.4892L5.99854 9.10267Z" fill="currentColor"/>`,
|
||||
"scan-2": `<path d="M5.67127 4.25705L13.4142 12L12 13.4142L8.55382 9.96803C8.20193 10.5635 8 11.2582 8 12C8 14.2091 9.79086 16 12 16C14.2091 16 16 14.2091 16 12C16 9.87494 14.3429 8.13693 12.2503 8.00771L10.4459 6.20323C10.9416 6.07067 11.4625 6 12 6C15.3137 6 18 8.68629 18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12C6 10.7042 6.41079 9.50428 7.10925 8.52347L5.68014 7.09436C4.62708 8.44904 4 10.1513 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C10.8915 4 9.83557 4.22547 8.8757 4.63306L7.37443 3.13179C8.75768 2.40883 10.3311 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 8.87842 3.43029 6.09091 5.67127 4.25705Z" fill="currentColor"/>`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FilesView } from '@/components/views/FilesView';
|
||||
import { GitView } from '@/components/views/GitView';
|
||||
import { PullRequestView } from '@/components/views/PullRequestView';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { WalkthroughView } from '@/components/views/walkthrough/WalkthroughView';
|
||||
import { PlanView } from '@/components/views/PlanView';
|
||||
import { ProjectContextPanel } from './RightSidebarTabs';
|
||||
import { SidebarFilesTree } from './SidebarFilesTree';
|
||||
@@ -158,6 +159,7 @@ const getModeLabel = (
|
||||
if (mode === 'chat') return t('contextPanel.mode.chat');
|
||||
if (mode === 'file') return t('contextPanel.mode.files');
|
||||
if (mode === 'diff') return t('contextPanel.mode.diff');
|
||||
if (mode === 'walkthrough') return t('contextPanel.mode.walkthrough');
|
||||
if (mode === 'plan') return t('contextPanel.mode.plan');
|
||||
if (mode === 'preview') return t('contextPanel.mode.preview');
|
||||
if (mode === 'browser') return t('contextPanel.mode.browser');
|
||||
@@ -247,6 +249,10 @@ const getTabIcon = (tab: { mode: ContextPanelMode; targetPath: string | null }):
|
||||
return <Icon name="arrow-left-right" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'walkthrough') {
|
||||
return <Icon name="route" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
|
||||
if (tab.mode === 'git') {
|
||||
return <Icon name="git-branch" className="h-3.5 w-3.5" />;
|
||||
}
|
||||
@@ -2717,6 +2723,12 @@ export const ContextPanel: React.FC = () => {
|
||||
() => tabs.some((tab) => tab.mode === 'terminal'),
|
||||
[tabs],
|
||||
);
|
||||
// Keep-alive: the walkthrough holds reading progress and scroll position that
|
||||
// a remount would silently throw away.
|
||||
const hasWalkthroughTab = React.useMemo(
|
||||
() => tabs.some((tab) => tab.mode === 'walkthrough'),
|
||||
[tabs],
|
||||
);
|
||||
const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane;
|
||||
const hasFileTabs = React.useMemo(
|
||||
() => tabs.some((tab) => tab.mode === 'file'),
|
||||
@@ -2979,7 +2991,12 @@ export const ContextPanel: React.FC = () => {
|
||||
<TerminalView visible={isOpen && activeTab?.mode === 'terminal'} />
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' ? activeNonChatContent : null}
|
||||
{hasWalkthroughTab ? (
|
||||
<div className={cn('absolute inset-0', activeTab?.mode === 'walkthrough' ? 'block' : 'hidden')}>
|
||||
<WalkthroughView directory={effectiveDirectory} />
|
||||
</div>
|
||||
) : null}
|
||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' && activeTab?.mode !== 'diff' && activeTab?.mode !== 'terminal' && activeTab?.mode !== 'walkthrough' ? activeNonChatContent : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
sortContextSurfaces,
|
||||
@@ -30,6 +32,9 @@ import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
const RAIL_TOOLTIP_DELAY_MS = 150;
|
||||
// Tablet width and up: below this the walkthrough cannot show a stop and its
|
||||
// code side by side, which is the whole point of the surface.
|
||||
const WALKTHROUGH_MIN_WIDTH = 768;
|
||||
const EMPTY_TABS: never[] = [];
|
||||
|
||||
type RailItemProps = {
|
||||
@@ -105,6 +110,7 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const setContextRailOrder = useUIStore((state) => state.setContextRailOrder);
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const gitStatus = useGitStatus(directoryKey || null);
|
||||
|
||||
const sensors = useSensors(
|
||||
@@ -124,12 +130,17 @@ export const ContextPanelRail: React.FC = () => {
|
||||
if (surface.id === 'plan' && !planModeEnabled) {
|
||||
return false;
|
||||
}
|
||||
// The walkthrough needs room for a stop list beside real code, and its
|
||||
// diffs come from OpenChamber's Git routes, which VS Code does not serve.
|
||||
if (surface.id === 'walkthrough' && (isVSCodeRuntime() || screenWidth < WALKTHROUGH_MIN_WIDTH)) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [contextRailOrder, planModeEnabled, tabs]);
|
||||
}, [contextRailOrder, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -339,6 +339,11 @@ interface ModelPickerListProps {
|
||||
selectedModel?: { providerID: string; modelID: string } | null;
|
||||
hiddenModels?: HiddenModel[];
|
||||
allowedProviderIds?: string[];
|
||||
/**
|
||||
* Per-model gate, for callers whose feature needs a capability rather than a
|
||||
* provider (e.g. structured output). Applied on top of `allowedProviderIds`.
|
||||
*/
|
||||
isModelAllowed?: (providerID: string, modelID: string) => boolean;
|
||||
includeNotSelected?: boolean;
|
||||
onSelectNone?: () => void;
|
||||
selectionCount?: (entry: ModelPickerEntry) => number;
|
||||
@@ -379,6 +384,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
selectedModel,
|
||||
hiddenModels = [],
|
||||
allowedProviderIds,
|
||||
isModelAllowed,
|
||||
includeNotSelected = false,
|
||||
onSelectNone,
|
||||
selectionCount,
|
||||
@@ -448,17 +454,19 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
|
||||
const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => {
|
||||
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
|
||||
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
||||
if (isHidden(providerID, modelID)) return false;
|
||||
const providerName = providerById.get(providerID)?.name || providerID;
|
||||
return matchesQuery(getModelDisplayName(model), providerName);
|
||||
}), [allowedProviderSet, favoriteModels, isHidden, matchesQuery, providerById]);
|
||||
}), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]);
|
||||
|
||||
const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => {
|
||||
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
|
||||
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
||||
if (isHidden(providerID, modelID)) return false;
|
||||
const providerName = providerById.get(providerID)?.name || providerID;
|
||||
return matchesQuery(getModelDisplayName(model), providerName);
|
||||
}), [allowedProviderSet, isHidden, matchesQuery, providerById, recentModels]);
|
||||
}), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]);
|
||||
|
||||
const orderedProviders = React.useMemo(() => {
|
||||
if (!providerOrder || providerOrder.length === 0) return providers;
|
||||
@@ -477,11 +485,12 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
||||
const filteredModels = models.filter((model) => {
|
||||
const modelID = typeof model.id === 'string' ? model.id : '';
|
||||
if (!modelID || isHidden(provider.id, modelID)) return false;
|
||||
if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false;
|
||||
return matchesQuery(getModelDisplayName(model), provider.name || provider.id);
|
||||
});
|
||||
return { ...provider, models: filteredModels };
|
||||
})
|
||||
.filter((provider) => provider.models.length > 0), [allowedProviderSet, isHidden, matchesQuery, orderedProviders]);
|
||||
.filter((provider) => provider.models.length > 0), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, orderedProviders]);
|
||||
|
||||
const flatModelList = React.useMemo(() => {
|
||||
const items: ModelPickerEntry[] = [];
|
||||
|
||||
@@ -23,6 +23,7 @@ interface ModelSelectorProps {
|
||||
onChange: (providerId: string, modelId: string) => void;
|
||||
className?: string;
|
||||
allowedProviderIds?: string[];
|
||||
isModelAllowed?: (providerId: string, modelId: string) => boolean;
|
||||
placeholder?: string;
|
||||
tooltipsEnabled?: boolean;
|
||||
dropdownPortalToBody?: boolean;
|
||||
@@ -34,6 +35,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
onChange,
|
||||
className,
|
||||
allowedProviderIds,
|
||||
isModelAllowed,
|
||||
placeholder,
|
||||
tooltipsEnabled = true,
|
||||
dropdownPortalToBody = false,
|
||||
@@ -115,6 +117,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
selectedModel={selectedModel}
|
||||
hiddenModels={hiddenModels}
|
||||
allowedProviderIds={allowedProviderIds}
|
||||
isModelAllowed={isModelAllowed}
|
||||
includeNotSelected
|
||||
onSelectNone={handleSelectNone}
|
||||
onEscape={closePicker}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||
|
||||
const [defaultModel, setDefaultModel] = React.useState<string | undefined>();
|
||||
const [defaultVariant, setDefaultVariant] = React.useState<string | undefined>();
|
||||
@@ -52,6 +53,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
const [smallModelUseDefault, setSmallModelUseDefault] = React.useState(true);
|
||||
const [smallModelOverride, setSmallModelOverride] = React.useState<string | undefined>();
|
||||
const [smallModelProviders, setSmallModelProviders] = React.useState<string[] | undefined>();
|
||||
const [walkthroughModelOverride, setWalkthroughModelOverride] = React.useState<string | undefined>();
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
const parsedModel = React.useMemo(() => getDisplayModel(defaultModel), [defaultModel]);
|
||||
@@ -65,6 +67,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
defaultAgent?: string;
|
||||
smallModelUseDefault?: boolean;
|
||||
smallModelOverride?: string;
|
||||
walkthroughModelOverride?: string;
|
||||
} | null = null;
|
||||
|
||||
if (!data) {
|
||||
@@ -84,6 +87,8 @@ export const DefaultsSettings: React.FC = () => {
|
||||
defaultAgent: typeof settings.defaultAgent === 'string' ? settings.defaultAgent : undefined,
|
||||
smallModelUseDefault: typeof raw.smallModelUseDefault === 'boolean' ? raw.smallModelUseDefault : undefined,
|
||||
smallModelOverride: typeof raw.smallModelOverride === 'string' ? raw.smallModelOverride : undefined,
|
||||
walkthroughModelOverride:
|
||||
typeof raw.walkthroughModelOverride === 'string' ? raw.walkthroughModelOverride : undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -123,6 +128,9 @@ export const DefaultsSettings: React.FC = () => {
|
||||
if (typeof data.smallModelOverride === 'string' && data.smallModelOverride.trim()) {
|
||||
setSmallModelOverride(data.smallModelOverride.trim());
|
||||
}
|
||||
if (typeof data.walkthroughModelOverride === 'string' && data.walkthroughModelOverride.trim()) {
|
||||
setWalkthroughModelOverride(data.walkthroughModelOverride.trim());
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load defaults settings:', error);
|
||||
@@ -236,10 +244,43 @@ export const DefaultsSettings: React.FC = () => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleWalkthroughModelOverrideChange = React.useCallback(
|
||||
async (providerId: string, modelId: string) => {
|
||||
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
|
||||
setWalkthroughModelOverride(newValue);
|
||||
try {
|
||||
// Clearing the picker is how the user goes back to the small model, so
|
||||
// an empty value is a real choice rather than a no-op.
|
||||
await updateDesktopSettings({ walkthroughModelOverride: newValue ?? '' });
|
||||
} catch (error) {
|
||||
console.warn('Failed to save walkthrough model override:', error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// The walkthrough cannot work at all without schema-shaped output, so models
|
||||
// the catalog says cannot do it are hidden rather than offered and then
|
||||
// refused. A missing capability is not a "no": roughly half the catalog omits
|
||||
// the field, and those models usually work.
|
||||
const isStructuredOutputCapable = React.useCallback(
|
||||
(providerId: string, modelId: string) =>
|
||||
modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false,
|
||||
[modelsMetadata]
|
||||
);
|
||||
|
||||
const parsedSmallModel = React.useMemo(() => getDisplayModel(smallModelOverride), [smallModelOverride]);
|
||||
const parsedWalkthroughModel = React.useMemo(
|
||||
() => getDisplayModel(walkthroughModelOverride),
|
||||
[walkthroughModelOverride]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (smallModelUseDefault || smallModelProviders !== undefined) return;
|
||||
// Both pickers filter by the same authenticated-provider list, so either
|
||||
// one being open is reason enough to fetch it.
|
||||
// Both pickers filter by the same authenticated-provider list, and the
|
||||
// walkthrough picker is always visible, so this is always worth fetching.
|
||||
if (smallModelProviders !== undefined) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -256,7 +297,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [smallModelUseDefault, smallModelProviders]);
|
||||
}, [smallModelProviders]);
|
||||
|
||||
const availableVariants = React.useMemo(() => {
|
||||
if (!parsedModel.providerId || !parsedModel.modelId) return [];
|
||||
@@ -396,6 +437,32 @@ export const DefaultsSettings: React.FC = () => {
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
) : null}
|
||||
|
||||
<SettingsInset className={SETTINGS_OPTION_STACK_CLASS}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<SettingsGroupTitle>
|
||||
{t('settings.openchamber.defaults.walkthroughModel.title')}
|
||||
</SettingsGroupTitle>
|
||||
<SettingsInfoHint>
|
||||
{t('settings.openchamber.defaults.walkthroughModel.description')}
|
||||
</SettingsInfoHint>
|
||||
</div>
|
||||
|
||||
<SettingsFieldRow
|
||||
settingsItem="sessions.walkthrough-model"
|
||||
label={t('settings.openchamber.defaults.walkthroughModel.overrideModel')}
|
||||
>
|
||||
<ModelSelector
|
||||
providerId={parsedWalkthroughModel.providerId}
|
||||
modelId={parsedWalkthroughModel.modelId}
|
||||
onChange={handleWalkthroughModelOverrideChange}
|
||||
allowedProviderIds={smallModelProviders}
|
||||
isModelAllowed={isStructuredOutputCapable}
|
||||
placeholder={t('settings.openchamber.defaults.walkthroughModel.usesSmallModel')}
|
||||
className={SETTINGS_CUSTOM_TRIGGER_CLASS}
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
</SettingsInset>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -39,6 +39,8 @@ import type { I18nKey } from '@/lib/i18n/store';
|
||||
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils';
|
||||
@@ -961,6 +963,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const { t } = useI18n();
|
||||
const { git, files } = useRuntimeAPIs();
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
|
||||
const { screenWidth, isMobile } = useDeviceInfo();
|
||||
|
||||
const isGitRepo = useIsGitRepo(effectiveDirectory ?? null);
|
||||
@@ -1003,6 +1007,9 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
// Same runtime and width rules as the rail surface: no point offering an
|
||||
// entry point to a surface that cannot open here.
|
||||
const showWalkthroughAction = activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
|
||||
const diffScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
|
||||
@@ -1724,6 +1731,32 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{changedFiles.length > 0 && showWalkthroughAction && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Carry the scope across: opening the walkthrough
|
||||
// while looking at staged changes should review
|
||||
// staged changes, not whatever the panel showed last.
|
||||
const directory = effectiveDirectory ?? '';
|
||||
requestWalkthroughSource(directory, {
|
||||
kind: 'working-tree',
|
||||
scope: activeDiffScope === 'staged' || activeDiffScope === 'working'
|
||||
? activeDiffScope
|
||||
: 'all',
|
||||
});
|
||||
openContextSurface(directory, 'walkthrough');
|
||||
}}
|
||||
className={cn('diff-toolbar__walkthrough-button h-7 flex-shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
||||
aria-label={t('walkthrough.action.open')}
|
||||
>
|
||||
<Icon name="route" className="size-4" />
|
||||
<span className="diff-toolbar__walkthrough-label typography-ui-label">
|
||||
{t('walkthrough.action.open')}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
{changedFiles.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -21,6 +21,9 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { formatDateTimeForPreference } from '@/lib/timeFormat';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInlineCommentDraftStore, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
@@ -327,7 +330,12 @@ export const PullRequestSection: React.FC<{
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const { isMobile, hasTouchInput } = useDeviceInfo();
|
||||
const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo();
|
||||
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
||||
const requestWalkthroughSource = useWalkthroughStore((state) => state.requestSource);
|
||||
// Mirrors the rail's gating: the surface is not available on mobile widths or
|
||||
// in VS Code, so neither is its entry point.
|
||||
const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime();
|
||||
|
||||
const openGitHubSettings = React.useCallback(() => {
|
||||
setSettingsPage('github');
|
||||
@@ -1487,7 +1495,7 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
|
||||
{pr ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="@container/pr-actions flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
|
||||
<span style={{ color: prColorVar }}>{prStatusText}</span>
|
||||
{checks ? (
|
||||
@@ -1503,6 +1511,23 @@ export const PullRequestSection: React.FC<{
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{showWalkthroughAction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('pr-actions__walkthrough-button h-7 shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
||||
onClick={() => {
|
||||
requestWalkthroughSource(directory, { kind: 'pr', number: pr.number });
|
||||
openContextSurface(directory, 'walkthrough');
|
||||
}}
|
||||
aria-label={t('walkthrough.action.open')}
|
||||
>
|
||||
<Icon name="route" className="size-4" />
|
||||
<span className="pr-actions__walkthrough-label typography-ui-label">
|
||||
{t('walkthrough.action.open')}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canMerge && pr.draft && pr.state === 'open' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { ModelSelector } from '@/components/sections/agents/ModelSelector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import type { WalkthroughBlockedReason, WalkthroughModel } from '@/lib/walkthrough/types';
|
||||
|
||||
interface WalkthroughBlockerProps {
|
||||
reason: WalkthroughBlockedReason;
|
||||
model?: WalkthroughModel;
|
||||
requiredChars?: number;
|
||||
availableChars?: number;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const modelLabel = (model?: WalkthroughModel) =>
|
||||
model ? `${model.providerID}/${model.modelID}` : '';
|
||||
|
||||
/**
|
||||
* A refusal the user can act on. Both blocking reasons come down to "this small
|
||||
* model cannot do this job", so the remedy — pick a different one — is offered
|
||||
* in place rather than sending the user to Settings to guess.
|
||||
*/
|
||||
export const WalkthroughBlocker = ({
|
||||
reason,
|
||||
model,
|
||||
requiredChars,
|
||||
availableChars,
|
||||
onRetry,
|
||||
}: WalkthroughBlockerProps) => {
|
||||
const { t } = useI18n();
|
||||
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
||||
const [providers, setProviders] = useState<string[] | undefined>(undefined);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Every one of these means "this small model cannot do this job", so the
|
||||
// remedy is the same: choose a different one, here, without a detour through
|
||||
// Settings.
|
||||
const canChooseModel = reason === 'context-too-small'
|
||||
|| reason === 'structured-output-unsupported'
|
||||
|| reason === 'output-exhausted';
|
||||
|
||||
useEffect(() => {
|
||||
if (!canChooseModel || providers !== 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)) {
|
||||
setProviders(payload.authenticatedProviders.filter((id): id is string => typeof id === 'string'));
|
||||
}
|
||||
} catch {
|
||||
// Leave undefined: the picker then offers every provider, which is a
|
||||
// worse experience but not a broken one.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canChooseModel, providers]);
|
||||
|
||||
const handleModelChange = useCallback(
|
||||
async (providerId: string, modelId: string) => {
|
||||
if (!providerId || !modelId || saving) return;
|
||||
const value = `${providerId}/${modelId}`;
|
||||
setSaving(true);
|
||||
try {
|
||||
// Scoped to this feature: fixing the walkthrough must not quietly
|
||||
// change the model used for commit messages and recaps.
|
||||
await updateDesktopSettings({ walkthroughModelOverride: value });
|
||||
onRetry();
|
||||
} catch (error) {
|
||||
console.warn('Failed to save small model override:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[onRetry, saving]
|
||||
);
|
||||
|
||||
// Offering a model the catalog already says cannot do this would just move
|
||||
// the same refusal one click later.
|
||||
const isStructuredOutputCapable = useCallback(
|
||||
(providerId: string, modelId: string) =>
|
||||
modelsMetadata.get(`${providerId}/${modelId}`)?.structured_output !== false,
|
||||
[modelsMetadata]
|
||||
);
|
||||
|
||||
const label = modelLabel(model);
|
||||
|
||||
const description = () => {
|
||||
if (reason === 'no-model') return t('walkthrough.blocked.noModel.description');
|
||||
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description');
|
||||
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description');
|
||||
if (reason === 'output-exhausted') {
|
||||
return label
|
||||
? t('walkthrough.blocked.outputExhausted.description', { model: label })
|
||||
: t('walkthrough.blocked.outputExhausted.descriptionUnknownModel');
|
||||
}
|
||||
if (reason === 'structured-output-unsupported') {
|
||||
// Naming the model that was actually tried is the whole point of this
|
||||
// screen; the unnamed variant is a defensive fallback, not the norm.
|
||||
return label
|
||||
? t('walkthrough.blocked.structuredOutput.description', { model: label })
|
||||
: t('walkthrough.blocked.structuredOutput.descriptionUnknownModel');
|
||||
}
|
||||
const required = Math.ceil((requiredChars ?? 0) / 1000);
|
||||
const available = Math.ceil((availableChars ?? 0) / 1000);
|
||||
return label
|
||||
? t('walkthrough.blocked.contextTooSmall.description', { model: label, required, available })
|
||||
: t('walkthrough.blocked.contextTooSmall.descriptionUnknownModel', { required, available });
|
||||
};
|
||||
|
||||
const title = () => {
|
||||
if (reason === 'no-model') return t('walkthrough.blocked.noModel.title');
|
||||
if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title');
|
||||
if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title');
|
||||
if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title');
|
||||
if (reason === 'structured-output-unsupported') return t('walkthrough.blocked.structuredOutput.title');
|
||||
return t('walkthrough.blocked.contextTooSmall.title');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<Icon
|
||||
name={reason === 'empty-diff' || reason === 'only-generated' ? 'information' : 'error-warning'}
|
||||
className="size-6 text-muted-foreground"
|
||||
/>
|
||||
<h3 className="typography-ui-label font-semibold text-foreground">{title()}</h3>
|
||||
<p className="typography-meta max-w-md text-muted-foreground">{description()}</p>
|
||||
|
||||
{canChooseModel && (
|
||||
<div className="flex flex-col items-center gap-2 pt-2">
|
||||
<span className="typography-micro text-muted-foreground">
|
||||
{t('walkthrough.blocked.chooseModel')}
|
||||
</span>
|
||||
<ModelSelector
|
||||
providerId={model?.providerID ?? ''}
|
||||
modelId={model?.modelID ?? ''}
|
||||
onChange={(providerId, modelId) => {
|
||||
void handleModelChange(providerId, modelId);
|
||||
}}
|
||||
allowedProviderIds={providers}
|
||||
isModelAllowed={isStructuredOutputCapable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(reason === 'empty-diff' || reason === 'only-generated') && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||
{t('walkthrough.action.refresh')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { PierreDiffViewer } from '@/components/views/PierreDiffViewer';
|
||||
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { getLanguageFromExtension } from '@/lib/toolHelpers';
|
||||
import { mergeRunPatch } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughHunk } from '@/lib/walkthrough/types';
|
||||
|
||||
interface WalkthroughHunkRunProps {
|
||||
path: string;
|
||||
hunks: WalkthroughHunk[];
|
||||
renderSideBySide: boolean;
|
||||
wrapLines: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file's contribution to a stop. Consecutive hunks are merged back into a
|
||||
* single patch so the reader sees continuous code rather than a stack of
|
||||
* one-hunk cards.
|
||||
*
|
||||
* Inline comments are on: a review you cannot annotate is a reader, not a tool.
|
||||
* They work here because the merged patch keeps the original `@@` headers, so
|
||||
* the line numbers a comment captures are the file's real ones and not offsets
|
||||
* into an excerpt.
|
||||
*/
|
||||
export const WalkthroughHunkRun = memo(function WalkthroughHunkRun({
|
||||
path,
|
||||
hunks,
|
||||
renderSideBySide,
|
||||
wrapLines,
|
||||
}: WalkthroughHunkRunProps) {
|
||||
const fileDiff = useMemo(() => {
|
||||
const patch = mergeRunPatch(hunks);
|
||||
return patch ? fileDiffFromPatch(path, patch) : undefined;
|
||||
}, [hunks, path]);
|
||||
|
||||
if (!fileDiff) return null;
|
||||
|
||||
return (
|
||||
<PierreDiffViewer
|
||||
original=""
|
||||
modified=""
|
||||
fileDiff={fileDiff}
|
||||
language={getLanguageFromExtension(path) || ''}
|
||||
fileName={path}
|
||||
renderSideBySide={renderSideBySide}
|
||||
wrapLines={wrapLines}
|
||||
layout="inline"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WalkthroughStageProgress } from './useWalkthroughStageProgress';
|
||||
|
||||
interface WalkthroughStagesProps {
|
||||
progress: WalkthroughStageProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wait is long and uneven — collecting a pull request diff is seconds of
|
||||
* network, the model call is minutes — and a lone spinner makes those look
|
||||
* identical. Naming the phase says which one you are waiting on, and that the
|
||||
* cost has been committed once it reads "waiting on the model".
|
||||
*
|
||||
* No durations: a stopwatch on a step nobody can hurry adds pressure, not
|
||||
* information. And no mention of the schema fallback — from out here it is the
|
||||
* same wait, and naming our plumbing only invites the question of what it is.
|
||||
*/
|
||||
const STAGES: Array<{ labelKey: I18nKey }> = [
|
||||
{ labelKey: 'walkthrough.stage.collecting' },
|
||||
{ labelKey: 'walkthrough.stage.asking' },
|
||||
{ labelKey: 'walkthrough.stage.assembling' },
|
||||
];
|
||||
|
||||
export const WalkthroughStages = ({ progress }: WalkthroughStagesProps) => {
|
||||
const { t } = useI18n();
|
||||
const { completedCount, activeIndex } = progress;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-2 text-left">
|
||||
{STAGES.map((entry, index) => {
|
||||
const isDone = index < completedCount;
|
||||
const isActive = activeIndex === index;
|
||||
|
||||
return (
|
||||
<li key={entry.labelKey} className="flex items-center gap-2">
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">
|
||||
{isDone ? (
|
||||
<Icon name="check" className="size-3.5 text-status-success" />
|
||||
) : isActive ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin text-[var(--status-info)]" />
|
||||
) : (
|
||||
<span className="size-1.5 rounded-full bg-surface-muted" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'typography-meta',
|
||||
isActive ? 'text-foreground' : isDone ? 'text-muted-foreground' : 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{t(entry.labelKey)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { summarizeHunkFiles } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
|
||||
import type { WalkthroughChapterIcon } from '@/lib/walkthrough/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WalkthroughTocProps {
|
||||
view: WalkthroughView;
|
||||
activeStopId: string | null;
|
||||
visitedStopIds: ReadonlySet<string>;
|
||||
onSelectStop: (stopId: string) => void;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const CHAPTER_ICONS: Record<WalkthroughChapterIcon, IconName> = {
|
||||
bug: 'bug',
|
||||
wrench: 'tools',
|
||||
path: 'compass-3',
|
||||
flask: 'flask',
|
||||
doc: 'file-text',
|
||||
gear: 'settings-3',
|
||||
};
|
||||
|
||||
const TocStop = ({
|
||||
stopView,
|
||||
isActive,
|
||||
isVisited,
|
||||
onSelect,
|
||||
activeRef,
|
||||
}: {
|
||||
stopView: WalkthroughStopView;
|
||||
isActive: boolean;
|
||||
isVisited: boolean;
|
||||
onSelect: () => void;
|
||||
activeRef: React.Ref<HTMLButtonElement>;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const files = summarizeHunkFiles(stopView.hunks);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
ref={isActive ? activeRef : undefined}
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
className={cn(
|
||||
'flex w-full flex-col gap-1 rounded px-2 py-1.5 text-left transition-colors',
|
||||
'hover:bg-interactive-hover',
|
||||
isActive && 'bg-interactive-selection text-interactive-selection-foreground'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
// A pill rather than a fixed circle: two-digit steps were cramped
|
||||
// and visibly off-centre in a square.
|
||||
'typography-micro flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full px-1 tabular-nums',
|
||||
isActive
|
||||
? 'bg-interactive-selection-foreground/20'
|
||||
: isVisited
|
||||
? 'bg-status-success/15 text-status-success'
|
||||
: 'bg-surface-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isVisited && !isActive ? <Icon name="check" className="size-2.5" /> : stopView.position}
|
||||
</span>
|
||||
<span className="typography-meta truncate font-medium">{stopView.stop.title}</span>
|
||||
{stopView.isStale && (
|
||||
<Icon
|
||||
name="error-warning"
|
||||
className="size-3 shrink-0 text-status-warning"
|
||||
aria-label={t('walkthrough.stop.staleShort')}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{files.length > 0 && (
|
||||
<span className="typography-micro flex flex-col gap-0.5 pl-6 text-muted-foreground">
|
||||
{files.slice(0, 3).map((file) => (
|
||||
<span key={file.path} className="truncate font-mono">
|
||||
{file.path}
|
||||
</span>
|
||||
))}
|
||||
{files.length > 3 && (
|
||||
<span>{t('walkthrough.toc.moreFiles', { count: files.length - 3 })}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const WalkthroughToc = memo(function WalkthroughToc({
|
||||
view,
|
||||
activeStopId,
|
||||
visitedStopIds,
|
||||
onSelectStop,
|
||||
width,
|
||||
}: WalkthroughTocProps) {
|
||||
const { t } = useI18n();
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
// Scrolling the stream moves the active step, and past a certain point the
|
||||
// highlighted row leaves the contents column entirely — the reader loses
|
||||
// their place in the very thing meant to hold it. `nearest` keeps the move
|
||||
// minimal, so clicking a row that is already visible does not jolt the list.
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeStopId]);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex shrink-0 flex-col overflow-y-auto border-r border-border/60 p-2"
|
||||
style={{ width }}
|
||||
>
|
||||
{view.walkthrough.focus && (
|
||||
<p className="typography-meta px-2 pb-3 pt-1 text-muted-foreground">{view.walkthrough.focus}</p>
|
||||
)}
|
||||
{view.chapters.map(({ chapter, stops }) => (
|
||||
<section key={chapter.id} className="pb-3">
|
||||
<h4 className="typography-micro flex items-center gap-1.5 px-2 py-1 font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<Icon name={CHAPTER_ICONS[chapter.icon] ?? 'file-text'} className="size-3 shrink-0" />
|
||||
<span className="truncate">{chapter.title}</span>
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{stops.map((stopView) => (
|
||||
<TocStop
|
||||
key={stopView.stop.id}
|
||||
stopView={stopView}
|
||||
isActive={activeStopId === stopView.stop.id}
|
||||
isVisited={visitedStopIds.has(stopView.stop.id)}
|
||||
onSelect={() => onSelectStop(stopView.stop.id)}
|
||||
activeRef={activeRef}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{view.uncoveredHunks.length > 0 && (
|
||||
<p className="typography-micro mt-auto px-2 pt-3 text-muted-foreground">
|
||||
{t('walkthrough.toc.uncovered', { count: view.uncoveredHunks.length })}
|
||||
</p>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** DOM id for a stop section, shared by the stream and its scroll callers. */
|
||||
export const stopElementId = (stopId: string): string => `walkthrough-stop-${stopId}`;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { WALKTHROUGH_STAGE_ORDER, __testing } from './useWalkthroughStageProgress';
|
||||
|
||||
const { indexOfStage, nextIndex } = __testing;
|
||||
|
||||
// The pacing itself is a React effect, but the two decisions it rests on are
|
||||
// plain functions and are where the mistakes would live.
|
||||
describe('stage ordering', () => {
|
||||
test('maps every server stage onto a visible step', () => {
|
||||
expect(indexOfStage('collecting')).toBe(0);
|
||||
expect(indexOfStage('asking')).toBe(1);
|
||||
expect(indexOfStage('assembling')).toBe(2);
|
||||
});
|
||||
|
||||
test('folds the schema fallback into the same wait', () => {
|
||||
// From out here it is still "waiting on the model"; the fallback is our
|
||||
// plumbing and must not show up as its own step or as going backwards.
|
||||
expect(indexOfStage('retrying')).toBe(indexOfStage('asking'));
|
||||
});
|
||||
|
||||
test('treats an absent stage as not started', () => {
|
||||
expect(indexOfStage(null)).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancing', () => {
|
||||
test('moves one step at a time so none is skipped', () => {
|
||||
expect(nextIndex(-1, 2)).toBe(0);
|
||||
expect(nextIndex(0, 2)).toBe(1);
|
||||
expect(nextIndex(1, 2)).toBe(2);
|
||||
});
|
||||
|
||||
test('stops at the target', () => {
|
||||
expect(nextIndex(2, 2)).toBe(2);
|
||||
});
|
||||
|
||||
test('completion goes one past the last step so everything reads as done', () => {
|
||||
expect(nextIndex(2, WALKTHROUGH_STAGE_ORDER.length)).toBe(WALKTHROUGH_STAGE_ORDER.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { WalkthroughStage } from '@/lib/walkthrough/types';
|
||||
|
||||
/**
|
||||
* Paces the stage display so every step is actually seen.
|
||||
*
|
||||
* Assembling the walkthrough takes milliseconds, so on the real timeline it
|
||||
* flickers past between "waiting on the model" and the finished result — the
|
||||
* user is told about a step they never observe, which is worse than not
|
||||
* naming it. Each step is therefore held for a floor before the next one is
|
||||
* revealed, including the final all-done state.
|
||||
*
|
||||
* This delays the result by well under a second at the end of a wait measured
|
||||
* in minutes, and buys a legible finish in exchange.
|
||||
*/
|
||||
|
||||
const ORDER: WalkthroughStage[] = ['collecting', 'asking', 'assembling'];
|
||||
const MIN_STEP_MS = 450;
|
||||
|
||||
/** `retrying` is the same wait from the user's side: still waiting on the model. */
|
||||
const indexOfStage = (stage: WalkthroughStage | null): number => {
|
||||
if (stage === 'retrying') return ORDER.indexOf('asking');
|
||||
return stage ? ORDER.indexOf(stage) : -1;
|
||||
};
|
||||
|
||||
/** One step at a time, so a fast stage is still shown rather than skipped. */
|
||||
const nextIndex = (current: number, target: number): number => Math.min(current + 1, target);
|
||||
|
||||
export interface WalkthroughStageProgress {
|
||||
/** Steps to render as finished. */
|
||||
completedCount: number;
|
||||
/** Step to render as running, or `null` when everything is done. */
|
||||
activeIndex: number | null;
|
||||
/** True while the display still owes the user time after the work finished. */
|
||||
holding: boolean;
|
||||
}
|
||||
|
||||
export const useWalkthroughStageProgress = (
|
||||
stage: WalkthroughStage | null,
|
||||
active: boolean
|
||||
): WalkthroughStageProgress => {
|
||||
// `ORDER.length` means "all done"; -1 means nothing started.
|
||||
const [shownIndex, setShownIndex] = useState(-1);
|
||||
const shownAtRef = useRef(0);
|
||||
|
||||
const target = active ? Math.max(indexOfStage(stage), 0) : (shownIndex < 0 ? -1 : ORDER.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (target <= shownIndex) {
|
||||
// Work restarted: fall back to the earlier step immediately rather than
|
||||
// pretending the later one is still running.
|
||||
if (target < shownIndex && active) {
|
||||
setShownIndex(target);
|
||||
shownAtRef.current = Date.now();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - shownAtRef.current;
|
||||
const advance = () => {
|
||||
shownAtRef.current = Date.now();
|
||||
setShownIndex((current) => nextIndex(current, target));
|
||||
};
|
||||
|
||||
if (shownIndex < 0 || elapsed >= MIN_STEP_MS) {
|
||||
advance();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(advance, MIN_STEP_MS - elapsed);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, shownIndex, target]);
|
||||
|
||||
// A fresh run resets the display so the next generation starts from the top.
|
||||
useEffect(() => {
|
||||
if (active || shownIndex < ORDER.length) return;
|
||||
const timer = setTimeout(() => setShownIndex(-1), MIN_STEP_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, shownIndex]);
|
||||
|
||||
const done = shownIndex >= ORDER.length;
|
||||
|
||||
return {
|
||||
completedCount: Math.max(0, Math.min(shownIndex, ORDER.length)),
|
||||
activeIndex: done || shownIndex < 0 ? null : shownIndex,
|
||||
holding: !active && shownIndex >= 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const WALKTHROUGH_STAGE_ORDER = ORDER;
|
||||
|
||||
export const __testing = { indexOfStage, nextIndex };
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Shared chrome for every "open / generate the AI walkthrough" action.
|
||||
*
|
||||
* The walkthrough is informational rather than a primary or destructive act, so
|
||||
* it carries the info status tint instead of competing with the primary button
|
||||
* next to it (Review in the diff toolbar, Merge in the pull request header).
|
||||
* Keeping it in one constant is what stops the three entry points from drifting
|
||||
* apart.
|
||||
*
|
||||
* No sparkle iconography: "this is AI" is not what the button does, and the
|
||||
* cliché tells the user nothing about the outcome.
|
||||
*/
|
||||
export const WALKTHROUGH_ACTION_CLASS =
|
||||
'border-[var(--status-info-border)] bg-[var(--status-info-background)] text-[var(--status-info)] hover:bg-[var(--status-info-background)] hover:text-[var(--status-info)]';
|
||||
Reference in New Issue
Block a user