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:
Bohdan Triapitsyn
2026-08-02 16:22:55 +03:00
committed by GitHub
parent b1ec34162e
commit 34d0ff7383
99 changed files with 7316 additions and 53 deletions
@@ -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)]';
+26 -1
View File
@@ -891,7 +891,20 @@ html:not(.dark) .chat-scroll {
}
}
/* Diff toolbar: drop low-priority labels only when the context panel is genuinely tight. */
/* Diff toolbar: the walkthrough label goes first. Review is the primary action
in this row, so it keeps its label longest; the walkthrough is reachable from
the rail as well and reads fine as an icon. */
@container diff-toolbar (max-width: 36rem) {
.diff-toolbar__walkthrough-label {
display: none;
}
.diff-toolbar__walkthrough-button {
padding-inline: 0.5rem;
}
}
/* Then the rest, once the panel is genuinely tight. */
@container diff-toolbar (max-width: 28rem) {
.diff-toolbar__scope-count,
.diff-toolbar__review-label,
@@ -905,6 +918,18 @@ html:not(.dark) .chat-scroll {
}
}
/* Pull request actions: the walkthrough label goes first, since merge controls
are the reason the row exists. */
@container pr-actions (max-width: 30rem) {
.pr-actions__walkthrough-label {
display: none;
}
.pr-actions__walkthrough-button {
padding-inline: 0.5rem;
}
}
/* Status row: collapse optional text when narrow to keep both sides in one line. */
@container status-row (max-width: 30rem) {
.status-row__active-todo {
+12
View File
@@ -146,6 +146,17 @@ export interface GetGitDiffOptions {
contextLines?: number;
}
/**
* Diff between two refs. Uses three-dot (`base...head`) semantics server-side, so changes
* pulled into `head` by merging `base` are excluded only the branch's own work is returned.
*/
export interface GetGitRangeDiffOptions {
base: string;
head: string;
path?: string;
contextLines?: number;
}
export interface GitFileDiffResponse {
original: string;
modified: string;
@@ -453,6 +464,7 @@ export interface GitAPI {
getGitStatus(directory: string, options?: { mode?: 'light' }): Promise<GitStatus>;
getGitDiff(directory: string, options: GetGitDiffOptions): Promise<GitDiffResponse>;
getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse>;
getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise<GitDiffResponse>;
revertGitFile(directory: string, filePath: string, options?: { scope?: 'all' | 'working' }): Promise<void>;
stageGitFile(directory: string, filePath: string): Promise<void>;
stageGitFiles?(directory: string, filePaths: string[]): Promise<void>;
+4
View File
@@ -131,6 +131,10 @@ export type DesktopSettings = {
sessionGoalDefaultBudgetEnabled?: boolean;
sessionGoalDefaultBudget?: number;
smallModelOverride?: string; // format: "provider/model"
// The walkthrough needs structured output and a roomy context, which the
// small model is often deliberately not chosen for. Unset means "use the
// small model"; a value replaces it for this feature only.
walkthroughModelOverride?: string; // format: "provider/model"
defaultGitIdentityId?: string; // ''/undefined = unset, 'global' or profile id
openInAppId?: string;
autoCreateWorktree?: boolean;
+9
View File
@@ -135,6 +135,15 @@ export async function getGitFileDiff(
return gitHttp.getGitFileDiff(directory, options);
}
export async function getGitRangeDiff(
directory: string,
options: import('./api/types').GetGitRangeDiffOptions
): Promise<import('./api/types').GitDiffResponse> {
const runtime = getRuntimeGit();
if (runtime?.getGitRangeDiff) return runtime.getGitRangeDiff(directory, options);
return gitHttp.getGitRangeDiff(directory, options);
}
export async function revertGitFile(
directory: string,
filePath: string,
+26
View File
@@ -2,6 +2,7 @@ import type {
GitStatus,
GitDiffResponse,
GetGitDiffOptions,
GetGitRangeDiffOptions,
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
@@ -222,6 +223,31 @@ export async function getGitDiff(directory: string, options: GetGitDiffOptions):
return response.json();
}
export async function getGitRangeDiff(
directory: string,
options: GetGitRangeDiffOptions
): Promise<GitDiffResponse> {
const { base, head, path, contextLines } = options;
if (!base || !head) {
throw new Error('base and head are required to fetch git range diff');
}
const response = await runtimeFetch(
buildUrl(`${API_BASE}/range-diff`, directory, {
base,
head,
path: path || undefined,
context: contextLines,
})
);
if (!response.ok) {
throw new Error(`Failed to get git range diff: ${response.statusText}`);
}
return response.json();
}
export async function getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise<GitFileDiffResponse> {
const { path, staged } = options;
if (!path) {
@@ -1554,6 +1554,10 @@ export const settingsDict = {
'settings.openchamber.defaults.smallModel.useDefault': 'Use default small model',
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Use default small model',
'settings.openchamber.defaults.smallModel.overrideModel': 'Override model',
'settings.openchamber.defaults.walkthroughModel.title': 'Changes Walkthrough Model',
'settings.openchamber.defaults.walkthroughModel.description': 'The AI review of your changes needs structured output and room for a whole diff, which a cheap small model often cannot give. Models the catalog reports as unable to produce structured output are hidden from this picker. Leave it unset and the small model is used.',
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Walkthrough model',
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Small model',
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Open previewable files in preview mode',
'settings.openchamber.defaults.field.openFilesPreview': 'Open previewable files in preview mode',
'settings.openchamber.defaults.option.default': 'Default',
+50
View File
@@ -1095,6 +1095,56 @@ export const dict = {
'contextRail.surface.git.description': 'Commits, branches, and pull requests',
'contextRail.surface.terminal.description': 'Built-in terminal',
'contextRail.surface.diff.description': 'Review working changes',
'contextPanel.mode.walkthrough': 'Walkthrough',
'contextRail.surface.walkthrough.description': 'An AI-guided walkthrough of your changes',
'walkthrough.scope.all': 'All uncommitted',
'walkthrough.scope.group.workingTree': 'Working tree',
'walkthrough.scope.group.committed': 'Committed',
'walkthrough.scope.staged': 'Staged',
'walkthrough.scope.working': 'Unstaged',
'walkthrough.scope.branch': 'This branch',
'walkthrough.scope.selectorAria': 'Select what to review',
'walkthrough.scope.pullRequest': 'PR #{number}',
'walkthrough.action.generate': 'Generate walkthrough',
'walkthrough.action.regenerate': 'Regenerate',
'walkthrough.action.cancel': 'Cancel',
'walkthrough.action.next': 'Next step',
'walkthrough.action.previous': 'Previous step',
'walkthrough.action.refresh': 'Refresh',
'walkthrough.action.open': 'AI walkthrough',
'walkthrough.stage.collecting': 'Collecting the changes',
'walkthrough.stage.asking': 'Waiting on the model',
'walkthrough.stage.assembling': 'Assembling the walkthrough',
'walkthrough.empty.title': 'No walkthrough yet',
'walkthrough.empty.description': 'Build a guided reading path through these changes. This calls the small model and costs tokens, so it only ever runs when you ask.',
'walkthrough.stale.banner': 'The code changed after this review. Outdated steps: {count}',
'walkthrough.stop.staleAll': 'All of the code this step described has changed.',
'walkthrough.stop.stalePartial': 'Part of the code this step described has changed. Missing pieces: {count}',
'walkthrough.stop.staleShort': 'Outdated',
'walkthrough.stop.noCode': 'None of the code for this step is in the current diff.',
'walkthrough.uncovered.title': 'Changes the review left out: {count}',
'walkthrough.uncovered.description': 'The review treated these as routine. Expand to check them yourself.',
'walkthrough.toc.moreFiles': 'More files: {count}',
'walkthrough.toc.uncovered': 'Not covered: {count}',
'walkthrough.toc.resize': 'Resize the contents column',
'walkthrough.importance.critical': 'Critical',
'walkthrough.importance.context': 'Context',
'walkthrough.blocked.noModel.title': 'No small model available',
'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.',
'walkthrough.blocked.emptyDiff.title': 'Nothing to review',
'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.',
'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model',
'walkthrough.blocked.contextTooSmall.description': '{model} fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.',
'walkthrough.blocked.structuredOutput.title': 'This model cannot produce structured output',
'walkthrough.blocked.structuredOutput.description': '{model} does not support the structured responses a walkthrough needs.',
'walkthrough.blocked.chooseModel': 'Choose the small model',
'walkthrough.blocked.outputExhausted.title': 'This model ran out of answer budget',
'walkthrough.blocked.outputExhausted.description': '{model} spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.',
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'The small model spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.',
'walkthrough.blocked.onlyGenerated.title': 'Only generated files changed',
'walkthrough.blocked.onlyGenerated.description': 'Every change here is a lockfile or other tool-produced output, which the review deliberately skips.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'The small model fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'The small model does not support the structured responses a walkthrough needs.',
'contextRail.surface.plan.description': 'View the current plan',
'contextRail.surface.pr.description': 'Create, review, and merge the pull request for the current branch',
'contextRail.surface.notes.description': 'Notes, todos, and plans for the project',
@@ -1520,6 +1520,10 @@ export const settingsDict = {
"settings.openchamber.defaults.smallModel.useDefault": "Usar el modelo pequeño predeterminado",
"settings.openchamber.defaults.smallModel.useDefaultAria": "Usar el modelo pequeño predeterminado",
"settings.openchamber.defaults.smallModel.overrideModel": "Modelo de anulación",
"settings.openchamber.defaults.walkthroughModel.title": "Modelo del recorrido de cambios",
"settings.openchamber.defaults.walkthroughModel.description": "La revisión con IA de tus cambios necesita salida estructurada y espacio para un diff completo, algo que un modelo pequeño y barato a menudo no ofrece. Los modelos que el catálogo declara incapaces de producir salida estructurada se ocultan aquí. Si no eliges nada, se usa el modelo pequeño.",
"settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo del recorrido",
"settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeño",
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir archivos previsualizables en modo vista previa",
"settings.openchamber.defaults.field.openFilesPreview": "Abrir archivos previsualizables en modo vista previa",
+50
View File
@@ -1096,6 +1096,56 @@ export const dict: Record<I18nKey, string> = {
"contextRail.surface.git.description": "Commits, ramas y pull requests",
"contextRail.surface.terminal.description": "Terminal integrada",
"contextRail.surface.diff.description": "Revisar cambios en curso",
"contextPanel.mode.walkthrough": "Recorrido",
"contextRail.surface.walkthrough.description": "Un recorrido por tus cambios guiado por IA",
"walkthrough.scope.all": "Todo sin confirmar",
"walkthrough.scope.group.workingTree": "Árbol de trabajo",
"walkthrough.scope.group.committed": "Confirmado",
"walkthrough.scope.staged": "Preparados",
"walkthrough.scope.working": "Sin preparar",
"walkthrough.scope.branch": "Esta rama",
"walkthrough.scope.selectorAria": "Elegir qué revisar",
"walkthrough.scope.pullRequest": "PR n.º {number}",
"walkthrough.action.generate": "Generar recorrido",
"walkthrough.action.regenerate": "Regenerar",
"walkthrough.action.cancel": "Cancelar",
"walkthrough.action.next": "Paso siguiente",
"walkthrough.action.previous": "Paso anterior",
"walkthrough.action.refresh": "Actualizar",
"walkthrough.action.open": "Recorrido con IA",
"walkthrough.stage.collecting": "Reuniendo los cambios",
"walkthrough.stage.asking": "Esperando al modelo",
"walkthrough.stage.assembling": "Montando el recorrido",
"walkthrough.empty.title": "Todavía no hay recorrido",
"walkthrough.empty.description": "Crea una ruta de lectura guiada por estos cambios. Esto llama al modelo pequeño y consume tokens, así que solo se ejecuta cuando lo pides.",
"walkthrough.stale.banner": "El código cambió después de esta revisión. Pasos desactualizados: {count}",
"walkthrough.stop.staleAll": "Todo el código que describía este paso ha cambiado.",
"walkthrough.stop.stalePartial": "Parte del código que describía este paso ha cambiado. Fragmentos perdidos: {count}",
"walkthrough.stop.staleShort": "Desactualizado",
"walkthrough.stop.noCode": "El código de este paso no está en el diff actual.",
"walkthrough.uncovered.title": "Cambios que la revisión omitió: {count}",
"walkthrough.uncovered.description": "La revisión los consideró rutinarios. Despliega para revisarlos tú mismo.",
"walkthrough.toc.moreFiles": "Más archivos: {count}",
"walkthrough.toc.uncovered": "Sin cubrir: {count}",
"walkthrough.toc.resize": "Cambiar el ancho de la columna de contenidos",
"walkthrough.importance.critical": "Crítico",
"walkthrough.importance.context": "Contexto",
"walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible",
"walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.",
"walkthrough.blocked.emptyDiff.title": "Nada que revisar",
"walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.",
"walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual",
"walkthrough.blocked.contextTooSmall.description": "{model} admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.",
"walkthrough.blocked.structuredOutput.title": "Este modelo no puede generar salida estructurada",
"walkthrough.blocked.structuredOutput.description": "{model} no admite las respuestas estructuradas que necesita un recorrido.",
"walkthrough.blocked.chooseModel": "Elige el modelo pequeño",
"walkthrough.blocked.outputExhausted.title": "El modelo se quedó sin presupuesto de respuesta",
"walkthrough.blocked.outputExhausted.description": "{model} gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.",
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "El modelo pequeño gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.",
"walkthrough.blocked.onlyGenerated.title": "Solo cambiaron archivos generados",
"walkthrough.blocked.onlyGenerated.description": "Todos los cambios son archivos de bloqueo u otra salida generada por herramientas, que la revisión omite a propósito.",
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "El modelo pequeño admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.",
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "El modelo pequeño no admite las respuestas estructuradas que necesita un recorrido.",
"contextRail.surface.plan.description": "Ver el plan actual",
"contextRail.surface.pr.description": "Crea, revisa y fusiona el pull request de la rama actual",
"contextRail.surface.notes.description": "Notas, tareas y planes del proyecto",
@@ -1441,6 +1441,10 @@ export const settingsDict = {
'settings.openchamber.defaults.smallModel.useDefault': 'Utiliser le petit modèle par défaut',
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Utiliser le petit modèle par défaut',
'settings.openchamber.defaults.smallModel.overrideModel': 'Modèle de remplacement',
'settings.openchamber.defaults.walkthroughModel.title': 'Modèle du parcours des modifications',
'settings.openchamber.defaults.walkthroughModel.description': 'La revue par IA de vos modifications exige une sortie structurée et de la place pour un diff entier, ce quun petit modèle économique ne fournit pas toujours. Les modèles que le catalogue déclare incapables de sortie structurée sont masqués ici. Sans choix explicite, le petit modèle est utilisé.',
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Modèle du parcours',
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Petit modèle',
'settings.openchamber.defaults.field.showDeletionDialog': 'Afficher la boîte de dialogue de suppression',
'settings.openchamber.defaults.field.openFilesPreviewAria': 'Ouvrir les fichiers prévisualisables en mode aperçu',
'settings.openchamber.defaults.field.openFilesPreview': 'Ouvrir les fichiers prévisualisables en mode aperçu',
+50
View File
@@ -919,6 +919,56 @@ export const dict = {
'contextRail.surface.git.description': 'Commits, branches et pull requests',
'contextRail.surface.terminal.description': 'Terminal intégré',
'contextRail.surface.diff.description': 'Passer en revue les modifications',
'contextPanel.mode.walkthrough': 'Parcours',
'contextRail.surface.walkthrough.description': 'Un parcours de vos modifications guidé par lIA',
'walkthrough.scope.all': 'Tout non validé',
'walkthrough.scope.group.workingTree': 'Copie de travail',
'walkthrough.scope.group.committed': 'Validé',
'walkthrough.scope.staged': 'Indexées',
'walkthrough.scope.working': 'Non indexées',
'walkthrough.scope.branch': 'Cette branche',
'walkthrough.scope.selectorAria': 'Choisir ce qui est examiné',
'walkthrough.scope.pullRequest': 'PR n° {number}',
'walkthrough.action.generate': 'Générer le parcours',
'walkthrough.action.regenerate': 'Régénérer',
'walkthrough.action.cancel': 'Annuler',
'walkthrough.action.next': 'Étape suivante',
'walkthrough.action.previous': 'Étape précédente',
'walkthrough.action.refresh': 'Actualiser',
'walkthrough.action.open': 'Parcours IA',
'walkthrough.stage.collecting': 'Collecte des modifications',
'walkthrough.stage.asking': 'En attente du modèle',
'walkthrough.stage.assembling': 'Assemblage du parcours',
'walkthrough.empty.title': 'Aucun parcours pour linstant',
'walkthrough.empty.description': 'Construisez un parcours de lecture guidé à travers ces modifications. Cela appelle le petit modèle et consomme des jetons, donc rien ne se lance sans votre demande.',
'walkthrough.stale.banner': 'Le code a changé après cette revue. Étapes obsolètes : {count}',
'walkthrough.stop.staleAll': 'Tout le code décrit par cette étape a changé.',
'walkthrough.stop.stalePartial': 'Une partie du code décrit par cette étape a changé. Fragments manquants : {count}',
'walkthrough.stop.staleShort': 'Obsolète',
'walkthrough.stop.noCode': 'Le code de cette étape est absent du diff actuel.',
'walkthrough.uncovered.title': 'Modifications non traitées par la revue : {count}',
'walkthrough.uncovered.description': 'La revue les a jugées courantes. Dépliez pour les vérifier vous-même.',
'walkthrough.toc.moreFiles': 'Autres fichiers : {count}',
'walkthrough.toc.uncovered': 'Non traité : {count}',
'walkthrough.toc.resize': 'Redimensionner la colonne du sommaire',
'walkthrough.importance.critical': 'Critique',
'walkthrough.importance.context': 'Contexte',
'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible',
'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.',
'walkthrough.blocked.emptyDiff.title': 'Rien à examiner',
'walkthrough.blocked.emptyDiff.description': 'Il ny a encore aucune modification dans cette portée.',
'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel',
'walkthrough.blocked.contextTooSmall.description': '{model} accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien nest tronqué : choisissez un modèle au contexte plus large.',
'walkthrough.blocked.structuredOutput.title': 'Ce modèle ne produit pas de sortie structurée',
'walkthrough.blocked.structuredOutput.description': '{model} ne prend pas en charge les réponses structurées nécessaires à un parcours.',
'walkthrough.blocked.chooseModel': 'Choisissez le petit modèle',
'walkthrough.blocked.outputExhausted.title': 'Le modèle a épuisé son budget de réponse',
'walkthrough.blocked.outputExhausted.description': '{model} a dépensé toute sa marge de sortie en raisonnement et na rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.',
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Le petit modèle a dépensé toute sa marge de sortie en raisonnement et na rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.',
'walkthrough.blocked.onlyGenerated.title': 'Seuls des fichiers générés ont changé',
'walkthrough.blocked.onlyGenerated.description': 'Toutes les modifications concernent des fichiers de verrouillage ou dautres sorties générées, que la revue ignore délibérément.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Le petit modèle accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien nest tronqué : choisissez un modèle au contexte plus large.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Le petit modèle ne prend pas en charge les réponses structurées nécessaires à un parcours.',
'contextRail.surface.plan.description': 'Voir le plan actuel',
'contextRail.surface.pr.description': 'Créer, relire et fusionner la pull request de la branche actuelle',
'contextRail.surface.notes.description': 'Notes, tâches et plans du projet',
@@ -1553,6 +1553,10 @@ export const settingsDict = {
'settings.openchamber.defaults.smallModel.useDefault': 'デフォルトの小型モデルを使用',
'settings.openchamber.defaults.smallModel.useDefaultAria': 'デフォルトの小型モデルを使用',
'settings.openchamber.defaults.smallModel.overrideModel': '上書きモデル',
'settings.openchamber.defaults.walkthroughModel.title': '変更ウォークスルーのモデル',
'settings.openchamber.defaults.walkthroughModel.description': '変更の AI レビューには構造化出力と差分全体が収まる余裕が必要で、安価なスモールモデルでは足りないことがあります。カタログが構造化出力に非対応と示すモデルは、この一覧から除外されます。 未選択のままならスモールモデルが使われます。',
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'ウォークスルーのモデル',
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'スモールモデル',
'settings.openchamber.defaults.field.showDeletionDialog': '削除ダイアログを表示',
'settings.openchamber.defaults.field.openFilesPreviewAria': 'プレビュー可能なファイルをプレビューモードで開く',
'settings.openchamber.defaults.field.openFilesPreview': 'プレビュー可能なファイルをプレビューモードで開く',
+50
View File
@@ -1092,6 +1092,56 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.git.description': 'コミット・ブランチ・プルリクエスト',
'contextRail.surface.terminal.description': '内蔵ターミナル',
'contextRail.surface.diff.description': '作業中の変更をレビュー',
'contextPanel.mode.walkthrough': 'ウォークスルー',
'contextRail.surface.walkthrough.description': 'AI による変更のガイド付きウォークスルー',
'walkthrough.scope.all': '未コミットすべて',
'walkthrough.scope.group.workingTree': '作業ツリー',
'walkthrough.scope.group.committed': 'コミット済み',
'walkthrough.scope.staged': 'ステージ済み',
'walkthrough.scope.working': '未ステージ',
'walkthrough.scope.branch': 'このブランチ',
'walkthrough.scope.selectorAria': 'レビュー対象を選択',
'walkthrough.scope.pullRequest': 'PR #{number}',
'walkthrough.action.generate': 'ウォークスルーを生成',
'walkthrough.action.regenerate': '再生成',
'walkthrough.action.cancel': 'キャンセル',
'walkthrough.action.next': '次のステップ',
'walkthrough.action.previous': '前のステップ',
'walkthrough.action.refresh': '更新',
'walkthrough.action.open': 'AI ウォークスルー',
'walkthrough.stage.collecting': '変更を集めています',
'walkthrough.stage.asking': 'モデルの応答を待っています',
'walkthrough.stage.assembling': 'ウォークスルーを組み立てています',
'walkthrough.empty.title': 'ウォークスルーはまだありません',
'walkthrough.empty.description': 'この変更を読み進めるためのガイドを作成します。スモールモデルを呼び出してトークンを消費するため、実行は明示的な操作時のみです。',
'walkthrough.stale.banner': 'このレビュー後にコードが変更されました。古くなったステップ: {count}',
'walkthrough.stop.staleAll': 'このステップが説明していたコードはすべて変更されました。',
'walkthrough.stop.stalePartial': 'このステップが説明していたコードの一部が変更されました。失われた箇所: {count}',
'walkthrough.stop.staleShort': '古い内容',
'walkthrough.stop.noCode': 'このステップのコードは現在の差分にありません。',
'walkthrough.uncovered.title': 'レビューが扱わなかった変更: {count}',
'walkthrough.uncovered.description': 'レビューは定型的と判断しました。展開してご自身で確認できます。',
'walkthrough.toc.moreFiles': 'その他のファイル: {count}',
'walkthrough.toc.uncovered': '未対応: {count}',
'walkthrough.toc.resize': '目次の列幅を変更',
'walkthrough.importance.critical': '重要',
'walkthrough.importance.context': '補足',
'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません',
'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。',
'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません',
'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。',
'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます',
'walkthrough.blocked.contextTooSmall.description': '{model} が扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。',
'walkthrough.blocked.structuredOutput.title': 'このモデルは構造化出力に対応していません',
'walkthrough.blocked.structuredOutput.description': '{model} はウォークスルーに必要な構造化応答をサポートしていません。',
'walkthrough.blocked.chooseModel': 'スモールモデルを選択',
'walkthrough.blocked.outputExhausted.title': 'モデルの出力枠が尽きました',
'walkthrough.blocked.outputExhausted.description': '{model} は出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。',
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。',
'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています',
'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。',
'contextRail.surface.plan.description': '現在のプランを表示',
'contextRail.surface.pr.description': '現在のブランチのプルリクエストを作成・確認・マージ',
'contextRail.surface.notes.description': 'プロジェクトのノート・ToDo・プラン',
@@ -1520,6 +1520,10 @@ export const settingsDict = {
'settings.openchamber.defaults.smallModel.useDefault': '기본 소형 모델 사용',
'settings.openchamber.defaults.smallModel.useDefaultAria': '기본 소형 모델 사용',
'settings.openchamber.defaults.smallModel.overrideModel': '재정의 모델',
'settings.openchamber.defaults.walkthroughModel.title': '변경 워크스루 모델',
'settings.openchamber.defaults.walkthroughModel.description': '변경 사항의 AI 리뷰에는 구조화된 출력과 전체 diff를 담을 여유가 필요한데, 저렴한 스몰 모델로는 부족할 때가 많습니다. 카탈로그가 구조화된 출력을 지원하지 않는다고 알리는 모델은 이 목록에서 숨겨집니다. 선택하지 않으면 스몰 모델이 사용됩니다.',
'settings.openchamber.defaults.walkthroughModel.overrideModel': '워크스루 모델',
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '스몰 모델',
'settings.openchamber.defaults.field.showDeletionDialog': '삭제 확인 대화상자 표시',
'settings.openchamber.defaults.field.openFilesPreviewAria': '미리보기 가능한 파일을 미리보기 모드로 열기',
'settings.openchamber.defaults.field.openFilesPreview': '미리보기 가능한 파일을 미리보기 모드로 열기',
+50
View File
@@ -1096,6 +1096,56 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.git.description': '커밋, 브랜치, 풀 리퀘스트',
'contextRail.surface.terminal.description': '내장 터미널',
'contextRail.surface.diff.description': '작업 중인 변경 사항 검토',
'contextPanel.mode.walkthrough': '워크스루',
'contextRail.surface.walkthrough.description': 'AI가 안내하는 변경 사항 워크스루',
'walkthrough.scope.all': '커밋되지 않은 전체',
'walkthrough.scope.group.workingTree': '작업 트리',
'walkthrough.scope.group.committed': '커밋됨',
'walkthrough.scope.staged': '스테이지됨',
'walkthrough.scope.working': '스테이지 안 됨',
'walkthrough.scope.branch': '이 브랜치',
'walkthrough.scope.selectorAria': '리뷰 대상 선택',
'walkthrough.scope.pullRequest': 'PR #{number}',
'walkthrough.action.generate': '워크스루 생성',
'walkthrough.action.regenerate': '다시 생성',
'walkthrough.action.cancel': '취소',
'walkthrough.action.next': '다음 단계',
'walkthrough.action.previous': '이전 단계',
'walkthrough.action.refresh': '새로 고침',
'walkthrough.action.open': 'AI 워크스루',
'walkthrough.stage.collecting': '변경 사항을 모으는 중',
'walkthrough.stage.asking': '모델을 기다리는 중',
'walkthrough.stage.assembling': '워크스루를 조립하는 중',
'walkthrough.empty.title': '아직 워크스루가 없습니다',
'walkthrough.empty.description': '이 변경 사항을 따라 읽을 수 있는 경로를 만듭니다. 스몰 모델을 호출해 토큰을 사용하므로 요청할 때만 실행됩니다.',
'walkthrough.stale.banner': '이 리뷰 이후에 코드가 바뀌었습니다. 오래된 단계: {count}',
'walkthrough.stop.staleAll': '이 단계가 설명하던 코드가 모두 바뀌었습니다.',
'walkthrough.stop.stalePartial': '이 단계가 설명하던 코드 일부가 바뀌었습니다. 사라진 부분: {count}',
'walkthrough.stop.staleShort': '오래됨',
'walkthrough.stop.noCode': '이 단계의 코드는 현재 diff에 없습니다.',
'walkthrough.uncovered.title': '리뷰가 다루지 않은 변경: {count}',
'walkthrough.uncovered.description': '리뷰가 일상적인 변경으로 판단했습니다. 펼쳐서 직접 확인하세요.',
'walkthrough.toc.moreFiles': '다른 파일: {count}',
'walkthrough.toc.uncovered': '미포함: {count}',
'walkthrough.toc.resize': '목차 열 너비 조절',
'walkthrough.importance.critical': '중요',
'walkthrough.importance.context': '참고',
'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다',
'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.',
'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다',
'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.',
'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다',
'walkthrough.blocked.contextTooSmall.description': '{model}은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.',
'walkthrough.blocked.structuredOutput.title': '이 모델은 구조화된 출력을 지원하지 않습니다',
'walkthrough.blocked.structuredOutput.description': '{model}은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.',
'walkthrough.blocked.chooseModel': '스몰 모델 선택',
'walkthrough.blocked.outputExhausted.title': '모델의 응답 예산이 바닥났습니다',
'walkthrough.blocked.outputExhausted.description': '{model}이(가) 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.',
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.',
'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다',
'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.',
'contextRail.surface.plan.description': '현재 계획 보기',
'contextRail.surface.pr.description': '현재 브랜치의 풀 리퀘스트를 생성, 검토, 병합',
'contextRail.surface.notes.description': '프로젝트의 노트, 할 일, 계획',
@@ -739,6 +739,10 @@ export const settingsDict = {
'settings.openchamber.defaults.smallModel.useDefault': 'Używaj domyślnego małego modelu',
'settings.openchamber.defaults.smallModel.useDefaultAria': 'Używaj domyślnego małego modelu',
'settings.openchamber.defaults.smallModel.overrideModel': 'Model zastępczy',
'settings.openchamber.defaults.walkthroughModel.title': 'Model przewodnika po zmianach',
'settings.openchamber.defaults.walkthroughModel.description': 'Przegląd zmian przez AI wymaga ustrukturyzowanej odpowiedzi i miejsca na całe różnice, czego tani mały model często nie zapewnia. Modele, które katalog zgłasza jako niezdolne do ustrukturyzowanej odpowiedzi, są tu ukryte. Bez wyboru używany jest mały model.',
'settings.openchamber.defaults.walkthroughModel.overrideModel': 'Model przewodnika',
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': 'Mały model',
'settings.openchamber.defaults.field.thinkingPlaceholder': 'Myślenie',
'settings.openchamber.defaults.option.default': 'Domyślne',
'settings.openchamber.defaults.option.defaultLowercase': 'domyślne',
+50
View File
@@ -1411,6 +1411,56 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.git.description': 'Commity, gałęzie i pull requesty',
'contextRail.surface.terminal.description': 'Wbudowany terminal',
'contextRail.surface.diff.description': 'Przeglądaj bieżące zmiany',
'contextPanel.mode.walkthrough': 'Przewodnik',
'contextRail.surface.walkthrough.description': 'Przewodnik po zmianach prowadzony przez AI',
'walkthrough.scope.all': 'Wszystko niezatwierdzone',
'walkthrough.scope.group.workingTree': 'Drzewo robocze',
'walkthrough.scope.group.committed': 'Zatwierdzone',
'walkthrough.scope.staged': 'W poczekalni',
'walkthrough.scope.working': 'Poza poczekalnią',
'walkthrough.scope.branch': 'Ta gałąź',
'walkthrough.scope.selectorAria': 'Wybierz, co przejrzeć',
'walkthrough.scope.pullRequest': 'PR #{number}',
'walkthrough.action.generate': 'Wygeneruj przewodnik',
'walkthrough.action.regenerate': 'Wygeneruj ponownie',
'walkthrough.action.cancel': 'Anuluj',
'walkthrough.action.next': 'Następny krok',
'walkthrough.action.previous': 'Poprzedni krok',
'walkthrough.action.refresh': 'Odśwież',
'walkthrough.action.open': 'Przewodnik AI',
'walkthrough.stage.collecting': 'Zbieram zmiany',
'walkthrough.stage.asking': 'Czekam na model',
'walkthrough.stage.assembling': 'Składam przewodnik',
'walkthrough.empty.title': 'Nie ma jeszcze przewodnika',
'walkthrough.empty.description': 'Zbuduj prowadzoną ścieżkę czytania tych zmian. Wywołuje to mały model i zużywa tokeny, więc uruchamia się tylko na Twoje żądanie.',
'walkthrough.stale.banner': 'Kod zmienił się po tym przeglądzie. Nieaktualne kroki: {count}',
'walkthrough.stop.staleAll': 'Cały kod opisywany przez ten krok uległ zmianie.',
'walkthrough.stop.stalePartial': 'Część kodu opisywanego przez ten krok uległa zmianie. Brakujące fragmenty: {count}',
'walkthrough.stop.staleShort': 'Nieaktualne',
'walkthrough.stop.noCode': 'Kodu z tego kroku nie ma w bieżących różnicach.',
'walkthrough.uncovered.title': 'Zmiany pominięte w przeglądzie: {count}',
'walkthrough.uncovered.description': 'Przegląd uznał je za rutynowe. Rozwiń, aby sprawdzić je samodzielnie.',
'walkthrough.toc.moreFiles': 'Więcej plików: {count}',
'walkthrough.toc.uncovered': 'Nieuwzględnione: {count}',
'walkthrough.toc.resize': 'Zmień szerokość kolumny spisu treści',
'walkthrough.importance.critical': 'Krytyczne',
'walkthrough.importance.context': 'Kontekst',
'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu',
'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.',
'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać',
'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.',
'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu',
'walkthrough.blocked.contextTooSmall.description': '{model} mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.',
'walkthrough.blocked.structuredOutput.title': 'Ten model nie tworzy ustrukturyzowanych odpowiedzi',
'walkthrough.blocked.structuredOutput.description': '{model} nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.',
'walkthrough.blocked.chooseModel': 'Wybierz mały model',
'walkthrough.blocked.outputExhausted.title': 'Modelowi zabrakło budżetu na odpowiedź',
'walkthrough.blocked.outputExhausted.description': '{model} zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.',
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Mały model zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.',
'walkthrough.blocked.onlyGenerated.title': 'Zmieniły się tylko pliki generowane',
'walkthrough.blocked.onlyGenerated.description': 'Wszystkie zmiany to pliki blokad lub inne wyniki pracy narzędzi, które przegląd celowo pomija.',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Mały model mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Mały model nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.',
'contextRail.surface.plan.description': 'Zobacz bieżący plan',
'contextRail.surface.pr.description': 'Twórz, przeglądaj i scalaj pull request bieżącej gałęzi',
'contextRail.surface.notes.description': 'Notatki, zadania i plany projektu',
@@ -1520,6 +1520,10 @@ export const settingsDict = {
"settings.openchamber.defaults.smallModel.useDefault": "Usar o modelo pequeno padrão",
"settings.openchamber.defaults.smallModel.useDefaultAria": "Usar o modelo pequeno padrão",
"settings.openchamber.defaults.smallModel.overrideModel": "Modelo de substituição",
"settings.openchamber.defaults.walkthroughModel.title": "Modelo do percurso de mudanças",
"settings.openchamber.defaults.walkthroughModel.description": "A revisão das suas mudanças por IA precisa de saída estruturada e espaço para um diff inteiro, o que um modelo pequeno e barato muitas vezes não oferece. Modelos que o catálogo indica como incapazes de saída estruturada ficam ocultos aqui. Sem escolha, o modelo pequeno é usado.",
"settings.openchamber.defaults.walkthroughModel.overrideModel": "Modelo do percurso",
"settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Modelo pequeno",
"settings.openchamber.defaults.field.showDeletionDialog": "Mostrar diálogo de eliminación",
"settings.openchamber.defaults.field.openFilesPreviewAria": "Abrir arquivos com visualização em modo de prévia",
"settings.openchamber.defaults.field.openFilesPreview": "Abrir arquivos com visualização em modo de prévia",
@@ -1096,6 +1096,56 @@ export const dict: Record<I18nKey, string> = {
"contextRail.surface.git.description": "Commits, branches e pull requests",
"contextRail.surface.terminal.description": "Terminal integrado",
"contextRail.surface.diff.description": "Revisar alterações em andamento",
"contextPanel.mode.walkthrough": "Percurso",
"contextRail.surface.walkthrough.description": "Um percurso pelas suas mudanças guiado por IA",
"walkthrough.scope.all": "Tudo sem commit",
"walkthrough.scope.group.workingTree": "Árvore de trabalho",
"walkthrough.scope.group.committed": "Com commit",
"walkthrough.scope.staged": "No stage",
"walkthrough.scope.working": "Fora do stage",
"walkthrough.scope.branch": "Este branch",
"walkthrough.scope.selectorAria": "Escolher o que revisar",
"walkthrough.scope.pullRequest": "PR nº {number}",
"walkthrough.action.generate": "Gerar percurso",
"walkthrough.action.regenerate": "Gerar novamente",
"walkthrough.action.cancel": "Cancelar",
"walkthrough.action.next": "Próxima etapa",
"walkthrough.action.previous": "Etapa anterior",
"walkthrough.action.refresh": "Atualizar",
"walkthrough.action.open": "Percurso com IA",
"walkthrough.stage.collecting": "Reunindo as mudanças",
"walkthrough.stage.asking": "Aguardando o modelo",
"walkthrough.stage.assembling": "Montando o percurso",
"walkthrough.empty.title": "Ainda não há percurso",
"walkthrough.empty.description": "Monte um percurso de leitura por estas mudanças. Isso chama o modelo pequeno e consome tokens, então só roda quando você pede.",
"walkthrough.stale.banner": "O código mudou depois desta revisão. Etapas desatualizadas: {count}",
"walkthrough.stop.staleAll": "Todo o código que esta etapa descrevia mudou.",
"walkthrough.stop.stalePartial": "Parte do código que esta etapa descrevia mudou. Trechos perdidos: {count}",
"walkthrough.stop.staleShort": "Desatualizado",
"walkthrough.stop.noCode": "O código desta etapa não está no diff atual.",
"walkthrough.uncovered.title": "Mudanças que a revisão deixou de fora: {count}",
"walkthrough.uncovered.description": "A revisão considerou essas mudanças rotineiras. Expanda para conferir você mesmo.",
"walkthrough.toc.moreFiles": "Mais arquivos: {count}",
"walkthrough.toc.uncovered": "Sem cobertura: {count}",
"walkthrough.toc.resize": "Redimensionar a coluna de conteúdo",
"walkthrough.importance.critical": "Crítico",
"walkthrough.importance.context": "Contexto",
"walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível",
"walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.",
"walkthrough.blocked.emptyDiff.title": "Nada para revisar",
"walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.",
"walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual",
"walkthrough.blocked.contextTooSmall.description": "{model} comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.",
"walkthrough.blocked.structuredOutput.title": "Este modelo não produz saída estruturada",
"walkthrough.blocked.structuredOutput.description": "{model} não suporta as respostas estruturadas que um percurso exige.",
"walkthrough.blocked.chooseModel": "Escolha o modelo pequeno",
"walkthrough.blocked.outputExhausted.title": "O modelo ficou sem orçamento de resposta",
"walkthrough.blocked.outputExhausted.description": "{model} gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.",
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "O modelo pequeno gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.",
"walkthrough.blocked.onlyGenerated.title": "Só mudaram arquivos gerados",
"walkthrough.blocked.onlyGenerated.description": "Todas as mudanças são arquivos de lock ou outra saída gerada por ferramentas, que a revisão ignora de propósito.",
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "O modelo pequeno comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.",
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "O modelo pequeno não suporta as respostas estruturadas que um percurso exige.",
"contextRail.surface.plan.description": "Ver o plano atual",
"contextRail.surface.pr.description": "Crie, revise e faça merge do pull request do branch atual",
"contextRail.surface.notes.description": "Notas, tarefas e planos do projeto",
@@ -1520,6 +1520,10 @@ export const settingsDict = {
"settings.openchamber.defaults.smallModel.useDefault": "Використовувати типову малу модель",
"settings.openchamber.defaults.smallModel.useDefaultAria": "Використовувати типову малу модель",
"settings.openchamber.defaults.smallModel.overrideModel": "Модель заміни",
"settings.openchamber.defaults.walkthroughModel.title": "Модель для розбору змін",
"settings.openchamber.defaults.walkthroughModel.description": "AI-розбір ваших змін потребує structured output і місця під увесь diff, чого дешева small model часто дати не може. Моделі, які за каталогом не вміють structured output, у цьому списку приховані. Якщо нічого не обрано, використовується small model.",
"settings.openchamber.defaults.walkthroughModel.overrideModel": "Модель для розбору",
"settings.openchamber.defaults.walkthroughModel.usesSmallModel": "Small model",
"settings.openchamber.defaults.field.showDeletionDialog": "Показати діалогове вікно видалення",
"settings.openchamber.defaults.field.openFilesPreviewAria": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду",
"settings.openchamber.defaults.field.openFilesPreview": "Відкривати файли з підтримкою перегляду в режимі попереднього перегляду",
+50
View File
@@ -1096,6 +1096,56 @@ export const dict: Record<I18nKey, string> = {
"contextRail.surface.git.description": "Коміти, гілки та pull request-и",
"contextRail.surface.terminal.description": "Вбудований термінал",
"contextRail.surface.diff.description": "Перегляд поточних змін",
"contextPanel.mode.walkthrough": "Розбір",
"contextRail.surface.walkthrough.description": "Покроковий розбір ваших змін за допомогою AI",
"walkthrough.scope.all": "Усе незакомічене",
"walkthrough.scope.group.workingTree": "Робоче дерево",
"walkthrough.scope.group.committed": "Закомічене",
"walkthrough.scope.staged": "В індексі",
"walkthrough.scope.working": "Поза індексом",
"walkthrough.scope.branch": "Ця гілка",
"walkthrough.scope.selectorAria": "Обрати, що розбирати",
"walkthrough.scope.pullRequest": "PR #{number}",
"walkthrough.action.generate": "Створити розбір",
"walkthrough.action.regenerate": "Створити заново",
"walkthrough.action.cancel": "Скасувати",
"walkthrough.action.next": "Наступний крок",
"walkthrough.action.previous": "Попередній крок",
"walkthrough.action.refresh": "Оновити",
"walkthrough.action.open": "AI-розбір",
"walkthrough.stage.collecting": "Збираю зміни",
"walkthrough.stage.asking": "Чекаю на модель",
"walkthrough.stage.assembling": "Складаю розбір",
"walkthrough.empty.title": "Розбору ще немає",
"walkthrough.empty.description": "Побудуйте маршрут читання цих змін. Це звертається до small model і витрачає токени, тому запускається лише на ваш запит.",
"walkthrough.stale.banner": "Код змінився після цього розбору. Застарілих кроків: {count}",
"walkthrough.stop.staleAll": "Увесь код, який описував цей крок, змінився.",
"walkthrough.stop.stalePartial": "Частина коду, який описував цей крок, змінилася. Втрачених фрагментів: {count}",
"walkthrough.stop.staleShort": "Застаріле",
"walkthrough.stop.noCode": "Коду для цього кроку немає в поточному diff.",
"walkthrough.uncovered.title": "Зміни, які розбір не описав: {count}",
"walkthrough.uncovered.description": "Розбір визнав їх рутинними. Розгорніть, щоб переглянути самостійно.",
"walkthrough.toc.moreFiles": "Ще файлів: {count}",
"walkthrough.toc.uncovered": "Не описано: {count}",
"walkthrough.toc.resize": "Змінити ширину колонки змісту",
"walkthrough.importance.critical": "Критично",
"walkthrough.importance.context": "Контекст",
"walkthrough.blocked.noModel.title": "Немає доступної small model",
"walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.",
"walkthrough.blocked.emptyDiff.title": "Немає що оглядати",
"walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.",
"walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі",
"walkthrough.blocked.contextTooSmall.description": "{model} вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.",
"walkthrough.blocked.structuredOutput.title": "Ця модель не вміє structured output",
"walkthrough.blocked.structuredOutput.description": "{model} не підтримує структуровані відповіді, потрібні для розбору.",
"walkthrough.blocked.chooseModel": "Оберіть small model",
"walkthrough.blocked.outputExhausted.title": "Моделі забракло бюджету на відповідь",
"walkthrough.blocked.outputExhausted.description": "{model} витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.",
"walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.",
"walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли",
"walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.",
"walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.",
"walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.",
"contextRail.surface.plan.description": "Перегляд поточного плану",
"contextRail.surface.pr.description": "Створюйте, переглядайте та зливайте pull request поточної гілки",
"contextRail.surface.notes.description": "Нотатки, задачі та плани проєкту",
@@ -1520,6 +1520,10 @@ export const settingsDict = {
'settings.openchamber.defaults.smallModel.useDefault': '使用默认小模型',
'settings.openchamber.defaults.smallModel.useDefaultAria': '使用默认小模型',
'settings.openchamber.defaults.smallModel.overrideModel': '覆盖模型',
'settings.openchamber.defaults.walkthroughModel.title': '改动导读模型',
'settings.openchamber.defaults.walkthroughModel.description': 'AI 评审改动需要结构化输出和容纳整份差异的空间,廉价的小模型往往做不到。目录中标注为不支持结构化输出的模型不会出现在此列表里。 未选择时将使用小模型。',
'settings.openchamber.defaults.walkthroughModel.overrideModel': '导读模型',
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型',
'settings.openchamber.defaults.field.showDeletionDialog': '显示删除对话框',
'settings.openchamber.defaults.field.openFilesPreviewAria': '以预览模式打开可预览文件',
'settings.openchamber.defaults.field.openFilesPreview': '以预览模式打开可预览文件',
@@ -1096,6 +1096,56 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.git.description': '提交、分支和拉取请求',
'contextRail.surface.terminal.description': '内置终端',
'contextRail.surface.diff.description': '查看工作区更改',
'contextPanel.mode.walkthrough': '导读',
'contextRail.surface.walkthrough.description': '由 AI 引导的改动导读',
'walkthrough.scope.all': '全部未提交',
'walkthrough.scope.group.workingTree': '工作区',
'walkthrough.scope.group.committed': '已提交',
'walkthrough.scope.staged': '已暂存',
'walkthrough.scope.working': '未暂存',
'walkthrough.scope.branch': '当前分支',
'walkthrough.scope.selectorAria': '选择评审范围',
'walkthrough.scope.pullRequest': 'PR #{number}',
'walkthrough.action.generate': '生成导读',
'walkthrough.action.regenerate': '重新生成',
'walkthrough.action.cancel': '取消',
'walkthrough.action.next': '下一步',
'walkthrough.action.previous': '上一步',
'walkthrough.action.refresh': '刷新',
'walkthrough.action.open': 'AI 导读',
'walkthrough.stage.collecting': '正在收集改动',
'walkthrough.stage.asking': '正在等待模型',
'walkthrough.stage.assembling': '正在组装导读',
'walkthrough.empty.title': '还没有导读',
'walkthrough.empty.description': '为这些改动生成一条引导式阅读路线。该操作会调用小模型并消耗 token,因此只在你主动请求时运行。',
'walkthrough.stale.banner': '本次评审之后代码有变动。过时的步骤:{count}',
'walkthrough.stop.staleAll': '该步骤描述的代码已全部变动。',
'walkthrough.stop.stalePartial': '该步骤描述的部分代码已变动。缺失片段:{count}',
'walkthrough.stop.staleShort': '已过时',
'walkthrough.stop.noCode': '当前差异中没有该步骤的代码。',
'walkthrough.uncovered.title': '评审未涵盖的改动:{count}',
'walkthrough.uncovered.description': '评审认为这些改动较常规。展开可自行查看。',
'walkthrough.toc.moreFiles': '其他文件:{count}',
'walkthrough.toc.uncovered': '未涵盖:{count}',
'walkthrough.toc.resize': '调整目录栏宽度',
'walkthrough.importance.critical': '关键',
'walkthrough.importance.context': '背景',
'walkthrough.blocked.noModel.title': '没有可用的小模型',
'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。',
'walkthrough.blocked.emptyDiff.title': '没有可评审的内容',
'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。',
'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异',
'walkthrough.blocked.contextTooSmall.description': '{model} 大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。',
'walkthrough.blocked.structuredOutput.title': '该模型不支持结构化输出',
'walkthrough.blocked.structuredOutput.description': '{model} 不支持导读所需的结构化响应。',
'walkthrough.blocked.chooseModel': '选择小模型',
'walkthrough.blocked.outputExhausted.title': '模型的回答额度已用尽',
'walkthrough.blocked.outputExhausted.description': '{model} 把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。',
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。',
'walkthrough.blocked.onlyGenerated.title': '只有生成文件发生了改动',
'walkthrough.blocked.onlyGenerated.description': '这里的改动全部是锁文件或其他工具生成的产物,评审会有意跳过它们。',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支持导读所需的结构化响应。',
'contextRail.surface.plan.description': '查看当前计划',
'contextRail.surface.pr.description': '创建、审查并合并当前分支的拉取请求',
'contextRail.surface.notes.description': '项目的笔记、待办和计划',
@@ -1426,6 +1426,10 @@
'settings.openchamber.defaults.smallModel.useDefault': '使用預設小模型',
'settings.openchamber.defaults.smallModel.useDefaultAria': '使用預設小模型',
'settings.openchamber.defaults.smallModel.overrideModel': '覆寫模型',
'settings.openchamber.defaults.walkthroughModel.title': '變更導讀模型',
'settings.openchamber.defaults.walkthroughModel.description': 'AI 審閱變更需要結構化輸出與容納整份差異的空間,便宜的小模型常常做不到。目錄標示為不支援結構化輸出的模型不會出現在這份清單中。 未選擇時將使用小模型。',
'settings.openchamber.defaults.walkthroughModel.overrideModel': '導讀模型',
'settings.openchamber.defaults.walkthroughModel.usesSmallModel': '小模型',
'settings.openchamber.defaults.field.showDeletionDialog': '顯示刪除對話方塊',
'settings.openchamber.defaults.field.openFilesPreviewAria': '以預覽模式開啟可預覽檔案',
'settings.openchamber.defaults.field.openFilesPreview': '以預覽模式開啟可預覽檔案',
@@ -1108,6 +1108,56 @@ export const dict: Record<I18nKey, string> = {
'contextRail.surface.git.description': '提交、分支與拉取請求',
'contextRail.surface.terminal.description': '內建終端機',
'contextRail.surface.diff.description': '檢視工作區變更',
'contextPanel.mode.walkthrough': '導讀',
'contextRail.surface.walkthrough.description': '由 AI 引導的變更導讀',
'walkthrough.scope.all': '全部未提交',
'walkthrough.scope.group.workingTree': '工作區',
'walkthrough.scope.group.committed': '已提交',
'walkthrough.scope.staged': '已暫存',
'walkthrough.scope.working': '未暫存',
'walkthrough.scope.branch': '目前分支',
'walkthrough.scope.selectorAria': '選擇審閱範圍',
'walkthrough.scope.pullRequest': 'PR #{number}',
'walkthrough.action.generate': '產生導讀',
'walkthrough.action.regenerate': '重新產生',
'walkthrough.action.cancel': '取消',
'walkthrough.action.next': '下一步',
'walkthrough.action.previous': '上一步',
'walkthrough.action.refresh': '重新整理',
'walkthrough.action.open': 'AI 導讀',
'walkthrough.stage.collecting': '正在收集變更',
'walkthrough.stage.asking': '正在等待模型',
'walkthrough.stage.assembling': '正在組裝導讀',
'walkthrough.empty.title': '尚未有導讀',
'walkthrough.empty.description': '為這些變更建立一條引導式閱讀路線。此操作會呼叫小模型並消耗 token,因此只在你主動要求時執行。',
'walkthrough.stale.banner': '本次審閱之後程式碼有變動。過時的步驟:{count}',
'walkthrough.stop.staleAll': '此步驟描述的程式碼已全部變動。',
'walkthrough.stop.stalePartial': '此步驟描述的部分程式碼已變動。缺少的片段:{count}',
'walkthrough.stop.staleShort': '已過時',
'walkthrough.stop.noCode': '目前差異中沒有此步驟的程式碼。',
'walkthrough.uncovered.title': '審閱未涵蓋的變更:{count}',
'walkthrough.uncovered.description': '審閱認為這些變更較為例行。展開即可自行檢視。',
'walkthrough.toc.moreFiles': '其他檔案:{count}',
'walkthrough.toc.uncovered': '未涵蓋:{count}',
'walkthrough.toc.resize': '調整目錄欄寬度',
'walkthrough.importance.critical': '關鍵',
'walkthrough.importance.context': '背景',
'walkthrough.blocked.noModel.title': '沒有可用的小模型',
'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。',
'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容',
'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。',
'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異',
'walkthrough.blocked.contextTooSmall.description': '{model} 大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。',
'walkthrough.blocked.structuredOutput.title': '此模型不支援結構化輸出',
'walkthrough.blocked.structuredOutput.description': '{model} 不支援導讀所需的結構化回應。',
'walkthrough.blocked.chooseModel': '選擇小模型',
'walkthrough.blocked.outputExhausted.title': '模型的回答額度已用完',
'walkthrough.blocked.outputExhausted.description': '{model} 把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。',
'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。',
'walkthrough.blocked.onlyGenerated.title': '只有產生的檔案有變動',
'walkthrough.blocked.onlyGenerated.description': '這裡的變更全部是鎖定檔或其他工具產生的輸出,審閱會刻意略過它們。',
'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。',
'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支援導讀所需的結構化回應。',
'contextRail.surface.plan.description': '檢視目前計畫',
'contextRail.surface.pr.description': '建立、審查並合併目前分支的提取請求',
'contextRail.surface.notes.description': '專案的筆記、待辦與計畫',
+3
View File
@@ -1169,6 +1169,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.smallModelOverride === 'string' && candidate.smallModelOverride.length > 0) {
result.smallModelOverride = candidate.smallModelOverride;
}
if (typeof candidate.walkthroughModelOverride === 'string' && candidate.walkthroughModelOverride.length > 0) {
result.walkthroughModelOverride = candidate.walkthroughModelOverride;
}
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
+7
View File
@@ -378,6 +378,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
descriptionKey: 'settings.openchamber.defaults.smallModel.description',
keywords: ['small model', 'utility', 'summary', 'recap', 'cheap', 'override'],
},
{
id: 'sessions.walkthrough-model',
page: 'sessions',
titleKey: 'settings.openchamber.defaults.walkthroughModel.title',
descriptionKey: 'settings.openchamber.defaults.walkthroughModel.description',
keywords: ['walkthrough', 'diff', 'review', 'changes', 'structured output', 'model', 'override'],
},
{
id: 'sessions.auto-cleanup',
page: 'sessions',
+10
View File
@@ -7,6 +7,7 @@ export type ContextSurfaceId =
| 'git'
| 'pr'
| 'diff'
| 'walkthrough'
| 'terminal'
| 'plan'
| 'notes'
@@ -74,6 +75,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
labelKey: 'contextPanel.mode.diff',
availability: 'always',
},
{
id: 'walkthrough',
descriptionKey: 'contextRail.surface.walkthrough.description',
defaultWidthFraction: 3 / 5,
mode: 'walkthrough',
icon: 'route',
labelKey: 'contextPanel.mode.walkthrough',
availability: 'always',
},
{
id: 'editor',
descriptionKey: 'contextRail.surface.editor.description',
+106
View File
@@ -0,0 +1,106 @@
import { runtimeFetch } from '@/lib/runtime-fetch';
import {
WalkthroughError,
type WalkthroughResult,
type WalkthroughSource,
type WalkthroughStage,
} from './types';
const BASE = '/api/walkthrough';
interface ErrorPayload {
error?: unknown;
code?: unknown;
model?: unknown;
requiredChars?: unknown;
availableChars?: unknown;
}
// An authoritative read that fails must never look like "there is nothing
// here" — the caller would clear a perfectly good walkthrough off the screen.
const throwFromResponse = async (response: Response, fallback: string): Promise<never> => {
const payload = (await response.json().catch(() => null)) as ErrorPayload | null;
throw new WalkthroughError(typeof payload?.error === 'string' ? payload.error : fallback, {
code: typeof payload?.code === 'string' ? (payload.code as WalkthroughError['code']) : undefined,
model: (payload?.model as WalkthroughResult['model']) ?? undefined,
requiredChars: typeof payload?.requiredChars === 'number' ? payload.requiredChars : undefined,
availableChars: typeof payload?.availableChars === 'number' ? payload.availableChars : undefined,
});
};
export async function fetchWalkthrough(
directory: string,
source: WalkthroughSource,
options: { model?: string; signal?: AbortSignal } = {}
): Promise<WalkthroughResult> {
const response = await runtimeFetch(BASE, {
query: {
directory,
source: JSON.stringify(source),
...(options.model ? { model: options.model } : {}),
},
signal: options.signal,
});
if (!response.ok) {
return throwFromResponse(response, 'Failed to load walkthrough');
}
return response.json();
}
export async function generateWalkthrough(
directory: string,
source: WalkthroughSource,
options: { force?: boolean; model?: string; signal?: AbortSignal } = {}
): Promise<WalkthroughResult> {
const response = await runtimeFetch(`${BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
directory,
source,
force: options.force === true,
...(options.model ? { model: options.model } : {}),
}),
signal: options.signal,
});
if (!response.ok) {
return throwFromResponse(response, 'Failed to generate walkthrough');
}
return response.json();
}
/**
* Stop a running generation. Explicit, because merely leaving the page must not
* throw away work the user is paying for.
*/
export async function cancelWalkthroughGeneration(
directory: string,
source: WalkthroughSource
): Promise<void> {
const response = await runtimeFetch(`${BASE}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory, source }),
});
if (!response.ok) {
await throwFromResponse(response, 'Failed to cancel walkthrough generation');
}
}
/**
* Current stage of a running generation. Reads server memory only, so this is
* safe to poll unlike the full read, which re-runs the whole git pipeline.
*/
export async function fetchWalkthroughStage(
directory: string,
source: WalkthroughSource,
signal?: AbortSignal
): Promise<WalkthroughStage | null> {
const response = await runtimeFetch(`${BASE}/progress`, {
query: { directory, source: JSON.stringify(source) },
signal,
});
if (!response.ok) return null;
const payload = (await response.json().catch(() => null)) as { stage?: unknown } | null;
return typeof payload?.stage === 'string' ? (payload.stage as WalkthroughStage) : null;
}
@@ -0,0 +1,135 @@
import { describe, expect, test as it } from 'bun:test';
import { buildWalkthroughView, groupHunksByFile, mergeRunPatch, summarizeHunkFiles } from './model';
import type { WalkthroughHunk, WalkthroughResult } from './types';
const hunk = (id: string, path: string, overrides: Partial<WalkthroughHunk> = {}): WalkthroughHunk => ({
id,
path,
oldPath: null,
status: 'modified',
scope: 'working',
header: '@@ -1,2 +1,3 @@',
newStart: 1,
added: 1,
deleted: 0,
patch: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1,2 +1,3 @@\n+line\n`,
...overrides,
});
const result = (overrides: Partial<WalkthroughResult> = {}): WalkthroughResult => ({
source: { kind: 'working-tree', scope: 'all' },
walkthrough: {
title: 'Change',
focus: 'why',
chapters: [
{
id: 'chapter-1',
title: 'Data',
icon: 'doc',
blurb: '',
stops: [
{ id: 'stop-1-1', title: 'First', hunkIds: ['a', 'b'], importance: 'critical', prose: 'p1' },
{ id: 'stop-1-2', title: 'Second', hunkIds: ['c'], importance: 'normal', prose: 'p2' },
],
},
],
},
hunks: [hunk('a', 'src/a.ts'), hunk('b', 'src/a.ts'), hunk('c', 'src/b.ts')],
hunkCount: 3,
...overrides,
});
describe('buildWalkthroughView', () => {
it('resolves stops and numbers them globally', () => {
const view = buildWalkthroughView(result())!;
expect(view.stops).toHaveLength(2);
expect(view.stops.map((stop) => stop.position)).toEqual([1, 2]);
expect(view.stops[0].hunks.map((h) => h.id)).toEqual(['a', 'b']);
expect(view.isStale).toBe(false);
expect(view.uncoveredHunks).toEqual([]);
});
it('marks only the stops whose code changed', () => {
const view = buildWalkthroughView(result({
hunks: [hunk('a', 'src/a.ts'), hunk('c', 'src/b.ts')],
}))!;
expect(view.stops[0].isStale).toBe(true);
expect(view.stops[0].missingHunkIds).toEqual(['b']);
expect(view.stops[0].hunks.map((h) => h.id)).toEqual(['a']);
expect(view.stops[1].isStale).toBe(false);
expect(view.staleStopCount).toBe(1);
expect(view.isStale).toBe(true);
});
it('surfaces hunks no stop covers instead of dropping them', () => {
const view = buildWalkthroughView(result({
hunks: [hunk('a', 'src/a.ts'), hunk('b', 'src/a.ts'), hunk('c', 'src/b.ts'), hunk('d', 'src/c.ts')],
}))!;
expect(view.uncoveredHunks.map((h) => h.id)).toEqual(['d']);
});
it('returns null without a walkthrough', () => {
expect(buildWalkthroughView(null)).toBeNull();
expect(buildWalkthroughView(result({ walkthrough: null }))).toBeNull();
});
});
describe('groupHunksByFile', () => {
it('coalesces consecutive hunks from the same file only', () => {
const runs = groupHunksByFile([
hunk('a', 'src/a.ts'),
hunk('b', 'src/a.ts'),
hunk('c', 'src/b.ts'),
hunk('d', 'src/a.ts'),
]);
expect(runs.map((run) => [run.path, run.hunks.length])).toEqual([
['src/a.ts', 2],
['src/b.ts', 1],
['src/a.ts', 1],
]);
});
});
describe('mergeRunPatch', () => {
it('keeps a single patch untouched', () => {
const single = hunk('a', 'src/a.ts');
expect(mergeRunPatch([single])).toBe(single.patch);
});
it('joins hunks under one file header', () => {
const first = hunk('a', 'src/a.ts');
const second = hunk('b', 'src/a.ts', {
patch: 'diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -10,2 +11,3 @@\n+second\n',
});
const merged = mergeRunPatch([first, second]);
expect(merged.match(/^diff --git/gm)).toHaveLength(1);
expect(merged.match(/^@@/gm)).toHaveLength(2);
expect(merged).toContain('+line');
expect(merged).toContain('+second');
});
it('returns nothing for an empty run', () => {
expect(mergeRunPatch([])).toBe('');
});
});
describe('summarizeHunkFiles', () => {
it('totals per file in first-appearance order', () => {
const files = summarizeHunkFiles([
hunk('a', 'src/b.ts', { added: 2, deleted: 1 }),
hunk('b', 'src/a.ts', { added: 1, deleted: 0 }),
hunk('c', 'src/b.ts', { added: 3, deleted: 4 }),
]);
expect(files).toEqual([
{ path: 'src/b.ts', added: 5, deleted: 5 },
{ path: 'src/a.ts', added: 1, deleted: 0 },
]);
});
});
+151
View File
@@ -0,0 +1,151 @@
import type {
Walkthrough,
WalkthroughChapter,
WalkthroughHunk,
WalkthroughResult,
WalkthroughStop,
} from './types';
/**
* Flattens a walkthrough plus the current hunk index into the ordered stream
* the view renders: every stop with its resolved hunks, followed by everything
* the walkthrough did not cover.
*
* Resolution is pure id matching the server owns hunk identity, so a stop
* whose hunks are missing is not a bug to paper over, it is a stop whose code
* has changed.
*/
export interface WalkthroughStopView {
stop: WalkthroughStop;
chapter: WalkthroughChapter;
chapterIndex: number;
/** Global 1-based position, used for the stepper and keyboard navigation. */
position: number;
hunks: WalkthroughHunk[];
/** Anchors that no longer resolve: their code changed or was removed. */
missingHunkIds: string[];
isStale: boolean;
}
export interface WalkthroughView {
walkthrough: Walkthrough;
stops: WalkthroughStopView[];
chapters: Array<{ chapter: WalkthroughChapter; stops: WalkthroughStopView[] }>;
/** Current hunks no stop covers. Never dropped — rendered as a collapsed tail. */
uncoveredHunks: WalkthroughHunk[];
staleStopCount: number;
isStale: boolean;
}
export const buildWalkthroughView = (result: WalkthroughResult | null): WalkthroughView | null => {
if (!result?.walkthrough) return null;
const index = new Map(result.hunks.map((hunk) => [hunk.id, hunk]));
const covered = new Set<string>();
const stops: WalkthroughStopView[] = [];
const chapters: WalkthroughView['chapters'] = [];
for (const [chapterIndex, chapter] of result.walkthrough.chapters.entries()) {
const chapterStops: WalkthroughStopView[] = [];
for (const stop of chapter.stops) {
const hunks: WalkthroughHunk[] = [];
const missingHunkIds: string[] = [];
for (const id of stop.hunkIds) {
const hunk = index.get(id);
if (hunk) {
hunks.push(hunk);
covered.add(id);
} else {
missingHunkIds.push(id);
}
}
const view: WalkthroughStopView = {
stop,
chapter,
chapterIndex,
position: stops.length + 1,
hunks,
missingHunkIds,
isStale: missingHunkIds.length > 0,
};
stops.push(view);
chapterStops.push(view);
}
chapters.push({ chapter, stops: chapterStops });
}
return {
walkthrough: result.walkthrough,
stops,
chapters,
uncoveredHunks: result.hunks.filter((hunk) => !covered.has(hunk.id)),
staleStopCount: stops.filter((stop) => stop.isStale).length,
isStale: stops.some((stop) => stop.isStale),
};
};
/**
* Files touched by a set of hunks, in first-appearance order, with their
* per-file totals. Used for the table of contents rows.
*/
export const summarizeHunkFiles = (
hunks: WalkthroughHunk[]
): Array<{ path: string; added: number; deleted: number }> => {
const byPath = new Map<string, { path: string; added: number; deleted: number }>();
for (const hunk of hunks) {
const existing = byPath.get(hunk.path);
if (existing) {
existing.added += hunk.added;
existing.deleted += hunk.deleted;
continue;
}
byPath.set(hunk.path, { path: hunk.path, added: hunk.added, deleted: hunk.deleted });
}
return [...byPath.values()];
};
/**
* Consecutive hunks from the same file are rendered as one diff block so the
* reader sees continuous code instead of a stack of one-hunk cards.
*/
export const groupHunksByFile = (
hunks: WalkthroughHunk[]
): Array<{ path: string; hunks: WalkthroughHunk[] }> => {
const runs: Array<{ path: string; hunks: WalkthroughHunk[] }> = [];
for (const hunk of hunks) {
const last = runs.at(-1);
if (last && last.path === hunk.path) {
last.hunks.push(hunk);
continue;
}
runs.push({ path: hunk.path, hunks: [hunk] });
}
return runs;
};
/**
* Merge a file's hunk patches back into one patch so a run renders as a single
* diff. All hunks in a run share a file header, so only the first one's header
* is kept.
*/
export const mergeRunPatch = (hunks: WalkthroughHunk[]): string => {
if (hunks.length === 0) return '';
if (hunks.length === 1) return hunks[0].patch;
const first = hunks[0].patch;
const headerEnd = first.indexOf('\n@@');
if (headerEnd === -1) return first;
const header = first.slice(0, headerEnd);
const bodies = hunks.map((hunk) => {
const start = hunk.patch.indexOf('\n@@');
return start === -1 ? '' : hunk.patch.slice(start + 1);
});
return `${header}\n${bodies.join('')}`;
};
+132
View File
@@ -0,0 +1,132 @@
/**
* Contract for the AI diff walkthrough, mirrored from
* `packages/web/server/lib/walkthrough`.
*
* Hunk ids are opaque here on purpose: the server owns how they are derived,
* and the client only ever matches them against the index it is handed.
*/
export type WalkthroughWorkingTreeScope = 'all' | 'staged' | 'working';
export type WalkthroughSource =
| { kind: 'working-tree'; scope: WalkthroughWorkingTreeScope }
| { kind: 'branch'; baseRef: string; headRef: string }
| { kind: 'pr'; number: number };
export type WalkthroughChapterIcon = 'bug' | 'wrench' | 'path' | 'flask' | 'doc' | 'gear';
export type WalkthroughStopImportance = 'critical' | 'normal' | 'context';
export interface WalkthroughStop {
id: string;
title: string;
hunkIds: string[];
importance: WalkthroughStopImportance;
prose: string;
}
export interface WalkthroughChapter {
id: string;
title: string;
icon: WalkthroughChapterIcon;
blurb: string;
stops: WalkthroughStop[];
}
export interface Walkthrough {
title: string;
focus: string;
chapters: WalkthroughChapter[];
}
export interface WalkthroughHunk {
id: string;
path: string;
oldPath: string | null;
status: 'added' | 'deleted' | 'modified' | 'renamed';
scope: string;
header: string;
newStart: number;
added: number;
deleted: number;
/** Standalone patch for this hunk, including the file header. */
patch: string;
}
export interface WalkthroughModel {
providerID: string;
modelID: string;
source?: string;
}
export interface WalkthroughResult {
source: WalkthroughSource;
walkthrough: Walkthrough | null;
model?: WalkthroughModel;
generatedAt?: string;
fromCache?: boolean;
hunks: WalkthroughHunk[];
hunkCount: number;
/** True when at least one stop points at code that has since changed. */
isStale?: boolean;
missingHunkIds?: string[];
staleStopIds?: string[];
/** Hunks in the current diff that no stop covers. Rendered as a tail. */
uncoveredHunkIds?: string[];
/** Whether generating is possible at all, computed from the same diff. */
readiness?: WalkthroughReadiness;
/** A generation is already running on the server for this source. */
generating?: boolean;
}
/**
* Only phases a person can wait on. Building the digest and reading the cache
* take milliseconds; naming them would imply progress that is not happening.
*/
export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assembling';
export type WalkthroughBlockedReason =
| 'no-model'
| 'empty-diff'
| 'only-generated'
| 'context-too-small'
| 'structured-output-unsupported'
| 'output-exhausted';
export interface WalkthroughReadiness {
ready: boolean;
reason?: WalkthroughBlockedReason;
model?: WalkthroughModel & {
inputCharBudget?: number;
contextTokens?: number;
structuredOutput?: boolean | null;
};
requiredChars?: number;
availableChars?: number;
hunkCount?: number;
fileCount?: number;
generatedFileCount?: number;
}
export class WalkthroughError extends Error {
readonly code?: WalkthroughBlockedReason | 'invalid-walkthrough' | 'github-not-connected' | 'no-github-remote';
readonly model?: WalkthroughModel;
readonly requiredChars?: number;
readonly availableChars?: number;
constructor(
message: string,
details: {
code?: WalkthroughError['code'];
model?: WalkthroughModel;
requiredChars?: number;
availableChars?: number;
} = {}
) {
super(message);
this.name = 'WalkthroughError';
this.code = details.code;
this.model = details.model;
this.requiredChars = details.requiredChars;
this.availableChars = details.availableChars;
}
}
+3
View File
@@ -441,6 +441,7 @@ interface ModelsDevModelEntry {
reasoning?: boolean;
temperature?: boolean;
attachment?: boolean;
structured_output?: boolean;
modalities?: {
input?: string[];
output?: string[];
@@ -577,6 +578,8 @@ const transformModelsDevResponse = (payload: unknown): Map<string, ModelMetadata
reasoning: typeof modelValue.reasoning === 'boolean' ? modelValue.reasoning : undefined,
temperature: typeof modelValue.temperature === 'boolean' ? modelValue.temperature : undefined,
attachment: typeof modelValue.attachment === 'boolean' ? modelValue.attachment : undefined,
structured_output:
typeof modelValue.structured_output === 'boolean' ? modelValue.structured_output : undefined,
modalities: modelValue.modalities
? {
input: isStringArray(modelValue.modalities.input) ? modelValue.modalities.input : undefined,
+11 -2
View File
@@ -14,7 +14,7 @@ import { isWindowsArm64 } from '@/lib/platform';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type PendingDiffScope = 'working' | 'staged' | 'turn';
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser' | 'git' | 'pr' | 'notes' | 'terminal';
export type MermaidRenderingMode = 'svg' | 'ascii';
export type UserMessageRenderingMode = 'markdown' | 'plain';
export type ChatRenderMode = 'sorted' | 'live';
@@ -288,7 +288,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
touchedAt?: unknown;
};
if (candidate.mode !== 'diff' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
if (candidate.mode !== 'diff' && candidate.mode !== 'walkthrough' && candidate.mode !== 'file' && candidate.mode !== 'context' && candidate.mode !== 'plan' && candidate.mode !== 'chat' && candidate.mode !== 'preview' && candidate.mode !== 'browser' && candidate.mode !== 'git' && candidate.mode !== 'pr' && candidate.mode !== 'notes' && candidate.mode !== 'terminal') {
continue;
}
@@ -654,6 +654,8 @@ interface UIStore {
diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side';
diffFileLayout: Record<string, 'inline' | 'side-by-side'>;
diffWrapLines: boolean;
/** Width of the walkthrough table of contents, in pixels. */
walkthroughTocWidth: number;
gitChangesViewMode: 'flat' | 'tree';
isTimelineDialogOpen: boolean;
isPromptNavigatorPanelOpen: boolean;
@@ -825,6 +827,7 @@ interface UIStore {
setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void;
setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void;
setDiffWrapLines: (wrap: boolean) => void;
setWalkthroughTocWidth: (width: number) => void;
setGitChangesViewMode: (mode: 'flat' | 'tree') => void;
setMultiRunLauncherOpen: (open: boolean) => void;
setTimelineDialogOpen: (open: boolean) => void;
@@ -966,6 +969,7 @@ export const useUIStore = create<UIStore>()(
diffLayoutPreference: 'inline',
diffFileLayout: {},
diffWrapLines: false,
walkthroughTocWidth: 224,
gitChangesViewMode: 'flat',
isTimelineDialogOpen: false,
isPromptNavigatorPanelOpen: false,
@@ -1828,6 +1832,10 @@ export const useUIStore = create<UIStore>()(
set({ diffWrapLines: wrap });
},
setWalkthroughTocWidth: (width) => {
set({ walkthroughTocWidth: Math.round(width) });
},
setGitChangesViewMode: (mode) => {
set({ gitChangesViewMode: mode });
},
@@ -2434,6 +2442,7 @@ export const useUIStore = create<UIStore>()(
recentEfforts: state.recentEfforts,
diffLayoutPreference: state.diffLayoutPreference,
diffWrapLines: state.diffWrapLines,
walkthroughTocWidth: state.walkthroughTocWidth,
gitChangesViewMode: state.gitChangesViewMode,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import type { WalkthroughResult, WalkthroughSource } from '@/lib/walkthrough/types';
const SOURCE: WalkthroughSource = { kind: 'working-tree', scope: 'all' };
const result = (overrides: Partial<WalkthroughResult> = {}): WalkthroughResult => ({
source: SOURCE,
walkthrough: null,
hunks: [],
hunkCount: 0,
...overrides,
});
const finished = result({
walkthrough: {
title: 'Change',
focus: '',
chapters: [{
id: 'chapter-1',
title: 'Data',
icon: 'doc',
blurb: '',
stops: [{ id: 'stop-1-1', title: 'A', hunkIds: ['h'], importance: 'normal', prose: 'p' }],
}],
},
generatedAt: '2026-08-02T00:00:00.000Z',
});
// Plain closures rather than mock helpers: bun's `mock()` is not typed with
// vitest's `mockResolvedValue` family, and the repo already prefers this style.
let readResult: WalkthroughResult = result();
let generateCalls = 0;
let releaseGeneration: (() => void) | undefined;
let lastReadModel: string | undefined;
let lastGenerateModel: string | undefined;
mock.module('@/lib/walkthrough/api', () => ({
fetchWalkthrough: async (
_directory: string,
_source: WalkthroughSource,
options: { model?: string } = {},
) => {
lastReadModel = options.model;
return readResult;
},
generateWalkthrough: async (
_directory: string,
_source: WalkthroughSource,
options: { model?: string } = {},
) => {
generateCalls += 1;
lastGenerateModel = options.model;
return new Promise<WalkthroughResult>((resolve) => {
releaseGeneration = () => resolve(finished);
});
},
cancelWalkthroughGeneration: async () => {},
}));
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'local' }));
const { useWalkthroughStore } = await import('./useWalkthroughStore');
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('useWalkthroughStore — reattaching to a running generation', () => {
beforeEach(() => {
useWalkthroughStore.getState().reset();
readResult = result();
generateCalls = 0;
releaseGeneration = undefined;
});
afterEach(() => {
useWalkthroughStore.getState().reset();
});
test('a reload that finds work in progress ends up showing the finished result', async () => {
// What a refresh looks like: the read says a job is running, and the
// generation the client re-attaches to finishes a moment later.
readResult = result({ generating: true });
await useWalkthroughStore.getState().load('/repo', SOURCE);
await flush();
expect(generateCalls).toBe(1);
expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating');
releaseGeneration?.();
await flush();
const entry = useWalkthroughStore.getState().getEntry('/repo', SOURCE);
expect(entry.status).toBe('ready');
expect(entry.result?.walkthrough?.title).toBe('Change');
});
test('does not re-attach when nothing is running', async () => {
readResult = result({ generating: false });
await useWalkthroughStore.getState().load('/repo', SOURCE);
await flush();
expect(generateCalls).toBe(0);
expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('ready');
});
test('a load while generating does not overwrite the pending state', async () => {
readResult = result({ generating: true });
await useWalkthroughStore.getState().load('/repo', SOURCE);
await flush();
await useWalkthroughStore.getState().load('/repo', SOURCE);
await flush();
expect(generateCalls).toBe(1);
expect(useWalkthroughStore.getState().getEntry('/repo', SOURCE).status).toBe('generating');
});
});
describe('useWalkthroughStore — model selection', () => {
beforeEach(() => {
useWalkthroughStore.getState().reset();
readResult = result();
generateCalls = 0;
lastReadModel = undefined;
lastGenerateModel = undefined;
});
afterEach(() => {
useWalkthroughStore.getState().reset();
});
test('sends the chosen model with both the read and the generation', async () => {
useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5');
await useWalkthroughStore.getState().load('/repo', SOURCE);
await flush();
expect(lastReadModel).toBe('anthropic/claude-haiku-4-5');
void useWalkthroughStore.getState().generate('/repo', SOURCE);
await flush();
expect(lastGenerateModel).toBe('anthropic/claude-haiku-4-5');
releaseGeneration?.();
await flush();
});
test('clearing the choice falls back to whatever the server resolves', async () => {
useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5');
useWalkthroughStore.getState().selectModel('/repo', SOURCE, null);
await useWalkthroughStore.getState().load('/repo', SOURCE);
await flush();
expect(lastReadModel).toBe(undefined);
});
test('keeps choices apart per source', async () => {
const branch: WalkthroughSource = { kind: 'branch', baseRef: 'main', headRef: 'feature' };
useWalkthroughStore.getState().selectModel('/repo', SOURCE, 'anthropic/claude-haiku-4-5');
expect(useWalkthroughStore.getState().getSelectedModel("/repo", branch)).toBe(undefined);
expect(useWalkthroughStore.getState().getSelectedModel('/repo', SOURCE))
.toBe('anthropic/claude-haiku-4-5');
});
});
Binary file not shown.
+7
View File
@@ -13,6 +13,13 @@ export interface ModelMetadata {
reasoning?: boolean;
temperature?: boolean;
attachment?: boolean;
/**
* Whether the model can be constrained to a JSON schema. Tri-state on
* purpose: `undefined` means the catalog does not say common for
* aggregators and proxies and must be treated as "worth trying", never as
* unsupported.
*/
structured_output?: boolean;
modalities?: {
input?: string[];
output?: string[];