Stabilize team provisioning and runtime diagnostics

This commit is contained in:
iliya 2026-04-04 20:04:16 +03:00
parent 074b614469
commit a591ccf297
69 changed files with 4493 additions and 624 deletions

View file

@ -255,6 +255,43 @@ function resolveTeamMembers(paths) {
}; };
} }
function getCurrentRuntimeMemberIdentity() {
const args = Array.isArray(process.argv) ? process.argv.slice(2) : [];
let agentName = '';
let agentId = '';
let teamName = '';
for (let i = 0; i < args.length; i += 1) {
const arg = typeof args[i] === 'string' ? args[i] : '';
const next = typeof args[i + 1] === 'string' ? args[i + 1].trim() : '';
if (!next) continue;
if (arg === '--agent-name') {
agentName = next;
continue;
}
if (arg === '--agent-id') {
agentId = next;
continue;
}
if (arg === '--team-name') {
teamName = next;
}
}
const normalizedAgentName = typeof agentName === 'string' ? agentName.trim() : '';
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
const normalizedTeamName = typeof teamName === 'string' ? teamName.trim() : '';
if (!normalizedAgentName && !normalizedAgentId) {
return null;
}
return {
agentName: normalizedAgentName,
agentId: normalizedAgentId,
teamName: normalizedTeamName,
};
}
function resolveLeadSessionId(paths) { function resolveLeadSessionId(paths) {
const config = readTeamConfig(paths); const config = readTeamConfig(paths);
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim() return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
@ -459,6 +496,7 @@ module.exports = {
readMembersMeta, readMembersMeta,
readTeamConfig, readTeamConfig,
resolveTeamMembers, resolveTeamMembers,
getCurrentRuntimeMemberIdentity,
resolveLeadSessionId, resolveLeadSessionId,
saveTaskAttachmentFile, saveTaskAttachmentFile,
}; };

View file

@ -28,6 +28,13 @@ function isSameTaskMember(left, right, leadName) {
); );
} }
function mergeMemberRecord(base, overlay) {
return {
...(base && typeof base === 'object' ? base : {}),
...(overlay && typeof overlay === 'object' ? overlay : {}),
};
}
function quoteMarkdown(text) { function quoteMarkdown(text) {
return String(text) return String(text)
.split('\n') .split('\n')
@ -563,13 +570,14 @@ Failure to follow this protocol means the task board will show incorrect status.
* Context-free does NOT follow the (context, ...) convention. * Context-free does NOT follow the (context, ...) convention.
*/ */
function buildProcessProtocolText(teamName) { function buildProcessProtocolText(teamName) {
return `BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.): return `BACKGROUND SERVICE PROCESS REGISTRATION — this is ONLY for extra background services started by teammates (dev server, watcher, database, etc.). It is NOT a list of teammate agents themselves.
1. Launch with & to get PID: 1. Launch with & to get PID:
pnpm dev & pnpm dev &
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port): 2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
{ teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" } { teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" }
3. VERIFY registration succeeded (MANDATORY never skip this step) using MCP tool process_list: 3. VERIFY registration succeeded (MANDATORY never skip this step) using MCP tool process_list:
{ teamName: "${teamName}" } { teamName: "${teamName}" }
process_list shows ONLY registered background services for the team. It does NOT show whether teammate agents themselves are alive.
4. When stopping a process, use MCP tool process_stop: 4. When stopping a process, use MCP tool process_stop:
{ teamName: "${teamName}", pid: <PID> } { teamName: "${teamName}", pid: <PID> }
5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister: 5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister:
@ -606,9 +614,43 @@ async function memberBriefing(context, memberName) {
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) { if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
throw new Error(`Member is removed from the team: ${requestedMemberName}`); throw new Error(`Member is removed from the team: ${requestedMemberName}`);
} }
const member = let member =
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) || resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
null; null;
if (!member) {
const runtimeIdentity = runtimeHelpers.getCurrentRuntimeMemberIdentity();
const runtimeAgentName = normalizeMemberName(runtimeIdentity && runtimeIdentity.agentName);
const runtimeAgentId = String((runtimeIdentity && runtimeIdentity.agentId) || '').trim().toLowerCase();
const runtimeTeamName = String((runtimeIdentity && runtimeIdentity.teamName) || '').trim().toLowerCase();
const requestedAgentId = `${requestedMemberKey}@${String(context.teamName || '').trim().toLowerCase()}`;
const isCurrentRuntimeMember =
requestedMemberKey &&
((runtimeAgentName && runtimeAgentName === requestedMemberKey) ||
(runtimeAgentId && runtimeAgentId === requestedAgentId)) &&
(!runtimeTeamName || runtimeTeamName === String(context.teamName || '').trim().toLowerCase());
if (isCurrentRuntimeMember) {
const configMembers = Array.isArray(config.members) ? config.members : [];
const configMember =
configMembers.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
null;
const metaMember =
Array.isArray(resolved.members)
? resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey)
: null;
member = mergeMemberRecord(
{
name: requestedMemberName,
...(runtimeIdentity && runtimeIdentity.agentName
? { name: String(runtimeIdentity.agentName).trim() }
: {}),
...(typeof config.projectPath === 'string' && config.projectPath.trim()
? { cwd: config.projectPath.trim() }
: {}),
},
mergeMemberRecord(configMember || {}, metaMember || {})
);
}
}
if (!member) { if (!member) {
throw new Error( throw new Error(
`Member not found in team metadata or inboxes: ${requestedMemberName}` `Member not found in team metadata or inboxes: ${requestedMemberName}`
@ -730,4 +772,4 @@ module.exports = {
updateTask: (context, taskRef, updater) => updateTask: (context, taskRef, updater) =>
taskStore.updateTask(context.paths, taskRef, updater), taskStore.updateTask(context.paths, taskRef, updater),
updateTaskFields, updateTaskFields,
}; };

View file

@ -12,7 +12,8 @@ const toolContextSchema = {
export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) { export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'process_register', name: 'process_register',
description: 'Register a running process for a team member', description:
'Register a background service started by a teammate, such as a dev server, watcher, or database. This is not for teammate-agent liveness.',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
pid: z.number().int().positive(), pid: z.number().int().positive(),
@ -51,7 +52,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'process_list', name: 'process_list',
description: 'List registered team processes', description:
'List registered background services for the team, such as dev servers, watchers, or databases. This does not show teammate-agent liveness.',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
}), }),
@ -63,7 +65,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'process_unregister', name: 'process_unregister',
description: 'Unregister a previously registered process', description:
'Unregister a previously registered background service while keeping teammate-agent state separate.',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
pid: z.number().int().positive(), pid: z.number().int().positive(),
@ -76,7 +79,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'process_stop', name: 'process_stop',
description: 'Mark a registered process as stopped while preserving history', description:
'Mark a registered background service as stopped while preserving history. This is not for stopping teammate agents.',
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
pid: z.number().int().positive(), pid: z.number().int().positive(),

View file

