Merge origin/main into deferred OpenCode restart branch
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user