Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import path from 'node:path';
import {
createClaudeCliProvider,
createCodexCliProvider,
createGeminiCliProvider,
createOpenRouterProvider,
createProviderRegistry,
GhCli,
Expand Down Expand Up @@ -307,6 +308,7 @@ function createWindow() {
const providers = createProviderRegistry({
claude: createClaudeCliProvider(processManager),
codex: createCodexCliProvider(processManager),
gemini: createGeminiCliProvider(processManager),
openrouter: createOpenRouterProvider({
getApiKey: () => process.env.OPENROUTER_API_KEY,
getSettings: () => queries.settings.get(),
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/ipc/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,11 @@ export async function assertCliPhaseModelsSupported(phaseModels: PhaseModels): P
}>;

for (const phase of phases) {
if (phase.provider === 'openrouter') continue;
const cliProvider = phase.provider;
if (cliProvider === 'openrouter') continue;
const selection = assessCliSelectionAvailabilityFromCapabilities(
capabilities,
phase.provider,
cliProvider,
phase.modelId,
phase.effort,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function makeGraph(): ProjectIssueGraph {
issueNumber: 1,
title: 'One',
state: 'open',
pipelineStatus: ISSUE_PIPELINE_STATUS.pending,
pipelineStatus: ISSUE_PIPELINE_STATUS.queued,
threadId: 'thread-1',
},
{
Expand All @@ -38,7 +38,7 @@ function makeGraph(): ProjectIssueGraph {
issueNumber: 2,
title: 'Two',
state: 'open',
pipelineStatus: ISSUE_PIPELINE_STATUS.pending,
pipelineStatus: ISSUE_PIPELINE_STATUS.queued,
threadId: 'thread-2',
},
],
Expand Down Expand Up @@ -72,7 +72,7 @@ function makeDeps(graph = makeGraph()) {
id: 'issue-2',
projectId: 'project-1',
issueNumber: 2,
pipelineStatus: ISSUE_PIPELINE_STATUS.pending,
pipelineStatus: ISSUE_PIPELINE_STATUS.queued,
threadId: 'thread-2',
},
];
Expand Down Expand Up @@ -185,7 +185,7 @@ describe('registerIssueGraphHandlers', () => {
threadId: 'thread-2',
phase: PIPELINE_PHASE.failed,
});
notifyIssueGraphPipelinePhaseChange({ threadId: 'thread-3', phase: PIPELINE_PHASE.plan });
notifyIssueGraphPipelinePhaseChange({ threadId: 'thread-3', phase: PIPELINE_PHASE.planning });
});

it('refreshes body-derived dependency edges and skips unknown issue references', () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/ipc/register-pr-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ function makeDeps(
getById: vi.fn(() => project),
},
githubIssues: {
getByLinkedPrNumber: vi.fn(() => ({ threadId: 'thread-1' })),
getByLinkedPrNumber: vi.fn((): { threadId: string } | null => ({ threadId: 'thread-1' })),
},
diffs: {
list: vi.fn(() => [{ id: 'diff-1', filePath: 'src/app.ts' }]),
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/ipc/register-support-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const queries = {
const processManager = {
on: vi.fn(),
get: vi.fn(),
} as never;
};

const notificationService = {
listActive: vi.fn(() => []),
Expand Down Expand Up @@ -132,7 +132,7 @@ beforeEach(() => {
ipcMain,
mainWindow: mainWindow as never,
queries: queries as unknown as never,
processManager,
processManager: processManager as never,
pipeline: {} as never,
emitter: {} as never,
notificationService,
Expand Down Expand Up @@ -440,7 +440,7 @@ describe('process manager forwarding', () => {
ipcMain,
mainWindow: destroyedWindow as never,
queries: queries as unknown as never,
processManager,
processManager: processManager as never,
pipeline: {} as never,
emitter: {} as never,
notificationService,
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/main/ipc/register-support-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,10 +340,16 @@ export function registerSupportHandlers({
if ((state === 'running' || state === 'exited') && proc?.threadId) {
const ts = formatClockTime(new Date());
const agentColor =
type === 'claude' ? '\x1b[36m' : type === 'codex' ? '\x1b[33m' : '\x1b[35m';
type === 'claude'
? '\x1b[36m'
: type === 'codex'
? '\x1b[33m'
: type === 'gemini'
? '\x1b[32m'
: '\x1b[35m';
const exitColor = state === 'exited' ? '\x1b[2m' : '';
const typeLabel =
type === 'claude' || type === 'codex' || type === 'openrouter'
type === 'claude' || type === 'codex' || type === 'gemini' || type === 'openrouter'
? `${providerDisplay(type as ExecutorModel)}${type === 'openrouter' ? '' : ' CLI'}`
: type;
emitTerminalEvent(proc.threadId, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// @vitest-environment jsdom

import type { FeatureQaResult, GitHubIssueCacheRecord, Thread } from '@shipcode/shared';
import '@testing-library/jest-dom/vitest';
import type {
FeatureQaResult,
GitHubIssueCacheRecord,
IntegrationStatus,
Thread,
} from '@shipcode/shared';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PipelineTab } from './PipelineTab';
Expand Down Expand Up @@ -100,9 +106,13 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {

function renderPipelineTab({
issueOverrides = {},
executorEditable = false,
integrationStatus,
qaResults = [],
}: {
issueOverrides?: Partial<GitHubIssueCacheRecord>;
executorEditable?: boolean;
integrationStatus?: IntegrationStatus;
qaResults?: FeatureQaResult[];
} = {}) {
render(
Expand All @@ -117,7 +127,10 @@ function renderPipelineTab({
verifier: 'high',
}}
currentPhaseSelections={{
planner: { provider: 'claude', modelId: null },
planner: {
provider: executorEditable ? 'gemini' : 'claude',
modelId: executorEditable ? 'missing-gemini-model' : null,
},
reviewer: { provider: 'codex', modelId: null },
executor: { provider: 'claude', modelId: null },
verifier: { provider: 'claude', modelId: null },
Expand All @@ -130,7 +143,7 @@ function renderPipelineTab({
}}
effectiveRequireApproval={false}
effectiveRevisionCount={1}
executorEditable={false}
executorEditable={executorEditable}
hasPrFeedbackBlockers={false}
inheritedPhaseReasoningEfforts={{
planner: 'high',
Expand All @@ -140,6 +153,7 @@ function renderPipelineTab({
}}
inheritedRequireApproval={true}
inheritedRevisionCount={0}
integrationStatus={integrationStatus}
isSubmitting={false}
linkedPrUrl={null}
phaseEffortSelectValues={{
Expand All @@ -151,7 +165,7 @@ function renderPipelineTab({
phaseModelValidation={{}}
qaResults={qaResults}
phaseSelectValues={{
planner: '__inherit__',
planner: executorEditable ? 'gemini::missing-gemini-model' : '__inherit__',
reviewer: '__inherit__',
executor: '__inherit__',
verifier: '__inherit__',
Expand Down Expand Up @@ -206,6 +220,27 @@ describe('PipelineTab', () => {
expect(screen.getByText('1 revision before approval/execution.')).toBeInTheDocument();
});

it('renders Gemini in editable thread-level phase selectors with degraded readiness', () => {
renderPipelineTab({
executorEditable: true,
integrationStatus: {
modelCapabilities: {
gemini: {
provider: 'gemini',
source: 'unavailable',
models: [],
error: 'Gemini CLI is not authenticated.',
checkedAt: '2026-05-08T00:00:00.000Z',
},
},
} as unknown as IntegrationStatus,
});

expect(screen.getByText('missing-gemini-model (Unavailable)')).toBeInTheDocument();
expect(screen.getByText(/missing-gemini-model is not reported/)).toBeInTheDocument();
expect(screen.getByText('Human Approval')).toBeInTheDocument();
});

it('renders visual QA assertion evidence', () => {
renderPipelineTab({
qaResults: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,10 @@ export function PipelineTab({
value={phaseSelectValues[phase]}
onValueChange={(value: string) => onPhaseAgentChange(phase, value)}
>
<SelectTrigger className="h-6 min-w-0 w-full text-[11px]">
<SelectTrigger
className="h-6 min-w-0 w-full text-[11px]"
data-testid={`phase-provider-select-${phase}`}
>
<SelectValue>
{phaseSelectValues[phase] === '__inherit__' ? (
<InheritValueDisplay
Expand Down
20 changes: 14 additions & 6 deletions apps/desktop/src/renderer/components/issue-detail/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import type {
ReviewRecord,
ShipCodePlan,
} from '@shipcode/shared';
import { PIPELINE_PHASE, shipCodePlanSchema, stripAnsi } from '@shipcode/shared';
import {
PIPELINE_EXECUTOR_PROVIDERS,
PIPELINE_PHASE,
shipCodePlanSchema,
stripAnsi,
} from '@shipcode/shared';

export { formatRelativeTime as timeAgo } from '@shipcode/shared';
export { stripAnsi };
Expand Down Expand Up @@ -35,10 +40,10 @@ export const PHASE_PROVIDER_OPTIONS: Record<
'planner' | 'reviewer' | 'executor' | 'verifier',
ExecutorModel[]
> = {
planner: ['claude', 'codex', 'openrouter'],
reviewer: ['claude', 'codex', 'openrouter'],
executor: ['claude', 'codex', 'openrouter'],
verifier: ['claude', 'codex', 'openrouter'],
planner: [...PIPELINE_EXECUTOR_PROVIDERS],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict phase-provider options to providers accepted by override IPC

Adding PIPELINE_EXECUTOR_PROVIDERS here now exposes gemini in issue-level phase override selectors, but the main-process handler for github:set-phase-model-override still rejects anything except claude|codex|openrouter (apps/desktop/src/main/ipc/register-github-handlers.ts:1133-1134). Selecting Gemini in the UI therefore throws Invalid <phase> model and the override cannot be saved, so the newly added option is non-functional in this workflow.

Useful? React with 👍 / 👎.

reviewer: [...PIPELINE_EXECUTOR_PROVIDERS],
executor: [...PIPELINE_EXECUTOR_PROVIDERS],
verifier: [...PIPELINE_EXECUTOR_PROVIDERS],
};

export type PlanStatusBadgeVariant =
Expand Down Expand Up @@ -116,7 +121,10 @@ export function decodePhaseOption(value: string): {
} {
const [providerRaw, modelIdRaw] = value.split('::');
const provider =
providerRaw === 'claude' || providerRaw === 'codex' || providerRaw === 'openrouter'
providerRaw === 'claude' ||
providerRaw === 'codex' ||
providerRaw === 'gemini' ||
providerRaw === 'openrouter'
? providerRaw
: 'claude';
return { provider, modelId: modelIdRaw === '__default__' ? null : modelIdRaw };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import {
export const PROVIDER_DISPLAY: Record<ExecutorModel, string> = {
claude: 'Anthropic',
codex: 'OpenAI',
gemini: 'Google',
openrouter: 'OpenRouter',
};

export function getModelOptions(
provider: ExecutorModel,
integrationStatus?: IntegrationStatus,
): ReadonlyArray<{ value: string; label: string }> {
if (provider === 'claude' || provider === 'codex') {
if (provider === 'claude' || provider === 'codex' || provider === 'gemini') {
return getCapabilityModelOptions(integrationStatus, provider);
}
return OPENROUTER_MODEL_OPTIONS;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ import {
} from './shared';

function asExecutorModel(value: Project['plannerModelOverride']): ExecutorModel | null {
if (value === 'claude' || value === 'codex' || value === 'openrouter') return value;
if (value === 'claude' || value === 'codex' || value === 'gemini' || value === 'openrouter') {
return value;
}
return null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
// @vitest-environment jsdom

import '@testing-library/jest-dom/vitest';
import type {
AppSettings,
ContextFileInfo,
IntegrationStatus,
Project,
ProjectReadinessReport,
} from '@shipcode/shared';
import { DEFAULT_SETTINGS, SHIPCODE_DEFAULT_LABELS } from '@shipcode/shared';
import {
DEFAULT_SETTINGS,
PIPELINE_EXECUTOR_PROVIDERS,
SHIPCODE_DEFAULT_LABELS,
} from '@shipcode/shared';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderWithQueryClient } from '../../test/render';
Expand Down Expand Up @@ -604,6 +609,8 @@ describe('project settings leaf tabs', () => {
expect(screen.getByText('Planner')).toBeInTheDocument();
expect(screen.getByText('Verifier')).toBeInTheDocument();

expect(PIPELINE_EXECUTOR_PROVIDERS).toContain('gemini');

fireEvent.pointerDown(screen.getByRole('button', { name: 'Apply Preset' }));
fireEvent.click(screen.getByText('Claude'));
fireEvent.pointerDown(screen.getByRole('button', { name: 'Apply Preset' }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export function IntegrationsSettingsSection({
};

const getCliVersionLine = (version: string | null) => version?.split('\n')[0]?.trim() ?? null;
const missingCli = {
available: false,
version: null,
path: null,
error: null,
authenticated: false,
};
const projectOpenTargets: ProjectOpenTarget[] = [
'cursor',
'finder',
Expand Down Expand Up @@ -237,14 +244,17 @@ export function IntegrationsSettingsSection({
{[
{ key: 'claude', label: 'Claude CLI' },
{ key: 'codex', label: 'Codex CLI' },
{ key: 'gemini', label: 'Gemini CLI' },
{ key: 'gh', label: 'GitHub CLI' },
].map(({ key, label }) => {
const cli =
key === 'claude'
? integrationStatus.system.claude
: key === 'codex'
? integrationStatus.system.codex
: integrationStatus.system.gh;
: key === 'gemini'
? (integrationStatus.system.gemini ?? missingCli)
: integrationStatus.system.gh;
const ghScope =
key === 'gh'
? integrationStatus.ghAuth.hasProjectScope === true
Expand All @@ -254,9 +264,14 @@ export function IntegrationsSettingsSection({
: null
: null;
const versionLine = getCliVersionLine(cli.version);
const Icon = key === 'claude' ? Sparkles : key === 'codex' ? Terminal : FolderGit;
const Icon =
key === 'claude' || key === 'gemini'
? Sparkles
: key === 'codex'
? Terminal
: FolderGit;
const modelCapabilities =
key === 'claude' || key === 'codex'
key === 'claude' || key === 'codex' || key === 'gemini'
? integrationStatus.modelCapabilities?.[key]
: null;

Expand Down
Loading