@ -12,6 +12,10 @@ const toolContextSchema = {
claudeDir: z.string().min(1).optional(), claudeDir: z.string().min(1).optional(),
}; };
const ALWAYS_LOAD_META = {
'anthropic/alwaysLoad': true,
} as const;
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']); const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */ /** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
@ -468,6 +472,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
server.addTool({ server.addTool({
name: 'member_briefing', name: 'member_briefing',
description: 'Get bootstrap briefing for a team member', description: 'Get bootstrap briefing for a team member',
_meta: ALWAYS_LOAD_META,
parameters: z.object({ parameters: z.object({
...toolContextSchema, ...toolContextSchema,
memberName: z.string().min(1), memberName: z.string().min(1),

View file

@ -62,6 +62,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { setReviewMainWindow } from './ipc/review'; import { setReviewMainWindow } from './ipc/review';
import { import {
ApiKeyService, ApiKeyService,
RUNTIME_MANAGED_API_KEY_ENV_VARS,
ExtensionFacadeService, ExtensionFacadeService,
GlamaMcpEnrichmentService, GlamaMcpEnrichmentService,
McpCatalogAggregator, McpCatalogAggregator,
@ -536,9 +537,6 @@ function wireFileWatcherEvents(context: ServiceContext): void {
if (match && teamDataService) { if (match && teamDataService) {
const inboxName = match[1]; const inboxName = match[1];
// Mark member as online when their first inbox message arrives (spawn tracking).
teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName);
void teamDataService void teamDataService
.getLeadMemberName(teamName) .getLeadMemberName(teamName)
.then((leadName) => { .then((leadName) => {
@ -716,7 +714,7 @@ function reconfigureLocalContextForClaudeRoot(): void {
/** /**
* Initializes all services. * Initializes all services.
*/ */
function initializeServices(): void { async function initializeServices(): Promise<void> {
logger.info('Initializing services...'); logger.info('Initializing services...');
// Initialize SSH connection manager // Initialize SSH connection manager
@ -839,6 +837,7 @@ function initializeServices(): void {
const pluginInstallService = new PluginInstallService(pluginCatalogService); const pluginInstallService = new PluginInstallService(pluginCatalogService);
const mcpInstallService = new McpInstallService(mcpAggregator); const mcpInstallService = new McpInstallService(mcpAggregator);
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
// warmup() and ensureInstalled() are deferred to after window creation // warmup() and ensureInstalled() are deferred to after window creation
// (did-finish-load handler) to avoid thread pool contention at startup. // (did-finish-load handler) to avoid thread pool contention at startup.
httpServer = new HttpServer(); httpServer = new HttpServer();
@ -1392,7 +1391,7 @@ function createWindow(): void {
/** /**
* Application ready handler. * Application ready handler.
*/ */
void app.whenReady().then(() => { void app.whenReady().then(async () => {
logger.info('App ready, initializing...'); logger.info('App ready, initializing...');
// Pre-warm interactive shell env cache (non-blocking). // Pre-warm interactive shell env cache (non-blocking).
@ -1403,7 +1402,7 @@ void app.whenReady().then(() => {
try { try {
// Initialize services first // Initialize services first
initializeServices(); await initializeServices();
// Apply configuration settings // Apply configuration settings
const config = configManager.getConfig(); const config = configManager.getConfig();

View file

@ -9,6 +9,7 @@
import { import {
CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_INSTALL, CLI_INSTALLER_INSTALL,
CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_INVALIDATE_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
@ -18,13 +19,19 @@ import { createLogger } from '@shared/utils/logger';
import type { CliInstallerService } from '../services'; import type { CliInstallerService } from '../services';
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
import type { CliInstallationStatus, IpcResult } from '@shared/types'; import type {
CliInstallationStatus,
CliProviderId,
CliProviderStatus,
IpcResult,
} from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron'; import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:cliInstaller'); const logger = createLogger('IPC:cliInstaller');
let service: CliInstallerService; let service: CliInstallerService;
let statusInFlight: Promise<CliInstallationStatus> | null = null; let statusInFlight: Promise<CliInstallationStatus> | null = null;
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null; let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
const STATUS_CACHE_TTL_MS = 5_000; const STATUS_CACHE_TTL_MS = 5_000;
@ -40,6 +47,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer
*/ */
export function registerCliInstallerHandlers(ipcMain: IpcMain): void { export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus); ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus);
ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall); ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall);
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus); ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
@ -51,6 +59,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
*/ */
export function removeCliInstallerHandlers(ipcMain: IpcMain): void { export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS); ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS);
ipcMain.removeHandler(CLI_INSTALLER_INSTALL); ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS); ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
@ -99,6 +108,58 @@ async function handleGetStatus(
} }
} }
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
if (!cachedStatus || !providerStatus) {
return;
}
const nextProviders = cachedStatus.value.providers.map((provider) =>
provider.providerId === providerStatus.providerId ? providerStatus : provider
);
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
cachedStatus = {
value: {
...cachedStatus.value,
providers: nextProviders,
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
authMethod: authenticatedProvider?.authMethod ?? null,
},
at: Date.now(),
};
}
async function handleGetProviderStatus(
_event: IpcMainInvokeEvent,
providerId: CliProviderId
): Promise<IpcResult<CliProviderStatus | null>> {
try {
const inFlight = providerStatusInFlight.get(providerId);
if (inFlight) {
const status = await inFlight;
return { success: true, data: status };
}
const request = service
.getProviderStatus(providerId)
.then((status) => {
patchCachedProviderStatus(status);
return status;
})
.finally(() => {
providerStatusInFlight.delete(providerId);
});
providerStatusInFlight.set(providerId, request);
const status = await request;
return { success: true, data: status };
} catch (error) {
const msg = getErrorMessage(error);
logger.error(`Error in cliInstaller:getProviderStatus(${providerId}):`, msg);
return { success: false, error: msg };
}
}
async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> { async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> {
try { try {
await service.install(); await service.install();
@ -112,6 +173,7 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> { function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
cachedStatus = null; cachedStatus = null;
providerStatusInFlight.clear();
ClaudeBinaryResolver.clearCache(); ClaudeBinaryResolver.clearCache();
return { success: true, data: undefined }; return { success: true, data: undefined };
} }

View file

@ -12,6 +12,7 @@ import type {
HttpServerConfig, HttpServerConfig,
NotificationConfig, NotificationConfig,
NotificationTrigger, NotificationTrigger,
RuntimeConfig,
SshPersistConfig, SshPersistConfig,
} from '../services'; } from '../services';
@ -31,6 +32,7 @@ interface ValidationFailure {
export type ConfigUpdateValidationResult = export type ConfigUpdateValidationResult =
| ValidationSuccess<'notifications'> | ValidationSuccess<'notifications'>
| ValidationSuccess<'general'> | ValidationSuccess<'general'>
| ValidationSuccess<'runtime'>
| ValidationSuccess<'display'> | ValidationSuccess<'display'>
| ValidationSuccess<'httpServer'> | ValidationSuccess<'httpServer'>
| ValidationSuccess<'ssh'> | ValidationSuccess<'ssh'>
@ -39,6 +41,7 @@ export type ConfigUpdateValidationResult =
const VALID_SECTIONS = new Set<ConfigSection>([ const VALID_SECTIONS = new Set<ConfigSection>([
'notifications', 'notifications',
'general', 'general',
'runtime',
'display', 'display',
'httpServer', 'httpServer',
'ssh', 'ssh',
@ -398,6 +401,60 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
}; };
} }
function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'runtime update must be an object' };
}
const result: Partial<RuntimeConfig> = {};
for (const [key, value] of Object.entries(data)) {
if (key !== 'providerBackends') {
return { valid: false, error: `runtime.${key} is not a valid setting` };
}
if (!isPlainObject(value)) {
return { valid: false, error: 'runtime.providerBackends must be an object' };
}
const providerBackends: Partial<RuntimeConfig['providerBackends']> = {};
for (const [providerId, backendId] of Object.entries(value)) {
if (providerId === 'gemini') {
if (backendId !== 'auto' && backendId !== 'api' && backendId !== 'cli-sdk') {
return {
valid: false,
error: 'runtime.providerBackends.gemini must be one of: auto, api, cli-sdk',
};
}
providerBackends.gemini = backendId;
continue;
}
if (providerId === 'codex') {
if (backendId !== 'auto' && backendId !== 'adapter') {
return {
valid: false,
error: 'runtime.providerBackends.codex must be one of: auto, adapter',
};
}
providerBackends.codex = backendId;
continue;
}
return { valid: false, error: `runtime.providerBackends.${providerId} is not supported` };
}
result.providerBackends = providerBackends as RuntimeConfig['providerBackends'];
}
return {
valid: true,
section: 'runtime',
data: result,
};
}
function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure { function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure {
if (!isPlainObject(data)) { if (!isPlainObject(data)) {
return { valid: false, error: 'display update must be an object' }; return { valid: false, error: 'display update must be an object' };
@ -544,7 +601,7 @@ export function validateConfigUpdatePayload(
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) { if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
return { return {
valid: false, valid: false,
error: 'Section must be one of: notifications, general, display, httpServer, ssh', error: 'Section must be one of: notifications, general, runtime, display, httpServer, ssh',
}; };
} }
@ -553,6 +610,8 @@ export function validateConfigUpdatePayload(
return validateNotificationsSection(data); return validateNotificationsSection(data);
case 'general': case 'general':
return validateGeneralSection(data); return validateGeneralSection(data);
case 'runtime':
return validateRuntimeSection(data);
case 'display': case 'display':
return validateDisplaySection(data); return validateDisplaySection(data);
case 'httpServer': case 'httpServer':

View file

@ -30,7 +30,10 @@ import { createLogger } from '@shared/utils/logger';
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService'; import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService'; import {
RUNTIME_MANAGED_API_KEY_ENV_VARS,
type ApiKeyService,
} from '../services/extensions/apikeys/ApiKeyService';
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService'; import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
import type { McpInstallService } from '../services/extensions/install/McpInstallService'; import type { McpInstallService } from '../services/extensions/install/McpInstallService';
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService'; import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
@ -388,7 +391,12 @@ async function handleApiKeysSave(
): Promise<IpcResult<ApiKeyEntry>> { ): Promise<IpcResult<ApiKeyEntry>> {
return wrapHandler('apiKeysSave', () => { return wrapHandler('apiKeysSave', () => {
if (!request) throw new Error('Request is required'); if (!request) throw new Error('Request is required');
return getApiKeyService().save(request); return getApiKeyService()
.save(request)
.then(async (entry) => {
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
return entry;
});
}); });
} }
@ -398,7 +406,11 @@ async function handleApiKeysDelete(
): Promise<IpcResult<void>> { ): Promise<IpcResult<void>> {
return wrapHandler('apiKeysDelete', () => { return wrapHandler('apiKeysDelete', () => {
if (typeof id !== 'string' || !id) throw new Error('Key ID is required'); if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
return getApiKeyService().delete(id); return getApiKeyService()
.delete(id)
.then(async () => {
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
});
}); });
} }

View file

@ -50,10 +50,13 @@ const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_KEY_BYTES = 32; const PBKDF2_KEY_BYTES = 32;
const PBKDF2_SALT = 'claude-apikey-storage-v1'; const PBKDF2_SALT = 'claude-apikey-storage-v1';
export const RUNTIME_MANAGED_API_KEY_ENV_VARS = ['GEMINI_API_KEY'] as const;
export class ApiKeyService { export class ApiKeyService {
private readonly filePath: string; private readonly filePath: string;
private cache: StoredApiKey[] | null = null; private cache: StoredApiKey[] | null = null;
private aesKey: Buffer | null = null; private aesKey: Buffer | null = null;
private readonly originalProcessEnv = new Map<string, string | undefined>();
constructor(claudeDir?: string) { constructor(claudeDir?: string) {
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude'); const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
@ -163,6 +166,34 @@ export class ApiKeyService {
}; };
} }
async syncProcessEnv(envVarNames: readonly string[]): Promise<void> {
if (!envVarNames.length) {
return;
}
const lookups = await this.lookup([...envVarNames]);
const valueByEnv = new Map(lookups.map((entry) => [entry.envVarName, entry.value]));
for (const envVarName of envVarNames) {
if (!this.originalProcessEnv.has(envVarName)) {
this.originalProcessEnv.set(envVarName, process.env[envVarName]);
}
const nextValue = valueByEnv.get(envVarName);
if (nextValue && nextValue.trim().length > 0) {
process.env[envVarName] = nextValue;
continue;
}
const originalValue = this.originalProcessEnv.get(envVarName);
if (typeof originalValue === 'string' && originalValue.length > 0) {
process.env[envVarName] = originalValue;
} else {
delete process.env[envVarName];
}
}
}
// ── Encryption ────────────────────────────────────────────────────────── // ── Encryption ──────────────────────────────────────────────────────────
/** /**

View file

@ -2,7 +2,7 @@
* Extension services barrel export. * Extension services barrel export.
*/ */
export { ApiKeyService } from './apikeys/ApiKeyService'; export { ApiKeyService, RUNTIME_MANAGED_API_KEY_ENV_VARS } from './apikeys/ApiKeyService';
export { GitHubStarsService } from './catalog/GitHubStarsService'; export { GitHubStarsService } from './catalog/GitHubStarsService';
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService'; export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator'; export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';

View file

@ -41,7 +41,13 @@ import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridge
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor'; import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor';
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types'; import type {
CliInstallationStatus,
CliInstallerProgress,
CliPlatform,
CliProviderId,
CliProviderStatus,
} from '@shared/types';
import type { BrowserWindow } from 'electron'; import type { BrowserWindow } from 'electron';
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
@ -131,6 +137,11 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
providers: status.providers.map((provider) => ({ providers: status.providers.map((provider) => ({
...provider, ...provider,
capabilities: { ...provider.capabilities }, capabilities: { ...provider.capabilities },
selectedBackendId: provider.selectedBackendId ?? null,
resolvedBackendId: provider.resolvedBackendId ?? null,
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
externalRuntimeDiagnostics:
provider.externalRuntimeDiagnostics?.map((diagnostic) => ({ ...diagnostic })) ?? [],
backend: provider.backend ? { ...provider.backend } : null, backend: provider.backend ? { ...provider.backend } : null,
models: [...provider.models], models: [...provider.models],
})), })),
@ -479,6 +490,23 @@ export class CliInstallerService {
} }
} }
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
await resolveInteractiveShellEnv();
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
return null;
}
const flavor = getConfiguredCliFlavor();
if (flavor !== 'free-code') {
const fullStatus = await this.getStatus();
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
}
return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
}
/** /**
* Gathers CLI status information, mutating the provided result object. * Gathers CLI status information, mutating the provided result object.
* Split from getStatus() to enable overall timeout via Promise.race * Split from getStatus() to enable overall timeout via Promise.race

View file

@ -215,6 +215,13 @@ export interface GeneralConfig {
telemetryEnabled: boolean; telemetryEnabled: boolean;
} }
export interface RuntimeConfig {
providerBackends: {
gemini: 'auto' | 'api' | 'cli-sdk';
codex: 'auto' | 'adapter';
};
}
export interface DisplayConfig { export interface DisplayConfig {
showTimestamps: boolean; showTimestamps: boolean;
compactMode: boolean; compactMode: boolean;
@ -247,6 +254,7 @@ export interface HttpServerConfig {
export interface AppConfig { export interface AppConfig {
notifications: NotificationConfig; notifications: NotificationConfig;
general: GeneralConfig; general: GeneralConfig;
runtime: RuntimeConfig;
display: DisplayConfig; display: DisplayConfig;
sessions: SessionsConfig; sessions: SessionsConfig;
ssh: SshPersistConfig; ssh: SshPersistConfig;
@ -299,6 +307,12 @@ const DEFAULT_CONFIG: AppConfig = {
customProjectPaths: [], customProjectPaths: [],
telemetryEnabled: true, telemetryEnabled: true,
}, },
runtime: {
providerBackends: {
gemini: 'auto',
codex: 'auto',
},
},
display: { display: {
showTimestamps: true, showTimestamps: true,
compactMode: false, compactMode: false,
@ -468,6 +482,12 @@ export class ConfigManager {
triggers: mergedTriggers, triggers: mergedTriggers,
}, },
general: mergedGeneral, general: mergedGeneral,
runtime: {
providerBackends: {
...DEFAULT_CONFIG.runtime.providerBackends,
...(loaded.runtime?.providerBackends ?? {}),
},
},
display: { display: {
...DEFAULT_CONFIG.display, ...DEFAULT_CONFIG.display,
...(loaded.display ?? {}), ...(loaded.display ?? {}),
@ -540,10 +560,21 @@ export class ConfigManager {
section: K, section: K,
data: Partial<AppConfig[K]> data: Partial<AppConfig[K]>
): Partial<AppConfig[K]> { ): Partial<AppConfig[K]> {
if (section !== 'general') { if (section !== 'general' && section !== 'runtime') {
return data; return data;
} }
if (section === 'runtime') {
const runtimeUpdate = data as Partial<RuntimeConfig>;
return {
...runtimeUpdate,
providerBackends: {
...this.config.runtime.providerBackends,
...runtimeUpdate.providerBackends,
},
} as unknown as Partial<AppConfig[K]>;
}
if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) { if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {
return data; return data;
} }

View file

@ -8,7 +8,9 @@ import {
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import type { CliProviderId, CliProviderStatus } from '@shared/types'; import type { CliProviderId, CliProviderStatus } from '@shared/types';
import { configManager } from '../infrastructure/ConfigManager';
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth'; import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
import { applyConfiguredRuntimeBackendsEnv } from './providerRuntimeEnv';
const logger = createLogger('ClaudeMultimodelBridgeService'); const logger = createLogger('ClaudeMultimodelBridgeService');
@ -51,6 +53,53 @@ interface ProviderModelsCommandResponse {
>; >;
} }
interface UnifiedRuntimeStatusResponse {
schemaVersion?: number;
providers?: Record<
string,
{
supported?: boolean;
authenticated?: boolean;
authMethod?: string | null;
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
canLoginFromUi?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
selectedBackendId?: string | null;
resolvedBackendId?: string | null;
availableBackends?: Array<{
id?: string;
label?: string;
description?: string;
selectable?: boolean;
recommended?: boolean;
available?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}>;
externalRuntimeDiagnostics?: Array<{
id?: string;
label?: string;
detected?: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}>;
models?: Array<string | { id?: string; label?: string; description?: string }>;
capabilities?: {
teamLaunch?: boolean;
oneShot?: boolean;
};
backend?: {
kind?: string;
label?: string;
endpointLabel?: string | null;
projectId?: string | null;
authMethodDetail?: string | null;
} | null;
}
>;
}
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini']; const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
function extractJsonObject<T>(raw: string): T { function extractJsonObject<T>(raw: string): T {
@ -83,6 +132,10 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
teamLaunch: false, teamLaunch: false,
oneShot: false, oneShot: false,
}, },
selectedBackendId: null,
resolvedBackendId: null,
availableBackends: [],
externalRuntimeDiagnostics: [],
backend: null, backend: null,
}; };
} }
@ -117,11 +170,12 @@ export class ClaudeMultimodelBridgeService {
if (home) { if (home) {
env.HOME = home; env.HOME = home;
} }
return env; return applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
} }
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv { private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
const env = { ...this.buildCliEnv(binaryPath) }; const env = { ...this.buildCliEnv(binaryPath) };
delete env.CLAUDE_CODE_ENTRY_PROVIDER;
delete env.CLAUDE_CODE_USE_OPENAI; delete env.CLAUDE_CODE_USE_OPENAI;
delete env.CLAUDE_CODE_USE_BEDROCK; delete env.CLAUDE_CODE_USE_BEDROCK;
delete env.CLAUDE_CODE_USE_VERTEX; delete env.CLAUDE_CODE_USE_VERTEX;
@ -129,14 +183,116 @@ export class ClaudeMultimodelBridgeService {
delete env.CLAUDE_CODE_USE_GEMINI; delete env.CLAUDE_CODE_USE_GEMINI;
if (providerId === 'codex') { if (providerId === 'codex') {
env.CLAUDE_CODE_USE_OPENAI = '1'; env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
} else if (providerId === 'gemini') { } else if (providerId === 'gemini') {
env.CLAUDE_CODE_USE_GEMINI = '1'; env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
} }
return env; return env;
} }
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const lower = message.toLowerCase();
return (
lower.includes('unknown command') ||
lower.includes('unknown option') ||
lower.includes('no such command') ||
lower.includes('did you mean') ||
lower.includes('runtime status')
);
}
private mapRuntimeProviderStatus(
providerId: CliProviderId,
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
): CliProviderStatus {
const provider = createDefaultProviderStatus(providerId);
if (!runtimeStatus) {
return provider;
}
return {
...provider,
supported: runtimeStatus.supported === true,
authenticated: runtimeStatus.authenticated === true,
authMethod: runtimeStatus.authMethod ?? null,
verificationState: runtimeStatus.verificationState ?? 'unknown',
statusMessage: runtimeStatus.statusMessage ?? null,
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
capabilities: {
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
oneShot: runtimeStatus.capabilities?.oneShot === true,
},
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
availableBackends:
runtimeStatus.availableBackends?.map((backend) => ({
id: backend.id ?? 'unknown',
label: backend.label ?? backend.id ?? 'Unknown',
description: backend.description ?? '',
selectable: backend.selectable !== false,
recommended: backend.recommended === true,
available: backend.available === true,
statusMessage: backend.statusMessage ?? null,
detailMessage: backend.detailMessage ?? null,
})) ?? [],
externalRuntimeDiagnostics:
runtimeStatus.externalRuntimeDiagnostics?.map((diagnostic) => ({
id: diagnostic.id ?? 'unknown',
label: diagnostic.label ?? diagnostic.id ?? 'Unknown',
detected: diagnostic.detected === true,
statusMessage: diagnostic.statusMessage ?? null,
detailMessage: diagnostic.detailMessage ?? null,
})) ?? [],
models: extractModelIds(runtimeStatus.models),
backend: runtimeStatus.backend?.kind
? {
kind: runtimeStatus.backend.kind,
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
projectId: runtimeStatus.backend.projectId ?? null,
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
}
: null,
};
}
async getProviderStatus(
binaryPath: string,
providerId: CliProviderId
): Promise<CliProviderStatus> {
await resolveInteractiveShellEnv();
const env = this.buildCliEnv(binaryPath);
try {
const { stdout } = await execCli(
binaryPath,
['runtime', 'status', '--json', '--provider', providerId],
{
timeout: PROVIDER_STATUS_TIMEOUT_MS,
env,
}
);
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
return this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]);
} catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) {
logger.warn(
`Provider-scoped runtime status unavailable for ${providerId}, falling back to full probe: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const providers = await this.getProviderStatuses(binaryPath);
return (
providers.find((provider) => provider.providerId === providerId) ??
createDefaultProviderStatus(providerId)
);
}
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> { private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
const provider = createDefaultProviderStatus('gemini'); const provider = createDefaultProviderStatus('gemini');
const env = this.buildProviderCliEnv(binaryPath, 'gemini'); const env = this.buildProviderCliEnv(binaryPath, 'gemini');
@ -200,6 +356,27 @@ export class ClaudeMultimodelBridgeService {
await resolveInteractiveShellEnv(); await resolveInteractiveShellEnv();
const env = this.buildCliEnv(binaryPath); const env = this.buildCliEnv(binaryPath);
try {
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
timeout: PROVIDER_STATUS_TIMEOUT_MS,
env,
});
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
const providers = ORDERED_PROVIDER_IDS.map((providerId) =>
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
);
onUpdate?.(providers);
return providers;
} catch (error) {
if (!this.isUnifiedRuntimeUnsupported(error)) {
logger.warn(
`Unified runtime status unavailable, falling back to legacy probes: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
const [statusResult, modelsResult] = await Promise.allSettled([ const [statusResult, modelsResult] = await Promise.allSettled([
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], { execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
timeout: PROVIDER_STATUS_TIMEOUT_MS, timeout: PROVIDER_STATUS_TIMEOUT_MS,

View file

@ -2,8 +2,8 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
export type GeminiGlobalConfig = { export type GeminiGlobalConfig = {
geminiBackendPreference?: 'auto' | 'api' | 'cli'; geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
geminiResolvedBackend?: 'api' | 'cli'; geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
geminiLastAuthMethod?: string; geminiLastAuthMethod?: string;
geminiProjectId?: string; geminiProjectId?: string;
}; };
@ -11,11 +11,37 @@ export type GeminiGlobalConfig = {
export type GeminiRuntimeAuthState = { export type GeminiRuntimeAuthState = {
authenticated: boolean; authenticated: boolean;
authMethod: string | null; authMethod: string | null;
resolvedBackend: 'auto' | 'api' | 'cli'; resolvedBackend: 'auto' | 'api' | 'cli-sdk';
projectId: string | null; projectId: string | null;
statusMessage: string | null; statusMessage: string | null;
}; };
function normalizeGeminiBackend(
value: string | null | undefined
): GeminiRuntimeAuthState['resolvedBackend'] {
if (value === 'api') return 'api';
if (value === 'cli' || value === 'cli-sdk') return 'cli-sdk';
return 'auto';
}
function resolveEffectiveGeminiBackend(
requestedBackend: GeminiRuntimeAuthState['resolvedBackend'],
authMethod: string | null,
hasGeminiApiKey: boolean,
hasAdcWithProject: boolean
): Exclude<GeminiRuntimeAuthState['resolvedBackend'], 'auto'> | 'auto' {
if (requestedBackend !== 'auto') {
return requestedBackend;
}
if (hasGeminiApiKey || hasAdcWithProject) {
return 'api';
}
if (authMethod === 'cli_oauth_personal') {
return 'cli-sdk';
}
return 'auto';
}
export async function readGeminiGlobalConfig( export async function readGeminiGlobalConfig(
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv
): Promise<GeminiGlobalConfig | null> { ): Promise<GeminiGlobalConfig | null> {
@ -43,11 +69,11 @@ export async function resolveGeminiRuntimeAuth(
env: NodeJS.ProcessEnv env: NodeJS.ProcessEnv
): Promise<GeminiRuntimeAuthState> { ): Promise<GeminiRuntimeAuthState> {
const config = await readGeminiGlobalConfig(env); const config = await readGeminiGlobalConfig(env);
const resolvedBackend = const resolvedBackend = normalizeGeminiBackend(
env.CLAUDE_CODE_GEMINI_BACKEND?.trim() || env.CLAUDE_CODE_GEMINI_BACKEND?.trim() ||
config?.geminiResolvedBackend?.trim() || config?.geminiResolvedBackend?.trim() ||
config?.geminiBackendPreference?.trim() || config?.geminiBackendPreference?.trim()
'auto'; );
const authMethod = config?.geminiLastAuthMethod?.trim() ?? null; const authMethod = config?.geminiLastAuthMethod?.trim() ?? null;
const projectId = const projectId =
env.GOOGLE_CLOUD_PROJECT?.trim() || env.GOOGLE_CLOUD_PROJECT?.trim() ||
@ -56,34 +82,41 @@ export async function resolveGeminiRuntimeAuth(
config?.geminiProjectId?.trim() || config?.geminiProjectId?.trim() ||
null; null;
const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim()); const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim());
const hasAdcWithProject = Boolean(
(authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId
);
const effectiveBackend = resolveEffectiveGeminiBackend(
resolvedBackend,
authMethod,
hasGeminiApiKey,
hasAdcWithProject
);
if (hasGeminiApiKey) { if (hasGeminiApiKey) {
return { return {
authenticated: true, authenticated: true,
authMethod: 'api_key', authMethod: 'api_key',
resolvedBackend: resolvedBackend: effectiveBackend,
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId, projectId,
statusMessage: null, statusMessage: null,
}; };
} }
if ((authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId) { if (hasAdcWithProject) {
return { return {
authenticated: true, authenticated: true,
authMethod, authMethod,
resolvedBackend: resolvedBackend: effectiveBackend,
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId, projectId,
statusMessage: null, statusMessage: null,
}; };
} }
if (authMethod === 'cli_oauth_personal' && resolvedBackend === 'cli') { if (authMethod === 'cli_oauth_personal' && effectiveBackend === 'cli-sdk') {
return { return {
authenticated: true, authenticated: true,
authMethod, authMethod,
resolvedBackend: 'cli', resolvedBackend: 'cli-sdk',
projectId, projectId,
statusMessage: null, statusMessage: null,
}; };
@ -93,19 +126,17 @@ export async function resolveGeminiRuntimeAuth(
return { return {
authenticated: false, authenticated: false,
authMethod, authMethod,
resolvedBackend: resolvedBackend: effectiveBackend,
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId, projectId,
statusMessage: statusMessage:
'Gemini CLI OAuth was detected, but the active Gemini backend is not set to cli.', 'Gemini CLI OAuth was detected, but the active Gemini backend is not set to CLI SDK.',
}; };
} }
return { return {
authenticated: false, authenticated: false,
authMethod, authMethod,
resolvedBackend: resolvedBackend,
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
projectId, projectId,
statusMessage: statusMessage:
'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.', 'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',

View file

@ -1,6 +1,9 @@
import type { TeamProviderId } from '@shared/types'; import type { TeamProviderId } from '@shared/types';
import { ConfigManager } from '../infrastructure/ConfigManager';
const THIRD_PARTY_PROVIDER_ENV_KEYS = [ const THIRD_PARTY_PROVIDER_ENV_KEYS = [
'CLAUDE_CODE_ENTRY_PROVIDER',
'CLAUDE_CODE_USE_OPENAI', 'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_VERTEX',
@ -8,6 +11,24 @@ const THIRD_PARTY_PROVIDER_ENV_KEYS = [
'CLAUDE_CODE_USE_GEMINI', 'CLAUDE_CODE_USE_GEMINI',
] as const; ] as const;
const BACKEND_SELECTION_ENV_KEYS = [
'CLAUDE_CODE_GEMINI_BACKEND',
'CLAUDE_CODE_CODEX_BACKEND',
] as const;
export function applyConfiguredRuntimeBackendsEnv(
env: NodeJS.ProcessEnv,
runtimeConfig = ConfigManager.getInstance().getConfig().runtime
): NodeJS.ProcessEnv {
for (const key of BACKEND_SELECTION_ENV_KEYS) {
env[key] = undefined;
}
env.CLAUDE_CODE_GEMINI_BACKEND = runtimeConfig.providerBackends.gemini;
env.CLAUDE_CODE_CODEX_BACKEND = runtimeConfig.providerBackends.codex;
return env;
}
export function applyProviderRuntimeEnv( export function applyProviderRuntimeEnv(
env: NodeJS.ProcessEnv, env: NodeJS.ProcessEnv,
providerId: TeamProviderId | undefined providerId: TeamProviderId | undefined
@ -20,9 +41,9 @@ export function applyProviderRuntimeEnv(
} }
if (resolvedProvider === 'codex') { if (resolvedProvider === 'codex') {
env.CLAUDE_CODE_USE_OPENAI = '1'; env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
} else if (resolvedProvider === 'gemini') { } else if (resolvedProvider === 'gemini') {
env.CLAUDE_CODE_USE_GEMINI = '1'; env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
} }
return env; return env;

View file

@ -13,7 +13,10 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { applyProviderRuntimeEnv } from '../runtime/providerRuntimeEnv'; import {
applyConfiguredRuntimeBackendsEnv,
applyProviderRuntimeEnv,
} from '../runtime/providerRuntimeEnv';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types'; import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
@ -103,7 +106,11 @@ export class ScheduledTaskExecutor {
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
const env = applyProviderRuntimeEnv( const env = applyProviderRuntimeEnv(
{ ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined }, applyConfiguredRuntimeBackendsEnv({
...buildEnrichedEnv(binaryPath),
...shellEnv,
CLAUDECODE: undefined,
}),
request.config.providerId request.config.providerId
); );

View file

@ -183,9 +183,11 @@ function getRepoLocalCliCandidates(): string[] {
const repoRoot = process.cwd(); const repoRoot = process.cwd();
return [ return [
// Prefer an already compiled repo-local binary when available.
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
// Fall back to launcher scripts for normal local development.
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'), path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'),
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'), path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'),
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
]; ];
} }

View file

@ -24,6 +24,63 @@ const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full conf
const PER_TEAM_READ_TIMEOUT_MS = 5_000; const PER_TEAM_READ_TIMEOUT_MS = 5_000;
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
interface PartialLaunchStateSummary {
partialLaunchFailure: true;
expectedMemberCount: number;
confirmedMemberCount: number;
missingMembers: string[];
}
async function readPartialLaunchStateSummary(
teamDir: string
): Promise<PartialLaunchStateSummary | null> {
const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE);
try {
const stat = await fs.promises.stat(launchStatePath);
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
return null;
}
const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS);
const parsed = JSON.parse(raw) as {
state?: unknown;
expectedMembers?: unknown;
confirmedMembers?: unknown;
missingMembers?: unknown;
};
if (parsed.state !== 'partial_launch_failure') {
return null;
}
const expectedMembers = Array.isArray(parsed.expectedMembers)
? parsed.expectedMembers.filter(
(name): name is string => typeof name === 'string' && name.trim().length > 0
)
: [];
const confirmedMembers = Array.isArray(parsed.confirmedMembers)
? parsed.confirmedMembers.filter(
(name): name is string => typeof name === 'string' && name.trim().length > 0
)
: [];
const missingMembers = Array.isArray(parsed.missingMembers)
? parsed.missingMembers.filter(
(name): name is string => typeof name === 'string' && name.trim().length > 0
)
: [];
if (expectedMembers.length === 0 || missingMembers.length === 0) {
return null;
}
return {
partialLaunchFailure: true,
expectedMemberCount: expectedMembers.length,
confirmedMemberCount: confirmedMembers.length,
missingMembers,
};
} catch {
return null;
}
}
async function mapLimit<T, R>( async function mapLimit<T, R>(
items: readonly T[], items: readonly T[],
@ -132,6 +189,7 @@ export class TeamConfigReader {
private async readTeamSummary(teamsDir: string, teamName: string): Promise<TeamSummary | null> { private async readTeamSummary(teamsDir: string, teamName: string): Promise<TeamSummary | null> {
const configPath = path.join(teamsDir, teamName, 'config.json'); const configPath = path.join(teamsDir, teamName, 'config.json');
const teamDir = path.join(teamsDir, teamName);
try { try {
let config: TeamConfig | null = null; let config: TeamConfig | null = null;
@ -204,6 +262,8 @@ export class TeamConfigReader {
// Case-insensitive dedup: key is lowercase name, value keeps the original casing // Case-insensitive dedup: key is lowercase name, value keeps the original casing
const memberMap = new Map<string, TeamSummaryMember>(); const memberMap = new Map<string, TeamSummaryMember>();
const removedKeys = new Set<string>(); const removedKeys = new Set<string>();
const expectedTeammateNames = new Set<string>();
const confirmedArtifactNames = new Set<string>();
const mergeMember = (m: TeamMember): void => { const mergeMember = (m: TeamMember): void => {
const name = m.name?.trim(); const name = m.name?.trim();
@ -235,6 +295,7 @@ export class TeamConfigReader {
removedKeys.add(key); removedKeys.add(key);
continue; continue;
} }
expectedTeammateNames.add(name);
mergeMember(member); mergeMember(member);
} }
} catch { } catch {
@ -245,11 +306,28 @@ export class TeamConfigReader {
if (config && Array.isArray(config.members)) { if (config && Array.isArray(config.members)) {
for (const member of config.members) { for (const member of config.members) {
if (member && typeof member.name === 'string') { if (member && typeof member.name === 'string') {
const name = member.name.trim();
if (name && name !== 'user' && !isLeadMember(member)) {
confirmedArtifactNames.add(name);
}
mergeMember(member); mergeMember(member);
} }
} }
} }
try {
const inboxDir = path.join(teamDir, 'inboxes');
const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true });
for (const entry of inboxEntries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const inboxName = entry.name.slice(0, -'.json'.length).trim();
if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue;
confirmedArtifactNames.add(inboxName);
}
} catch {
// best-effort
}
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists. // Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
const allNames = Array.from(memberMap.values()).map((m) => m.name); const allNames = Array.from(memberMap.values()).map((m) => m.name);
const keepName = createCliAutoSuffixNameGuard(allNames); const keepName = createCliAutoSuffixNameGuard(allNames);
@ -262,6 +340,29 @@ export class TeamConfigReader {
} }
const members = Array.from(memberMap.values()); const members = Array.from(memberMap.values());
const partialLaunchState =
(await readPartialLaunchStateSummary(teamDir)) ??
(() => {
if (
!leadSessionId ||
expectedTeammateNames.size === 0 ||
confirmedArtifactNames.size === 0
) {
return null;
}
const missingMembers = Array.from(expectedTeammateNames).filter(
(name) => !confirmedArtifactNames.has(name)
);
if (missingMembers.length === 0) {
return null;
}
return {
partialLaunchFailure: true as const,
expectedMemberCount: expectedTeammateNames.size,
confirmedMemberCount: confirmedArtifactNames.size,
missingMembers,
};
})();
const summary: TeamSummary = { const summary: TeamSummary = {
teamName, teamName,
displayName, displayName,
@ -276,6 +377,7 @@ export class TeamConfigReader {
...(projectPathHistory ? { projectPathHistory } : {}), ...(projectPathHistory ? { projectPathHistory } : {}),
...(sessionHistory ? { sessionHistory } : {}), ...(sessionHistory ? { sessionHistory } : {}),
...(deletedAt ? { deletedAt } : {}), ...(deletedAt ? { deletedAt } : {}),
...(partialLaunchState ?? {}),
}; };
return summary; return summary;
} catch { } catch {

View file

@ -21,6 +21,7 @@ import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/ut
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands'; import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName'; import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary'; import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
import * as agentTeamsControllerModule from 'agent-teams-controller'; import * as agentTeamsControllerModule from 'agent-teams-controller';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
@ -589,6 +590,68 @@ export class TeamDataService {
}); });
} }
// Dedup exact message copies that can appear as both live lead_process rows and
// their persisted inbox/sent-message counterpart. If the messageId is identical,
// keep a single row so the UI does not show the same SendMessage twice
// (for example "LIVE" plus the stored copy).
const duplicateMessageIds = new Set<string>();
const messageIdCounts = new Map<string, number>();
for (const msg of messages) {
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
if (!id) continue;
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
messageIdCounts.set(id, nextCount);
if (nextCount > 1) duplicateMessageIds.add(id);
}
if (duplicateMessageIds.size > 0) {
const choosePreferredMessage = (
current: InboxMessage,
candidate: InboxMessage
): InboxMessage => {
const score = (msg: InboxMessage): number => {
let value = 0;
if (msg.source !== 'lead_process') value += 4;
if (msg.read === false) value += 2;
if (msg.relayOfMessageId) value += 1;
if (msg.summary) value += 1;
if (msg.to) value += 1;
return value;
};
const currentScore = score(current);
const candidateScore = score(candidate);
if (candidateScore !== currentScore) {
return candidateScore > currentScore ? candidate : current;
}
const currentTs = Date.parse(current.timestamp);
const candidateTs = Date.parse(candidate.timestamp);
if (
Number.isFinite(currentTs) &&
Number.isFinite(candidateTs) &&
candidateTs !== currentTs
) {
return candidateTs > currentTs ? candidate : current;
}
return current;
};
const dedupedById = new Map<string, InboxMessage>();
const dedupedWithoutId: InboxMessage[] = [];
for (const msg of messages) {
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
if (!id) {
dedupedWithoutId.push(msg);
continue;
}
const existing = dedupedById.get(id);
if (!existing) {
dedupedById.set(id, msg);
continue;
}
dedupedById.set(id, choosePreferredMessage(existing, msg));
}
messages = [...dedupedWithoutId, ...dedupedById.values()];
}
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's // Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
// session ID (by timestamp). This avoids the old forward-only propagation bug. // session ID (by timestamp). This avoids the old forward-only propagation bug.
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) { if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
@ -1021,10 +1084,7 @@ export class TeamDataService {
name, name,
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
providerId: providerId: normalizeOptionalTeamProviderId(member.providerId),
member.providerId === 'codex' || member.providerId === 'gemini'
? member.providerId
: undefined,
model: member.model?.trim() || undefined, model: member.model?.trim() || undefined,
effort: effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
@ -1985,10 +2045,7 @@ export class TeamDataService {
})(), })(),
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
providerId: providerId: normalizeOptionalTeamProviderId(member.providerId),
member.providerId === 'codex' || member.providerId === 'gemini'
? member.providerId
: undefined,
model: member.model?.trim() || undefined, model: member.model?.trim() || undefined,
effort: effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'

View file

@ -1,5 +1,6 @@
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName'; import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -24,10 +25,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
name: trimmedName, name: trimmedName,
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
providerId: providerId: normalizeOptionalTeamProviderId(member.providerId),
member.providerId === 'codex' || member.providerId === 'gemini'
? member.providerId
: undefined,
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: effort:
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high' member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'

File diff suppressed because it is too large Load diff

View file

@ -86,6 +86,9 @@ interface TaskReadDiag {
skipReasons: Record<string, number>; skipReasons: Record<string, number>;
} }
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parsed JSON types (loose shapes from disk) // Parsed JSON types (loose shapes from disk)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -316,6 +319,54 @@ function dropCliProvisionerMembers(
} }
} }
async function readPartialLaunchState(
teamsDir: string,
teamName: string
): Promise<{
partialLaunchFailure: true;
expectedMemberCount: number;
confirmedMemberCount: number;
missingMembers: string[];
} | null> {
const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE);
try {
const stat = await fs.promises.stat(launchStatePath);
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) return null;
const raw = await fs.promises.readFile(launchStatePath, 'utf8');
const parsed = JSON.parse(raw) as {
state?: unknown;
expectedMembers?: unknown;
confirmedMembers?: unknown;
missingMembers?: unknown;
};
if (parsed.state !== 'partial_launch_failure') return null;
const expectedMembers = Array.isArray(parsed.expectedMembers)
? parsed.expectedMembers.filter(
(name): name is string => typeof name === 'string' && name.trim().length > 0
)
: [];
const confirmedMembers = Array.isArray(parsed.confirmedMembers)
? parsed.confirmedMembers.filter(
(name): name is string => typeof name === 'string' && name.trim().length > 0
)
: [];
const missingMembers = Array.isArray(parsed.missingMembers)
? parsed.missingMembers.filter(
(name): name is string => typeof name === 'string' && name.trim().length > 0
)
: [];
if (expectedMembers.length === 0 || missingMembers.length === 0) return null;
return {
partialLaunchFailure: true,
expectedMemberCount: expectedMembers.length,
confirmedMemberCount: confirmedMembers.length,
missingMembers,
};
} catch {
return null;
}
}
/** /**
* Reads a draft team summary from team.meta.json when config.json is missing. * Reads a draft team summary from team.meta.json when config.json is missing.
* Returns null if team.meta.json doesn't exist or is invalid. * Returns null if team.meta.json doesn't exist or is invalid.
@ -482,6 +533,8 @@ async function listTeams(
const memberMap = new Map<string, { name: string; role?: string; color?: string }>(); const memberMap = new Map<string, { name: string; role?: string; color?: string }>();
const removedKeys = new Set<string>(); const removedKeys = new Set<string>();
const expectedTeammateNames = new Set<string>();
const confirmedArtifactNames = new Set<string>();
try { try {
const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json'); const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json');
@ -500,6 +553,7 @@ async function listTeams(
removedKeys.add(key); removedKeys.add(key);
continue; continue;
} }
expectedTeammateNames.add(name);
mergeMember(member, memberMap, removedKeys); mergeMember(member, memberMap, removedKeys);
} }
} }
@ -511,15 +565,55 @@ async function listTeams(
if (config && Array.isArray(config.members)) { if (config && Array.isArray(config.members)) {
for (const member of config.members as unknown[]) { for (const member of config.members as unknown[]) {
if (isRawMember(member)) { if (isRawMember(member)) {
const name = typeof member.name === 'string' ? member.name.trim() : '';
if (name && name !== 'user' && !isLeadMember(member)) {
confirmedArtifactNames.add(name);
}
mergeMember(member, memberMap, removedKeys); mergeMember(member, memberMap, removedKeys);
} }
} }
} }
try {
const inboxDir = path.join(payload.teamsDir, teamName, 'inboxes');
const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true });
for (const entry of inboxEntries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const inboxName = entry.name.slice(0, -'.json'.length).trim();
if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue;
confirmedArtifactNames.add(inboxName);
}
} catch {
// best-effort
}
dropCliAutoSuffixedMembers(memberMap); dropCliAutoSuffixedMembers(memberMap);
dropCliProvisionerMembers(memberMap); dropCliProvisionerMembers(memberMap);
const members = Array.from(memberMap.values()); const members = Array.from(memberMap.values());
const partialLaunchState =
(await readPartialLaunchState(payload.teamsDir, teamName)) ??
(() => {
if (
!leadSessionId ||
expectedTeammateNames.size === 0 ||
confirmedArtifactNames.size === 0
) {
return null;
}
const missingMembers = Array.from(expectedTeammateNames).filter(
(name) => !confirmedArtifactNames.has(name)
);
if (missingMembers.length === 0) {
return null;
}
return {
partialLaunchFailure: true as const,
expectedMemberCount: expectedTeammateNames.size,
confirmedMemberCount: confirmedArtifactNames.size,
missingMembers,
};
})();
const summary = { const summary = {
teamName, teamName,
displayName, displayName,
@ -534,6 +628,7 @@ async function listTeams(
...(projectPathHistory ? { projectPathHistory } : {}), ...(projectPathHistory ? { projectPathHistory } : {}),
...(sessionHistory ? { sessionHistory } : {}), ...(sessionHistory ? { sessionHistory } : {}),
...(deletedAt ? { deletedAt } : {}), ...(deletedAt ? { deletedAt } : {}),
...(partialLaunchState ?? {}),
}; };
const ms = nowMs() - t0; const ms = nowMs() - t0;

View file

@ -417,6 +417,9 @@ export const CROSS_TEAM_GET_OUTBOX = 'crossTeam:getOutbox';
/** Get CLI installation status */ /** Get CLI installation status */
export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus'; export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus';
/** Get status for a single provider */
export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus';
/** Start CLI install/update */ /** Start CLI install/update */
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install'; export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';

View file

@ -9,6 +9,7 @@ import {
API_KEYS_STORAGE_STATUS, API_KEYS_STORAGE_STATUS,
APP_RELAUNCH, APP_RELAUNCH,
CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_INSTALL, CLI_INSTALLER_INSTALL,
CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_INVALIDATE_STATUS,
CLI_INSTALLER_PROGRESS, CLI_INSTALLER_PROGRESS,
@ -1344,6 +1345,9 @@ const electronAPI: ElectronAPI = {
getStatus: async (): Promise<CliInstallationStatus> => { getStatus: async (): Promise<CliInstallationStatus> => {
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS); return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
}, },
getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => {
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
},
install: async (): Promise<void> => { install: async (): Promise<void> => {
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL); return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
}, },

View file

@ -1076,6 +1076,7 @@ export class HttpAPIClient implements ElectronAPI {
authMethod: null, authMethod: null,
providers: [], providers: [],
}), }),
getProviderStatus: async (): Promise<null> => null,
install: async (): Promise<void> => { install: async (): Promise<void> => {
console.warn('[HttpAPIClient] CLI installer not available in browser mode'); console.warn('[HttpAPIClient] CLI installer not available in browser mode');
}, },

View file

@ -13,6 +13,7 @@ import {
DIFF_REMOVED_TEXT, DIFF_REMOVED_TEXT,
} from '@renderer/constants/cssVariables'; } from '@renderer/constants/cssVariables';
import { highlightLines } from '@renderer/utils/syntaxHighlighter'; import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary';
/** /**
* Renders the input section based on tool type with theme-aware styling. * Renders the input section based on tool type with theme-aware styling.
@ -99,6 +100,71 @@ export function renderInput(toolName: string, input: Record<string, unknown>): R
); );
} }
// Special rendering for Agent tool - do not leak full bootstrap prompts in UI logs.
if (toolName === 'Agent') {
const details = getAgentToolDisplayDetails(input);
return (
<div className="space-y-3" style={{ color: COLOR_TEXT }}>
<div className="space-y-2">
<div>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
action
</div>
<div className="whitespace-pre-wrap break-all">{details.action}</div>
</div>
{details.teammateName && (
<div>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
teammate
</div>
<div>{details.teammateName}</div>
</div>
)}
{details.teamName && (
<div>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
team
</div>
<div>{details.teamName}</div>
</div>
)}
{details.runtime && (
<div>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
runtime
</div>
<div>{details.runtime}</div>
</div>
)}
{details.subagentType && (
<div>
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
type
</div>
<div>{details.subagentType}</div>
</div>
)}
</div>
<div
className="rounded px-3 py-2 text-[11px]"
style={{
backgroundColor: 'rgba(250, 204, 21, 0.08)',
border: '1px solid rgba(250, 204, 21, 0.22)',
color: COLOR_TEXT_MUTED,
}}
>
Startup instructions are hidden in the UI.
</div>
</div>
);
}
// Default: key-value format with readable string values // Default: key-value format with readable string values
return ( return (
<div className="space-y-2" style={{ color: COLOR_TEXT }}> <div className="space-y-2" style={{ color: COLOR_TEXT }}>

View file

@ -11,7 +11,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { SettingsToggle } from '@renderer/components/settings/components'; import { SettingsToggle } from '@renderer/components/settings/components';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel'; import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
@ -29,6 +32,7 @@ import {
LogOut, LogOut,
Puzzle, Puzzle,
RefreshCw, RefreshCw,
SlidersHorizontal,
Terminal, Terminal,
} from 'lucide-react'; } from 'lucide-react';
@ -167,6 +171,7 @@ const CliCheckingSpinner = ({
interface InstalledBannerProps { interface InstalledBannerProps {
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>; cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>;
cliStatusLoading: boolean; cliStatusLoading: boolean;
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
cliStatusError: string | null; cliStatusError: string | null;
isBusy: boolean; isBusy: boolean;
multimodelEnabled: boolean; multimodelEnabled: boolean;
@ -176,6 +181,8 @@ interface InstalledBannerProps {
onMultimodelToggle: (enabled: boolean) => void; onMultimodelToggle: (enabled: boolean) => void;
onProviderLogin: (providerId: CliProviderId) => void; onProviderLogin: (providerId: CliProviderId) => void;
onProviderLogout: (providerId: CliProviderId) => void; onProviderLogout: (providerId: CliProviderId) => void;
onProviderManage: (providerId: CliProviderId) => void;
onProviderRefresh: (providerId: CliProviderId) => void;
variant: BannerVariant; variant: BannerVariant;
} }
@ -190,35 +197,41 @@ function getProviderLabel(providerId: CliProviderId): string {
} }
} }
function getProviderTerminalCommand(providerId: CliProviderId): { function getProviderTerminalCommand(provider: CliProviderStatus): {
args: string[]; args: string[];
env?: Record<string, string>; env?: Record<string, string>;
} { } {
if (providerId === 'gemini') { if (provider.providerId === 'gemini') {
return { return {
args: ['login'], args: ['login'],
env: { CLAUDE_CODE_USE_GEMINI: '1' }, env: {
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
},
}; };
} }
return { return {
args: ['auth', 'login', '--provider', providerId], args: ['auth', 'login', '--provider', provider.providerId],
}; };
} }
function getProviderTerminalLogoutCommand(providerId: CliProviderId): { function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
args: string[]; args: string[];
env?: Record<string, string>; env?: Record<string, string>;
} { } {
if (providerId === 'gemini') { if (provider.providerId === 'gemini') {
return { return {
args: ['logout'], args: ['logout'],
env: { CLAUDE_CODE_USE_GEMINI: '1' }, env: {
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
},
}; };
} }
return { return {
args: ['auth', 'logout', '--provider', providerId], args: ['auth', 'logout', '--provider', provider.providerId],
}; };
} }
@ -278,6 +291,40 @@ function ModelBadges({
); );
} }
function ProviderDetailSkeleton(): React.JSX.Element {
return (
<div className="mt-1 space-y-2">
<div
className="skeleton-shimmer h-3 rounded-sm"
style={{ width: '58%', backgroundColor: 'var(--skeleton-base)' }}
/>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="skeleton-shimmer h-6 rounded-md border"
style={{
width: index === 0 ? 56 : index === 1 ? 84 : index === 2 ? 72 : 96,
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'var(--skeleton-base-dim)',
}}
/>
))}
</div>
</div>
);
}
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
return (
providerLoading ||
(!provider.authenticated &&
provider.statusMessage === 'Checking...' &&
provider.models.length === 0 &&
provider.backend == null)
);
}
function formatRuntimeLabel( function formatRuntimeLabel(
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']> cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
): string | null { ): string | null {
@ -301,12 +348,8 @@ function formatRuntimeAuthSummary(
) { ) {
return 'Checking providers...'; return 'Checking providers...';
} }
const supportedProviders = cliStatus.providers.filter((provider) => provider.supported); const denominator = cliStatus.providers.length;
const denominator = const connected = cliStatus.providers.filter((provider) => provider.authenticated).length;
supportedProviders.length > 0 ? supportedProviders.length : cliStatus.providers.length;
const connected = (
supportedProviders.length > 0 ? supportedProviders : cliStatus.providers
).filter((provider) => provider.authenticated).length;
return `Providers: ${connected}/${denominator} connected`; return `Providers: ${connected}/${denominator} connected`;
} }
@ -334,48 +377,10 @@ function isCheckingMultimodelStatus(
); );
} }
function createLoadingMultimodelStatus(): CliInstallationStatus {
const providers: CliProviderStatus[] = [
{ providerId: 'anthropic' as const, displayName: 'Anthropic' },
{ providerId: 'codex' as const, displayName: 'Codex' },
{ providerId: 'gemini' as const, displayName: 'Gemini' },
].map((provider) => ({
...provider,
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown' as const,
statusMessage: 'Checking...',
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
}));
return {
flavor: 'free-code',
displayName: 'free-code-gemini-research',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: null,
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: true,
authMethod: null,
providers,
};
}
const InstalledBanner = ({ const InstalledBanner = ({
cliStatus, cliStatus,
cliStatusLoading, cliStatusLoading,
cliProviderStatusLoading,
cliStatusError, cliStatusError,
isBusy, isBusy,
multimodelEnabled, multimodelEnabled,
@ -385,6 +390,8 @@ const InstalledBanner = ({
onMultimodelToggle, onMultimodelToggle,
onProviderLogin, onProviderLogin,
onProviderLogout, onProviderLogout,
onProviderManage,
onProviderRefresh,
variant, variant,
}: InstalledBannerProps): React.JSX.Element => { }: InstalledBannerProps): React.JSX.Element => {
const openExtensionsTab = useStore((s) => s.openExtensionsTab); const openExtensionsTab = useStore((s) => s.openExtensionsTab);
@ -499,48 +506,56 @@ const InstalledBanner = ({
{cliStatus.providers.map((provider) => { {cliStatus.providers.map((provider) => {
const statusText = formatProviderStatus(provider); const statusText = formatProviderStatus(provider);
const actionDisabled = isBusy || !cliStatus.binaryPath; const actionDisabled = isBusy || !cliStatus.binaryPath;
const runtimeSummary = getProviderRuntimeBackendSummary(provider);
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
const showSkeleton = isProviderCardLoading(provider, providerLoading);
return ( return (
<div <div
key={provider.providerId} key={provider.providerId}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md px-2 py-2" className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md px-2 py-2"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }} style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
> >
<div className="min-w-0"> <div className="col-span-2 flex items-start justify-between gap-3">
<div className="flex items-center gap-2"> <div className="min-w-0 flex-1">
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}> <div className="flex items-center gap-2">
{provider.displayName} <span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
</span> {provider.displayName}
<span
className="text-xs"
style={{
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
}}
>
{statusText}
</span>
</div>
<div
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (
<span>
Backend: {provider.backend.label}
{provider.backend.endpointLabel
? ` (${provider.backend.endpointLabel})`
: ''}
</span> </span>
)} <span
{provider.models.length === 0 && ( className="text-xs"
<span>Models unavailable for this runtime build</span> style={{
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
}}
>
{statusText}
</span>
</div>
{showSkeleton ? (
<ProviderDetailSkeleton />
) : (
<div
className="mt-1 flex min-h-[2.75rem] flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && (
<span>
Backend: {provider.backend.label}
{provider.backend.endpointLabel
? ` (${provider.backend.endpointLabel})`
: ''}
</span>
)}
{runtimeSummary ? <span>Runtime: {runtimeSummary}</span> : null}
{provider.models.length === 0 && (
<span>Models unavailable for this runtime build</span>
)}
</div>
)} )}
</div> </div>
</div> <div className="flex shrink-0 items-start gap-2">
<div className="flex shrink-0 items-center gap-2">
{provider.authenticated ? (
<button <button
onClick={() => onProviderLogout(provider.providerId)} onClick={() => onProviderManage(provider.providerId)}
disabled={actionDisabled} disabled={actionDisabled}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50" className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ style={{
@ -548,39 +563,57 @@ const InstalledBanner = ({
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
}} }}
> >
<LogOut className="size-3" /> <SlidersHorizontal className="size-3" />
Logout Manage
</button> </button>
) : provider.canLoginFromUi ? ( {provider.authenticated && provider.canLoginFromUi ? (
<button
onClick={() => onProviderLogout(provider.providerId)}
disabled={actionDisabled}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogOut className="size-3" />
Logout
</button>
) : provider.canLoginFromUi ? (
<button
onClick={() => onProviderLogin(provider.providerId)}
disabled={actionDisabled}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogIn className="size-3" />
Login
</button>
) : null}
<button <button
onClick={() => onProviderLogin(provider.providerId)} onClick={() => onProviderRefresh(provider.providerId)}
disabled={actionDisabled} disabled={cliStatusLoading || providerLoading}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50" className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
style={{ style={{
borderColor: 'var(--color-border)', borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)', color: 'var(--color-text-secondary)',
}} }}
title={`Re-check ${provider.displayName}`}
> >
<LogIn className="size-3" /> <RefreshCw
Login className={
cliStatusLoading || providerLoading
? 'size-[11px] animate-spin'
: 'size-[11px]'
}
/>
</button> </button>
) : null} </div>
<button
onClick={onRefresh}
disabled={cliStatusLoading}
className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
title={`Re-check ${provider.displayName}`}
>
<RefreshCw
className={cliStatusLoading ? 'size-[11px] animate-spin' : 'size-[11px]'}
/>
</button>
</div> </div>
{provider.models.length > 0 && ( {!showSkeleton && provider.models.length > 0 && (
<div className="col-span-2"> <div className="col-span-2">
<ModelBadges providerId={provider.providerId} models={provider.models} /> <ModelBadges providerId={provider.providerId} models={provider.models} />
</div> </div>
@ -605,6 +638,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const { const {
cliStatus, cliStatus,
cliStatusLoading, cliStatusLoading,
cliProviderStatusLoading,
cliStatusError, cliStatusError,
installerState, installerState,
downloadProgress, downloadProgress,
@ -614,7 +648,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
installerDetail, installerDetail,
installerRawChunks, installerRawChunks,
completedVersion, completedVersion,
bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
fetchCliProviderStatus,
invalidateCliStatus, invalidateCliStatus,
installCli, installCli,
isBusy, isBusy,
@ -625,6 +661,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
providerId: CliProviderId; providerId: CliProviderId;
action: 'login' | 'logout'; action: 'login' | 'logout';
} | null>(null); } | null>(null);
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false); const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const [showTroubleshoot, setShowTroubleshoot] = useState(false); const [showTroubleshoot, setShowTroubleshoot] = useState(false);
@ -655,26 +693,34 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}, [installCli]); }, [installCli]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
if (multimodelEnabled) {
void bootstrapCliStatus({ multimodelEnabled: true });
return;
}
void fetchCliStatus(); void fetchCliStatus();
}, [fetchCliStatus]); }, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
const handleMultimodelToggle = useCallback( const handleMultimodelToggle = useCallback(
async (enabled: boolean) => { async (enabled: boolean) => {
setIsSwitchingFlavor(true); setIsSwitchingFlavor(true);
try { try {
useStore.setState({ useStore.setState({
cliStatus: enabled ? createLoadingMultimodelStatus() : null, cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
cliStatusLoading: true, cliStatusLoading: true,
cliStatusError: null, cliStatusError: null,
}); });
await updateConfig('general', { multimodelEnabled: enabled }); await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally { } finally {
setIsSwitchingFlavor(false); setIsSwitchingFlavor(false);
} }
}, },
[fetchCliStatus, invalidateCliStatus, updateConfig] [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
); );
const recheckAuthState = useCallback(() => { const recheckAuthState = useCallback(() => {
@ -711,6 +757,40 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
})(); })();
}, []); }, []);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
setManageProviderId(providerId);
setManageDialogOpen(true);
}, []);
const handleProviderRefresh = useCallback(
(providerId: CliProviderId) => {
void fetchCliProviderStatus(providerId);
},
[fetchCliProviderStatus]
);
const handleProviderBackendChange = useCallback(
async (providerId: CliProviderId, backendId: string) => {
if (providerId !== 'gemini' && providerId !== 'codex') {
return;
}
const currentBackends = appConfig?.runtime?.providerBackends ?? {
gemini: 'auto' as const,
codex: 'auto' as const,
};
await updateConfig('runtime', {
providerBackends: {
...currentBackends,
[providerId]: backendId,
},
});
await fetchCliProviderStatus(providerId);
},
[appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
);
if (!isElectron) return null; if (!isElectron) return null;
// Determine variant for styling // Determine variant for styling
@ -729,11 +809,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
const variant = getVariant(); const variant = getVariant();
const styles = VARIANT_STYLES[variant]; const styles = VARIANT_STYLES[variant];
const providerTerminalCommand = providerTerminal const activeTerminalProvider = providerTerminal
? providerTerminal.action === 'login' ? (cliStatus?.providers.find(
? getProviderTerminalCommand(providerTerminal.providerId) (provider) => provider.providerId === providerTerminal.providerId
: getProviderTerminalLogoutCommand(providerTerminal.providerId) ) ?? null)
: null; : null;
const providerTerminalCommand =
providerTerminal && activeTerminalProvider
? providerTerminal.action === 'login'
? getProviderTerminalCommand(activeTerminalProvider)
: getProviderTerminalLogoutCommand(activeTerminalProvider)
: null;
// ── Loading / fetch error state ──────────────────────────────────────── // ── Loading / fetch error state ────────────────────────────────────────
if (!cliStatus && installerState === 'idle') { if (!cliStatus && installerState === 'idle') {
@ -794,8 +880,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
if (multimodelEnabled) { if (multimodelEnabled) {
return ( return (
<InstalledBanner <InstalledBanner
cliStatus={createLoadingMultimodelStatus()} cliStatus={createLoadingMultimodelCliStatus()}
cliStatusLoading={cliStatusLoading} cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading}
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled} multimodelEnabled={multimodelEnabled}
@ -805,6 +892,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout} onProviderLogout={handleProviderLogout}
onProviderManage={handleProviderManage}
onProviderRefresh={handleProviderRefresh}
variant="info" variant="info"
/> />
); );
@ -1072,7 +1161,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
setIsVerifyingAuth(true); setIsVerifyingAuth(true);
try { try {
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); if (multimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally { } finally {
setIsVerifyingAuth(false); setIsVerifyingAuth(false);
} }
@ -1142,7 +1235,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
void (async () => { void (async () => {
try { try {
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); if (multimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally { } finally {
setIsVerifyingAuth(false); setIsVerifyingAuth(false);
} }
@ -1153,7 +1250,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
void (async () => { void (async () => {
try { try {
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); if (multimodelEnabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally { } finally {
setIsVerifyingAuth(false); setIsVerifyingAuth(false);
} }
@ -1174,6 +1275,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
<InstalledBanner <InstalledBanner
cliStatus={cliStatus} cliStatus={cliStatus}
cliStatusLoading={cliStatusLoading} cliStatusLoading={cliStatusLoading}
cliProviderStatusLoading={cliProviderStatusLoading}
cliStatusError={cliStatusError ?? null} cliStatusError={cliStatusError ?? null}
isBusy={isBusy} isBusy={isBusy}
multimodelEnabled={multimodelEnabled} multimodelEnabled={multimodelEnabled}
@ -1183,8 +1285,24 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)} onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
onProviderLogin={handleProviderLogin} onProviderLogin={handleProviderLogin}
onProviderLogout={handleProviderLogout} onProviderLogout={handleProviderLogout}
onProviderManage={handleProviderManage}
onProviderRefresh={handleProviderRefresh}
variant={variant} variant={variant}
/> />
{cliStatus && (
<ProviderRuntimeSettingsDialog
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
providers={cliStatus.providers}
initialProviderId={manageProviderId}
providerStatusLoading={cliProviderStatusLoading}
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath}
onSelectBackend={(providerId, backendId) => {
void handleProviderBackendChange(providerId, backendId);
}}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
/>
)}
{providerTerminal && cliStatus.binaryPath && ( {providerTerminal && cliStatus.binaryPath && (
<TerminalModal <TerminalModal
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel( title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(

View file

@ -0,0 +1,194 @@
import type { CliProviderStatus } from '@shared/types';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
type Props = {
provider: CliProviderStatus;
disabled?: boolean;
onSelect: (providerId: CliProviderStatus['providerId'], backendId: string) => void;
};
export function getOptionDisplayLabel(
option: NonNullable<CliProviderStatus['availableBackends']>[number],
resolvedOption: NonNullable<CliProviderStatus['availableBackends']>[number] | null
): string {
if (option.id !== 'auto') {
return option.label;
}
if (resolvedOption?.label) {
return `Auto (currently: ${resolvedOption.label})`;
}
return 'Auto';
}
export function getProviderRuntimeBackendSummary(provider: CliProviderStatus): string | null {
const options = provider.availableBackends ?? [];
if (options.length === 0) {
return null;
}
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
return getOptionDisplayLabel(selectedOption, resolvedOption);
}
export function ProviderRuntimeBackendSelector({
provider,
disabled = false,
onSelect,
}: Props): React.JSX.Element | null {
const options = provider.availableBackends ?? [];
if (options.length === 0) {
return null;
}
const selectedBackendId = provider.selectedBackendId ?? options[0]?.id ?? '';
const selectedOption = options.find((option) => option.id === selectedBackendId) ?? options[0];
const resolvedOption = options.find((option) => option.id === provider.resolvedBackendId) ?? null;
const selectedLabel = getOptionDisplayLabel(selectedOption, resolvedOption);
return (
<div className="mt-2 space-y-2.5">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] font-medium" style={{ color: 'var(--color-text-muted)' }}>
Runtime backend
</span>
{provider.resolvedBackendId &&
provider.resolvedBackendId !== provider.selectedBackendId && (
<span
className="rounded-full px-2 py-0.5 text-[10px]"
style={{
color: 'var(--color-text-secondary)',
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
Resolved: {resolvedOption?.label ?? provider.resolvedBackendId}
</span>
)}
</div>
<Select
value={selectedBackendId}
disabled={disabled}
onValueChange={(backendId) => onSelect(provider.providerId, backendId)}
>
<SelectTrigger className="h-10 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
Current
</span>
<span className="truncate">{selectedLabel}</span>
</div>
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.id}
value={option.id}
disabled={!option.available && option.id !== selectedBackendId}
className="py-2.5"
>
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate">{getOptionDisplayLabel(option, resolvedOption)}</span>
{option.recommended ? (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"
style={{
color: '#86efac',
backgroundColor: 'rgba(74, 222, 128, 0.14)',
}}
>
Recommended
</span>
) : null}
{!option.available ? (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]"
style={{
color: '#fca5a5',
backgroundColor: 'rgba(248, 113, 113, 0.14)',
}}
>
Unavailable
</span>
) : null}
</div>
<span className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{selectedOption && (
<div
className="rounded-lg border p-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.025)',
}}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{selectedLabel}
</span>
{selectedOption.recommended ? (
<span
className="rounded-full px-1.5 py-0.5 text-[10px]"
style={{
color: '#86efac',
backgroundColor: 'rgba(74, 222, 128, 0.14)',
}}
>
Recommended
</span>
) : null}
{!selectedOption.available ? (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="cursor-help rounded-full px-1.5 py-0.5 text-[10px]"
style={{
color: '#fca5a5',
backgroundColor: 'rgba(248, 113, 113, 0.14)',
}}
>
Unavailable
</span>
</TooltipTrigger>
<TooltipContent>
{selectedOption.detailMessage ?? selectedOption.statusMessage ?? 'Unavailable'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
<div className="mt-2 space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
<div>{selectedOption.description}</div>
{selectedOption.statusMessage ? <div>{selectedOption.statusMessage}</div> : null}
{selectedOption.detailMessage && selectedOption.available ? (
<div className="break-words opacity-80">{selectedOption.detailMessage}</div>
) : null}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,414 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { useStore } from '@renderer/store';
import { AlertTriangle, Key, Trash2 } from 'lucide-react';
import {
ProviderRuntimeBackendSelector,
getProviderRuntimeBackendSummary,
} from './ProviderRuntimeBackendSelector';
import type { CliProviderId, CliProviderStatus } from '@shared/types';
import type { ApiKeyEntry } from '@shared/types/extensions';
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
providers: CliProviderStatus[];
initialProviderId: CliProviderId;
providerStatusLoading?: Partial<Record<CliProviderId, boolean>>;
disabled?: boolean;
onSelectBackend: (providerId: CliProviderId, backendId: string) => void;
onRefreshProvider?: (providerId: CliProviderId) => Promise<void> | void;
};
export function ProviderRuntimeSettingsDialog({
open,
onOpenChange,
providers,
initialProviderId,
providerStatusLoading = {},
disabled = false,
onSelectBackend,
onRefreshProvider,
}: Props): React.JSX.Element {
const [selectedProviderId, setSelectedProviderId] = useState<CliProviderId>(initialProviderId);
const [showGeminiApiKeyForm, setShowGeminiApiKeyForm] = useState(false);
const [geminiApiKeyValue, setGeminiApiKeyValue] = useState('');
const [geminiApiKeyScope, setGeminiApiKeyScope] = useState<'user' | 'project'>('user');
const [geminiApiKeyError, setGeminiApiKeyError] = useState<string | null>(null);
const apiKeys = useStore((s) => s.apiKeys);
const apiKeysLoading = useStore((s) => s.apiKeysLoading);
const apiKeysError = useStore((s) => s.apiKeysError);
const apiKeySaving = useStore((s) => s.apiKeySaving);
const apiKeyStorageStatus = useStore((s) => s.apiKeyStorageStatus);
const fetchApiKeys = useStore((s) => s.fetchApiKeys);
const fetchApiKeyStorageStatus = useStore((s) => s.fetchApiKeyStorageStatus);
const saveApiKey = useStore((s) => s.saveApiKey);
const deleteApiKey = useStore((s) => s.deleteApiKey);
useEffect(() => {
if (open) {
setSelectedProviderId(initialProviderId);
void fetchApiKeys();
void fetchApiKeyStorageStatus();
}
}, [fetchApiKeyStorageStatus, fetchApiKeys, initialProviderId, open]);
useEffect(() => {
if (!open) {
setShowGeminiApiKeyForm(false);
setGeminiApiKeyValue('');
setGeminiApiKeyError(null);
}
}, [open]);
const selectedProvider = useMemo(() => {
return (
providers.find((provider) => provider.providerId === selectedProviderId) ??
providers.find(
(provider) => provider.availableBackends && provider.availableBackends.length > 0
) ??
providers[0] ??
null
);
}, [providers, selectedProviderId]);
const summary = selectedProvider ? getProviderRuntimeBackendSummary(selectedProvider) : null;
const canConfigure = (selectedProvider?.availableBackends?.length ?? 0) > 0;
const geminiApiKey = useMemo(() => {
const matches = apiKeys.filter((entry) => entry.envVarName === 'GEMINI_API_KEY');
const preferred = matches.find((entry) => entry.scope === 'user') ?? matches[0] ?? null;
return preferred;
}, [apiKeys]);
const handleSaveGeminiApiKey = async (): Promise<void> => {
if (!geminiApiKeyValue.trim()) {
setGeminiApiKeyError('API key is required');
return;
}
setGeminiApiKeyError(null);
try {
await saveApiKey({
id: geminiApiKey?.id,
name: 'Gemini API Key',
envVarName: 'GEMINI_API_KEY',
value: geminiApiKeyValue.trim(),
scope: geminiApiKeyScope,
});
setShowGeminiApiKeyForm(false);
setGeminiApiKeyValue('');
await onRefreshProvider?.('gemini');
} catch (error) {
setGeminiApiKeyError(error instanceof Error ? error.message : 'Failed to save API key');
}
};
const handleDeleteGeminiApiKey = async (entry: ApiKeyEntry): Promise<void> => {
setGeminiApiKeyError(null);
await deleteApiKey(entry.id);
await fetchApiKeys();
await onRefreshProvider?.('gemini');
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Provider Runtime Settings</DialogTitle>
<DialogDescription>
Choose a provider and adjust which internal runtime backend `free-code` should use.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-[11px] font-medium" style={{ color: 'var(--color-text-muted)' }}>
Provider
</div>
<Tabs
value={selectedProvider?.providerId ?? selectedProviderId}
onValueChange={(value) => setSelectedProviderId(value as CliProviderId)}
>
<div
className="-mx-1 border-b px-1"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<TabsList className="gap-1 rounded-b-none">
{providers.map((provider) => (
<TabsTrigger
key={provider.providerId}
value={provider.providerId}
className="relative rounded-b-none data-[state=active]:z-10 data-[state=active]:-mb-px data-[state=active]:bg-[var(--color-surface)] data-[state=active]:shadow-none data-[state=active]:after:absolute data-[state=active]:after:inset-x-0 data-[state=active]:after:-bottom-px data-[state=active]:after:h-1 data-[state=active]:after:bg-[var(--color-surface)] data-[state=active]:after:content-['']"
>
{provider.displayName}
</TabsTrigger>
))}
</TabsList>
</div>
</Tabs>
</div>
{selectedProvider ? (
<div
className="rounded-lg border px-3 py-2.5"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.025)',
}}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
{selectedProvider.displayName}
</span>
<span
className="text-xs"
style={{
color: selectedProvider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
}}
>
{selectedProvider.authenticated
? selectedProvider.authMethod
? `Authenticated via ${selectedProvider.authMethod}`
: 'Authenticated'
: selectedProvider.statusMessage || 'Not connected'}
</span>
{summary ? (
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
Runtime: {summary}
</span>
) : null}
</div>
</div>
) : null}
{selectedProvider && canConfigure ? (
<ProviderRuntimeBackendSelector
provider={selectedProvider}
disabled={disabled || providerStatusLoading[selectedProvider.providerId] === true}
onSelect={onSelectBackend}
/>
) : (
<div
className="rounded-lg border px-3 py-2.5 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.025)',
color: 'var(--color-text-muted)',
}}
>
Runtime backend is not configurable for this provider in the current version.
</div>
)}
{selectedProvider?.providerId === 'gemini' && (
<div
className="space-y-3 rounded-lg border p-3"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.025)',
}}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div
className="flex size-7 items-center justify-center rounded-md border"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255,255,255,0.03)',
}}
>
<Key className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
</div>
<div>
<div className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
API access
</div>
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK does not require
it.
</div>
</div>
</div>
</div>
{!showGeminiApiKeyForm ? (
<Button
size="sm"
variant="outline"
onClick={() => {
setShowGeminiApiKeyForm(true);
setGeminiApiKeyScope(geminiApiKey?.scope ?? 'user');
setGeminiApiKeyError(null);
}}
>
{geminiApiKey ? 'Replace key' : 'Set API key'}
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span
className="rounded-full px-2 py-0.5"
style={{
color: geminiApiKey ? '#86efac' : 'var(--color-text-muted)',
backgroundColor: geminiApiKey
? 'rgba(74, 222, 128, 0.14)'
: 'rgba(255, 255, 255, 0.05)',
}}
>
{geminiApiKey ? 'Configured' : 'Not configured'}
</span>
{geminiApiKey ? (
<span style={{ color: 'var(--color-text-secondary)' }}>
{geminiApiKey.maskedValue} · {geminiApiKey.scope}
</span>
) : null}
{apiKeyStorageStatus ? (
<span style={{ color: 'var(--color-text-muted)' }}>
Stored in {apiKeyStorageStatus.backend}
</span>
) : null}
</div>
{selectedProvider.availableBackends?.some(
(option) => option.id === 'api' && !option.available
) ? (
<div
className="flex items-start gap-2 rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(245, 158, 11, 0.25)',
backgroundColor: 'rgba(245, 158, 11, 0.06)',
color: '#fbbf24',
}}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use
valid Google ADC credentials.
</span>
</div>
) : null}
{showGeminiApiKeyForm ? (
<div
className="space-y-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
>
<div className="space-y-1.5">
<Label htmlFor="gemini-api-key" className="text-xs">
Gemini API key
</Label>
<Input
id="gemini-api-key"
type="password"
value={geminiApiKeyValue}
onChange={(e) => setGeminiApiKeyValue(e.target.value)}
placeholder="AIza..."
className="h-9 text-sm"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Scope</Label>
<Select
value={geminiApiKeyScope}
onValueChange={(value) => setGeminiApiKeyScope(value as 'user' | 'project')}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="project">Project</SelectItem>
</SelectContent>
</Select>
</div>
{(geminiApiKeyError || apiKeysError) && (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
borderColor: 'rgba(248, 113, 113, 0.25)',
backgroundColor: 'rgba(248, 113, 113, 0.06)',
color: '#fca5a5',
}}
>
{geminiApiKeyError ?? apiKeysError}
</div>
)}
<div className="flex justify-between gap-2">
{geminiApiKey ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void handleDeleteGeminiApiKey(geminiApiKey)}
disabled={apiKeySaving}
>
<Trash2 className="mr-1 size-3.5" />
Delete
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setShowGeminiApiKeyForm(false);
setGeminiApiKeyValue('');
setGeminiApiKeyError(null);
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleSaveGeminiApiKey()}
disabled={apiKeySaving || !geminiApiKeyValue.trim()}
>
{apiKeySaving ? 'Saving...' : geminiApiKey ? 'Update key' : 'Save key'}
</Button>
</div>
</div>
</div>
) : null}
{apiKeysLoading && !geminiApiKey ? (
<div className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
Loading stored credentials...
</div>
) : null}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View file

@ -321,6 +321,12 @@ export function useSettingsHandlers({
useNativeTitleBar: false, useNativeTitleBar: false,
telemetryEnabled: true, telemetryEnabled: true,
}, },
runtime: {
providerBackends: {
gemini: 'auto',
codex: 'auto',
},
},
display: { display: {
showTimestamps: true, showTimestamps: true,
compactMode: false, compactMode: false,
@ -334,6 +340,7 @@ export function useSettingsHandlers({
await api.config.update('notifications', defaultConfig.notifications); await api.config.update('notifications', defaultConfig.notifications);
await api.config.update('general', defaultConfig.general); await api.config.update('general', defaultConfig.general);
await api.config.update('runtime', defaultConfig.runtime);
const updatedConfig = await api.config.update('display', defaultConfig.display); const updatedConfig = await api.config.update('display', defaultConfig.display);
setConfig(updatedConfig); setConfig(updatedConfig);
setOptimisticConfig(updatedConfig); setOptimisticConfig(updatedConfig);

View file

@ -9,10 +9,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
import { SettingsToggle } from '@renderer/components/settings/components'; import { SettingsToggle } from '@renderer/components/settings/components';
import { TerminalModal } from '@renderer/components/terminal/TerminalModal'; import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
import { useCliInstaller } from '@renderer/hooks/useCliInstaller'; import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { formatBytes } from '@renderer/utils/formatters'; import { formatBytes } from '@renderer/utils/formatters';
import { import {
AlertTriangle, AlertTriangle,
@ -23,12 +26,13 @@ import {
LogOut, LogOut,
Puzzle, Puzzle,
RefreshCw, RefreshCw,
SlidersHorizontal,
Terminal, Terminal,
} from 'lucide-react'; } from 'lucide-react';
import { SettingsSectionHeader } from '../components'; import { SettingsSectionHeader } from '../components';
import type { CliInstallationStatus, CliProviderId } from '@shared/types'; import type { CliProviderId, CliProviderStatus } from '@shared/types';
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
if (providerId === 'anthropic') { if (providerId === 'anthropic') {
@ -69,6 +73,40 @@ function ModelBadges({
); );
} }
function ProviderDetailSkeleton(): React.JSX.Element {
return (
<div className="mt-1 space-y-2">
<div
className="skeleton-shimmer h-3 rounded-sm"
style={{ width: '58%', backgroundColor: 'var(--skeleton-base)' }}
/>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="skeleton-shimmer h-6 rounded-md border"
style={{
width: index === 0 ? 56 : index === 1 ? 84 : index === 2 ? 72 : 96,
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'var(--skeleton-base-dim)',
}}
/>
))}
</div>
</div>
);
}
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
return (
providerLoading ||
(!provider.authenticated &&
provider.statusMessage === 'Checking...' &&
provider.models.length === 0 &&
provider.backend == null)
);
}
function getProviderLabel(providerId: CliProviderId): string { function getProviderLabel(providerId: CliProviderId): string {
switch (providerId) { switch (providerId) {
case 'anthropic': case 'anthropic':
@ -80,74 +118,41 @@ function getProviderLabel(providerId: CliProviderId): string {
} }
} }
function getProviderTerminalCommand(providerId: CliProviderId): { function getProviderTerminalCommand(provider: CliProviderStatus): {
args: string[]; args: string[];
env?: Record<string, string>; env?: Record<string, string>;
} { } {
if (providerId === 'gemini') { if (provider.providerId === 'gemini') {
return { return {
args: ['login'], args: ['login'],
env: { CLAUDE_CODE_USE_GEMINI: '1' }, env: {
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
},
}; };
} }
return { return {
args: ['auth', 'login', '--provider', providerId], args: ['auth', 'login', '--provider', provider.providerId],
}; };
} }
function getProviderTerminalLogoutCommand(providerId: CliProviderId): { function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
args: string[]; args: string[];
env?: Record<string, string>; env?: Record<string, string>;
} { } {
if (providerId === 'gemini') { if (provider.providerId === 'gemini') {
return { return {
args: ['logout'], args: ['logout'],
env: { CLAUDE_CODE_USE_GEMINI: '1' }, env: {
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
},
}; };
} }
return { return {
args: ['auth', 'logout', '--provider', providerId], args: ['auth', 'logout', '--provider', provider.providerId],
};
}
function createLoadingMultimodelStatus(): CliInstallationStatus {
const providers: Array<{ providerId: CliProviderId; displayName: string }> = [
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
];
return {
flavor: 'free-code',
displayName: 'free-code-gemini-research',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: null,
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: true,
authMethod: null,
providers: providers.map((provider) => ({
...provider,
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown' as const,
statusMessage: 'Checking...',
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
})),
}; };
} }
@ -163,28 +168,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
downloadTotal, downloadTotal,
installerError, installerError,
completedVersion, completedVersion,
bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
fetchCliProviderStatus,
installCli, installCli,
isBusy, isBusy,
cliStatusLoading, cliStatusLoading,
cliProviderStatusLoading,
invalidateCliStatus, invalidateCliStatus,
} = useCliInstaller(); } = useCliInstaller();
const [providerTerminal, setProviderTerminal] = useState<{ const [providerTerminal, setProviderTerminal] = useState<{
providerId: CliProviderId; providerId: CliProviderId;
action: 'login' | 'logout'; action: 'login' | 'logout';
} | null>(null); } | null>(null);
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
const [manageDialogOpen, setManageDialogOpen] = useState(false);
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false); const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true; const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
const effectiveCliStatus = const effectiveCliStatus =
!cliStatus && cliStatusLoading && multimodelEnabled !cliStatus && cliStatusLoading && multimodelEnabled
? createLoadingMultimodelStatus() ? createLoadingMultimodelCliStatus()
: cliStatus; : cliStatus;
useEffect(() => { useEffect(() => {
if (isElectron) { if (isElectron) {
void fetchCliStatus(); if (!cliStatus) {
if (multimodelEnabled) {
void bootstrapCliStatus({ multimodelEnabled: true });
} else {
void fetchCliStatus();
}
}
} }
}, [isElectron, fetchCliStatus]); }, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]);
const handleInstall = useCallback(() => { const handleInstall = useCallback(() => {
installCli(); installCli();
@ -213,6 +229,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}); });
}, []); }, []);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
setManageProviderId(providerId);
setManageDialogOpen(true);
}, []);
const recheckStatus = useCallback(() => { const recheckStatus = useCallback(() => {
void (async () => { void (async () => {
await invalidateCliStatus(); await invalidateCliStatus();
@ -225,18 +246,22 @@ export const CliStatusSection = (): React.JSX.Element | null => {
setIsSwitchingFlavor(true); setIsSwitchingFlavor(true);
try { try {
useStore.setState({ useStore.setState({
cliStatus: enabled ? createLoadingMultimodelStatus() : null, cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
cliStatusLoading: true, cliStatusLoading: true,
cliStatusError: null, cliStatusError: null,
}); });
await updateConfig('general', { multimodelEnabled: enabled }); await updateConfig('general', { multimodelEnabled: enabled });
await invalidateCliStatus(); await invalidateCliStatus();
await fetchCliStatus(); if (enabled) {
await bootstrapCliStatus({ multimodelEnabled: true });
} else {
await fetchCliStatus();
}
} finally { } finally {
setIsSwitchingFlavor(false); setIsSwitchingFlavor(false);
} }
}, },
[fetchCliStatus, invalidateCliStatus, updateConfig] [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
); );
if (!isElectron) return null; if (!isElectron) return null;
@ -250,11 +275,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}` ? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
: (effectiveCliStatus?.displayName ?? 'Claude CLI'); : (effectiveCliStatus?.displayName ?? 'Claude CLI');
const providerTerminalCommand = providerTerminal const activeTerminalProvider = providerTerminal
? providerTerminal.action === 'login' ? (effectiveCliStatus?.providers.find(
? getProviderTerminalCommand(providerTerminal.providerId) (provider) => provider.providerId === providerTerminal.providerId
: getProviderTerminalLogoutCommand(providerTerminal.providerId) ) ?? null)
: null; : null;
const providerTerminalCommand =
providerTerminal && activeTerminalProvider
? providerTerminal.action === 'login'
? getProviderTerminalCommand(activeTerminalProvider)
: getProviderTerminalLogoutCommand(activeTerminalProvider)
: null;
const handleRuntimeBackendChange = useCallback(
async (providerId: CliProviderId, backendId: string) => {
const currentBackends = appConfig?.runtime?.providerBackends ?? {
gemini: 'auto' as const,
codex: 'auto' as const,
};
if (providerId !== 'gemini' && providerId !== 'codex') {
return;
}
await updateConfig('runtime', {
providerBackends: {
...currentBackends,
[providerId]: backendId,
},
});
await fetchCliProviderStatus(providerId);
},
[appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
);
return ( return (
<div className="mb-2"> <div className="mb-2">
@ -381,94 +434,141 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{effectiveCliStatus.providers.map((provider) => ( {effectiveCliStatus.providers.map((provider) => (
<div <div
key={provider.providerId} key={provider.providerId}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2" className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
style={{ style={{
borderColor: 'var(--color-border-subtle)', borderColor: 'var(--color-border-subtle)',
backgroundColor: 'rgba(255, 255, 255, 0.02)', backgroundColor: 'rgba(255, 255, 255, 0.02)',
}} }}
> >
<div className="min-w-0"> {(() => {
<div className="flex items-center gap-2 text-xs"> const providerLoading =
<span cliProviderStatusLoading[provider.providerId] === true;
className="font-medium" const showSkeleton = isProviderCardLoading(provider, providerLoading);
style={{ color: 'var(--color-text-secondary)' }} const runtimeSummary = getProviderRuntimeBackendSummary(provider);
>
{provider.displayName} return (
</span> <>
<span <div className="col-span-2 flex items-start justify-between gap-3">
style={{ <div className="min-w-0 flex-1">
color: provider.authenticated <div className="flex items-center gap-2 text-xs">
? '#4ade80' <span
: 'var(--color-text-muted)', className="font-medium"
}} style={{ color: 'var(--color-text-secondary)' }}
> >
{provider.authenticated {provider.displayName}
? provider.authMethod </span>
? `Authenticated via ${provider.authMethod}` <span
: 'Authenticated' style={{
: provider.statusMessage || 'Not connected'} color: provider.authenticated
</span> ? '#4ade80'
</div> : 'var(--color-text-muted)',
<div }}
className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px]" >
style={{ color: 'var(--color-text-muted)' }} {provider.authenticated
> ? provider.authMethod
{provider.backend?.label && ( ? `Authenticated via ${provider.authMethod}`
<span>Backend: {provider.backend.label}</span> : 'Authenticated'
)} : provider.statusMessage || 'Not connected'}
{provider.models.length === 0 && ( </span>
<span>Models unavailable for this runtime build</span> </div>
)} {showSkeleton ? (
</div> <ProviderDetailSkeleton />
</div> ) : (
<div className="flex shrink-0 items-center gap-2"> <div
{provider.authenticated ? ( className="mt-1 flex min-h-[2.75rem] flex-wrap gap-x-3 gap-y-1 text-[11px]"
<button style={{ color: 'var(--color-text-muted)' }}
type="button" >
onClick={() => void handleProviderLogout(provider.providerId)} {provider.backend?.label && (
disabled={!effectiveCliStatus.binaryPath} <span>Backend: {provider.backend.label}</span>
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50" )}
style={{ {runtimeSummary ? (
borderColor: 'var(--color-border)', <span>Runtime: {runtimeSummary}</span>
color: 'var(--color-text-secondary)', ) : null}
}} {provider.models.length === 0 && (
> <span>Models unavailable for this runtime build</span>
<LogOut className="size-3" /> )}
Logout </div>
</button> )}
) : provider.canLoginFromUi ? ( </div>
<button <div className="flex shrink-0 items-start gap-2">
type="button" <button
onClick={() => type="button"
setProviderTerminal({ onClick={() => handleProviderManage(provider.providerId)}
providerId: provider.providerId, disabled={!effectiveCliStatus.binaryPath}
action: 'login', className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
}) style={{
} borderColor: 'var(--color-border)',
disabled={!effectiveCliStatus.binaryPath || !provider.canLoginFromUi} color: 'var(--color-text-secondary)',
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50" }}
style={{ >
borderColor: 'var(--color-border)', <SlidersHorizontal className="size-3" />
color: 'var(--color-text-secondary)', Manage
}} </button>
> {provider.authenticated && provider.canLoginFromUi ? (
<LogIn className="size-3" /> <button
Login type="button"
</button> onClick={() => void handleProviderLogout(provider.providerId)}
) : null} disabled={!effectiveCliStatus.binaryPath}
</div> className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
{provider.models.length > 0 && ( style={{
<div className="col-span-2"> borderColor: 'var(--color-border)',
<ModelBadges color: 'var(--color-text-secondary)',
providerId={provider.providerId} }}
models={provider.models} >
/> <LogOut className="size-3" />
</div> Logout
)} </button>
) : provider.canLoginFromUi ? (
<button
type="button"
onClick={() =>
setProviderTerminal({
providerId: provider.providerId,
action: 'login',
})
}
disabled={
!effectiveCliStatus.binaryPath || !provider.canLoginFromUi
}
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<LogIn className="size-3" />
Login
</button>
) : null}
</div>
</div>
{!showSkeleton && provider.models.length > 0 && (
<div className="col-span-2">
<ModelBadges
providerId={provider.providerId}
models={provider.models}
/>
</div>
)}
</>
);
})()}
</div> </div>
))} ))}
</div> </div>
)} )}
<ProviderRuntimeSettingsDialog
open={manageDialogOpen}
onOpenChange={setManageDialogOpen}
providers={effectiveCliStatus.providers}
initialProviderId={manageProviderId}
providerStatusLoading={cliProviderStatusLoading}
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
onSelectBackend={(providerId, backendId) => {
void handleRuntimeBackendChange(providerId, backendId);
}}
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
/>
</div> </div>
) : ( ) : (
<div <div

View file

@ -25,6 +25,7 @@ interface ClaudeLogsPanelProps {
ctrl: ClaudeLogsController; ctrl: ClaudeLogsController;
/** Maximum height class for the log viewer (e.g. "max-h-[213px]" for compact). */ /** Maximum height class for the log viewer (e.g. "max-h-[213px]" for compact). */
viewerClassName?: string; viewerClassName?: string;
viewerMaxHeight?: number;
/** Extra className for the panel wrapper. */ /** Extra className for the panel wrapper. */
className?: string; className?: string;
} }
@ -36,6 +37,7 @@ interface ClaudeLogsPanelProps {
export const ClaudeLogsPanel = ({ export const ClaudeLogsPanel = ({
ctrl, ctrl,
viewerClassName, viewerClassName,
viewerMaxHeight,
className, className,
}: ClaudeLogsPanelProps): React.JSX.Element => { }: ClaudeLogsPanelProps): React.JSX.Element => {
const { const {
@ -130,6 +132,7 @@ export const ClaudeLogsPanel = ({
order="newest-first" order="newest-first"
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined} searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
className={cn('p-2', viewerClassName)} className={cn('p-2', viewerClassName)}
style={viewerMaxHeight ? { maxHeight: `${viewerMaxHeight}px` } : undefined}
containerRefCallback={containerRefCallback} containerRefCallback={containerRefCallback}
onScroll={handleScroll} onScroll={handleScroll}
viewerState={viewerState} viewerState={viewerState}

View file

@ -29,6 +29,8 @@ const PREVIEW_ICONS = {
interface ClaudeLogsSectionProps { interface ClaudeLogsSectionProps {
teamName: string; teamName: string;
position?: 'sidebar' | 'inline'; position?: 'sidebar' | 'inline';
sidebarViewerMaxHeight?: number;
onOpenChange?: (isOpen: boolean) => void;
} }
/** /**
@ -70,6 +72,8 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E
export const ClaudeLogsSection = ({ export const ClaudeLogsSection = ({
teamName, teamName,
position = 'inline', position = 'inline',
sidebarViewerMaxHeight,
onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element => { }: ClaudeLogsSectionProps): React.JSX.Element => {
const ctrl = useClaudeLogsController(teamName); const ctrl = useClaudeLogsController(teamName);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
@ -95,7 +99,7 @@ export const ClaudeLogsSection = ({
<> <>
<CollapsibleTeamSection <CollapsibleTeamSection
sectionId="claude-logs" sectionId="claude-logs"
title="Claude logs" title="Logs"
icon={null} icon={null}
badge={ctrl.badge} badge={ctrl.badge}
afterBadge={ afterBadge={
@ -119,9 +123,13 @@ export const ClaudeLogsSection = ({
</Tooltip> </Tooltip>
) : undefined ) : undefined
} }
headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined}
headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined}
headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'} headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'}
headerExtra={sectionHeaderExtra} headerExtra={sectionHeaderExtra}
defaultOpen={false} defaultOpen={false}
onOpenChange={onOpenChange}
contentWrapperClassName={isSidebar ? 'mt-0 pb-0' : undefined}
contentClassName="pt-0 [overflow-anchor:none]" contentClassName="pt-0 [overflow-anchor:none]"
> >
{/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */} {/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */}
@ -131,7 +139,11 @@ export const ClaudeLogsSection = ({
Viewing in fullscreen mode Viewing in fullscreen mode
</div> </div>
) : ( ) : (
<ClaudeLogsPanel ctrl={ctrl} viewerClassName="max-h-[213px]" /> <ClaudeLogsPanel
ctrl={ctrl}
viewerClassName="max-h-[213px]"
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
/>
)} )}
</CollapsibleTeamSection> </CollapsibleTeamSection>

View file

@ -49,6 +49,7 @@ interface CliLogsRichViewProps {
/** Optional local search query override for inline highlighting */ /** Optional local search query override for inline highlighting */
searchQueryOverride?: string; searchQueryOverride?: string;
className?: string; className?: string;
style?: React.CSSProperties;
/** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */ /** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */
footer?: React.ReactNode; footer?: React.ReactNode;
@ -341,6 +342,7 @@ export const CliLogsRichView = ({
containerRefCallback, containerRefCallback,
searchQueryOverride, searchQueryOverride,
className, className,
style,
footer, footer,
viewerState: controlledState, viewerState: controlledState,
onViewerStateChange, onViewerStateChange,
@ -557,6 +559,7 @@ export const CliLogsRichView = ({
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
className className
)} )}
style={style}
onScroll={(e) => handleScrollEvent(e.currentTarget)} onScroll={(e) => handleScrollEvent(e.currentTarget)}
> >
<div className="flex items-center gap-2 p-3"> <div className="flex items-center gap-2 p-3">
@ -582,6 +585,7 @@ export const CliLogsRichView = ({
containerRefCallback?.(el); containerRefCallback?.(el);
}} }}
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)} className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
style={style}
onScroll={(e) => handleScrollEvent(e.currentTarget)} onScroll={(e) => handleScrollEvent(e.currentTarget)}
> >
{visibleEntries.map((entry) => {visibleEntries.map((entry) =>

View file

@ -31,10 +31,14 @@ interface CollapsibleTeamSectionProps {
sectionId?: string; sectionId?: string;
/** Extra classes applied to the content wrapper (e.g. padding). */ /** Extra classes applied to the content wrapper (e.g. padding). */
contentClassName?: string; contentClassName?: string;
/** Extra classes for the outer content wrapper (e.g. remove default top/bottom gaps). */
contentWrapperClassName?: string;
/** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */ /** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */
headerClassName?: string; headerClassName?: string;
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */ /** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
headerContentClassName?: string; headerContentClassName?: string;
/** Extra classes for the clickable header surface itself (e.g. override rounded corners). */
headerSurfaceClassName?: string;
/** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */ /** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */
keepMounted?: boolean; keepMounted?: boolean;
children: React.ReactNode; children: React.ReactNode;
@ -53,8 +57,10 @@ export const CollapsibleTeamSection = ({
action, action,
sectionId, sectionId,
contentClassName, contentClassName,
contentWrapperClassName,
headerClassName, headerClassName,
headerContentClassName, headerContentClassName,
headerSurfaceClassName,
keepMounted, keepMounted,
children, children,
}: CollapsibleTeamSectionProps): React.JSX.Element => { }: CollapsibleTeamSectionProps): React.JSX.Element => {
@ -88,7 +94,13 @@ export const CollapsibleTeamSection = ({
> >
<button <button
type="button" type="button"
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-xl bg-[var(--color-section-bg-open)] hover:bg-[var(--color-section-hover-open)]' : 'rounded-xl bg-[var(--color-section-bg)] hover:bg-[var(--color-section-hover)]'}`} className={cn(
'absolute inset-0 z-0 cursor-pointer transition-colors',
isOpen
? 'rounded-t-xl bg-[var(--color-section-bg-open)] hover:bg-[var(--color-section-hover-open)]'
: 'rounded-xl bg-[var(--color-section-bg)] hover:bg-[var(--color-section-hover)]',
headerSurfaceClassName
)}
onClick={() => onClick={() =>
setOpen((prev) => { setOpen((prev) => {
const next = !prev; const next = !prev;
@ -138,14 +150,24 @@ export const CollapsibleTeamSection = ({
</div> </div>
{keepMounted ? ( {keepMounted ? (
<div <div
className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)} className={cn(
'mt-1.5 min-w-0 overflow-x-clip pb-2',
contentWrapperClassName,
contentClassName
)}
style={isOpen ? undefined : { display: 'none' }} style={isOpen ? undefined : { display: 'none' }}
> >
{children} {children}
</div> </div>
) : ( ) : (
isOpen && ( isOpen && (
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}> <div
className={cn(
'mt-1.5 min-w-0 overflow-x-clip pb-2',
contentWrapperClassName,
contentClassName
)}
>
{children} {children}
</div> </div>
) )

View file

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react';
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
@ -39,6 +39,8 @@ export interface ProvisioningProgressBlockProps {
onCancel?: (() => void) | null; onCancel?: (() => void) | null;
/** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */ /** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */
successMessage?: string | null; successMessage?: string | null;
/** Visual tone for the status banner above the block. */
successMessageSeverity?: 'success' | 'warning';
/** Dismiss handler — renders an X button in the block header top-right */ /** Dismiss handler — renders an X button in the block header top-right */
onDismiss?: (() => void) | null; onDismiss?: (() => void) | null;
/** ISO timestamp when provisioning started */ /** ISO timestamp when provisioning started */
@ -132,6 +134,7 @@ export const ProvisioningProgressBlock = ({
loading = false, loading = false,
onCancel, onCancel,
successMessage, successMessage,
successMessageSeverity = 'success',
onDismiss, onDismiss,
startedAt, startedAt,
pid, pid,
@ -199,8 +202,21 @@ export const ProvisioningProgressBlock = ({
> >
{successMessage ? ( {successMessage ? (
<div className="mb-1.5 flex items-center gap-2"> <div className="mb-1.5 flex items-center gap-2">
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" /> {successMessageSeverity === 'warning' ? (
<p className="flex-1 text-xs text-[var(--step-success-text)]">{successMessage}</p> <AlertTriangle size={14} className="shrink-0 text-amber-400" />
) : (
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
)}
<p
className={cn(
'flex-1 text-xs',
successMessageSeverity === 'warning'
? 'text-amber-400'
: 'text-[var(--step-success-text)]'
)}
>
{successMessage}
</p>
{onDismiss ? ( {onDismiss ? (
<Button <Button
variant="ghost" variant="ghost"

View file

@ -458,8 +458,10 @@ export const TeamDetailView = ({
launchParams, launchParams,
messagesPanelMode, messagesPanelMode,
messagesPanelWidth, messagesPanelWidth,
sidebarLogsHeight,
setMessagesPanelMode, setMessagesPanelMode,
setMessagesPanelWidth, setMessagesPanelWidth,
setSidebarLogsHeight,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
data: s.selectedTeamData, data: s.selectedTeamData,
@ -507,8 +509,10 @@ export const TeamDetailView = ({
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
messagesPanelMode: s.messagesPanelMode, messagesPanelMode: s.messagesPanelMode,
messagesPanelWidth: s.messagesPanelWidth, messagesPanelWidth: s.messagesPanelWidth,
sidebarLogsHeight: s.sidebarLogsHeight,
setMessagesPanelMode: s.setMessagesPanelMode, setMessagesPanelMode: s.setMessagesPanelMode,
setMessagesPanelWidth: s.setMessagesPanelWidth, setMessagesPanelWidth: s.setMessagesPanelWidth,
setSidebarLogsHeight: s.setSidebarLogsHeight,
})) }))
); );
@ -533,6 +537,13 @@ export const TeamDetailView = ({
maxWidth: 600, maxWidth: 600,
side: 'left', side: 'left',
}); });
const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({
height: sidebarLogsHeight,
onHeightChange: setSidebarLogsHeight,
minHeight: 120,
maxHeight: 520,
side: 'top',
});
const toggleMessagesPanelMode = useCallback(() => { const toggleMessagesPanelMode = useCallback(() => {
setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar'); setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar');
@ -554,17 +565,28 @@ export const TeamDetailView = ({
// Fetch initial spawn statuses when provisioning starts // Fetch initial spawn statuses when provisioning starts
useEffect(() => { useEffect(() => {
if (isTeamProvisioning && teamName) { if (teamName && (isTeamProvisioning || memberSpawnStatuses == null)) {
void fetchMemberSpawnStatuses(teamName); void fetchMemberSpawnStatuses(teamName);
} }
}, [isTeamProvisioning, teamName, fetchMemberSpawnStatuses]); }, [isTeamProvisioning, memberSpawnStatuses, teamName, fetchMemberSpawnStatuses]);
// Convert Record<string, MemberSpawnStatusEntry> → Map<string, MemberSpawnEntry> // Convert Record<string, MemberSpawnStatusEntry> → Map<string, MemberSpawnEntry>
const memberSpawnStatusMap = useMemo(() => { const memberSpawnStatusMap = useMemo(() => {
if (!memberSpawnStatuses) return undefined; if (!memberSpawnStatuses) return undefined;
const map = new Map<string, { status: MemberSpawnStatusEntry['status']; error?: string }>(); const map = new Map<
string,
{
status: MemberSpawnStatusEntry['status'];
error?: string;
livenessSource?: MemberSpawnStatusEntry['livenessSource'];
}
>();
for (const [name, entry] of Object.entries(memberSpawnStatuses)) { for (const [name, entry] of Object.entries(memberSpawnStatuses)) {
map.set(name, { status: entry.status, error: entry.error }); map.set(name, {
status: entry.status,
error: entry.error,
livenessSource: entry.livenessSource,
});
} }
return map.size > 0 ? map : undefined; return map.size > 0 ? map : undefined;
}, [memberSpawnStatuses]); }, [memberSpawnStatuses]);
@ -632,6 +654,11 @@ export const TeamDetailView = ({
} }
}, [kanbanFilterQuery, clearKanbanFilter]); }, [kanbanFilterQuery, clearKanbanFilter]);
const currentTeamSummary = useMemo(
() => teams.find((team) => team.teamName === teamName) ?? null,
[teams, teamName]
);
// Load sessions for the team's project // Load sessions for the team's project
const projectId = useMemo( const projectId = useMemo(
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
@ -1303,6 +1330,9 @@ export const TeamDetailView = ({
messagesPanelProps={sharedMessagesPanelProps} messagesPanelProps={sharedMessagesPanelProps}
isResizing={isMessagesPanelResizing} isResizing={isMessagesPanelResizing}
onResizeMouseDown={messagesPanelHandleProps.onMouseDown} onResizeMouseDown={messagesPanelHandleProps.onMouseDown}
logsHeight={sidebarLogsHeight}
isLogsResizing={isLogsPanelResizing}
onLogsResizeMouseDown={logsPanelHandleProps.onMouseDown}
/> />
</TeamSidebarPortalSource> </TeamSidebarPortalSource>
</TeamSidebarHost> </TeamSidebarHost>
@ -1382,27 +1412,6 @@ export const TeamDetailView = ({
Launching... Launching...
</span> </span>
)} )}
{data.isAlive &&
launchParams?.model &&
(() => {
const MODEL_LABELS: Record<string, string> = {
default: 'Default',
opus: 'Opus 4.6',
sonnet: 'Sonnet 4.6',
haiku: 'Haiku 4.5',
};
const modelLabel = MODEL_LABELS[launchParams.model] ?? launchParams.model;
const effortLabel = launchParams.effort
? launchParams.effort.charAt(0).toUpperCase() + launchParams.effort.slice(1)
: '';
const limitLabel = launchParams.limitContext ? '200K' : '';
const parts = [modelLabel, effortLabel, limitLabel].filter(Boolean).join(' ');
return (
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-secondary)]">
{parts}
</span>
);
})()}
</div> </div>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
@ -1542,7 +1551,11 @@ export const TeamDetailView = ({
> >
<span className="flex items-center gap-1.5 text-xs"> <span className="flex items-center gap-1.5 text-xs">
<AlertTriangle size={14} className="shrink-0" /> <AlertTriangle size={14} className="shrink-0" />
Team is offline {currentTeamSummary?.partialLaunchFailure
? currentTeamSummary.missingMembers?.length
? `Last launch failed partway — ${currentTeamSummary.missingMembers.length}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMembers.length} teammates did not join`
: 'Last launch failed partway'
: 'Team is offline'}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"

View file

@ -64,7 +64,7 @@ function generateUniqueName(sourceName: string, existingNames: string[]): string
} }
} }
type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline'; type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline' | 'partial_failure';
function getRecentProjects(team: TeamSummary): string[] { function getRecentProjects(team: TeamSummary): string[] {
const history = team.projectPathHistory; const history = team.projectPathHistory;
@ -157,6 +157,7 @@ function renderTeamRecentPaths(
} }
function resolveTeamStatus( function resolveTeamStatus(
team: TeamSummary,
teamName: string, teamName: string,
aliveTeams: string[], aliveTeams: string[],
currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>, currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>,
@ -173,6 +174,9 @@ function resolveTeamStatus(
) { ) {
return 'provisioning'; return 'provisioning';
} }
if (team.partialLaunchFailure) {
return 'partial_failure';
}
return 'offline'; return 'offline';
} }
@ -206,6 +210,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
Offline Offline
</span> </span>
); );
case 'partial_failure':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-400">
<span className="size-1.5 rounded-full bg-amber-400" />
Launch failed partway
</span>
);
} }
}; };
@ -364,12 +375,13 @@ export const TeamListView = (): React.JSX.Element => {
if (filter.selectedStatuses.size > 0) { if (filter.selectedStatuses.size > 0) {
result = result.filter((t) => { result = result.filter((t) => {
const status = resolveTeamStatus( const status = resolveTeamStatus(
t,
t.teamName, t.teamName,
aliveTeams, aliveTeams,
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName), getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
leadActivityByTeam leadActivityByTeam
); );
const isRunning = status !== 'offline'; const isRunning = status !== 'offline' && status !== 'partial_failure';
if (filter.selectedStatuses.has('running') && isRunning) return true; if (filter.selectedStatuses.has('running') && isRunning) return true;
if (filter.selectedStatuses.has('offline') && !isRunning) return true; if (filter.selectedStatuses.has('offline') && !isRunning) return true;
return false; return false;
@ -780,6 +792,7 @@ export const TeamListView = (): React.JSX.Element => {
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{activeFiltered.map((team) => { {activeFiltered.map((team) => {
const status = resolveTeamStatus( const status = resolveTeamStatus(
team,
team.teamName, team.teamName,
aliveTeams, aliveTeams,
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
@ -837,24 +850,27 @@ export const TeamListView = (): React.JSX.Element => {
})()} })()}
</div> </div>
<div className="flex shrink-0 gap-1"> <div className="flex shrink-0 gap-1">
{status === 'offline' && team.projectPath && ( {(status === 'offline' || status === 'partial_failure') &&
<Tooltip> team.projectPath && (
<TooltipTrigger asChild> <Tooltip>
<button <TooltipTrigger asChild>
type="button" <button
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100" type="button"
onClick={(e) => handleLaunchTeam(team.teamName, team.projectPath, e)} className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
disabled={launchingTeamName === team.teamName} onClick={(e) =>
aria-label="Launch team" handleLaunchTeam(team.teamName, team.projectPath, e)
> }
<Play size={14} fill="currentColor" /> disabled={launchingTeamName === team.teamName}
</button> aria-label="Launch team"
</TooltipTrigger> >
<TooltipContent side="bottom"> <Play size={14} fill="currentColor" />
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'} </button>
</TooltipContent> </TooltipTrigger>
</Tooltip> <TooltipContent side="bottom">
)} {launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
</TooltipContent>
</Tooltip>
)}
{(status === 'active' || status === 'idle') && ( {(status === 'active' || status === 'idle') && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -908,6 +924,13 @@ export const TeamListView = (): React.JSX.Element => {
{team.description || 'No description'} {team.description || 'No description'}
</p> </p>
</div> </div>
{team.partialLaunchFailure ? (
<p className="mt-2 text-[11px] text-amber-400">
{team.missingMembers?.length
? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
: 'Last launch stopped before all teammates joined.'}
</p>
) : null}
<div className="mt-3 flex flex-wrap items-center gap-1.5"> <div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? ( {team.members && team.members.length > 0 ? (
renderMemberChips(team.members, isLight) renderMemberChips(team.members, isLight)

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice'; import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
import { isLeadMember } from '@shared/utils/leadDetection';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -16,11 +17,12 @@ interface TeamProvisioningBannerProps {
export const TeamProvisioningBanner = ({ export const TeamProvisioningBanner = ({
teamName, teamName,
}: TeamProvisioningBannerProps): React.JSX.Element | null => { }: TeamProvisioningBannerProps): React.JSX.Element | null => {
const { progress, cancelProvisioning, teamMembers } = useStore( const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses } = useStore(
useShallow((s) => ({ useShallow((s) => ({
progress: getCurrentProvisioningProgressForTeam(s, teamName), progress: getCurrentProvisioningProgressForTeam(s, teamName),
cancelProvisioning: s.cancelProvisioning, cancelProvisioning: s.cancelProvisioning,
teamMembers: s.selectedTeamData?.members, teamMembers: s.selectedTeamData?.members,
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
})) }))
); );
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
@ -102,15 +104,37 @@ export const TeamProvisioningBanner = ({
); );
} }
const allTeammatesOnline = const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member));
teamMembers != null && const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter(
teamMembers.length > 0 && ([, entry]) => entry.status === 'error'
teamMembers.every((m) => m.status === 'active' || m.status === 'idle'); );
const failedSpawnCount = failedSpawnEntries.length;
const heartbeatConfirmedCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.status === 'online' && entry.livenessSource === 'heartbeat';
}).length;
const processOnlyAliveCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.status === 'online' && entry.livenessSource === 'process';
}).length;
const awaitingHeartbeatCount = teammates.filter((member) => {
const entry = memberSpawnStatuses?.[member.name];
return entry?.status === 'waiting';
}).length;
const allTeammatesConfirmedAlive =
teammates.length > 0 && failedSpawnCount === 0 && heartbeatConfirmedCount === teammates.length;
if (isReady) { if (isReady) {
const readyMessage = allTeammatesOnline const readyMessage =
? `Team launched — all ${teamMembers.length} teammates online` failedSpawnCount > 0
: 'Team launched — teammates may still be starting'; ? `Launch finished with errors — ${failedSpawnCount}/${Math.max(teammates.length, failedSpawnCount)} teammates failed to start`
: teammates.length === 0
? 'Team launched — lead online'
: allTeammatesConfirmedAlive
? `Team launched — all ${teammates.length} teammates confirmed alive`
: processOnlyAliveCount > 0 || awaitingHeartbeatCount > 0
? `Team launched — ${heartbeatConfirmedCount}/${teammates.length} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${awaitingHeartbeatCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${awaitingHeartbeatCount} awaiting first heartbeat` : ''}`
: 'Team launched — teammate liveness is still being confirmed';
return ( return (
<div className="mb-3"> <div className="mb-3">
@ -127,6 +151,7 @@ export const TeamProvisioningBanner = ({
defaultLiveOutputOpen={false} defaultLiveOutputOpen={false}
onCancel={null} onCancel={null}
successMessage={readyMessage} successMessage={readyMessage}
successMessageSeverity={failedSpawnCount > 0 ? 'warning' : 'success'}
onDismiss={() => setDismissed(true)} onDismiss={() => setDismissed(true)}
/> />
</div> </div>

View file

@ -23,6 +23,12 @@ import {
parseMessageReply, parseMessageReply,
parseStructuredAgentMessage, parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting'; } from '@renderer/utils/agentMessageFormatting';
import {
getBootstrapAcknowledgementDisplay,
getBootstrapPromptDisplay,
getSanitizedInboxMessageSummary,
getSanitizedInboxMessageText,
} from '@renderer/utils/bootstrapPromptSanitizer';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { import {
@ -291,6 +297,80 @@ const NoiseRow = ({
</div> </div>
); );
const BootstrapSystemRow = ({
senderName,
recipientName,
runtime,
senderColor,
recipientColor,
timestamp,
onMemberNameClick,
}: {
senderName: string;
recipientName: string;
runtime?: string;
senderColor?: string;
recipientColor?: string;
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
}): React.JSX.Element => (
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.82 }}>
<span className="bg-sky-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-sky-300">
start
</span>
<MemberBadge name={senderName} color={senderColor} hideAvatar onClick={onMemberNameClick} />
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
hideAvatar
onClick={onMemberNameClick}
/>
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{runtime || 'Starting teammate'}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
const BootstrapAcknowledgementRow = ({
senderName,
recipientName,
senderColor,
recipientColor,
timestamp,
onMemberNameClick,
}: {
senderName: string;
recipientName: string;
senderColor?: string;
recipientColor?: string;
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
}): React.JSX.Element => (
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.72 }}>
<span className="bg-emerald-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-300">
bootstrap
</span>
<MemberBadge name={senderName} color={senderColor} hideAvatar onClick={onMemberNameClick} />
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
hideAvatar
onClick={onMemberNameClick}
/>
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_ICON_MUTED }}>
Bootstrap acknowledged
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Detect historical system/automated messages that should be collapsed by default. // Detect historical system/automated messages that should be collapsed by default.
// These patterns are kept only for legacy compatibility with old inbox/session rows; // These patterns are kept only for legacy compatibility with old inbox/session rows;
@ -452,6 +532,11 @@ export const ActivityItem = memo(
}, [message.timestamp]); }, [message.timestamp]);
const structured = parseStructuredAgentMessage(message.text); const structured = parseStructuredAgentMessage(message.text);
const bootstrapDisplay = useMemo(() => getBootstrapPromptDisplay(message), [message]);
const bootstrapAcknowledgement = useMemo(
() => getBootstrapAcknowledgementDisplay(message),
[message]
);
// Only flag agent messages as rate-limited, not user's own quotes // Only flag agent messages as rate-limited, not user's own quotes
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text); const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
// Highlight messages containing API errors // Highlight messages containing API errors
@ -510,7 +595,10 @@ export const ActivityItem = memo(
// Strip agent-only blocks + normalize escape sequences (before linkification) // Strip agent-only blocks + normalize escape sequences (before linkification)
const strippedText = useMemo(() => { const strippedText = useMemo(() => {
if (structured) return null; if (structured) return null;
let stripped = stripAgentBlocks(message.text).trim(); let stripped = getSanitizedInboxMessageText(message).trim();
if (!bootstrapDisplay) {
stripped = stripAgentBlocks(stripped).trim();
}
if (!stripped) return null; // All content was agent-only blocks → show summary instead if (!stripped) return null; // All content was agent-only blocks → show summary instead
// Strip cross-team metadata tag (e.g. `<cross-team from="team.lead" depth="0" />\n`) // Strip cross-team metadata tag (e.g. `<cross-team from="team.lead" depth="0" />\n`)
// — kept in stored text for CLI agents / durable artifacts. // — kept in stored text for CLI agents / durable artifacts.
@ -519,7 +607,7 @@ export const ActivityItem = memo(
} }
// Normalize literal \n from historical CLI-produced text to real newlines // Normalize literal \n from historical CLI-produced text to real newlines
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
}, [structured, message.text, isCrossTeamAny]); }, [structured, message, bootstrapDisplay, isCrossTeamAny]);
const standaloneSlashCommand = useMemo( const standaloneSlashCommand = useMemo(
() => (strippedText ? parseStandaloneSlashCommand(strippedText) : null), () => (strippedText ? parseStandaloneSlashCommand(strippedText) : null),
[strippedText] [strippedText]
@ -580,10 +668,12 @@ export const ActivityItem = memo(
} }
if (crossTeamPreview) return crossTeamPreview; if (crossTeamPreview) return crossTeamPreview;
const s = const s =
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || ''; getSanitizedInboxMessageSummary(message) ||
(structured ? getStructuredMessageSummary(structured) : '') ||
'';
if (s) return s; if (s) return s;
// Fallback: use the beginning of message text as preview for plain-text messages // Fallback: use the beginning of message text as preview for plain-text messages
const plain = stripAgentBlocks(message.text).trim(); const plain = getSanitizedInboxMessageText(message).trim();
if (!plain) return ''; if (!plain) return '';
const oneLine = plain.replace(/\n+/g, ' '); const oneLine = plain.replace(/\n+/g, ' ');
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine; return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
@ -592,10 +682,8 @@ export const ActivityItem = memo(
isSlashCommandMessage, isSlashCommandMessage,
isSlashCommandResult, isSlashCommandResult,
message.commandOutput, message.commandOutput,
message.summary, message,
message.text,
slashCommandMeta, slashCommandMeta,
standaloneSlashCommand,
structured, structured,
]); ]);
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
@ -632,6 +720,33 @@ export const ActivityItem = memo(
); );
} }
if (bootstrapDisplay) {
return (
<BootstrapSystemRow
senderName={senderName}
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
runtime={bootstrapDisplay.runtime}
senderColor={senderColor}
recipientColor={recipientColor}
timestamp={timestamp}
onMemberNameClick={onMemberNameClick}
/>
);
}
if (bootstrapAcknowledgement) {
return (
<BootstrapAcknowledgementRow
senderName={senderName}
recipientName={message.to ?? 'lead'}
senderColor={senderColor}
recipientColor={recipientColor}
timestamp={timestamp}
onMemberNameClick={onMemberNameClick}
/>
);
}
const messageType = const messageType =
structured && typeof structured.type === 'string' structured && typeof structured.type === 'string'
? getMessageTypeLabel(structured.type) ? getMessageTypeLabel(structured.type)
@ -642,18 +757,10 @@ export const ActivityItem = memo(
const subject = message.summary || autoSummary || `Task from ${message.from}`; const subject = message.summary || autoSummary || `Task from ${message.from}`;
const plainText = structured const plainText = structured
? JSON.stringify(structured, null, 2) ? JSON.stringify(structured, null, 2)
: stripAgentBlocks(message.text); : getSanitizedInboxMessageText(message);
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000); const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
onCreateTask?.(subject, description); onCreateTask?.(subject, description);
}, [ }, [autoSummary, message.from, message.summary, message, onCreateTask, structured, timestamp]);
autoSummary,
message.from,
message.summary,
message.text,
onCreateTask,
structured,
timestamp,
]);
const isHeaderClickable = isManaged && canToggleCollapse; const isHeaderClickable = isManaged && canToggleCollapse;
const showChevron = isHeaderClickable && !compactHeader; const showChevron = isHeaderClickable && !compactHeader;

View file

@ -37,6 +37,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
import { AdvancedCliSection } from './AdvancedCliSection'; import { AdvancedCliSection } from './AdvancedCliSection';
@ -44,6 +45,7 @@ import { OptionalSettingsSection } from './OptionalSettingsSection';
import { import {
createInitialProviderChecks, createInitialProviderChecks,
failIncompleteProviderChecks, failIncompleteProviderChecks,
getProvisioningProviderBackendSummary,
getProvisioningFailureHint, getProvisioningFailureHint,
ProvisioningProviderStatusList, ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList,
@ -270,6 +272,9 @@ export const CreateTeamDialog = ({
}: CreateTeamDialogProps): React.JSX.Element => { }: CreateTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
// ── Persisted draft state (survives tab navigation) ────────────────── // ── Persisted draft state (survives tab navigation) ──────────────────
const { const {
@ -460,12 +465,25 @@ export const CreateTeamDialog = ({
new Set([ new Set([
selectedProviderId, selectedProviderId,
...members.flatMap((member) => ...members.flatMap((member) =>
member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : [] isTeamProviderId(member.providerId) ? [member.providerId] : []
), ),
]) ])
); );
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]); }, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: Array<readonly [TeamProviderId, string | null]> = (
cliStatus?.providers ?? []
).map(
(provider) =>
[
provider.providerId as TeamProviderId,
getProvisioningProviderBackendSummary(provider),
] as const
);
return new Map<TeamProviderId, string | null>(entries);
}, [cliStatus?.providers]);
useEffect(() => { useEffect(() => {
if (multimodelEnabled) { if (multimodelEnabled) {
return; return;
@ -481,6 +499,13 @@ export const CreateTeamDialog = ({
} }
}, [members, multimodelEnabled, selectedProviderId, setMembers]); }, [members, multimodelEnabled, selectedProviderId, setMembers]);
useEffect(() => {
if (!open || cliStatus || cliStatusLoading) {
return;
}
void fetchCliStatus();
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
useEffect(() => { useEffect(() => {
if (!open || !canCreate || !launchTeam) { if (!open || !canCreate || !launchTeam) {
return; return;
@ -523,6 +548,7 @@ export const CreateTeamDialog = ({
for (const providerId of selectedMemberProviders) { for (const providerId of selectedMemberProviders) {
checks = updateProviderCheck(checks, providerId, { checks = updateProviderCheck(checks, providerId, {
status: 'checking', status: 'checking',
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
details: [], details: [],
}); });
if (!cancelled && prepareRequestSeqRef.current === requestSeq) { if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
@ -552,6 +578,7 @@ export const CreateTeamDialog = ({
} }
checks = updateProviderCheck(checks, providerId, { checks = updateProviderCheck(checks, providerId, {
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
details: detailLines, details: detailLines,
}); });
if (!cancelled && prepareRequestSeqRef.current === requestSeq) { if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
@ -584,7 +611,15 @@ export const CreateTeamDialog = ({
cancelled = true; cancelled = true;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]); }, [
open,
canCreate,
launchTeam,
effectiveCwd,
selectedProviderId,
selectedMemberProviders,
runtimeBackendSummaryByProvider,
]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
@ -660,12 +695,8 @@ export const CreateTeamDialog = ({
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
customRole: isCustom ? m.role : '', customRole: isCustom ? m.role : '',
workflow: m.workflow, workflow: m.workflow,
providerId: normalizeProviderForMode(m.providerId, multimodelEnabled), providerId: normalizeOptionalTeamProviderId(m.providerId),
model: model: m.model ?? '',
normalizeProviderForMode(m.providerId, multimodelEnabled) ===
normalizeProviderForMode(m.providerId, true)
? (m.model ?? '')
: '',
effort: m.effort, effort: m.effort,
}), }),
multimodelEnabled multimodelEnabled
@ -777,9 +808,10 @@ export const CreateTeamDialog = ({
); );
const sanitizedTeamName = sanitizeTeamName(teamName.trim()); const sanitizedTeamName = sanitizeTeamName(teamName.trim());
const teamNameInlineError = validateTeamNameInline(teamName);
const isNameTakenByExistingTeam = existingTeamNames.includes(sanitizedTeamName);
const isNameProvisioning = const isNameProvisioning =
provisioningTeamNames.includes(sanitizedTeamName) && provisioningTeamNames.includes(sanitizedTeamName) && !isNameTakenByExistingTeam;
!existingTeamNames.includes(sanitizedTeamName);
const request = useMemo<TeamCreateRequest>( const request = useMemo<TeamCreateRequest>(
() => ({ () => ({
@ -815,6 +847,15 @@ export const CreateTeamDialog = ({
customArgs, customArgs,
] ]
); );
const requestValidation = useMemo(
() => validateRequest(request, { requireCwd: launchTeam }),
[request, launchTeam]
);
const hasCreateFormErrors =
!!teamNameInlineError ||
isNameTakenByExistingTeam ||
isNameProvisioning ||
!requestValidation.valid;
const internalArgs = useMemo(() => { const internalArgs = useMemo(() => {
const args: string[] = []; const args: string[] = [];
@ -1055,20 +1096,24 @@ export const CreateTeamDialog = ({
id="team-name" id="team-name"
className={cn( className={cn(
'h-8 text-xs', 'h-8 text-xs',
(fieldErrors.teamName || allTakenTeamNames.includes(sanitizedTeamName)) && (fieldErrors.teamName || teamNameInlineError || isNameTakenByExistingTeam) &&
'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]' 'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]'
)} )}
value={teamName} value={teamName}
onChange={(event) => handleTeamNameChange(event.target.value)} onChange={(event) => handleTeamNameChange(event.target.value)}
placeholder={suggestedTeamName} placeholder={suggestedTeamName}
/> />
{allTakenTeamNames.includes(sanitizedTeamName) ? ( {isNameTakenByExistingTeam ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}> <p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{isNameProvisioning ? 'Team is currently launching' : 'Team name already exists'} Team name already exists
</p> </p>
) : validateTeamNameInline(teamName) ? ( ) : teamNameInlineError ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}> <p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
{validateTeamNameInline(teamName)} {teamNameInlineError}
</p>
) : isNameProvisioning ? (
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
A team with this name is currently launching
</p> </p>
) : fieldErrors.teamName ? ( ) : fieldErrors.teamName ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}> <p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
@ -1391,7 +1436,7 @@ export const CreateTeamDialog = ({
</Button> </Button>
<Button <Button
size="sm" size="sm"
disabled={!canCreate || !draftLoaded || isSubmitting} disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
onClick={handleSubmit} onClick={handleSubmit}
> >
{isSubmitting ? ( {isSubmitting ? (

View file

@ -36,6 +36,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions'; import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { import {
getCurrentProvisioningProgressForTeam, getCurrentProvisioningProgressForTeam,
isTeamProvisioningActive, isTeamProvisioningActive,
@ -61,6 +62,7 @@ import { OptionalSettingsSection } from './OptionalSettingsSection';
import { import {
createInitialProviderChecks, createInitialProviderChecks,
failIncompleteProviderChecks, failIncompleteProviderChecks,
getProvisioningProviderBackendSummary,
getProvisioningFailureHint, getProvisioningFailureHint,
ProvisioningProviderStatusList, ProvisioningProviderStatusList,
shouldHideProvisioningProviderStatusList, shouldHideProvisioningProviderStatusList,
@ -68,7 +70,11 @@ import {
type ProvisioningProviderCheck, type ProvisioningProviderCheck,
} from './ProvisioningProviderStatusList'; } from './ProvisioningProviderStatusList';
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector'; import {
computeEffectiveTeamModel,
formatTeamModelSummary,
TeamModelSelector,
} from './TeamModelSelector';
import type { ActiveTeamRef } from './CreateTeamDialog'; import type { ActiveTeamRef } from './CreateTeamDialog';
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
@ -155,6 +161,32 @@ function getProviderLabel(providerId: TeamProviderId): string {
} }
} }
function resolveMemberDraftRuntime(
member: Pick<MemberDraft, 'providerId' | 'model' | 'effort'>,
inheritedProviderId: TeamProviderId,
inheritedModel: string,
inheritedEffort: EffortLevel | undefined
): { providerId: TeamProviderId; model: string; effort: EffortLevel | undefined } {
return {
providerId: member.providerId ?? inheritedProviderId,
model: member.model?.trim() || inheritedModel,
effort: member.effort ?? inheritedEffort,
};
}
function resolveResolvedMemberRuntime(
member: Pick<ResolvedTeamMember, 'providerId' | 'model' | 'effort'>,
inheritedProviderId: TeamProviderId,
inheritedModel: string,
inheritedEffort: EffortLevel | undefined
): { providerId: TeamProviderId; model: string; effort: EffortLevel | undefined } {
return {
providerId: normalizeOptionalTeamProviderId(member.providerId) ?? inheritedProviderId,
model: member.model?.trim() || inheritedModel,
effort: member.effort ?? inheritedEffort,
};
}
// ============================================================================= // =============================================================================
// Component // Component
// ============================================================================= // =============================================================================
@ -163,6 +195,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const { open, onClose } = props; const { open, onClose } = props;
const { isLight } = useTheme(); const { isLight } = useTheme();
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true); const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const isLaunch = props.mode === 'launch'; const isLaunch = props.mode === 'launch';
const isSchedule = props.mode === 'schedule'; const isSchedule = props.mode === 'schedule';
const schedule = isSchedule ? (props.schedule ?? null) : null; const schedule = isSchedule ? (props.schedule ?? null) : null;
@ -239,7 +274,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]); const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
const prepareRequestSeqRef = useRef(0); const prepareRequestSeqRef = useRef(0);
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []); const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
const previousLaunchParams = useStore((s) =>
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
);
const members = isLaunch ? props.members : storeMembers; const members = isLaunch ? props.members : storeMembers;
const [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
// Advanced CLI section state (with localStorage persistence) // Advanced CLI section state (with localStorage persistence)
const [worktreeEnabled, setWorktreeEnabledRaw] = useState( const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
@ -277,15 +316,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
new Set([ new Set([
selectedProviderId, selectedProviderId,
...effectiveMemberDrafts.flatMap((member) => ...effectiveMemberDrafts.flatMap((member) =>
member.providerId === 'codex' || member.providerId === 'gemini' isTeamProviderId(member.providerId) ? [member.providerId] : []
? [member.providerId]
: []
), ),
]) ])
), ),
[effectiveMemberDrafts, multimodelEnabled, selectedProviderId] [effectiveMemberDrafts, multimodelEnabled, selectedProviderId]
); );
const runtimeBackendSummaryByProvider = useMemo(() => {
const entries: Array<readonly [TeamProviderId, string | null]> = (
cliStatus?.providers ?? []
).map(
(provider) =>
[
provider.providerId as TeamProviderId,
getProvisioningProviderBackendSummary(provider),
] as const
);
return new Map<TeamProviderId, string | null>(entries);
}, [cliStatus?.providers]);
useEffect(() => { useEffect(() => {
if (multimodelEnabled) { if (multimodelEnabled) {
return; return;
@ -305,6 +355,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}); });
}, [multimodelEnabled, selectedProviderId]); }, [multimodelEnabled, selectedProviderId]);
useEffect(() => {
if (!open || cliStatus || cliStatusLoading) {
return;
}
void fetchCliStatus();
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
// Schedule store actions // Schedule store actions
const createSchedule = useStore((s) => s.createSchedule); const createSchedule = useStore((s) => s.createSchedule);
const updateSchedule = useStore((s) => s.updateSchedule); const updateSchedule = useStore((s) => s.updateSchedule);
@ -494,6 +551,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
? savedRequest.members ? savedRequest.members
: []; : [];
const storedEffort = localStorage.getItem('team:lastSelectedEffort'); const storedEffort = localStorage.getItem('team:lastSelectedEffort');
const savedProviderId =
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
? savedRequest.providerId
: savedRequest?.providerId === 'anthropic'
? 'anthropic'
: null;
setSavedLaunchProviderId(savedProviderId);
setMembersDrafts( setMembersDrafts(
createMemberDraftsFromInputs(nextMembersSource).map((member) => createMemberDraftsFromInputs(nextMembersSource).map((member) =>
@ -536,6 +600,177 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}; };
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]); }, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]);
const previousProviderId = useMemo<TeamProviderId | null>(() => {
if (!isLaunch) {
return null;
}
const fromLaunchParams = previousLaunchParams?.providerId;
if (
fromLaunchParams === 'anthropic' ||
fromLaunchParams === 'codex' ||
fromLaunchParams === 'gemini'
) {
return fromLaunchParams;
}
return savedLaunchProviderId;
}, [isLaunch, previousLaunchParams?.providerId, savedLaunchProviderId]);
const providerChangeForcesFreshLeadContext = useMemo(() => {
if (!isLaunch || !previousProviderId) {
return false;
}
return previousProviderId !== selectedProviderId;
}, [isLaunch, previousProviderId, selectedProviderId]);
const effectiveLeadRuntimeModel = useMemo(
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
[selectedModel, limitContext, selectedProviderId]
);
const runtimeChangeNotes = useMemo(() => {
if (!isLaunch) {
return [] as Array<{ key: string; memberName: string; message: string }>;
}
const notes: Array<{ key: string; memberName: string; message: string }> = [];
const previousLeadModel = previousLaunchParams?.model?.trim() || '';
const previousLeadEffort = previousLaunchParams?.effort;
const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel;
if (
previousProviderId &&
(previousProviderId !== selectedProviderId ||
previousLeadModel !== currentLeadDisplayModel ||
(previousLeadEffort ?? '') !== ((selectedEffort as EffortLevel | '') || ''))
) {
notes.push({
key: 'lead',
memberName: 'lead',
message: `${formatTeamModelSummary(
selectedProviderId,
currentLeadDisplayModel,
(selectedEffort as EffortLevel) || undefined
)} instead of ${formatTeamModelSummary(
previousProviderId,
previousLeadModel,
previousLeadEffort
)}`,
});
}
const previousMembersByName = new Map(
members.map((member) => [member.name.trim().toLowerCase(), member] as const)
);
for (const member of effectiveMemberDrafts) {
if (member.removedAt) {
continue;
}
const name = member.name.trim();
if (!name) {
continue;
}
const previousMember = previousMembersByName.get(name.toLowerCase());
if (!previousMember) {
continue;
}
const {
providerId: currentProviderId,
model: currentModel,
effort: currentEffort,
} = resolveMemberDraftRuntime(
member,
selectedProviderId,
currentLeadDisplayModel,
(selectedEffort as EffortLevel) || undefined
);
const {
providerId: previousProvider,
model: previousModel,
effort: previousEffort,
} = resolveResolvedMemberRuntime(
previousMember,
previousProviderId ?? 'anthropic',
previousLeadModel,
previousLeadEffort
);
if (
previousProvider === currentProviderId &&
previousModel === currentModel &&
(previousEffort ?? '') === (currentEffort ?? '')
) {
continue;
}
notes.push({
key: `member:${name.toLowerCase()}`,
memberName: name,
message: `${formatTeamModelSummary(
currentProviderId,
currentModel,
currentEffort
)} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`,
});
}
return notes;
}, [
isLaunch,
previousLaunchParams?.effort,
previousLaunchParams?.model,
previousProviderId,
selectedProviderId,
selectedModel,
effectiveLeadRuntimeModel,
selectedEffort,
members,
effectiveMemberDrafts,
]);
const runtimeChangeNoteByKey = useMemo(
() => new Map(runtimeChangeNotes.map((note) => [note.key, note.message] as const)),
[runtimeChangeNotes]
);
const leadRuntimeWarningText = useMemo(() => {
const parts: string[] = [];
if (providerChangeForcesFreshLeadContext && previousProviderId) {
parts.push(
`Provider changed from ${getProviderLabel(previousProviderId)} to ${getProviderLabel(selectedProviderId)}. The previous lead session will not be resumed and lead will start with a fresh context.`
);
}
const runtimeChange = runtimeChangeNoteByKey.get('lead');
if (runtimeChange) {
parts.push(`Next launch will use ${runtimeChange}.`);
}
return parts.length > 0 ? parts.join(' ') : null;
}, [
providerChangeForcesFreshLeadContext,
previousProviderId,
selectedProviderId,
runtimeChangeNoteByKey,
]);
const memberRuntimeWarningById = useMemo(() => {
const warnings: Record<string, string> = {};
for (const member of effectiveMemberDrafts) {
const name = member.name.trim();
if (!name || member.removedAt) {
continue;
}
const note = runtimeChangeNoteByKey.get(`member:${name.toLowerCase()}`);
if (note) {
warnings[member.id] = `Next launch will use ${note}.`;
}
}
return warnings;
}, [effectiveMemberDrafts, runtimeChangeNoteByKey]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Launch-only effects // Launch-only effects
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -588,6 +823,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
for (const providerId of selectedMemberProviders) { for (const providerId of selectedMemberProviders) {
checks = updateProviderCheck(checks, providerId, { checks = updateProviderCheck(checks, providerId, {
status: 'checking', status: 'checking',
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
details: [], details: [],
}); });
if (!cancelled && prepareRequestSeqRef.current === requestSeq) { if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
@ -615,6 +851,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} }
checks = updateProviderCheck(checks, providerId, { checks = updateProviderCheck(checks, providerId, {
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready', status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
details: detailLines, details: detailLines,
}); });
if (!cancelled && prepareRequestSeqRef.current === requestSeq) { if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
@ -645,7 +882,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]); }, [
open,
isLaunch,
effectiveCwd,
selectedProviderId,
selectedMemberProviders,
runtimeBackendSummaryByProvider,
]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared effects: projects // Shared effects: projects
@ -819,6 +1063,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} }
return errors; return errors;
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); }, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]);
const hasInvalidLaunchMemberNames = useMemo(
() =>
isLaunch &&
membersDrafts.some(
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
),
[isLaunch, membersDrafts]
);
const hasDuplicateLaunchMemberNames = useMemo(() => {
if (!isLaunch) return false;
const activeNames = membersDrafts
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
return new Set(activeNames).size !== activeNames.length;
}, [isLaunch, membersDrafts]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Error // Error
@ -927,10 +1186,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
onClose(); onClose();
} }
} catch (err) { } catch (err) {
if (isSchedule) { const message =
setLocalError(err instanceof Error ? err.message : 'Failed to save schedule'); err instanceof Error
? err.message
: isSchedule
? 'Failed to save schedule'
: 'Failed to launch team';
setLocalError(message);
if (isLaunch) {
console.error('Failed to launch team from dialog:', err);
} }
// launch errors shown via provisioningError prop
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -942,7 +1207,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const isDisabled = isLaunch const isDisabled = isLaunch
? isSubmitting || launchInFlight ? isSubmitting ||
launchInFlight ||
validationErrors.length > 0 ||
hasInvalidLaunchMemberNames ||
hasDuplicateLaunchMemberNames
: isSubmitting || validationErrors.length > 0; : isSubmitting || validationErrors.length > 0;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1265,6 +1534,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
onLimitContextChange={setLimitContext} onLimitContextChange={setLimitContext}
syncModelsWithTeammates={syncModelsWithLead} syncModelsWithTeammates={syncModelsWithLead}
onSyncModelsWithTeammatesChange={setSyncModelsWithLead} onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
leadWarningText={leadRuntimeWarningText}
memberWarningById={memberRuntimeWarningById}
softDeleteMembers softDeleteMembers
/> />
@ -1302,6 +1573,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{providerChangeForcesFreshLeadContext ? (
<div
className="rounded-md border px-3 py-2 text-xs"
style={{
backgroundColor: 'var(--warning-bg)',
borderColor: 'var(--warning-border)',
color: 'var(--warning-text)',
}}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<p>
Provider changed from {getProviderLabel(previousProviderId!)} to{' '}
{getProviderLabel(selectedProviderId)}. The previous lead session will not
be resumed, and the lead will start with fresh context so the new runtime
is applied correctly.
</p>
</div>
</div>
) : null}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="clear-context" id="clear-context"

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import type { TeamProviderId } from '@shared/types'; import type { TeamProviderId } from '@shared/types';
import type { CliProviderStatus } from '@shared/types';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed'; export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
@ -8,6 +9,7 @@ export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' |
export interface ProvisioningProviderCheck { export interface ProvisioningProviderCheck {
providerId: TeamProviderId; providerId: TeamProviderId;
status: ProvisioningProviderCheckStatus; status: ProvisioningProviderCheckStatus;
backendSummary?: string | null;
details: string[]; details: string[];
} }
@ -29,10 +31,35 @@ export function createInitialProviderChecks(
return providerIds.map((providerId) => ({ return providerIds.map((providerId) => ({
providerId, providerId,
status: 'pending', status: 'pending',
backendSummary: null,
details: [], details: [],
})); }));
} }
export function getProvisioningProviderBackendSummary(
provider:
| Pick<
CliProviderStatus,
'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend'
>
| null
| undefined
): string | null {
if (!provider) {
return null;
}
const options = provider.availableBackends ?? [];
const optionById = new Map(options.map((option) => [option.id, option.label]));
const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId;
if (effectiveBackendId) {
return optionById.get(effectiveBackendId) ?? provider.backend?.label ?? effectiveBackendId;
}
return provider.backend?.label ?? null;
}
export function updateProviderCheck( export function updateProviderCheck(
checks: ProvisioningProviderCheck[], checks: ProvisioningProviderCheck[],
providerId: TeamProviderId, providerId: TeamProviderId,
@ -207,7 +234,9 @@ export function ProvisioningProviderStatusList({
> >
<StatusIcon status={check.status} /> <StatusIcon status={check.status} />
<span> <span>
{getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)} {getProvisioningProviderLabel(check.providerId)}
{check.backendSummary ? ` (${check.backendSummary})` : ''}:{' '}
{getDisplayStatusText(check)}
</span> </span>
</div> </div>
{visibleDetails.length > 0 ? ( {visibleDetails.length > 0 ? (

View file

@ -123,7 +123,22 @@ export function formatTeamModelSummary(
const providerLabel = getTeamProviderLabel(providerId); const providerLabel = getTeamProviderLabel(providerId);
const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default'; const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : ''; const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · ');
const normalizedProvider = providerLabel.trim().toLowerCase();
const normalizedModel = modelLabel.trim().toLowerCase();
const modelAlreadyCarriesProviderBrand =
modelLabel !== 'Default' &&
(normalizedModel.startsWith(normalizedProvider) ||
(providerId === 'anthropic' && normalizedModel.startsWith('claude')) ||
(providerId === 'codex' && normalizedModel.startsWith('codex')) ||
(providerId === 'codex' && normalizedModel.startsWith('gpt')) ||
(providerId === 'gemini' && normalizedModel.startsWith('gemini')));
const parts = modelAlreadyCarriesProviderBrand
? [modelLabel, effortLabel]
: [providerLabel, modelLabel, effortLabel];
return parts.filter(Boolean).join(' · ');
} }
/** /**

View file

@ -250,7 +250,8 @@ export const KanbanTaskCard = memo(
[taskChangeRequestOptions, onViewChanges] [taskChangeRequestOptions, onViewChanges]
); );
const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer; const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim();
const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0;
const metaActions = ( const metaActions = (
<> <>
{canDisplay && task.changePresence === 'has_changes' ? ( {canDisplay && task.changePresence === 'has_changes' ? (

View file

@ -28,6 +28,7 @@ interface LeadModelRowProps {
onLimitContextChange: (value: boolean) => void; onLimitContextChange: (value: boolean) => void;
syncModelsWithTeammates: boolean; syncModelsWithTeammates: boolean;
onSyncModelsWithTeammatesChange: (value: boolean) => void; onSyncModelsWithTeammatesChange: (value: boolean) => void;
warningText?: string | null;
} }
export const LeadModelRow = ({ export const LeadModelRow = ({
@ -41,6 +42,7 @@ export const LeadModelRow = ({
onLimitContextChange, onLimitContextChange,
syncModelsWithTeammates, syncModelsWithTeammates,
onSyncModelsWithTeammatesChange, onSyncModelsWithTeammatesChange,
warningText,
}: LeadModelRowProps): React.JSX.Element => { }: LeadModelRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false); const [modelExpanded, setModelExpanded] = useState(false);
@ -102,6 +104,14 @@ export const LeadModelRow = ({
</Button> </Button>
</div> </div>
</div> </div>
{warningText ? (
<div className="md:col-span-3">
<div className="bg-amber-500/8 ml-3 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{warningText}</p>
</div>
</div>
) : null}
{modelExpanded ? ( {modelExpanded ? (
<div className="space-y-2 md:col-span-3"> <div className="space-y-2 md:col-span-3">
<TeamModelSelector <TeamModelSelector

View file

@ -18,6 +18,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { import type {
LeadActivityState, LeadActivityState,
MemberSpawnLivenessSource,
MemberSpawnStatus, MemberSpawnStatus,
ResolvedTeamMember, ResolvedTeamMember,
TeamTaskWithKanban, TeamTaskWithKanban,
@ -37,6 +38,7 @@ interface MemberCardProps {
isRemoved?: boolean; isRemoved?: boolean;
spawnStatus?: MemberSpawnStatus; spawnStatus?: MemberSpawnStatus;
spawnError?: string; spawnError?: string;
spawnLivenessSource?: MemberSpawnLivenessSource;
onOpenTask?: () => void; onOpenTask?: () => void;
onOpenReviewTask?: () => void; onOpenReviewTask?: () => void;
onClick?: () => void; onClick?: () => void;
@ -58,6 +60,7 @@ export const MemberCard = ({
isRemoved, isRemoved,
spawnStatus, spawnStatus,
spawnError, spawnError,
spawnLivenessSource,
onOpenTask, onOpenTask,
onOpenReviewTask, onOpenReviewTask,
onClick, onClick,
@ -79,6 +82,7 @@ export const MemberCard = ({
const presenceLabel = getSpawnAwarePresenceLabel( const presenceLabel = getSpawnAwarePresenceLabel(
member, member,
spawnStatus, spawnStatus,
spawnLivenessSource,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
leadActivity leadActivity

View file

@ -51,6 +51,7 @@ interface MemberDraftRowProps {
modelLockReason?: string; modelLockReason?: string;
isRemoved?: boolean; isRemoved?: boolean;
onRestore?: (id: string) => void; onRestore?: (id: string) => void;
warningText?: string | null;
} }
export const MemberDraftRow = ({ export const MemberDraftRow = ({
@ -81,6 +82,7 @@ export const MemberDraftRow = ({
modelLockReason, modelLockReason,
isRemoved = false, isRemoved = false,
onRestore, onRestore,
warningText,
}: MemberDraftRowProps): React.JSX.Element => { }: MemberDraftRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const memberColorSet = getTeamColorSet( const memberColorSet = getTeamColorSet(
@ -289,6 +291,14 @@ export const MemberDraftRow = ({
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div> <div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
) : null} ) : null}
</div> </div>
{!isRemoved && warningText ? (
<div className="md:col-span-3">
<div className="bg-amber-500/8 ml-3 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
<p>{warningText}</p>
</div>
</div>
) : null}
{showWorkflow && onWorkflowChange && workflowExpanded ? ( {showWorkflow && onWorkflowChange && workflowExpanded ? (
<div className="space-y-0.5 pl-3 md:col-span-3"> <div className="space-y-0.5 pl-3 md:col-span-3">
<label <label

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { import {
formatTeamModelSummary,
getTeamEffortLabel, getTeamEffortLabel,
getTeamModelLabel, getTeamModelLabel,
getTeamProviderLabel, getTeamProviderLabel,
@ -14,6 +15,7 @@ import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { import type {
LeadActivityState, LeadActivityState,
MemberSpawnLivenessSource,
MemberSpawnStatus, MemberSpawnStatus,
ResolvedTeamMember, ResolvedTeamMember,
TeamTaskWithKanban, TeamTaskWithKanban,
@ -22,6 +24,7 @@ import type {
export interface MemberSpawnEntry { export interface MemberSpawnEntry {
status: MemberSpawnStatus; status: MemberSpawnStatus;
error?: string; error?: string;
livenessSource?: MemberSpawnLivenessSource;
} }
interface MemberListProps { interface MemberListProps {
@ -87,39 +90,11 @@ export const MemberList = ({
const buildRuntimeSummary = useCallback( const buildRuntimeSummary = useCallback(
(member: ResolvedTeamMember): string | undefined => { (member: ResolvedTeamMember): string | undefined => {
const hasMemberOverride = Boolean(member.providerId || member.model || member.effort); const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic';
if (!hasMemberOverride && launchParams) { const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || '';
return undefined; const effectiveEffort = member.effort ?? launchParams?.effort;
}
const defaultProvider = launchParams?.providerId ?? 'anthropic'; return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort);
const memberProvider = member.providerId ?? defaultProvider;
const defaultModel = launchParams?.model?.trim() || '';
const memberModel = member.model?.trim() || '';
const defaultEffort = launchParams?.effort;
const memberEffort = member.effort;
const showProvider =
!launchParams || Boolean(member.providerId && memberProvider !== defaultProvider);
const showModel = !launchParams
? Boolean(memberModel)
: Boolean(memberModel && memberModel !== defaultModel);
const showEffort = !launchParams
? Boolean(memberEffort)
: Boolean(memberEffort && memberEffort !== defaultEffort);
const parts: string[] = [];
if (showProvider) {
parts.push(getTeamProviderLabel(memberProvider));
}
if (showModel) {
parts.push(getTeamModelLabel(memberModel));
}
if (showEffort && memberEffort) {
parts.push(getTeamEffortLabel(memberEffort));
}
return parts.length > 0 ? parts.join(' · ') : undefined;
}, },
[launchParams] [launchParams]
); );
@ -161,6 +136,7 @@ export const MemberList = ({
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)} runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error} spawnError={isRemoved ? undefined : spawnEntry?.error}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined} onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined} onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
onClick={() => onMemberClick?.(member)} onClick={() => onMemberClick?.(member)}

View file

@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor'; import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
@ -31,7 +32,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
if (role) obj.role = role; if (role) obj.role = role;
const workflow = getWorkflowForExport(d); const workflow = getWorkflowForExport(d);
if (workflow) obj.workflow = workflow; if (workflow) obj.workflow = workflow;
if (d.providerId && d.providerId !== 'anthropic') obj.providerId = d.providerId; if (d.providerId) obj.providerId = d.providerId;
if (d.model?.trim()) obj.model = d.model.trim(); if (d.model?.trim()) obj.model = d.model.trim();
if (d.effort) obj.effort = d.effort; if (d.effort) obj.effort = d.effort;
return obj; return obj;
@ -46,8 +47,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
const name = typeof item.name === 'string' ? item.name : ''; const name = typeof item.name === 'string' ? item.name : '';
const role = typeof item.role === 'string' ? item.role.trim() : ''; const role = typeof item.role === 'string' ? item.role.trim() : '';
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : ''; const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
const providerId: TeamProviderId = const providerId = normalizeOptionalTeamProviderId(item.providerId);
item.providerId === 'codex' || item.providerId === 'gemini' ? item.providerId : 'anthropic';
const model = typeof item.model === 'string' ? item.model.trim() : ''; const model = typeof item.model === 'string' ? item.model.trim() : '';
const effort: EffortLevel | undefined = const effort: EffortLevel | undefined =
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high' item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
@ -99,6 +99,7 @@ export interface MembersEditorSectionProps {
forceInheritedModelSettings?: boolean; forceInheritedModelSettings?: boolean;
modelLockReason?: string; modelLockReason?: string;
softDeleteMembers?: boolean; softDeleteMembers?: boolean;
memberWarningById?: Record<string, string | null | undefined>;
} }
export const MembersEditorSection = ({ export const MembersEditorSection = ({
@ -124,6 +125,7 @@ export const MembersEditorSection = ({
forceInheritedModelSettings = false, forceInheritedModelSettings = false,
modelLockReason, modelLockReason,
softDeleteMembers = false, softDeleteMembers = false,
memberWarningById,
}: MembersEditorSectionProps): React.JSX.Element => { }: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState(''); const [jsonText, setJsonText] = useState('');
@ -310,6 +312,7 @@ export const MembersEditorSection = ({
teamSuggestions={teamSuggestions} teamSuggestions={teamSuggestions}
lockProviderModel={lockProviderModel} lockProviderModel={lockProviderModel}
modelLockReason={modelLockReason} modelLockReason={modelLockReason}
warningText={memberWarningById?.[member.id] ?? null}
/> />
))} ))}
{softDeleteMembers && removedMembers.length > 0 ? ( {softDeleteMembers && removedMembers.length > 0 ? (
@ -348,6 +351,7 @@ export const MembersEditorSection = ({
lockProviderModel lockProviderModel
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings." modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved isRemoved
warningText={null}
/> />
))} ))}
</div> </div>

View file

@ -41,6 +41,8 @@ interface TeamRosterEditorSectionProps {
headerTop?: React.ReactNode; headerTop?: React.ReactNode;
headerBottom?: React.ReactNode; headerBottom?: React.ReactNode;
softDeleteMembers?: boolean; softDeleteMembers?: boolean;
leadWarningText?: string | null;
memberWarningById?: Record<string, string | null | undefined>;
} }
export const TeamRosterEditorSection = ({ export const TeamRosterEditorSection = ({
@ -77,6 +79,8 @@ export const TeamRosterEditorSection = ({
headerTop, headerTop,
headerBottom, headerBottom,
softDeleteMembers = false, softDeleteMembers = false,
leadWarningText,
memberWarningById,
}: TeamRosterEditorSectionProps): React.JSX.Element => { }: TeamRosterEditorSectionProps): React.JSX.Element => {
return ( return (
<MembersEditorSection <MembersEditorSection
@ -115,10 +119,12 @@ export const TeamRosterEditorSection = ({
onLimitContextChange={onLimitContextChange} onLimitContextChange={onLimitContextChange}
syncModelsWithTeammates={syncModelsWithTeammates} syncModelsWithTeammates={syncModelsWithTeammates}
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
warningText={leadWarningText}
/> />
{headerBottom} {headerBottom}
</div> </div>
} }
memberWarningById={memberWarningById}
/> />
); );
}; };

View file

@ -1,6 +1,7 @@
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { MemberDraft } from './membersEditorTypes'; import type { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
@ -62,10 +63,7 @@ export function createMemberDraftsFromInputs(
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '', customRole: role && !isPreset ? role : '',
workflow: member.workflow, workflow: member.workflow,
providerId: providerId: normalizeOptionalTeamProviderId(member.providerId),
member.providerId === 'codex' || member.providerId === 'gemini'
? member.providerId
: 'anthropic',
model: member.model ?? '', model: member.model ?? '',
effort: normalizeDraftEffort(member.effort), effort: normalizeDraftEffort(member.effort),
removedAt: member.removedAt, removedAt: member.removedAt,
@ -196,11 +194,8 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
const result: TeamProvisioningMemberInput = { name, role }; const result: TeamProvisioningMemberInput = { name, role };
const workflow = getWorkflowForExport(member); const workflow = getWorkflowForExport(member);
if (workflow) result.workflow = workflow; if (workflow) result.workflow = workflow;
const providerId: TeamProviderId = const providerId = normalizeOptionalTeamProviderId(member.providerId);
member.providerId === 'codex' || member.providerId === 'gemini' if (providerId) {
? member.providerId
: 'anthropic';
if (providerId !== 'anthropic') {
result.providerId = providerId; result.providerId = providerId;
} }
const model = member.model?.trim(); const model = member.model?.trim();

View file

@ -1,6 +1,7 @@
import { ClaudeLogsSection } from '../ClaudeLogsSection'; import { ClaudeLogsSection } from '../ClaudeLogsSection';
import { MessagesPanel } from '../messages/MessagesPanel'; import { MessagesPanel } from '../messages/MessagesPanel';
import { useState } from 'react';
import type { MouseEventHandler } from 'react'; import type { MouseEventHandler } from 'react';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
@ -11,6 +12,9 @@ interface TeamSidebarRailProps {
messagesPanelProps: SharedMessagesPanelProps; messagesPanelProps: SharedMessagesPanelProps;
isResizing: boolean; isResizing: boolean;
onResizeMouseDown: MouseEventHandler<HTMLDivElement>; onResizeMouseDown: MouseEventHandler<HTMLDivElement>;
logsHeight: number;
isLogsResizing: boolean;
onLogsResizeMouseDown: MouseEventHandler<HTMLDivElement>;
} }
export const TeamSidebarRail = ({ export const TeamSidebarRail = ({
@ -18,13 +22,39 @@ export const TeamSidebarRail = ({
messagesPanelProps, messagesPanelProps,
isResizing, isResizing,
onResizeMouseDown, onResizeMouseDown,
logsHeight,
isLogsResizing,
onLogsResizeMouseDown,
}: TeamSidebarRailProps): React.JSX.Element => { }: TeamSidebarRailProps): React.JSX.Element => {
const [logsOpen, setLogsOpen] = useState(false);
const logsSeparator = logsOpen ? (
<div
className={`group relative h-3 shrink-0 cursor-row-resize ${isLogsResizing ? 'bg-blue-500/10' : ''}`}
onMouseDown={onLogsResizeMouseDown}
>
<div
className={`absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 transition-colors ${
isLogsResizing
? 'bg-blue-500'
: 'bg-[var(--color-text-muted)]/35 group-hover:bg-blue-500/90'
}`}
/>
</div>
) : (
<div className="bg-[var(--color-text-muted)]/35 h-px shrink-0" />
);
return ( return (
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]"> <div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="shrink-0 overflow-hidden px-3"> <div className="shrink-0 overflow-hidden px-3">
<ClaudeLogsSection teamName={teamName} position="sidebar" /> <ClaudeLogsSection
teamName={teamName}
position="sidebar"
sidebarViewerMaxHeight={logsHeight}
onOpenChange={setLogsOpen}
/>
</div> </div>
<div className="bg-[var(--color-text-muted)]/35 mx-3 h-px shrink-0" /> {logsSeparator}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<MessagesPanel position="sidebar" {...messagesPanelProps} /> <MessagesPanel position="sidebar" {...messagesPanelProps} />
</div> </div>

View file

@ -7,11 +7,12 @@
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import type { CliInstallationStatus } from '@shared/types'; import type { CliInstallationStatus, CliProviderId } from '@shared/types';
export function useCliInstaller(): { export function useCliInstaller(): {
cliStatus: CliInstallationStatus | null; cliStatus: CliInstallationStatus | null;
cliStatusLoading: boolean; cliStatusLoading: boolean;
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
cliStatusError: string | null; cliStatusError: string | null;
installerState: installerState:
| 'idle' | 'idle'
@ -28,13 +29,19 @@ export function useCliInstaller(): {
installerDetail: string | null; installerDetail: string | null;
installerRawChunks: string[]; installerRawChunks: string[];
completedVersion: string | null; completedVersion: string | null;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: (
providerId: CliProviderId,
options?: { silent?: boolean; epoch?: number }
) => Promise<void>;
invalidateCliStatus: () => Promise<void>; invalidateCliStatus: () => Promise<void>;
installCli: () => void; installCli: () => void;
isBusy: boolean; isBusy: boolean;
} { } {
const cliStatus = useStore((s) => s.cliStatus); const cliStatus = useStore((s) => s.cliStatus);
const cliStatusLoading = useStore((s) => s.cliStatusLoading); const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
const cliStatusError = useStore((s) => s.cliStatusError); const cliStatusError = useStore((s) => s.cliStatusError);
const installerState = useStore((s) => s.cliInstallerState); const installerState = useStore((s) => s.cliInstallerState);
const downloadProgress = useStore((s) => s.cliDownloadProgress); const downloadProgress = useStore((s) => s.cliDownloadProgress);
@ -44,7 +51,9 @@ export function useCliInstaller(): {
const installerDetail = useStore((s) => s.cliInstallerDetail); const installerDetail = useStore((s) => s.cliInstallerDetail);
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks); const installerRawChunks = useStore((s) => s.cliInstallerRawChunks);
const completedVersion = useStore((s) => s.cliCompletedVersion); const completedVersion = useStore((s) => s.cliCompletedVersion);
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
const fetchCliStatus = useStore((s) => s.fetchCliStatus); const fetchCliStatus = useStore((s) => s.fetchCliStatus);
const fetchCliProviderStatus = useStore((s) => s.fetchCliProviderStatus);
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus); const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
const installCli = useStore((s) => s.installCli); const installCli = useStore((s) => s.installCli);
@ -53,6 +62,7 @@ export function useCliInstaller(): {
return { return {
cliStatus, cliStatus,
cliStatusLoading, cliStatusLoading,
cliProviderStatusLoading,
cliStatusError, cliStatusError,
installerState, installerState,
downloadProgress, downloadProgress,
@ -62,7 +72,9 @@ export function useCliInstaller(): {
installerDetail, installerDetail,
installerRawChunks, installerRawChunks,
completedVersion, completedVersion,
bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
fetchCliProviderStatus,
invalidateCliStatus, invalidateCliStatus,
installCli, installCli,
isBusy, isBusy,

View file

@ -1,30 +1,35 @@
/** /**
* useResizablePanel - Reusable hook for mouse-based panel resizing. * useResizablePanel - Reusable hook for mouse-based panel resizing.
* *
* Extracted from the resize pattern in Sidebar.tsx. * Supports both:
* Handles mousedown/mousemove/mouseup on document, cursor and userSelect overrides. * - horizontal resizing for left/right side panels
* * - vertical resizing for top/bottom stacked panels
* @param options.width Current panel width (controlled)
* @param options.onWidthChange Callback when width changes during drag
* @param options.minWidth Minimum allowed width (default 280)
* @param options.maxWidth Maximum allowed width (default 500)
* @param options.side Which side the panel is on:
* 'left' panel is on the left, resize handle on right edge
* 'right' panel is on the right, resize handle on left edge
*/ */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_MIN_WIDTH = 280; const DEFAULT_MIN_WIDTH = 280;
const DEFAULT_MAX_WIDTH = 500; const DEFAULT_MAX_WIDTH = 500;
const DEFAULT_MIN_HEIGHT = 120;
const DEFAULT_MAX_HEIGHT = 520;
interface UseResizablePanelOptions { type HorizontalResizeOptions = {
width: number; width: number;
onWidthChange: (width: number) => void; onWidthChange: (width: number) => void;
minWidth?: number; minWidth?: number;
maxWidth?: number; maxWidth?: number;
side: 'left' | 'right'; side: 'left' | 'right';
} };
type VerticalResizeOptions = {
height: number;
onHeightChange: (height: number) => void;
minHeight?: number;
maxHeight?: number;
side: 'top' | 'bottom';
};
type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions;
interface ResizeHandleProps { interface ResizeHandleProps {
onMouseDown: (e: React.MouseEvent) => void; onMouseDown: (e: React.MouseEvent) => void;
@ -35,47 +40,61 @@ interface UseResizablePanelReturn {
handleProps: ResizeHandleProps; handleProps: ResizeHandleProps;
} }
export function useResizablePanel({ function isVerticalOptions(options: UseResizablePanelOptions): options is VerticalResizeOptions {
width, return options.side === 'top' || options.side === 'bottom';
onWidthChange, }
minWidth = DEFAULT_MIN_WIDTH,
maxWidth = DEFAULT_MAX_WIDTH, export function useResizablePanel(options: UseResizablePanelOptions): UseResizablePanelReturn {
side,
}: UseResizablePanelOptions): UseResizablePanelReturn {
const [isResizing, setIsResizing] = useState(false); const [isResizing, setIsResizing] = useState(false);
const originRef = useRef(0);
const isVertical = isVerticalOptions(options);
// Store the panel's left offset for 'left' side panels. const onSizeChangeRef = useRef<(size: number) => void>(
// Updated on resize start so the formula stays correct if layout shifts. isVertical ? options.onHeightChange : options.onWidthChange
const panelLeftRef = useRef(0); );
const minSizeRef = useRef(
// Keep callbacks in refs to avoid stale closures in mousemove listener isVertical ? (options.minHeight ?? DEFAULT_MIN_HEIGHT) : (options.minWidth ?? DEFAULT_MIN_WIDTH)
const onWidthChangeRef = useRef(onWidthChange); );
const minWidthRef = useRef(minWidth); const maxSizeRef = useRef(
const maxWidthRef = useRef(maxWidth); isVertical ? (options.maxHeight ?? DEFAULT_MAX_HEIGHT) : (options.maxWidth ?? DEFAULT_MAX_WIDTH)
const sideRef = useRef(side); );
const sideRef = useRef(options.side);
useEffect(() => { useEffect(() => {
onWidthChangeRef.current = onWidthChange; sideRef.current = options.side;
minWidthRef.current = minWidth; if (isVerticalOptions(options)) {
maxWidthRef.current = maxWidth; onSizeChangeRef.current = options.onHeightChange;
sideRef.current = side; minSizeRef.current = options.minHeight ?? DEFAULT_MIN_HEIGHT;
}); maxSizeRef.current = options.maxHeight ?? DEFAULT_MAX_HEIGHT;
} else {
onSizeChangeRef.current = options.onWidthChange;
minSizeRef.current = options.minWidth ?? DEFAULT_MIN_WIDTH;
maxSizeRef.current = options.maxWidth ?? DEFAULT_MAX_WIDTH;
}
}, [options]);
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!isResizing) return; if (!isResizing) return;
let newWidth: number; let newSize: number;
if (sideRef.current === 'left') { switch (sideRef.current) {
// Panel on the left: width = cursor position - panel left edge case 'left':
newWidth = e.clientX - panelLeftRef.current; newSize = e.clientX - originRef.current;
} else { break;
// Panel on the right: width = viewport width - cursor position case 'right':
newWidth = window.innerWidth - e.clientX; newSize = window.innerWidth - e.clientX;
break;
case 'top':
newSize = e.clientY - originRef.current;
break;
case 'bottom':
newSize = window.innerHeight - e.clientY;
break;
} }
if (newWidth >= minWidthRef.current && newWidth <= maxWidthRef.current) { if (newSize >= minSizeRef.current && newSize <= maxSizeRef.current) {
onWidthChangeRef.current(newWidth); onSizeChangeRef.current(newSize);
} }
}, },
[isResizing] [isResizing]
@ -89,7 +108,7 @@ export function useResizablePanel({
if (isResizing) { if (isResizing) {
document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp); document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize'; document.body.style.cursor = isVertical ? 'row-resize' : 'col-resize';
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
} }
@ -99,20 +118,23 @@ export function useResizablePanel({
document.body.style.cursor = ''; document.body.style.cursor = '';
document.body.style.userSelect = ''; document.body.style.userSelect = '';
}; };
}, [isResizing, handleMouseMove, handleMouseUp]); }, [isResizing, handleMouseMove, handleMouseUp, isVertical]);
const onMouseDown = useCallback( const onMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
if (side === 'left') { if (isVerticalOptions(options)) {
// Calculate the left edge of the panel from cursor position minus current width if (options.side === 'top') {
panelLeftRef.current = e.clientX - width; originRef.current = e.clientY - options.height;
}
} else if (options.side === 'left') {
originRef.current = e.clientX - options.width;
} }
setIsResizing(true); setIsResizing(true);
}, },
[side, width] [options]
); );
return { return {

View file

@ -144,7 +144,12 @@ export function initializeNotificationListeners(): () => void {
const isWindows = platform.toLowerCase().includes('win'); const isWindows = platform.toLowerCase().includes('win');
const delayMs = isWindows ? 3000 : 0; const delayMs = isWindows ? 3000 : 0;
cliStatusTimer = setTimeout(() => { cliStatusTimer = setTimeout(() => {
void useStore.getState().fetchCliStatus(); const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
if (multimodelEnabled) {
void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });
} else {
void useStore.getState().fetchCliStatus();
}
cliStatusTimer = null; cliStatusTimer = null;
}, delayMs); }, delayMs);
} }

View file

@ -6,13 +6,55 @@ import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { CliInstallationStatus } from '@shared/types'; import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
import type { StateCreator } from 'zustand'; import type { StateCreator } from 'zustand';
const logger = createLogger('Store:cliInstaller'); const logger = createLogger('Store:cliInstaller');
/** Max log lines to keep in UI (reserved for future use) */ /** Max log lines to keep in UI (reserved for future use) */
const _MAX_LOG_LINES = 50; const _MAX_LOG_LINES = 50;
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
const providers: CliProviderStatus[] = (
[
{ providerId: 'anthropic', displayName: 'Anthropic' },
{ providerId: 'codex', displayName: 'Codex' },
{ providerId: 'gemini', displayName: 'Gemini' },
] as const
).map((provider) => ({
...provider,
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown' as const,
statusMessage: 'Checking...',
models: [],
canLoginFromUi: true,
capabilities: {
teamLaunch: false,
oneShot: false,
},
backend: null,
}));
return {
flavor: 'free-code',
displayName: 'free-code-gemini-research',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
installed: true,
installedVersion: null,
binaryPath: null,
latestVersion: null,
updateAvailable: false,
authLoggedIn: false,
authStatusChecking: true,
authMethod: null,
providers,
};
}
// ============================================================================= // =============================================================================
// Slice Interface // Slice Interface
@ -22,6 +64,7 @@ export interface CliInstallerSlice {
// State // State
cliStatus: CliInstallationStatus | null; cliStatus: CliInstallationStatus | null;
cliStatusLoading: boolean; cliStatusLoading: boolean;
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
cliStatusError: string | null; cliStatusError: string | null;
cliInstallerState: cliInstallerState:
| 'idle' | 'idle'
@ -41,23 +84,33 @@ export interface CliInstallerSlice {
cliCompletedVersion: string | null; cliCompletedVersion: string | null;
// Actions // Actions
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: (
providerId: CliProviderId,
options?: { silent?: boolean; epoch?: number }
) => Promise<void>;
invalidateCliStatus: () => Promise<void>; invalidateCliStatus: () => Promise<void>;
installCli: () => void; installCli: () => void;
} }
let cliStatusInFlight: Promise<void> | null = null; let cliStatusInFlight: Promise<void> | null = null;
const cliProviderStatusInFlight = new Map<CliProviderId, Promise<void>>();
let cliStatusEpoch = 0;
const cliProviderStatusSeq = new Map<CliProviderId, number>();
// ============================================================================= // =============================================================================
// Slice Creator // Slice Creator
// ============================================================================= // =============================================================================
export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstallerSlice> = ( export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstallerSlice> = (
set set,
get
) => ({ ) => ({
// Initial state // Initial state
cliStatus: null, cliStatus: null,
cliStatusLoading: false, cliStatusLoading: false,
cliProviderStatusLoading: {},
cliStatusError: null, cliStatusError: null,
cliInstallerState: 'idle', cliInstallerState: 'idle',
cliDownloadProgress: 0, cliDownloadProgress: 0,
@ -69,15 +122,92 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
cliInstallerRawChunks: [], cliInstallerRawChunks: [],
cliCompletedVersion: null, cliCompletedVersion: null,
bootstrapCliStatus: async (options) => {
if (!api.cliInstaller) return;
const multimodelEnabled = options?.multimodelEnabled ?? true;
if (!multimodelEnabled) {
return get().fetchCliStatus();
}
const epoch = ++cliStatusEpoch;
const providerLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true])
) as Partial<Record<CliProviderId, boolean>>;
set({
cliStatus: createLoadingMultimodelCliStatus(),
cliStatusLoading: true,
cliProviderStatusLoading: providerLoading,
cliStatusError: null,
});
void (async () => {
try {
const metadata = await api.cliInstaller.getStatus();
set((state) => {
if (epoch !== cliStatusEpoch || !state.cliStatus) {
return {};
}
return {
cliStatus: {
...state.cliStatus,
flavor: metadata.flavor,
displayName: metadata.displayName,
supportsSelfUpdate: metadata.supportsSelfUpdate,
showVersionDetails: metadata.showVersionDetails,
showBinaryPath: metadata.showBinaryPath,
installed: metadata.installed,
installedVersion: metadata.installedVersion,
binaryPath: metadata.binaryPath,
latestVersion: metadata.latestVersion,
updateAvailable: metadata.updateAvailable,
authStatusChecking: state.cliStatus.providers.some(
(provider) => provider.statusMessage === 'Checking...'
),
},
};
});
} catch (error) {
logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error);
}
})();
try {
await Promise.allSettled(
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
get().fetchCliProviderStatus(providerId, {
silent: false,
epoch,
})
)
);
} finally {
if (epoch === cliStatusEpoch) {
set({ cliStatusLoading: false });
}
}
},
fetchCliStatus: async () => { fetchCliStatus: async () => {
if (!api.cliInstaller) return; if (!api.cliInstaller) return;
if (cliStatusInFlight) return cliStatusInFlight; if (cliStatusInFlight) return cliStatusInFlight;
const epoch = ++cliStatusEpoch;
cliStatusInFlight = (async () => { cliStatusInFlight = (async () => {
set({ cliStatusLoading: true, cliStatusError: null }); set({ cliStatusLoading: true, cliStatusError: null });
try { try {
const status = await api.cliInstaller.getStatus(); const status = await api.cliInstaller.getStatus();
set({ cliStatus: status }); if (epoch !== cliStatusEpoch) {
return;
}
set({ cliStatus: status, cliProviderStatusLoading: {} });
for (const provider of status.providers) {
void get().fetchCliProviderStatus(provider.providerId, {
silent: true,
epoch,
});
}
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : 'Failed to check CLI status'; const message = error instanceof Error ? error.message : 'Failed to check CLI status';
logger.error('Failed to fetch CLI status:', error); logger.error('Failed to fetch CLI status:', error);
@ -91,6 +221,102 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return cliStatusInFlight; return cliStatusInFlight;
}, },
fetchCliProviderStatus: async (providerId, options) => {
if (!api.cliInstaller) return;
const inFlight = cliProviderStatusInFlight.get(providerId);
if (inFlight) return inFlight;
const requestEpoch = options?.epoch ?? cliStatusEpoch;
const requestSeq = (cliProviderStatusSeq.get(providerId) ?? 0) + 1;
const silent = options?.silent === true;
cliProviderStatusSeq.set(providerId, requestSeq);
const request = (async () => {
if (!silent) {
set((state) => ({
cliStatusError: null,
cliProviderStatusLoading: {
...state.cliProviderStatusLoading,
[providerId]: true,
},
}));
}
try {
const providerStatus = await api.cliInstaller.getProviderStatus(providerId);
set((state) => {
const nextLoading = silent
? state.cliProviderStatusLoading
: {
...state.cliProviderStatusLoading,
[providerId]: false,
};
if (
requestEpoch !== cliStatusEpoch ||
cliProviderStatusSeq.get(providerId) !== requestSeq
) {
return { cliProviderStatusLoading: nextLoading };
}
if (!providerStatus || !state.cliStatus) {
return { cliProviderStatusLoading: nextLoading };
}
const hasProvider = state.cliStatus.providers.some(
(provider) => provider.providerId === providerId
);
const nextProviders = hasProvider
? state.cliStatus.providers.map((provider) =>
provider.providerId === providerId ? providerStatus : provider
)
: [...state.cliStatus.providers, providerStatus];
const authenticatedProvider =
nextProviders.find((provider) => provider.authenticated) ?? null;
return {
cliStatus: {
...state.cliStatus,
providers: nextProviders,
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
authMethod: authenticatedProvider?.authMethod ?? null,
},
cliProviderStatusLoading: nextLoading,
};
});
} catch (error) {
const message =
error instanceof Error ? error.message : `Failed to refresh ${providerId} status`;
logger.error(`Failed to fetch ${providerId} CLI status:`, error);
set((state) => {
const nextLoading = silent
? state.cliProviderStatusLoading
: {
...state.cliProviderStatusLoading,
[providerId]: false,
};
if (
requestEpoch !== cliStatusEpoch ||
cliProviderStatusSeq.get(providerId) !== requestSeq
) {
return { cliProviderStatusLoading: nextLoading };
}
return {
cliStatusError: message,
cliProviderStatusLoading: nextLoading,
};
});
} finally {
cliProviderStatusInFlight.delete(providerId);
}
})();
cliProviderStatusInFlight.set(providerId, request);
return request;
},
invalidateCliStatus: async () => { invalidateCliStatus: async () => {
await api.cliInstaller?.invalidateStatus(); await api.cliInstaller?.invalidateStatus();
}, },

View file

@ -762,8 +762,10 @@ export interface TeamSlice {
// Messages panel UI state // Messages panel UI state
messagesPanelMode: 'sidebar' | 'inline'; messagesPanelMode: 'sidebar' | 'inline';
messagesPanelWidth: number; messagesPanelWidth: number;
sidebarLogsHeight: number;
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void; setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void;
setMessagesPanelWidth: (width: number) => void; setMessagesPanelWidth: (width: number) => void;
setSidebarLogsHeight: (height: number) => void;
} }
// --- Per-team launch params persistence --- // --- Per-team launch params persistence ---
@ -998,8 +1000,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Messages panel UI state // Messages panel UI state
messagesPanelMode: 'sidebar' as const, messagesPanelMode: 'sidebar' as const,
messagesPanelWidth: 340, messagesPanelWidth: 340,
sidebarLogsHeight: 213,
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }), setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }),
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }), setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
setSidebarLogsHeight: (height: number) => set({ sidebarLogsHeight: height }),
fetchBranches: async (paths: string[]) => { fetchBranches: async (paths: string[]) => {
const entries = await Promise.all( const entries = await Promise.all(
@ -2381,10 +2385,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) { if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) {
// Clear spawn statuses — provisioning is complete, members now tracked via normal status
set((prev) => { set((prev) => {
const next = { ...prev.memberSpawnStatusesByTeam }; const next = { ...prev.memberSpawnStatusesByTeam };
delete next[progress.teamName]; const currentStatuses = next[progress.teamName];
if (!currentStatuses) {
return { memberSpawnStatusesByTeam: next };
}
if (progress.state === 'ready') {
next[progress.teamName] = currentStatuses;
return { memberSpawnStatusesByTeam: next };
}
const retainedStatuses = Object.fromEntries(
Object.entries(currentStatuses).filter(([, entry]) => entry.status === 'error')
);
if (Object.keys(retainedStatuses).length > 0) {
next[progress.teamName] = retainedStatuses;
} else {
delete next[progress.teamName];
}
return { memberSpawnStatusesByTeam: next }; return { memberSpawnStatusesByTeam: next };
}); });
} }

View file

@ -0,0 +1,229 @@
import { displayMemberName } from '@renderer/utils/memberHelpers';
import type { InboxMessage } from '@shared/types';
const BOOTSTRAP_REQUIRED_MARKERS = [
'Your FIRST action: call MCP tool member_briefing',
'Do NOT start work, claim tasks, or improvise workflow/task/process rules before member_briefing succeeds.',
] as const;
const BOOTSTRAP_SUPPORTING_MARKERS = [
'If member_briefing fails, send',
'member_briefing is expected to be available in your initial MCP tool list.',
'IMPORTANT: When sending messages to the team lead',
] as const;
type TeamProviderId = 'anthropic' | 'codex' | 'gemini';
function parseProviderId(value: string | undefined): TeamProviderId | null {
const normalized = value?.trim().toLowerCase();
if (normalized === 'anthropic' || normalized === 'codex' || normalized === 'gemini') {
return normalized;
}
return null;
}
function getTeamModelLabel(model: string): string {
const trimmed = model.trim();
switch (trimmed) {
case 'gemini-2.5-pro':
return 'Gemini 2.5 Pro';
case 'gemini-2.5-flash':
return 'Gemini 2.5 Flash';
case 'gemini-2.5-flash-lite':
return 'Gemini 2.5 Flash Lite';
case 'gpt-5.4':
return 'GPT-5.4';
case 'gpt-5.4-mini':
return 'GPT-5.4 Mini';
case 'gpt-5.3-codex':
return 'GPT-5.3 Codex';
case 'gpt-5.3-codex-spark':
return 'GPT-5.3 Codex Spark';
case 'gpt-5.2':
return 'GPT-5.2';
case 'gpt-5.2-codex':
return 'GPT-5.2 Codex';
case 'gpt-5.1-codex-mini':
return 'GPT-5.1 Codex Mini';
case 'gpt-5.1-codex-max':
return 'GPT-5.1 Codex Max';
case 'claude-sonnet-4-6':
return 'Sonnet 4.6';
case 'claude-sonnet-4-6[1m]':
return 'Sonnet 4.6 (1M)';
case 'claude-opus-4-6':
return 'Opus 4.6';
case 'claude-opus-4-6[1m]':
return 'Opus 4.6 (1M)';
case 'claude-haiku-4-5-20251001':
return 'Haiku 4.5';
default:
return trimmed || 'Default';
}
}
function getTeamProviderLabel(providerId: TeamProviderId): string {
switch (providerId) {
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'anthropic':
default:
return 'Anthropic';
}
}
function getTeamEffortLabel(effort: string | undefined): string {
const trimmed = effort?.trim() ?? '';
return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : 'Default';
}
function matchField(text: string, pattern: RegExp): string | undefined {
const match = pattern.exec(text);
const value = match?.[1]?.trim();
return value ? value : undefined;
}
function buildRuntimeSummary(
providerId: TeamProviderId | null,
model: string | undefined,
effort: string | undefined
): string | undefined {
if (providerId) {
const providerLabel = getTeamProviderLabel(providerId);
const modelLabel = model ? getTeamModelLabel(model) : 'Default';
const effortLabel = getTeamEffortLabel(effort);
const normalizedProvider = providerLabel.trim().toLowerCase();
const normalizedModel = modelLabel.trim().toLowerCase();
const modelAlreadyCarriesProviderBrand =
modelLabel !== 'Default' &&
(normalizedModel.startsWith(normalizedProvider) ||
(providerId === 'anthropic' && normalizedModel.startsWith('claude')) ||
(providerId === 'codex' &&
(normalizedModel.startsWith('codex') || normalizedModel.startsWith('gpt'))) ||
(providerId === 'gemini' && normalizedModel.startsWith('gemini')));
const parts = modelAlreadyCarriesProviderBrand
? [modelLabel, effortLabel]
: [providerLabel, modelLabel, effortLabel];
return parts.filter(Boolean).join(' · ');
}
const modelLabel = model ? getTeamModelLabel(model) : '';
const effortLabel = effort ? getTeamEffortLabel(effort) : '';
const providerLabel = providerId ? getTeamProviderLabel(providerId) : '';
return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · ') || undefined;
}
export interface BootstrapPromptDisplay {
teammateName?: string;
teamName?: string;
runtime?: string;
summary: string;
body: string;
}
export interface BootstrapAcknowledgementDisplay {
teammateName?: string;
teamName?: string;
summary: string;
body: string;
}
export function getBootstrapPromptDisplay(
message: Pick<InboxMessage, 'text' | 'to'>
): BootstrapPromptDisplay | null {
const text = typeof message.text === 'string' ? message.text.trim() : '';
const hasRequiredMarkers = BOOTSTRAP_REQUIRED_MARKERS.every((marker) => text.includes(marker));
const hasSupportingMarker = BOOTSTRAP_SUPPORTING_MARKERS.some((marker) => text.includes(marker));
if (!text.startsWith('You are ') || !hasRequiredMarkers || !hasSupportingMarker) {
return null;
}
const teammateName =
matchField(text, /^You are\s+([^,\n]+),/m) ??
(typeof message.to === 'string' ? message.to.trim() : undefined);
const teamName = matchField(text, /on team "([^"]+)"/);
const providerId = parseProviderId(
matchField(text, /Provider override for this teammate:\s*([^\.\n]+)/i)
);
const model = matchField(text, /Model override for this teammate:\s*([^\.\n]+)/i);
const effort = matchField(text, /Effort override for this teammate:\s*([^\.\n]+)/i);
const runtime = buildRuntimeSummary(providerId, model, effort);
const displayName = teammateName ? displayMemberName(teammateName) : 'teammate';
const summary = `Starting ${displayName}`;
const bodyLines = [`Lead is starting \`${displayName}\` as a teammate.`];
if (runtime) {
bodyLines.push(`Runtime: ${runtime}`);
} else if (teamName) {
bodyLines.push(`Team: \`${teamName}\``);
}
bodyLines.push('Startup instructions are hidden in the UI.');
return {
teammateName,
teamName,
runtime,
summary,
body: bodyLines.join('\n\n'),
};
}
export function getBootstrapAcknowledgementDisplay(
message: Pick<InboxMessage, 'text' | 'from'>
): BootstrapAcknowledgementDisplay | null {
const text = typeof message.text === 'string' ? message.text.trim() : '';
if (!text.startsWith('{') || !text.endsWith('}')) {
return null;
}
const markers = [
"'concerns':",
"'existingRelationships':",
"'hasBootstrapGuidance':",
"'hasAcknowledged':",
"'isTeamLead':",
"'memberName':",
"'teamName':",
];
if (!markers.every((marker) => text.includes(marker))) {
return null;
}
const teammateName =
matchField(text, /'memberName':\s*'([^']+)'/) ??
(typeof message.from === 'string' ? message.from.trim() : undefined);
const teamName = matchField(text, /'teamName':\s*'([^']+)'/);
const displayName = teammateName ? displayMemberName(teammateName) : 'teammate';
return {
teammateName,
teamName,
summary: `${displayName} acknowledged bootstrap`,
body: `${displayName} acknowledged bootstrap.`,
};
}
export function getSanitizedInboxMessageText(message: Pick<InboxMessage, 'text' | 'to'>): string {
return (
getBootstrapPromptDisplay(message)?.body ??
getBootstrapAcknowledgementDisplay(message as Pick<InboxMessage, 'text' | 'from'>)?.body ??
message.text ??
''
);
}
export function getSanitizedInboxMessageSummary(
message: Pick<InboxMessage, 'text' | 'to' | 'from' | 'summary'>
): string {
return (
getBootstrapPromptDisplay(message)?.summary ??
getBootstrapAcknowledgementDisplay(message)?.summary ??
message.summary ??
''
);
}

View file

@ -7,6 +7,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
import type { import type {
LeadActivityState, LeadActivityState,
MemberSpawnLivenessSource,
MemberSpawnStatus, MemberSpawnStatus,
MemberStatus, MemberStatus,
ResolvedTeamMember, ResolvedTeamMember,
@ -98,9 +99,9 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = { export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
offline: 'offline', offline: 'offline',
waiting: 'waiting', waiting: 'awaiting heartbeat',
spawning: 'spawning', spawning: 'starting',
online: 'online', online: 'ready',
error: 'spawn failed', error: 'spawn failed',
}; };
@ -115,8 +116,20 @@ export function getSpawnAwareDotClass(
isTeamProvisioning?: boolean, isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState leadActivity?: LeadActivityState
): string { ): string {
if (spawnStatus && isTeamProvisioning) { if (spawnStatus === 'error') {
return SPAWN_DOT_COLORS[spawnStatus]; return SPAWN_DOT_COLORS.error;
}
if (spawnStatus === 'waiting') {
return SPAWN_DOT_COLORS.waiting;
}
if (spawnStatus === 'online') {
return SPAWN_DOT_COLORS.online;
}
if (spawnStatus === 'offline' && isTeamProvisioning) {
return SPAWN_DOT_COLORS.offline;
}
if (spawnStatus === 'spawning' && isTeamProvisioning) {
return SPAWN_DOT_COLORS.spawning;
} }
return getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); return getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
} }
@ -127,10 +140,23 @@ export function getSpawnAwareDotClass(
export function getSpawnAwarePresenceLabel( export function getSpawnAwarePresenceLabel(
member: ResolvedTeamMember, member: ResolvedTeamMember,
spawnStatus: MemberSpawnStatus | undefined, spawnStatus: MemberSpawnStatus | undefined,
livenessSource: MemberSpawnLivenessSource | undefined,
isTeamAlive?: boolean, isTeamAlive?: boolean,
isTeamProvisioning?: boolean, isTeamProvisioning?: boolean,
leadActivity?: LeadActivityState leadActivity?: LeadActivityState
): string { ): string {
if (spawnStatus === 'error') {
return SPAWN_PRESENCE_LABELS.error;
}
if (spawnStatus === 'offline' && isTeamProvisioning) {
return 'waiting for Agent';
}
if (spawnStatus === 'waiting') {
return SPAWN_PRESENCE_LABELS.waiting;
}
if (spawnStatus === 'online' && livenessSource === 'process') {
return 'running';
}
if (spawnStatus && isTeamProvisioning) { if (spawnStatus && isTeamProvisioning) {
return SPAWN_PRESENCE_LABELS[spawnStatus]; return SPAWN_PRESENCE_LABELS[spawnStatus];
} }

View file

@ -6,6 +6,7 @@
*/ */
import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers'; import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers';
import { summarizeAgentToolInput } from '@shared/utils/toolSummary';
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups'; import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
@ -363,8 +364,8 @@ export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] {
if (item.type === 'tool' && (item.tool.name === 'Agent' || item.tool.name === 'Task')) { if (item.type === 'tool' && (item.tool.name === 'Agent' || item.tool.name === 'Task')) {
const input = item.tool.input as Record<string, unknown> | undefined; const input = item.tool.input as Record<string, unknown> | undefined;
const desc = const desc =
(item.tool.name === 'Agent' && input ? summarizeAgentToolInput(input, 80) : null) ||
(typeof input?.description === 'string' && input.description) || (typeof input?.description === 'string' && input.description) ||
(typeof input?.prompt === 'string' && input.prompt.slice(0, 80)) ||
'Subagent'; 'Subagent';
pendingDescriptions.push(desc); pendingDescriptions.push(desc);
} }

View file

@ -1,3 +1,7 @@
import {
getSanitizedInboxMessageSummary,
getSanitizedInboxMessageText,
} from '@renderer/utils/bootstrapPromptSanitizer';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -48,8 +52,8 @@ export function filterTeamMessages(
const q = searchQuery.trim().toLowerCase(); const q = searchQuery.trim().toLowerCase();
if (q) { if (q) {
list = list.filter((m) => { list = list.filter((m) => {
const text = (m.text ?? '').toLowerCase(); const text = getSanitizedInboxMessageText(m).toLowerCase();
const summary = (m.summary ?? '').toLowerCase(); const summary = getSanitizedInboxMessageSummary(m).toLowerCase();
const from = (m.from ?? '').toLowerCase(); const from = (m.from ?? '').toLowerCase();
const to = (m.to ?? '').toLowerCase(); const to = (m.to ?? '').toLowerCase();
return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q); return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q);

View file

@ -5,6 +5,7 @@
*/ */
import { getBaseName } from '@renderer/utils/pathUtils'; import { getBaseName } from '@renderer/utils/pathUtils';
import { summarizeAgentToolInput } from '@shared/utils/toolSummary';
/** /**
* Truncates a string to a maximum length with ellipsis. * Truncates a string to a maximum length with ellipsis.
@ -249,8 +250,7 @@ export function getToolSummary(toolName: string, input: Record<string, unknown>)
return 'Delete team'; return 'Delete team';
case 'Agent': { case 'Agent': {
const desc = input.description ?? input.prompt; return summarizeAgentToolInput(input, 60);
return typeof desc === 'string' ? truncate(desc, 60) : 'Subagent';
} }
default: { default: {

View file

@ -25,6 +25,25 @@ export type CliFlavor = 'claude' | 'free-code';
export type CliProviderId = 'anthropic' | 'codex' | 'gemini'; export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
export interface CliProviderBackendOption {
id: string;
label: string;
description: string;
selectable: boolean;
recommended: boolean;
available: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}
export interface CliExternalRuntimeDiagnostic {
id: string;
label: string;
detected: boolean;
statusMessage?: string | null;
detailMessage?: string | null;
}
export interface CliProviderStatus { export interface CliProviderStatus {
providerId: CliProviderId; providerId: CliProviderId;
displayName: string; displayName: string;
@ -39,6 +58,10 @@ export interface CliProviderStatus {
teamLaunch: boolean; teamLaunch: boolean;
oneShot: boolean; oneShot: boolean;
}; };
selectedBackendId?: string | null;
resolvedBackendId?: string | null;
availableBackends?: CliProviderBackendOption[];
externalRuntimeDiagnostics?: CliExternalRuntimeDiagnostic[];
backend?: { backend?: {
kind: string; kind: string;
label: string; label: string;
@ -131,6 +154,8 @@ export interface CliInstallerProgress {
export interface CliInstallerAPI { export interface CliInstallerAPI {
/** Get current CLI installation status */ /** Get current CLI installation status */
getStatus: () => Promise<CliInstallationStatus>; getStatus: () => Promise<CliInstallationStatus>;
/** Get current runtime/auth status for a single provider */
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
/** Start install/update flow. Progress sent via onProgress events. */ /** Start install/update flow. Progress sent via onProgress events. */
install: () => Promise<void>; install: () => Promise<void>;
/** Invalidate cached status (forces fresh check on next getStatus) */ /** Invalidate cached status (forces fresh check on next getStatus) */

View file

@ -321,6 +321,13 @@ export interface AppConfig {
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */ /** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
telemetryEnabled: boolean; telemetryEnabled: boolean;
}; };
/** Runtime backend preferences for app-launched free-code sessions */
runtime: {
providerBackends: {
gemini: 'auto' | 'api' | 'cli-sdk';
codex: 'auto' | 'adapter';
};
};
/** Display and UI settings */ /** Display and UI settings */
display: { display: {
/** Whether to show timestamps in message views */ /** Whether to show timestamps in message views */

View file

@ -58,6 +58,14 @@ export interface TeamSummary {
deletedAt?: string; deletedAt?: string;
/** True when team.meta.json exists but config.json doesn't — provisioning failed before TeamCreate. */ /** True when team.meta.json exists but config.json doesn't — provisioning failed before TeamCreate. */
pendingCreate?: boolean; pendingCreate?: boolean;
/** True when the last launch partially succeeded (e.g. lead started, but not all teammates joined). */
partialLaunchFailure?: boolean;
/** Planned teammate count for the last persisted partial launch marker. */
expectedMemberCount?: number;
/** Confirmed teammate count from runtime artifacts/config for the last partial launch marker. */
confirmedMemberCount?: number;
/** Missing teammate names from the last partial launch marker. */
missingMembers?: string[];
} }
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
@ -434,9 +442,10 @@ export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
/** /**
* Spawn lifecycle status for a team member during team launch/reconnect. * Spawn lifecycle status for a team member during team launch/reconnect.
* - offline: not yet spawned (no Agent tool_use seen) * - offline: queued, Agent tool_use not sent yet
* - spawning: Agent tool_use sent, awaiting tool_result * - spawning: Agent tool_use sent, awaiting tool_result
* - online: tool_result received, agent is active * - waiting: teammate process accepted by runtime, awaiting first heartbeat/inbox signal
* - online: first heartbeat/inbox signal received
* - error: spawn failed (tool_result with error) * - error: spawn failed (tool_result with error)
*/ */
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
@ -574,6 +583,8 @@ export interface MemberSpawnStatusesSnapshot {
runId: string | null; runId: string | null;
} }
export type MemberSpawnLivenessSource = 'heartbeat' | 'process';
export interface TeamChangeEvent { export interface TeamChangeEvent {
type: type:
| 'config' | 'config'
@ -601,6 +612,12 @@ export interface MemberSpawnStatusEntry {
status: MemberSpawnStatus; status: MemberSpawnStatus;
/** Error message when status === 'error'. */ /** Error message when status === 'error'. */
error?: string; error?: string;
/**
* Optional provenance for `online`.
* - heartbeat: teammate sent a real inbox/native message after bootstrap
* - process: runtime process is alive, but bootstrap/first reply is not yet confirmed
*/
livenessSource?: MemberSpawnLivenessSource;
/** ISO timestamp of the last status change. */ /** ISO timestamp of the last status change. */
updatedAt: string; updatedAt: string;
} }

View file

@ -0,0 +1,16 @@
import type { TeamProviderId } from '@shared/types';
export function isTeamProviderId(value: unknown): value is TeamProviderId {
return value === 'anthropic' || value === 'codex' || value === 'gemini';
}
export function normalizeOptionalTeamProviderId(value: unknown): TeamProviderId | undefined {
return isTeamProviderId(value) ? value : undefined;
}
export function normalizeTeamProviderId(
value: unknown,
fallback: TeamProviderId = 'anthropic'
): TeamProviderId {
return normalizeOptionalTeamProviderId(value) ?? fallback;
}

View file

@ -73,6 +73,75 @@ function truncateStr(str: string, max: number): string {
return str.length <= max ? str : str.slice(0, max) + '...'; return str.length <= max ? str : str.slice(0, max) + '...';
} }
function formatProviderName(providerId: string): string {
switch (providerId.trim().toLowerCase()) {
case 'anthropic':
return 'Anthropic';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
default:
return providerId;
}
}
function formatEffortName(effort: string): string {
const trimmed = effort.trim();
return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : trimmed;
}
export interface AgentToolDisplayDetails {
action: string;
teammateName?: string;
teamName?: string;
runtime?: string;
subagentType?: string;
}
export function getAgentToolDisplayDetails(
input: Record<string, unknown>
): AgentToolDisplayDetails {
const teammateName = typeof input.name === 'string' ? input.name.trim() || undefined : undefined;
const teamName =
typeof input.team_name === 'string' ? input.team_name.trim() || undefined : undefined;
const description =
typeof input.description === 'string' ? input.description.trim() || undefined : undefined;
const provider =
typeof input.provider === 'string'
? formatProviderName(input.provider)
: typeof input.providerId === 'string'
? formatProviderName(input.providerId)
: undefined;
const model = typeof input.model === 'string' ? input.model.trim() || undefined : undefined;
const effort = typeof input.effort === 'string' ? formatEffortName(input.effort) : undefined;
const subagentType =
typeof input.subagent_type === 'string'
? input.subagent_type.trim() || undefined
: typeof input.subagentType === 'string'
? input.subagentType.trim() || undefined
: undefined;
const runtimeParts = [provider, model, effort].filter(
(part): part is string => typeof part === 'string' && part.length > 0
);
const runtime = runtimeParts.length > 0 ? runtimeParts.join(' · ') : undefined;
return {
action: description ?? (teammateName ? `Spawn teammate ${teammateName}` : 'Spawn subagent'),
teammateName,
teamName,
runtime,
subagentType,
};
}
export function summarizeAgentToolInput(input: Record<string, unknown>, max = 60): string {
const details = getAgentToolDisplayDetails(input);
const text = details.runtime ? `${details.action} · ${details.runtime}` : details.action;
return truncateStr(text, max);
}
/** Extract a short human-readable preview from tool_use input arguments. */ /** Extract a short human-readable preview from tool_use input arguments. */
export function extractToolPreview( export function extractToolPreview(
name: string, name: string,
@ -95,10 +164,13 @@ export function extractToolPreview(
case 'Agent': case 'Agent':
case 'Task': case 'Task':
case 'TaskCreate': case 'TaskCreate':
return typeof input.prompt === 'string' if (name === 'Agent') {
? input.prompt return summarizeAgentToolInput(input, 80);
: typeof input.description === 'string' }
? input.description return typeof input.description === 'string'
? input.description
: typeof input.prompt === 'string'
? truncateStr(input.prompt, 80)
: undefined; : undefined;
case 'WebFetch': case 'WebFetch':
if (typeof input.url === 'string') { if (typeof input.url === 'string') {

View file

@ -481,4 +481,34 @@ describe('TeamProvisioningService', () => {
}) })
).toContain('but no logs for 2m is already unusual.'); ).toContain('but no logs for 2m is already unusual.');
}); });
it('formats AskUserQuestion approvals with readable question text', () => {
const svc = new TeamProvisioningService();
expect(
(svc as any).formatToolApprovalBody('AskUserQuestion', {
questions: [
{
question:
'Я испытываю технические трудности с отправкой сообщений с помощью инструмента `SendMessage`.',
},
],
})
).toBe(
'Question: Я испытываю технические трудности с отправкой сообщений с помощью инструмента `SendMessage`.'
);
});
it('formats AskUserQuestion approvals with a compact multi-question summary', () => {
const svc = new TeamProvisioningService();
expect(
(svc as any).formatToolApprovalBody('AskUserQuestion', {
questions: [
{ question: ' First question with extra spacing. ' },
{ question: 'Second question.' },
],
})
).toBe('Questions (2): First question with extra spacing.');
});
}); });