Merge origin/main into deferred OpenCode restart branch

This commit is contained in:
Bohdan Triapitsyn
2026-08-07 10:08:50 +03:00
218 changed files with 12131 additions and 1293 deletions
+21 -2
View File
@@ -1417,12 +1417,32 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const currentBranch = status?.current ?? null;
// The repository's own default branch, so a repo whose default is neither
// main, master nor develop stops being compared against a branch that does
// not exist.
const defaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
}, [branches, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
defaultBranch,
headBranch: currentBranch,
}), [
currentBranch,
defaultBranch,
effectiveRemotes,
localBranches,
rootBranchHint,
worktreeMetadata?.createdFromBranch,
]);
const updateTargetBranch = React.useMemo(() => {
const remoteNames = effectiveRemotes.map((remote) => remote.name);
@@ -1511,7 +1531,6 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const stagedCount = stagedChangeEntries.length;
const isBusy = isLoading || syncAction !== null || commitAction !== null;
const currentBranch = status?.current ?? null;
const canShowIntegrateCommitsSection = Boolean(
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
);
@@ -213,14 +213,32 @@ export const PullRequestView: React.FC = () => {
}));
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
const currentBranch = status?.current ?? null;
// A pull request opened against a branch that does not exist is worse than a
// broken walkthrough, so this surface reads the repository's default branch
// too rather than guessing at main/master/develop.
const defaultBranch = React.useMemo(() => {
const trackingRemote = status?.tracking?.trim().split('/')[0];
return (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
}, [branches, status?.tracking]);
const baseBranch = React.useMemo(() => deriveBaseBranch({
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
localBranches,
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
rootBranchHint,
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
const currentBranch = status?.current ?? null;
defaultBranch,
headBranch: currentBranch,
}), [
currentBranch,
defaultBranch,
effectiveRemotes,
localBranches,
rootBranchHint,
worktreeMetadata?.createdFromBranch,
]);
if (!currentDirectory || !currentBranch) {
return (
@@ -46,7 +46,6 @@ import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRunti
import { isWindowsArm64 as isWindowsArm64Platform } from '@/lib/platform';
import { useI18n } from '@/lib/i18n';
import { Icon } from "@/components/icon/Icon";
import type { IconName } from "@/components/icon/icons";
import { McpIcon } from '@/components/icons/McpIcon';
import { OpenCodeReloadFooterAction } from '@/components/views/OpenCodeReloadFooterAction';
import {
@@ -55,6 +54,7 @@ import {
} from '@/stores/usePendingOpenCodeRestartStore';
import {
SETTINGS_PAGE_METADATA,
getSettingsNavIcon,
getSettingsPageMeta,
resolveSettingsSlug,
type SettingsPageSlug,
@@ -117,7 +117,6 @@ const pageOrder: SettingsPageSlug[] = [
const NAV_GROUP_ORDER = ['general', 'projects', 'opencode', 'content'] as const;
const SNIPPETS_SETTINGS_ICON = { icon: 'chat-thread' } as const;
const ADD_PROVIDER_SETTINGS_ID = '__add_provider__';
function buildRuntimeContext(isDesktop: boolean, isMobile: boolean): SettingsRuntimeContext {
@@ -175,65 +174,6 @@ function getCurrentHistoryState(): Record<string, unknown> {
return window.history.state;
}
// eslint-disable-next-line react-refresh/only-export-components
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
switch (slug) {
case 'general':
return 'settings-3';
case 'projects':
return 'folders';
case 'remote-instances':
return 'computer';
case 'appearance':
return 'palette';
case 'chat':
return 'chat-ai-3';
case 'magic-prompts':
return 'ai-generate-2';
case 'snippets':
return SNIPPETS_SETTINGS_ICON.icon;
case 'notifications':
return 'notification-3';
case 'shortcuts':
return 'command';
case 'sessions':
return 'chat-history';
case 'providers':
return 'cloud';
case 'agents':
return 'ai-agent';
case 'behavior':
return 'brain';
case 'commands':
return 'slash-commands-2';
case 'mcp':
return null;
case 'plugins':
return 'plug-2';
case 'skills.installed':
return 'book-open';
case 'skills.catalog':
return 'book';
case 'git':
return 'git-branch';
case 'usage':
return 'bar-chart-2';
case 'voice':
return 'mic';
case 'tunnel':
return 'home-office';
case 'about':
return 'information';
case 'home':
return null;
default:
return 'robot-2';
}
}
export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile, isWindowed, visiblePageSlugs, initialMobileStage = 'nav' }) => {
const { t } = useI18n();
@@ -0,0 +1,84 @@
import { describe, expect, test } from 'bun:test';
import { deriveBaseBranch, hasResolvableBaseBranch } from './baseBranch';
describe('deriveBaseBranch', () => {
test('prefers the repository default branch over conventional fallbacks', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next'],
defaultBranch: 'react',
})).toBe('react');
});
test('accepts a remote-qualified default branch', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next'],
defaultBranch: 'origin/react',
})).toBe('react');
});
test('keeps the more specific worktree origin ahead of the default branch', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next', 'react', 'feature'],
worktreeCreatedFromBranch: 'feature',
defaultBranch: 'react',
})).toBe('feature');
});
test('skips a hint that is the branch being compared', () => {
// In a plain checkout the project root is the current worktree, so the root
// branch hint is the current branch — a branch is never its own base.
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['next', 'react'],
rootBranchHint: 'next',
defaultBranch: 'react',
headBranch: 'next',
})).toBe('react');
});
test('falls back to conventional names when nothing is known', () => {
expect(deriveBaseBranch({
remoteNames: new Set(['origin']),
localBranches: ['master', 'next'],
})).toBe('master');
});
});
describe('hasResolvableBaseBranch', () => {
test('rejects the main fallback when it does not exist', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next', 'react'],
remoteBranches: ['origin/next', 'origin/react'],
})).toBe(false);
});
test('accepts a base branch available through a remote-tracking ref', () => {
// Safe because getRangeDiff resolves a base that exists only on a remote
// through that remote rather than passing the bare name to git.
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next'],
remoteBranches: ['origin/main', 'origin/next'],
})).toBe(true);
});
test('does not accept a differently-scoped branch that merely ends the same way', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'main',
localBranches: ['next'],
remoteBranches: ['origin/feature/main'],
})).toBe(false);
});
test('matches a base branch whose own name contains a slash', () => {
expect(hasResolvableBaseBranch({
baseBranch: 'release/2.0',
localBranches: ['next'],
remoteBranches: ['origin/release/2.0'],
})).toBe(true);
});
});
@@ -8,8 +8,30 @@ export const deriveBaseBranch = (options: {
localBranches: readonly string[];
worktreeCreatedFromBranch?: string | null;
rootBranchHint?: string | null;
/**
* The repository's own default branch, read from a `remote/HEAD` symbolic
* ref. Its own option rather than another hint: `rootBranchHint` means "the
* branch the project root worktree is on", and a parameter that means two
* things is one the next caller gets wrong.
*/
defaultBranch?: string | null;
/**
* The branch being compared. A branch is never its own base, so a candidate
* equal to it is skipped — in a plain checkout `rootBranchHint` *is* the
* current branch, and taking it produced a comparison with itself.
*/
headBranch?: string | null;
}): string => {
const { remoteNames, localBranches, worktreeCreatedFromBranch, rootBranchHint } = options;
const {
remoteNames,
localBranches,
worktreeCreatedFromBranch,
rootBranchHint,
defaultBranch,
headBranch,
} = options;
const head = typeof headBranch === 'string' ? headBranch.trim() : '';
const normalizeBaseCandidate = (value: string): string => {
if (!value) {
@@ -49,16 +71,47 @@ export const deriveBaseBranch = (options: {
return normalized;
};
const fromMeta = normalizeBaseCandidate(
typeof worktreeCreatedFromBranch === 'string' ? worktreeCreatedFromBranch : ''
);
const candidate = (value: unknown): string => {
const normalized = normalizeBaseCandidate(typeof value === 'string' ? value : '');
return normalized && normalized !== head ? normalized : '';
};
const fromMeta = candidate(worktreeCreatedFromBranch);
if (fromMeta) return fromMeta;
const fromHint = normalizeBaseCandidate(typeof rootBranchHint === 'string' ? rootBranchHint : '');
const fromHint = candidate(rootBranchHint);
if (fromHint) return fromHint;
// Authoritative where the hints are guesses: this is what the repository says
// its default branch is, so it outranks the conventional names below.
const fromDefault = candidate(defaultBranch);
if (fromDefault) return fromDefault;
if (localBranches.includes('main')) return 'main';
if (localBranches.includes('master')) return 'master';
if (localBranches.includes('develop')) return 'develop';
return 'main';
};
/**
* Whether a base branch can be resolved locally or through one of the active
* remote-tracking refs. Callers must not offer comparisons against the `main`
* fallback when that ref does not actually exist in the repository.
*
* `remoteBranches` are remote-relative (`origin/main`, `origin/feature/x`), so
* the remote name is dropped and the rest compared whole. A suffix test matched
* `origin/feature/main` for a base of `main`, which passes the check and then
* fails the comparison it was meant to prevent.
*/
export const hasResolvableBaseBranch = (options: {
baseBranch: string;
localBranches: readonly string[];
remoteBranches: readonly string[];
}): boolean => {
const { baseBranch, localBranches, remoteBranches } = options;
if (localBranches.includes(baseBranch)) return true;
return remoteBranches.some((branch) => {
const slashIndex = branch.indexOf('/');
return slashIndex > 0 && branch.slice(slashIndex + 1) === baseBranch;
});
};
@@ -6,10 +6,10 @@ 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';
import type { WalkthroughBlockedState, WalkthroughModel } from '@/lib/walkthrough/types';
interface WalkthroughBlockerProps {
reason: WalkthroughBlockedReason;
reason: WalkthroughBlockedState;
model?: WalkthroughModel;
requiredChars?: number;
availableChars?: number;
@@ -102,6 +102,7 @@ export const WalkthroughBlocker = ({
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 === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.description');
if (reason === 'output-exhausted') {
return label
? t('walkthrough.blocked.outputExhausted.description', { model: label })
@@ -125,6 +126,7 @@ export const WalkthroughBlocker = ({
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 === 'server-unsupported') return t('walkthrough.blocked.serverUnsupported.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');
@@ -150,13 +152,15 @@ export const WalkthroughBlocker = ({
onChange={(providerId, modelId) => {
void handleModelChange(providerId, modelId);
}}
allowedProviderIds={providers}
allowedProviderIds={providers ?? []}
isModelAllowed={isStructuredOutputCapable}
/>
</div>
)}
{(reason === 'empty-diff' || reason === 'only-generated') && (
{/* Retry is the whole remedy once the server is updated, so it stays in
reach rather than sending the user back through the panel header. */}
{(reason === 'empty-diff' || reason === 'only-generated' || reason === 'server-unsupported') && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
{t('walkthrough.action.refresh')}
</Button>
@@ -2,6 +2,7 @@ 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n } from '@/lib/i18n';
import { groupHunksByFile } from '@/lib/walkthrough/model';
import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model';
@@ -20,8 +21,13 @@ interface WalkthroughStreamProps {
wrapLines: boolean;
}
// Importance says where to spend attention, not what is wrong: a stop is marked
// because it drives the rest of the change, never because something was found in
// it. A red pill said the opposite — status colours are read as findings, and a
// walkthrough deliberately hands out no verdicts — so the emphasis is carried by
// weight and an outline instead, and the tooltip states the axis outright.
const IMPORTANCE_CLASS: Record<WalkthroughStopImportance, string> = {
critical: 'bg-status-error/10 text-status-error',
critical: 'border border-[var(--interactive-border)] font-medium text-foreground',
normal: 'bg-surface-muted text-muted-foreground',
context: 'bg-surface-muted text-muted-foreground',
};
@@ -41,11 +47,22 @@ const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => {
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>
<Tooltip>
<TooltipTrigger
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')}
</TooltipTrigger>
<TooltipContent className="max-w-64">
<p className="typography-micro leading-tight">
{stop.importance === 'critical'
? t('walkthrough.importance.criticalHint')
: t('walkthrough.importance.contextHint')}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
<p className="typography-body text-muted-foreground">{stop.prose}</p>
@@ -10,14 +10,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useI18n, type Locale } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
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 { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useConfigStore } from '@/stores/useConfigStore';
import { useGitBranches, useGitStatus } from '@/stores/useGitStore';
import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import {
getFreshestPrStatusForBranch,
@@ -41,6 +43,12 @@ interface WalkthroughViewProps {
const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working'];
// What a walkthrough is — and what it deliberately is not — cannot be read off
// the panel: the first question users asked about it was whether its marks were
// review findings. The guide answers that, so it is reachable from the surface
// itself rather than only from the release announcement.
const WALKTHROUGH_GUIDE_URL = 'https://docs.openchamber.dev/walkthrough/';
// 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.
@@ -152,6 +160,12 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const status = useGitStatus(directory || null);
const branches = useGitBranches(directory || null);
const ensureAll = useGitStore((state) => state.ensureAll);
const { github, git } = useRuntimeAPIs();
useEffect(() => {
if (directory) void ensureAll(directory, git);
}, [directory, ensureAll, git]);
// 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
@@ -162,22 +176,33 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
if (!headRef) return null;
const all = branches?.all ?? [];
const localBranches = all.filter((name) => !name.startsWith('remotes/'));
const remoteBranches = all
.filter((name) => name.startsWith('remotes/'))
.map((name) => name.slice('remotes/'.length));
const remoteNames = new Set(
all
.filter((name) => name.startsWith('remotes/'))
.map((name) => name.slice('remotes/'.length).split('/')[0])
remoteBranches
.map((name) => name.split('/')[0])
.filter(Boolean)
);
const baseRef = deriveBaseBranch({ remoteNames, localBranches });
if (!baseRef || baseRef === headRef) return null;
const trackingRemote = status?.tracking?.split('/')[0];
const defaultBranch = (trackingRemote && branches?.defaultBranches?.[trackingRemote])
?? branches?.defaultBranches?.origin;
const baseRef = deriveBaseBranch({
remoteNames,
localBranches,
defaultBranch,
headBranch: headRef,
});
if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) {
return null;
}
return { kind: 'branch', baseRef, headRef };
}, [branches, currentBranch]);
}, [branches, currentBranch, status?.tracking]);
// 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);
@@ -323,15 +348,36 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
// 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('/');
// Never present a provider without a usable login as the current selection —
// the picker already hides them from the menu; showing one as selected was
// the whole "why say so?" failure mode.
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
const [modelProviders, setModelProviders] = useState<string[] | undefined>(undefined);
const providerIsAuthenticated = (providerId: string | undefined) => {
if (!providerId) return false;
// Until the auth list loads, do not present a candidate as selected —
// otherwise an unauthenticated config model flashes in the picker.
if (modelProviders === undefined) return false;
return modelProviders.includes(providerId);
};
const readinessModelRef = entry.readiness?.model
&& entry.readiness.model.hasLogin !== false
&& providerIsAuthenticated(entry.readiness.model.providerID)
? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}`
: undefined;
const resultModelRef = entry.result?.model
&& providerIsAuthenticated(entry.result.model.providerID)
? `${entry.result.model.providerID}/${entry.result.model.modelID}`
: undefined;
const selectedModelUsable = selectedModel
&& providerIsAuthenticated(selectedModel.split('/')[0])
? selectedModel
: undefined;
const activeModel = selectedModelUsable ?? resultModelRef ?? readinessModelRef;
const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/');
const activeModelId = activeModelParts.join('/');
useEffect(() => {
if (modelProviders !== undefined) return;
let cancelled = false;
@@ -403,14 +449,20 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const showStages = startedFromEmptyRef.current
&& (entry.status === 'generating' || stageProgress.holding);
// Auth/login gaps are not a full-panel blocker: hide the unusable model and
// disable Generate instead of explaining a raw provider error.
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'
// Client-detected rather than reported: the server answered something that
// was not JSON, so it has no walkthrough routes at all.
|| entry.error?.code === 'server-unsupported'
? entry.error.code
: entry.readiness && !entry.readiness.ready && !view
&& entry.readiness.reason !== 'no-provider-login'
? entry.readiness.reason
: undefined;
@@ -420,11 +472,16 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars;
const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars;
// Not ready, or no usable selected model, means Generate must not look
// actionable — including when the resolved model has no login.
const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready);
const handleGenerate = useCallback(
(force: boolean) => {
if (generateDisabled) return;
void generate(directory, source, { force, language: activeLanguage });
},
[activeLanguage, directory, generate, source]
[activeLanguage, directory, generate, generateDisabled, source]
);
return (
@@ -495,6 +552,25 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
</DropdownMenu>
<div className="ml-auto flex min-w-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
aria-label={t('walkthrough.help.guide')}
onClick={() => {
void openExternalUrl(WALKTHROUGH_GUIDE_URL);
}}
>
<Icon name="question" className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="typography-micro leading-tight">{t('walkthrough.help.guide')}</p>
</TooltipContent>
</Tooltip>
{/* A walkthrough nobody can read is worth nothing, so the prose
language is a per-review choice like the model — defaulting to the
interface language, which is the best evidence of what the reader
@@ -544,7 +620,8 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
onChange={(providerId, modelId) => {
selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null);
}}
allowedProviderIds={modelProviders}
// While the auth list is loading, allow none — not every provider.
allowedProviderIds={modelProviders ?? []}
isModelAllowed={isStructuredOutputCapable}
tooltipsEnabled={false}
dropdownPortalToBody
@@ -592,7 +669,10 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
type="button"
variant="outline"
size="sm"
className={WALKTHROUGH_ACTION_CLASS}
className={generateDisabled
? 'border-border text-muted-foreground'
: WALKTHROUGH_ACTION_CLASS}
disabled={generateDisabled}
aria-label={compactHeader
? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate'))
: undefined}
@@ -646,6 +726,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
variant="ghost"
size="xs"
className="ml-auto"
disabled={generateDisabled}
// Not forced: if an entry for this exact request existed the banner
// would not be here, and a forced run would refuse the cache it may
// find on the way.
@@ -677,7 +758,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
</div>
)}
{entry.error && !blockedReason && (
{entry.error && !blockedReason && entry.error.code !== 'no-provider-login' && (
<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