Stabilize team provisioning and runtime diagnostics
This commit is contained in:
parent
074b614469
commit
a591ccf297
69 changed files with 4493 additions and 624 deletions
|
|
@ -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) {
|
||||
const config = readTeamConfig(paths);
|
||||
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
|
||||
|
|
@ -459,6 +496,7 @@ module.exports = {
|
|||
readMembersMeta,
|
||||
readTeamConfig,
|
||||
resolveTeamMembers,
|
||||
getCurrentRuntimeMemberIdentity,
|
||||
resolveLeadSessionId,
|
||||
saveTaskAttachmentFile,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
return String(text)
|
||||
.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.
|
||||
*/
|
||||
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:
|
||||
pnpm dev &
|
||||
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>" }
|
||||
3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list:
|
||||
{ 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:
|
||||
{ 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:
|
||||
|
|
@ -606,9 +614,43 @@ async function memberBriefing(context, memberName) {
|
|||
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
|
||||
throw new Error(`Member is removed from the team: ${requestedMemberName}`);
|
||||
}
|
||||
const member =
|
||||
let member =
|
||||
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
||||
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) {
|
||||
throw new Error(
|
||||
`Member not found in team metadata or inboxes: ${requestedMemberName}`
|
||||
|
|
@ -730,4 +772,4 @@ module.exports = {
|
|||
updateTask: (context, taskRef, updater) =>
|
||||
taskStore.updateTask(context.paths, taskRef, updater),
|
||||
updateTaskFields,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ const toolContextSchema = {
|
|||
export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
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({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
|
|
@ -51,7 +52,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
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({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
|
|
@ -63,7 +65,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
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({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
|
|
@ -76,7 +79,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
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({
|
||||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ const toolContextSchema = {
|
|||
claudeDir: z.string().min(1).optional(),
|
||||
};
|
||||
|
||||
const ALWAYS_LOAD_META = {
|
||||
'anthropic/alwaysLoad': true,
|
||||
} as const;
|
||||
|
||||
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. */
|
||||
|
|
@ -468,6 +472,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
server.addTool({
|
||||
name: 'member_briefing',
|
||||
description: 'Get bootstrap briefing for a team member',
|
||||
_meta: ALWAYS_LOAD_META,
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
|||
import { setReviewMainWindow } from './ipc/review';
|
||||
import {
|
||||
ApiKeyService,
|
||||
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||
ExtensionFacadeService,
|
||||
GlamaMcpEnrichmentService,
|
||||
McpCatalogAggregator,
|
||||
|
|
@ -536,9 +537,6 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
if (match && teamDataService) {
|
||||
const inboxName = match[1];
|
||||
|
||||
// Mark member as online when their first inbox message arrives (spawn tracking).
|
||||
teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName);
|
||||
|
||||
void teamDataService
|
||||
.getLeadMemberName(teamName)
|
||||
.then((leadName) => {
|
||||
|
|
@ -716,7 +714,7 @@ function reconfigureLocalContextForClaudeRoot(): void {
|
|||
/**
|
||||
* Initializes all services.
|
||||
*/
|
||||
function initializeServices(): void {
|
||||
async function initializeServices(): Promise<void> {
|
||||
logger.info('Initializing services...');
|
||||
|
||||
// Initialize SSH connection manager
|
||||
|
|
@ -839,6 +837,7 @@ function initializeServices(): void {
|
|||
const pluginInstallService = new PluginInstallService(pluginCatalogService);
|
||||
const mcpInstallService = new McpInstallService(mcpAggregator);
|
||||
const apiKeyService = new ApiKeyService();
|
||||
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||
// warmup() and ensureInstalled() are deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
httpServer = new HttpServer();
|
||||
|
|
@ -1392,7 +1391,7 @@ function createWindow(): void {
|
|||
/**
|
||||
* Application ready handler.
|
||||
*/
|
||||
void app.whenReady().then(() => {
|
||||
void app.whenReady().then(async () => {
|
||||
logger.info('App ready, initializing...');
|
||||
|
||||
// Pre-warm interactive shell env cache (non-blocking).
|
||||
|
|
@ -1403,7 +1402,7 @@ void app.whenReady().then(() => {
|
|||
|
||||
try {
|
||||
// Initialize services first
|
||||
initializeServices();
|
||||
await initializeServices();
|
||||
|
||||
// Apply configuration settings
|
||||
const config = configManager.getConfig();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import {
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
CLI_INSTALLER_INSTALL,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
// 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 { 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';
|
||||
|
||||
const logger = createLogger('IPC:cliInstaller');
|
||||
|
||||
let service: CliInstallerService;
|
||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||
const STATUS_CACHE_TTL_MS = 5_000;
|
||||
|
||||
|
|
@ -40,6 +47,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer
|
|||
*/
|
||||
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||
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_INVALIDATE_STATUS, handleInvalidateStatus);
|
||||
|
||||
|
|
@ -51,6 +59,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
|||
*/
|
||||
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS);
|
||||
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
|
||||
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>> {
|
||||
try {
|
||||
await service.install();
|
||||
|
|
@ -112,6 +173,7 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
|
|||
|
||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||
cachedStatus = null;
|
||||
providerStatusInFlight.clear();
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
HttpServerConfig,
|
||||
NotificationConfig,
|
||||
NotificationTrigger,
|
||||
RuntimeConfig,
|
||||
SshPersistConfig,
|
||||
} from '../services';
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ interface ValidationFailure {
|
|||
export type ConfigUpdateValidationResult =
|
||||
| ValidationSuccess<'notifications'>
|
||||
| ValidationSuccess<'general'>
|
||||
| ValidationSuccess<'runtime'>
|
||||
| ValidationSuccess<'display'>
|
||||
| ValidationSuccess<'httpServer'>
|
||||
| ValidationSuccess<'ssh'>
|
||||
|
|
@ -39,6 +41,7 @@ export type ConfigUpdateValidationResult =
|
|||
const VALID_SECTIONS = new Set<ConfigSection>([
|
||||
'notifications',
|
||||
'general',
|
||||
'runtime',
|
||||
'display',
|
||||
'httpServer',
|
||||
'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 {
|
||||
if (!isPlainObject(data)) {
|
||||
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)) {
|
||||
return {
|
||||
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);
|
||||
case 'general':
|
||||
return validateGeneralSection(data);
|
||||
case 'runtime':
|
||||
return validateRuntimeSection(data);
|
||||
case 'display':
|
||||
return validateDisplaySection(data);
|
||||
case 'httpServer':
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ import { createLogger } from '@shared/utils/logger';
|
|||
|
||||
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 { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||
|
|
@ -388,7 +391,12 @@ async function handleApiKeysSave(
|
|||
): Promise<IpcResult<ApiKeyEntry>> {
|
||||
return wrapHandler('apiKeysSave', () => {
|
||||
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>> {
|
||||
return wrapHandler('apiKeysDelete', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,10 +50,13 @@ const PBKDF2_ITERATIONS = 100_000;
|
|||
const PBKDF2_KEY_BYTES = 32;
|
||||
const PBKDF2_SALT = 'claude-apikey-storage-v1';
|
||||
|
||||
export const RUNTIME_MANAGED_API_KEY_ENV_VARS = ['GEMINI_API_KEY'] as const;
|
||||
|
||||
export class ApiKeyService {
|
||||
private readonly filePath: string;
|
||||
private cache: StoredApiKey[] | null = null;
|
||||
private aesKey: Buffer | null = null;
|
||||
private readonly originalProcessEnv = new Map<string, string | undefined>();
|
||||
|
||||
constructor(claudeDir?: string) {
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* 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 { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||
|
|
|
|||
|
|
@ -41,7 +41,13 @@ import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridge
|
|||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
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 { IncomingMessage } from 'http';
|
||||
|
||||
|
|
@ -131,6 +137,11 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
|||
providers: status.providers.map((provider) => ({
|
||||
...provider,
|
||||
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,
|
||||
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.
|
||||
* Split from getStatus() to enable overall timeout via Promise.race —
|
||||
|
|
|
|||
|
|
@ -215,6 +215,13 @@ export interface GeneralConfig {
|
|||
telemetryEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
providerBackends: {
|
||||
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||
codex: 'auto' | 'adapter';
|
||||
};
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
showTimestamps: boolean;
|
||||
compactMode: boolean;
|
||||
|
|
@ -247,6 +254,7 @@ export interface HttpServerConfig {
|
|||
export interface AppConfig {
|
||||
notifications: NotificationConfig;
|
||||
general: GeneralConfig;
|
||||
runtime: RuntimeConfig;
|
||||
display: DisplayConfig;
|
||||
sessions: SessionsConfig;
|
||||
ssh: SshPersistConfig;
|
||||
|
|
@ -299,6 +307,12 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
customProjectPaths: [],
|
||||
telemetryEnabled: true,
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto',
|
||||
codex: 'auto',
|
||||
},
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
compactMode: false,
|
||||
|
|
@ -468,6 +482,12 @@ export class ConfigManager {
|
|||
triggers: mergedTriggers,
|
||||
},
|
||||
general: mergedGeneral,
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
...DEFAULT_CONFIG.runtime.providerBackends,
|
||||
...(loaded.runtime?.providerBackends ?? {}),
|
||||
},
|
||||
},
|
||||
display: {
|
||||
...DEFAULT_CONFIG.display,
|
||||
...(loaded.display ?? {}),
|
||||
|
|
@ -540,10 +560,21 @@ export class ConfigManager {
|
|||
section: K,
|
||||
data: Partial<AppConfig[K]>
|
||||
): Partial<AppConfig[K]> {
|
||||
if (section !== 'general') {
|
||||
if (section !== 'general' && section !== 'runtime') {
|
||||
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')) {
|
||||
return data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import {
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||
import { applyConfiguredRuntimeBackendsEnv } from './providerRuntimeEnv';
|
||||
|
||||
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'];
|
||||
|
||||
function extractJsonObject<T>(raw: string): T {
|
||||
|
|
@ -83,6 +132,10 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
|||
teamLaunch: false,
|
||||
oneShot: false,
|
||||
},
|
||||
selectedBackendId: null,
|
||||
resolvedBackendId: null,
|
||||
availableBackends: [],
|
||||
externalRuntimeDiagnostics: [],
|
||||
backend: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -117,11 +170,12 @@ export class ClaudeMultimodelBridgeService {
|
|||
if (home) {
|
||||
env.HOME = home;
|
||||
}
|
||||
return env;
|
||||
return applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
||||
}
|
||||
|
||||
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
|
||||
const env = { ...this.buildCliEnv(binaryPath) };
|
||||
delete env.CLAUDE_CODE_ENTRY_PROVIDER;
|
||||
delete env.CLAUDE_CODE_USE_OPENAI;
|
||||
delete env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete env.CLAUDE_CODE_USE_VERTEX;
|
||||
|
|
@ -129,14 +183,116 @@ export class ClaudeMultimodelBridgeService {
|
|||
delete env.CLAUDE_CODE_USE_GEMINI;
|
||||
|
||||
if (providerId === 'codex') {
|
||||
env.CLAUDE_CODE_USE_OPENAI = '1';
|
||||
env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
|
||||
} else if (providerId === 'gemini') {
|
||||
env.CLAUDE_CODE_USE_GEMINI = '1';
|
||||
env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
|
||||
}
|
||||
|
||||
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> {
|
||||
const provider = createDefaultProviderStatus('gemini');
|
||||
const env = this.buildProviderCliEnv(binaryPath, 'gemini');
|
||||
|
|
@ -200,6 +356,27 @@ export class ClaudeMultimodelBridgeService {
|
|||
await resolveInteractiveShellEnv();
|
||||
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([
|
||||
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
|
||||
export type GeminiGlobalConfig = {
|
||||
geminiBackendPreference?: 'auto' | 'api' | 'cli';
|
||||
geminiResolvedBackend?: 'api' | 'cli';
|
||||
geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
|
||||
geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
|
||||
geminiLastAuthMethod?: string;
|
||||
geminiProjectId?: string;
|
||||
};
|
||||
|
|
@ -11,11 +11,37 @@ export type GeminiGlobalConfig = {
|
|||
export type GeminiRuntimeAuthState = {
|
||||
authenticated: boolean;
|
||||
authMethod: string | null;
|
||||
resolvedBackend: 'auto' | 'api' | 'cli';
|
||||
resolvedBackend: 'auto' | 'api' | 'cli-sdk';
|
||||
projectId: 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(
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<GeminiGlobalConfig | null> {
|
||||
|
|
@ -43,11 +69,11 @@ export async function resolveGeminiRuntimeAuth(
|
|||
env: NodeJS.ProcessEnv
|
||||
): Promise<GeminiRuntimeAuthState> {
|
||||
const config = await readGeminiGlobalConfig(env);
|
||||
const resolvedBackend =
|
||||
const resolvedBackend = normalizeGeminiBackend(
|
||||
env.CLAUDE_CODE_GEMINI_BACKEND?.trim() ||
|
||||
config?.geminiResolvedBackend?.trim() ||
|
||||
config?.geminiBackendPreference?.trim() ||
|
||||
'auto';
|
||||
config?.geminiResolvedBackend?.trim() ||
|
||||
config?.geminiBackendPreference?.trim()
|
||||
);
|
||||
const authMethod = config?.geminiLastAuthMethod?.trim() ?? null;
|
||||
const projectId =
|
||||
env.GOOGLE_CLOUD_PROJECT?.trim() ||
|
||||
|
|
@ -56,34 +82,41 @@ export async function resolveGeminiRuntimeAuth(
|
|||
config?.geminiProjectId?.trim() ||
|
||||
null;
|
||||
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) {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'api_key',
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
resolvedBackend: effectiveBackend,
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if ((authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId) {
|
||||
if (hasAdcWithProject) {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
resolvedBackend: effectiveBackend,
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (authMethod === 'cli_oauth_personal' && resolvedBackend === 'cli') {
|
||||
if (authMethod === 'cli_oauth_personal' && effectiveBackend === 'cli-sdk') {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod,
|
||||
resolvedBackend: 'cli',
|
||||
resolvedBackend: 'cli-sdk',
|
||||
projectId,
|
||||
statusMessage: null,
|
||||
};
|
||||
|
|
@ -93,19 +126,17 @@ export async function resolveGeminiRuntimeAuth(
|
|||
return {
|
||||
authenticated: false,
|
||||
authMethod,
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
resolvedBackend: effectiveBackend,
|
||||
projectId,
|
||||
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 {
|
||||
authenticated: false,
|
||||
authMethod,
|
||||
resolvedBackend:
|
||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
||||
resolvedBackend,
|
||||
projectId,
|
||||
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.',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { TeamProviderId } from '@shared/types';
|
||||
|
||||
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
const THIRD_PARTY_PROVIDER_ENV_KEYS = [
|
||||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
|
|
@ -8,6 +11,24 @@ const THIRD_PARTY_PROVIDER_ENV_KEYS = [
|
|||
'CLAUDE_CODE_USE_GEMINI',
|
||||
] 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(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: TeamProviderId | undefined
|
||||
|
|
@ -20,9 +41,9 @@ export function applyProviderRuntimeEnv(
|
|||
}
|
||||
|
||||
if (resolvedProvider === 'codex') {
|
||||
env.CLAUDE_CODE_USE_OPENAI = '1';
|
||||
env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
|
||||
} else if (resolvedProvider === 'gemini') {
|
||||
env.CLAUDE_CODE_USE_GEMINI = '1';
|
||||
env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
|
||||
}
|
||||
|
||||
return env;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
|||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { applyProviderRuntimeEnv } from '../runtime/providerRuntimeEnv';
|
||||
import {
|
||||
applyConfiguredRuntimeBackendsEnv,
|
||||
applyProviderRuntimeEnv,
|
||||
} from '../runtime/providerRuntimeEnv';
|
||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||
|
||||
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
||||
|
|
@ -103,7 +106,11 @@ export class ScheduledTaskExecutor {
|
|||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
||||
|
||||
const env = applyProviderRuntimeEnv(
|
||||
{ ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined },
|
||||
applyConfiguredRuntimeBackendsEnv({
|
||||
...buildEnrichedEnv(binaryPath),
|
||||
...shellEnv,
|
||||
CLAUDECODE: undefined,
|
||||
}),
|
||||
request.config.providerId
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -183,9 +183,11 @@ function getRepoLocalCliCandidates(): string[] {
|
|||
|
||||
const repoRoot = process.cwd();
|
||||
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-dev'),
|
||||
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
||||
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>(
|
||||
items: readonly T[],
|
||||
|
|
@ -132,6 +189,7 @@ export class TeamConfigReader {
|
|||
|
||||
private async readTeamSummary(teamsDir: string, teamName: string): Promise<TeamSummary | null> {
|
||||
const configPath = path.join(teamsDir, teamName, 'config.json');
|
||||
const teamDir = path.join(teamsDir, teamName);
|
||||
|
||||
try {
|
||||
let config: TeamConfig | null = null;
|
||||
|
|
@ -204,6 +262,8 @@ export class TeamConfigReader {
|
|||
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
|
||||
const memberMap = new Map<string, TeamSummaryMember>();
|
||||
const removedKeys = new Set<string>();
|
||||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
|
||||
const mergeMember = (m: TeamMember): void => {
|
||||
const name = m.name?.trim();
|
||||
|
|
@ -235,6 +295,7 @@ export class TeamConfigReader {
|
|||
removedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
expectedTeammateNames.add(name);
|
||||
mergeMember(member);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -245,11 +306,28 @@ export class TeamConfigReader {
|
|||
if (config && Array.isArray(config.members)) {
|
||||
for (const member of config.members) {
|
||||
if (member && typeof member.name === 'string') {
|
||||
const name = member.name.trim();
|
||||
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||
confirmedArtifactNames.add(name);
|
||||
}
|
||||
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.
|
||||
const allNames = Array.from(memberMap.values()).map((m) => m.name);
|
||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||
|
|
@ -262,6 +340,29 @@ export class TeamConfigReader {
|
|||
}
|
||||
|
||||
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 = {
|
||||
teamName,
|
||||
displayName,
|
||||
|
|
@ -276,6 +377,7 @@ export class TeamConfigReader {
|
|||
...(projectPathHistory ? { projectPathHistory } : {}),
|
||||
...(sessionHistory ? { sessionHistory } : {}),
|
||||
...(deletedAt ? { deletedAt } : {}),
|
||||
...(partialLaunchState ?? {}),
|
||||
};
|
||||
return summary;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/ut
|
|||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||
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
|
||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
|
|
@ -1021,10 +1084,7 @@ export class TeamDataService {
|
|||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
|
|
@ -1985,10 +2045,7 @@ export class TeamDataService {
|
|||
})(),
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model?.trim() || undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -24,10 +25,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
|||
name: trimmedName,
|
||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||
effort:
|
||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -86,6 +86,9 @@ interface TaskReadDiag {
|
|||
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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -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.
|
||||
* 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 removedKeys = new Set<string>();
|
||||
const expectedTeammateNames = new Set<string>();
|
||||
const confirmedArtifactNames = new Set<string>();
|
||||
|
||||
try {
|
||||
const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json');
|
||||
|
|
@ -500,6 +553,7 @@ async function listTeams(
|
|||
removedKeys.add(key);
|
||||
continue;
|
||||
}
|
||||
expectedTeammateNames.add(name);
|
||||
mergeMember(member, memberMap, removedKeys);
|
||||
}
|
||||
}
|
||||
|
|
@ -511,15 +565,55 @@ async function listTeams(
|
|||
if (config && Array.isArray(config.members)) {
|
||||
for (const member of config.members as unknown[]) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
dropCliProvisionerMembers(memberMap);
|
||||
|
||||
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 = {
|
||||
teamName,
|
||||
displayName,
|
||||
|
|
@ -534,6 +628,7 @@ async function listTeams(
|
|||
...(projectPathHistory ? { projectPathHistory } : {}),
|
||||
...(sessionHistory ? { sessionHistory } : {}),
|
||||
...(deletedAt ? { deletedAt } : {}),
|
||||
...(partialLaunchState ?? {}),
|
||||
};
|
||||
|
||||
const ms = nowMs() - t0;
|
||||
|
|
|
|||
|
|
@ -417,6 +417,9 @@ export const CROSS_TEAM_GET_OUTBOX = 'crossTeam:getOutbox';
|
|||
/** Get CLI installation status */
|
||||
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 */
|
||||
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
API_KEYS_STORAGE_STATUS,
|
||||
APP_RELAUNCH,
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
CLI_INSTALLER_INSTALL,
|
||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||
CLI_INSTALLER_PROGRESS,
|
||||
|
|
@ -1344,6 +1345,9 @@ const electronAPI: ElectronAPI = {
|
|||
getStatus: async (): Promise<CliInstallationStatus> => {
|
||||
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> => {
|
||||
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1076,6 +1076,7 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
authMethod: null,
|
||||
providers: [],
|
||||
}),
|
||||
getProviderStatus: async (): Promise<null> => null,
|
||||
install: async (): Promise<void> => {
|
||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
DIFF_REMOVED_TEXT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||
import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary';
|
||||
|
||||
/**
|
||||
* 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
|
||||
return (
|
||||
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
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 { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
|
|
@ -29,6 +32,7 @@ import {
|
|||
LogOut,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
|
|
@ -167,6 +171,7 @@ const CliCheckingSpinner = ({
|
|||
interface InstalledBannerProps {
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
cliStatusError: string | null;
|
||||
isBusy: boolean;
|
||||
multimodelEnabled: boolean;
|
||||
|
|
@ -176,6 +181,8 @@ interface InstalledBannerProps {
|
|||
onMultimodelToggle: (enabled: boolean) => void;
|
||||
onProviderLogin: (providerId: CliProviderId) => void;
|
||||
onProviderLogout: (providerId: CliProviderId) => void;
|
||||
onProviderManage: (providerId: CliProviderId) => void;
|
||||
onProviderRefresh: (providerId: CliProviderId) => void;
|
||||
variant: BannerVariant;
|
||||
}
|
||||
|
||||
|
|
@ -190,35 +197,41 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
}
|
||||
}
|
||||
|
||||
function getProviderTerminalCommand(providerId: CliProviderId): {
|
||||
function getProviderTerminalCommand(provider: CliProviderStatus): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
if (provider.providerId === 'gemini') {
|
||||
return {
|
||||
args: ['login'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
env: {
|
||||
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', providerId],
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
|
||||
function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
if (provider.providerId === 'gemini') {
|
||||
return {
|
||||
args: ['logout'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
env: {
|
||||
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
|
||||
): string | null {
|
||||
|
|
@ -301,12 +348,8 @@ function formatRuntimeAuthSummary(
|
|||
) {
|
||||
return 'Checking providers...';
|
||||
}
|
||||
const supportedProviders = cliStatus.providers.filter((provider) => provider.supported);
|
||||
const denominator =
|
||||
supportedProviders.length > 0 ? supportedProviders.length : cliStatus.providers.length;
|
||||
const connected = (
|
||||
supportedProviders.length > 0 ? supportedProviders : cliStatus.providers
|
||||
).filter((provider) => provider.authenticated).length;
|
||||
const denominator = cliStatus.providers.length;
|
||||
const connected = cliStatus.providers.filter((provider) => provider.authenticated).length;
|
||||
|
||||
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 = ({
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
cliStatusError,
|
||||
isBusy,
|
||||
multimodelEnabled,
|
||||
|
|
@ -385,6 +390,8 @@ const InstalledBanner = ({
|
|||
onMultimodelToggle,
|
||||
onProviderLogin,
|
||||
onProviderLogout,
|
||||
onProviderManage,
|
||||
onProviderRefresh,
|
||||
variant,
|
||||
}: InstalledBannerProps): React.JSX.Element => {
|
||||
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
||||
|
|
@ -499,48 +506,56 @@ const InstalledBanner = ({
|
|||
{cliStatus.providers.map((provider) => {
|
||||
const statusText = formatProviderStatus(provider);
|
||||
const actionDisabled = isBusy || !cliStatus.binaryPath;
|
||||
const runtimeSummary = getProviderRuntimeBackendSummary(provider);
|
||||
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||
const showSkeleton = isProviderCardLoading(provider, providerLoading);
|
||||
|
||||
return (
|
||||
<div
|
||||
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)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
<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})`
|
||||
: ''}
|
||||
<div className="col-span-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
)}
|
||||
{provider.models.length === 0 && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
<span
|
||||
className="text-xs"
|
||||
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 className="flex shrink-0 items-center gap-2">
|
||||
{provider.authenticated ? (
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<button
|
||||
onClick={() => onProviderLogout(provider.providerId)}
|
||||
onClick={() => onProviderManage(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={{
|
||||
|
|
@ -548,39 +563,57 @@ const InstalledBanner = ({
|
|||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-3" />
|
||||
Logout
|
||||
<SlidersHorizontal className="size-3" />
|
||||
Manage
|
||||
</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
|
||||
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"
|
||||
onClick={() => onProviderRefresh(provider.providerId)}
|
||||
disabled={cliStatusLoading || providerLoading}
|
||||
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}`}
|
||||
>
|
||||
<LogIn className="size-3" />
|
||||
Login
|
||||
<RefreshCw
|
||||
className={
|
||||
cliStatusLoading || providerLoading
|
||||
? 'size-[11px] animate-spin'
|
||||
: 'size-[11px]'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
<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">
|
||||
<ModelBadges providerId={provider.providerId} models={provider.models} />
|
||||
</div>
|
||||
|
|
@ -605,6 +638,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const {
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
cliStatusError,
|
||||
installerState,
|
||||
downloadProgress,
|
||||
|
|
@ -614,7 +648,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
installerDetail,
|
||||
installerRawChunks,
|
||||
completedVersion,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
invalidateCliStatus,
|
||||
installCli,
|
||||
isBusy,
|
||||
|
|
@ -625,6 +661,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
providerId: CliProviderId;
|
||||
action: 'login' | 'logout';
|
||||
} | null>(null);
|
||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||
|
|
@ -655,26 +693,34 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
}, [installCli]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
if (multimodelEnabled) {
|
||||
void bootstrapCliStatus({ multimodelEnabled: true });
|
||||
return;
|
||||
}
|
||||
void fetchCliStatus();
|
||||
}, [fetchCliStatus]);
|
||||
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
|
||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
if (enabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
// Determine variant for styling
|
||||
|
|
@ -729,11 +809,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
|
||||
const variant = getVariant();
|
||||
const styles = VARIANT_STYLES[variant];
|
||||
const providerTerminalCommand = providerTerminal
|
||||
? providerTerminal.action === 'login'
|
||||
? getProviderTerminalCommand(providerTerminal.providerId)
|
||||
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
|
||||
const activeTerminalProvider = providerTerminal
|
||||
? (cliStatus?.providers.find(
|
||||
(provider) => provider.providerId === providerTerminal.providerId
|
||||
) ?? null)
|
||||
: null;
|
||||
const providerTerminalCommand =
|
||||
providerTerminal && activeTerminalProvider
|
||||
? providerTerminal.action === 'login'
|
||||
? getProviderTerminalCommand(activeTerminalProvider)
|
||||
: getProviderTerminalLogoutCommand(activeTerminalProvider)
|
||||
: null;
|
||||
|
||||
// ── Loading / fetch error state ────────────────────────────────────────
|
||||
if (!cliStatus && installerState === 'idle') {
|
||||
|
|
@ -794,8 +880,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (multimodelEnabled) {
|
||||
return (
|
||||
<InstalledBanner
|
||||
cliStatus={createLoadingMultimodelStatus()}
|
||||
cliStatus={createLoadingMultimodelCliStatus()}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -805,6 +892,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
variant="info"
|
||||
/>
|
||||
);
|
||||
|
|
@ -1072,7 +1161,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
setIsVerifyingAuth(true);
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
|
|
@ -1142,7 +1235,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
|
|
@ -1153,7 +1250,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
void (async () => {
|
||||
try {
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
if (multimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsVerifyingAuth(false);
|
||||
}
|
||||
|
|
@ -1174,6 +1275,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
<InstalledBanner
|
||||
cliStatus={cliStatus}
|
||||
cliStatusLoading={cliStatusLoading}
|
||||
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||
cliStatusError={cliStatusError ?? null}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
|
|
@ -1183,8 +1285,24 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
onProviderManage={handleProviderManage}
|
||||
onProviderRefresh={handleProviderRefresh}
|
||||
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 && (
|
||||
<TerminalModal
|
||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -321,6 +321,12 @@ export function useSettingsHandlers({
|
|||
useNativeTitleBar: false,
|
||||
telemetryEnabled: true,
|
||||
},
|
||||
runtime: {
|
||||
providerBackends: {
|
||||
gemini: 'auto',
|
||||
codex: 'auto',
|
||||
},
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
compactMode: false,
|
||||
|
|
@ -334,6 +340,7 @@ export function useSettingsHandlers({
|
|||
|
||||
await api.config.update('notifications', defaultConfig.notifications);
|
||||
await api.config.update('general', defaultConfig.general);
|
||||
await api.config.update('runtime', defaultConfig.runtime);
|
||||
const updatedConfig = await api.config.update('display', defaultConfig.display);
|
||||
setConfig(updatedConfig);
|
||||
setOptimisticConfig(updatedConfig);
|
||||
|
|
|
|||
|
|
@ -9,10 +9,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
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 { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -23,12 +26,13 @@ import {
|
|||
LogOut,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
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 {
|
||||
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 {
|
||||
switch (providerId) {
|
||||
case 'anthropic':
|
||||
|
|
@ -80,74 +118,41 @@ function getProviderLabel(providerId: CliProviderId): string {
|
|||
}
|
||||
}
|
||||
|
||||
function getProviderTerminalCommand(providerId: CliProviderId): {
|
||||
function getProviderTerminalCommand(provider: CliProviderStatus): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
if (provider.providerId === 'gemini') {
|
||||
return {
|
||||
args: ['login'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
env: {
|
||||
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'login', '--provider', providerId],
|
||||
args: ['auth', 'login', '--provider', provider.providerId],
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
|
||||
function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
} {
|
||||
if (providerId === 'gemini') {
|
||||
if (provider.providerId === 'gemini') {
|
||||
return {
|
||||
args: ['logout'],
|
||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
||||
env: {
|
||||
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
args: ['auth', 'logout', '--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,
|
||||
})),
|
||||
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -163,28 +168,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
downloadTotal,
|
||||
installerError,
|
||||
completedVersion,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
installCli,
|
||||
isBusy,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
invalidateCliStatus,
|
||||
} = useCliInstaller();
|
||||
const [providerTerminal, setProviderTerminal] = useState<{
|
||||
providerId: CliProviderId;
|
||||
action: 'login' | 'logout';
|
||||
} | null>(null);
|
||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const effectiveCliStatus =
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
? createLoadingMultimodelStatus()
|
||||
? createLoadingMultimodelCliStatus()
|
||||
: cliStatus;
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
installCli();
|
||||
|
|
@ -213,6 +229,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const handleProviderManage = useCallback((providerId: CliProviderId) => {
|
||||
setManageProviderId(providerId);
|
||||
setManageDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const recheckStatus = useCallback(() => {
|
||||
void (async () => {
|
||||
await invalidateCliStatus();
|
||||
|
|
@ -225,18 +246,22 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
setIsSwitchingFlavor(true);
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
|
||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
await invalidateCliStatus();
|
||||
await fetchCliStatus();
|
||||
if (enabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||
);
|
||||
|
||||
if (!isElectron) return null;
|
||||
|
|
@ -250,11 +275,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
|
||||
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
|
||||
|
||||
const providerTerminalCommand = providerTerminal
|
||||
? providerTerminal.action === 'login'
|
||||
? getProviderTerminalCommand(providerTerminal.providerId)
|
||||
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
|
||||
const activeTerminalProvider = providerTerminal
|
||||
? (effectiveCliStatus?.providers.find(
|
||||
(provider) => provider.providerId === providerTerminal.providerId
|
||||
) ?? 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 (
|
||||
<div className="mb-2">
|
||||
|
|
@ -381,94 +434,141 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
{effectiveCliStatus.providers.map((provider) => (
|
||||
<div
|
||||
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={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: provider.authenticated
|
||||
? '#4ade80'
|
||||
: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{provider.authenticated
|
||||
? provider.authMethod
|
||||
? `Authenticated via ${provider.authMethod}`
|
||||
: 'Authenticated'
|
||||
: provider.statusMessage || 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{provider.backend?.label && (
|
||||
<span>Backend: {provider.backend.label}</span>
|
||||
)}
|
||||
{provider.models.length === 0 && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{provider.authenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleProviderLogout(provider.providerId)}
|
||||
disabled={!effectiveCliStatus.binaryPath}
|
||||
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
|
||||
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>
|
||||
{provider.models.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<ModelBadges
|
||||
providerId={provider.providerId}
|
||||
models={provider.models}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const providerLoading =
|
||||
cliProviderStatusLoading[provider.providerId] === true;
|
||||
const showSkeleton = isProviderCardLoading(provider, providerLoading);
|
||||
const runtimeSummary = getProviderRuntimeBackendSummary(provider);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-span-2 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{provider.displayName}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: provider.authenticated
|
||||
? '#4ade80'
|
||||
: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{provider.authenticated
|
||||
? provider.authMethod
|
||||
? `Authenticated via ${provider.authMethod}`
|
||||
: 'Authenticated'
|
||||
: provider.statusMessage || 'Not connected'}
|
||||
</span>
|
||||
</div>
|
||||
{showSkeleton ? (
|
||||
<ProviderDetailSkeleton />
|
||||
) : (
|
||||
<div
|
||||
className="mt-1 flex min-h-[2.75rem] flex-wrap gap-x-3 gap-y-1 text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{provider.backend?.label && (
|
||||
<span>Backend: {provider.backend.label}</span>
|
||||
)}
|
||||
{runtimeSummary ? (
|
||||
<span>Runtime: {runtimeSummary}</span>
|
||||
) : null}
|
||||
{provider.models.length === 0 && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderManage(provider.providerId)}
|
||||
disabled={!effectiveCliStatus.binaryPath}
|
||||
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)',
|
||||
}}
|
||||
>
|
||||
<SlidersHorizontal className="size-3" />
|
||||
Manage
|
||||
</button>
|
||||
{provider.authenticated && provider.canLoginFromUi ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleProviderLogout(provider.providerId)}
|
||||
disabled={!effectiveCliStatus.binaryPath}
|
||||
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
|
||||
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>
|
||||
)}
|
||||
<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
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface ClaudeLogsPanelProps {
|
|||
ctrl: ClaudeLogsController;
|
||||
/** Maximum height class for the log viewer (e.g. "max-h-[213px]" for compact). */
|
||||
viewerClassName?: string;
|
||||
viewerMaxHeight?: number;
|
||||
/** Extra className for the panel wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -36,6 +37,7 @@ interface ClaudeLogsPanelProps {
|
|||
export const ClaudeLogsPanel = ({
|
||||
ctrl,
|
||||
viewerClassName,
|
||||
viewerMaxHeight,
|
||||
className,
|
||||
}: ClaudeLogsPanelProps): React.JSX.Element => {
|
||||
const {
|
||||
|
|
@ -130,6 +132,7 @@ export const ClaudeLogsPanel = ({
|
|||
order="newest-first"
|
||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||
className={cn('p-2', viewerClassName)}
|
||||
style={viewerMaxHeight ? { maxHeight: `${viewerMaxHeight}px` } : undefined}
|
||||
containerRefCallback={containerRefCallback}
|
||||
onScroll={handleScroll}
|
||||
viewerState={viewerState}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const PREVIEW_ICONS = {
|
|||
interface ClaudeLogsSectionProps {
|
||||
teamName: string;
|
||||
position?: 'sidebar' | 'inline';
|
||||
sidebarViewerMaxHeight?: number;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -70,6 +72,8 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E
|
|||
export const ClaudeLogsSection = ({
|
||||
teamName,
|
||||
position = 'inline',
|
||||
sidebarViewerMaxHeight,
|
||||
onOpenChange,
|
||||
}: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||
const ctrl = useClaudeLogsController(teamName);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
|
@ -95,7 +99,7 @@ export const ClaudeLogsSection = ({
|
|||
<>
|
||||
<CollapsibleTeamSection
|
||||
sectionId="claude-logs"
|
||||
title="Claude logs"
|
||||
title="Logs"
|
||||
icon={null}
|
||||
badge={ctrl.badge}
|
||||
afterBadge={
|
||||
|
|
@ -119,9 +123,13 @@ export const ClaudeLogsSection = ({
|
|||
</Tooltip>
|
||||
) : 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'}
|
||||
headerExtra={sectionHeaderExtra}
|
||||
defaultOpen={false}
|
||||
onOpenChange={onOpenChange}
|
||||
contentWrapperClassName={isSidebar ? 'mt-0 pb-0' : undefined}
|
||||
contentClassName="pt-0 [overflow-anchor:none]"
|
||||
>
|
||||
{/* 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
|
||||
</div>
|
||||
) : (
|
||||
<ClaudeLogsPanel ctrl={ctrl} viewerClassName="max-h-[213px]" />
|
||||
<ClaudeLogsPanel
|
||||
ctrl={ctrl}
|
||||
viewerClassName="max-h-[213px]"
|
||||
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ interface CliLogsRichViewProps {
|
|||
/** Optional local search query override for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
/** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */
|
||||
footer?: React.ReactNode;
|
||||
|
||||
|
|
@ -341,6 +342,7 @@ export const CliLogsRichView = ({
|
|||
containerRefCallback,
|
||||
searchQueryOverride,
|
||||
className,
|
||||
style,
|
||||
footer,
|
||||
viewerState: controlledState,
|
||||
onViewerStateChange,
|
||||
|
|
@ -557,6 +559,7 @@ export const CliLogsRichView = ({
|
|||
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
|
|
@ -582,6 +585,7 @@ export const CliLogsRichView = ({
|
|||
containerRefCallback?.(el);
|
||||
}}
|
||||
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
|
||||
style={style}
|
||||
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
||||
>
|
||||
{visibleEntries.map((entry) =>
|
||||
|
|
|
|||
|
|
@ -31,10 +31,14 @@ interface CollapsibleTeamSectionProps {
|
|||
sectionId?: string;
|
||||
/** Extra classes applied to the content wrapper (e.g. padding). */
|
||||
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). */
|
||||
headerClassName?: string;
|
||||
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
|
||||
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). */
|
||||
keepMounted?: boolean;
|
||||
children: React.ReactNode;
|
||||
|
|
@ -53,8 +57,10 @@ export const CollapsibleTeamSection = ({
|
|||
action,
|
||||
sectionId,
|
||||
contentClassName,
|
||||
contentWrapperClassName,
|
||||
headerClassName,
|
||||
headerContentClassName,
|
||||
headerSurfaceClassName,
|
||||
keepMounted,
|
||||
children,
|
||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||
|
|
@ -88,7 +94,13 @@ export const CollapsibleTeamSection = ({
|
|||
>
|
||||
<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={() =>
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
|
|
@ -138,14 +150,24 @@ export const CollapsibleTeamSection = ({
|
|||
</div>
|
||||
{keepMounted ? (
|
||||
<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' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
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}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
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';
|
||||
|
||||
|
|
@ -39,6 +39,8 @@ export interface ProvisioningProgressBlockProps {
|
|||
onCancel?: (() => void) | null;
|
||||
/** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */
|
||||
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 */
|
||||
onDismiss?: (() => void) | null;
|
||||
/** ISO timestamp when provisioning started */
|
||||
|
|
@ -132,6 +134,7 @@ export const ProvisioningProgressBlock = ({
|
|||
loading = false,
|
||||
onCancel,
|
||||
successMessage,
|
||||
successMessageSeverity = 'success',
|
||||
onDismiss,
|
||||
startedAt,
|
||||
pid,
|
||||
|
|
@ -199,8 +202,21 @@ export const ProvisioningProgressBlock = ({
|
|||
>
|
||||
{successMessage ? (
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
|
||||
<p className="flex-1 text-xs text-[var(--step-success-text)]">{successMessage}</p>
|
||||
{successMessageSeverity === 'warning' ? (
|
||||
<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 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -458,8 +458,10 @@ export const TeamDetailView = ({
|
|||
launchParams,
|
||||
messagesPanelMode,
|
||||
messagesPanelWidth,
|
||||
sidebarLogsHeight,
|
||||
setMessagesPanelMode,
|
||||
setMessagesPanelWidth,
|
||||
setSidebarLogsHeight,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
data: s.selectedTeamData,
|
||||
|
|
@ -507,8 +509,10 @@ export const TeamDetailView = ({
|
|||
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
|
||||
messagesPanelMode: s.messagesPanelMode,
|
||||
messagesPanelWidth: s.messagesPanelWidth,
|
||||
sidebarLogsHeight: s.sidebarLogsHeight,
|
||||
setMessagesPanelMode: s.setMessagesPanelMode,
|
||||
setMessagesPanelWidth: s.setMessagesPanelWidth,
|
||||
setSidebarLogsHeight: s.setSidebarLogsHeight,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -533,6 +537,13 @@ export const TeamDetailView = ({
|
|||
maxWidth: 600,
|
||||
side: 'left',
|
||||
});
|
||||
const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({
|
||||
height: sidebarLogsHeight,
|
||||
onHeightChange: setSidebarLogsHeight,
|
||||
minHeight: 120,
|
||||
maxHeight: 520,
|
||||
side: 'top',
|
||||
});
|
||||
|
||||
const toggleMessagesPanelMode = useCallback(() => {
|
||||
setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar');
|
||||
|
|
@ -554,17 +565,28 @@ export const TeamDetailView = ({
|
|||
|
||||
// Fetch initial spawn statuses when provisioning starts
|
||||
useEffect(() => {
|
||||
if (isTeamProvisioning && teamName) {
|
||||
if (teamName && (isTeamProvisioning || memberSpawnStatuses == null)) {
|
||||
void fetchMemberSpawnStatuses(teamName);
|
||||
}
|
||||
}, [isTeamProvisioning, teamName, fetchMemberSpawnStatuses]);
|
||||
}, [isTeamProvisioning, memberSpawnStatuses, teamName, fetchMemberSpawnStatuses]);
|
||||
|
||||
// Convert Record<string, MemberSpawnStatusEntry> → Map<string, MemberSpawnEntry>
|
||||
const memberSpawnStatusMap = useMemo(() => {
|
||||
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)) {
|
||||
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;
|
||||
}, [memberSpawnStatuses]);
|
||||
|
|
@ -632,6 +654,11 @@ export const TeamDetailView = ({
|
|||
}
|
||||
}, [kanbanFilterQuery, clearKanbanFilter]);
|
||||
|
||||
const currentTeamSummary = useMemo(
|
||||
() => teams.find((team) => team.teamName === teamName) ?? null,
|
||||
[teams, teamName]
|
||||
);
|
||||
|
||||
// Load sessions for the team's project
|
||||
const projectId = useMemo(
|
||||
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
|
||||
|
|
@ -1303,6 +1330,9 @@ export const TeamDetailView = ({
|
|||
messagesPanelProps={sharedMessagesPanelProps}
|
||||
isResizing={isMessagesPanelResizing}
|
||||
onResizeMouseDown={messagesPanelHandleProps.onMouseDown}
|
||||
logsHeight={sidebarLogsHeight}
|
||||
isLogsResizing={isLogsPanelResizing}
|
||||
onLogsResizeMouseDown={logsPanelHandleProps.onMouseDown}
|
||||
/>
|
||||
</TeamSidebarPortalSource>
|
||||
</TeamSidebarHost>
|
||||
|
|
@ -1382,27 +1412,6 @@ export const TeamDetailView = ({
|
|||
Launching...
|
||||
</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 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">
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
const history = team.projectPathHistory;
|
||||
|
|
@ -157,6 +157,7 @@ function renderTeamRecentPaths(
|
|||
}
|
||||
|
||||
function resolveTeamStatus(
|
||||
team: TeamSummary,
|
||||
teamName: string,
|
||||
aliveTeams: string[],
|
||||
currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>,
|
||||
|
|
@ -173,6 +174,9 @@ function resolveTeamStatus(
|
|||
) {
|
||||
return 'provisioning';
|
||||
}
|
||||
if (team.partialLaunchFailure) {
|
||||
return 'partial_failure';
|
||||
}
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
|
|
@ -206,6 +210,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
|||
Offline
|
||||
</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) {
|
||||
result = result.filter((t) => {
|
||||
const status = resolveTeamStatus(
|
||||
t,
|
||||
t.teamName,
|
||||
aliveTeams,
|
||||
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
|
||||
leadActivityByTeam
|
||||
);
|
||||
const isRunning = status !== 'offline';
|
||||
const isRunning = status !== 'offline' && status !== 'partial_failure';
|
||||
if (filter.selectedStatuses.has('running') && isRunning) return true;
|
||||
if (filter.selectedStatuses.has('offline') && !isRunning) return true;
|
||||
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">
|
||||
{activeFiltered.map((team) => {
|
||||
const status = resolveTeamStatus(
|
||||
team,
|
||||
team.teamName,
|
||||
aliveTeams,
|
||||
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
|
||||
|
|
@ -837,24 +850,27 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
})()}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{status === 'offline' && team.projectPath && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="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"
|
||||
onClick={(e) => handleLaunchTeam(team.teamName, team.projectPath, e)}
|
||||
disabled={launchingTeamName === team.teamName}
|
||||
aria-label="Launch team"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(status === 'offline' || status === 'partial_failure') &&
|
||||
team.projectPath && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="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"
|
||||
onClick={(e) =>
|
||||
handleLaunchTeam(team.teamName, team.projectPath, e)
|
||||
}
|
||||
disabled={launchingTeamName === team.teamName}
|
||||
aria-label="Launch team"
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(status === 'active' || status === 'idle') && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -908,6 +924,13 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
{team.description || 'No description'}
|
||||
</p>
|
||||
</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">
|
||||
{team.members && team.members.length > 0 ? (
|
||||
renderMemberChips(team.members, isLight)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { X } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
|
|
@ -16,11 +17,12 @@ interface TeamProvisioningBannerProps {
|
|||
export const TeamProvisioningBanner = ({
|
||||
teamName,
|
||||
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
|
||||
const { progress, cancelProvisioning, teamMembers } = useStore(
|
||||
const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses } = useStore(
|
||||
useShallow((s) => ({
|
||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||
cancelProvisioning: s.cancelProvisioning,
|
||||
teamMembers: s.selectedTeamData?.members,
|
||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
}))
|
||||
);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
|
@ -102,15 +104,37 @@ export const TeamProvisioningBanner = ({
|
|||
);
|
||||
}
|
||||
|
||||
const allTeammatesOnline =
|
||||
teamMembers != null &&
|
||||
teamMembers.length > 0 &&
|
||||
teamMembers.every((m) => m.status === 'active' || m.status === 'idle');
|
||||
const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member));
|
||||
const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter(
|
||||
([, entry]) => entry.status === 'error'
|
||||
);
|
||||
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) {
|
||||
const readyMessage = allTeammatesOnline
|
||||
? `Team launched — all ${teamMembers.length} teammates online`
|
||||
: 'Team launched — teammates may still be starting';
|
||||
const readyMessage =
|
||||
failedSpawnCount > 0
|
||||
? `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 (
|
||||
<div className="mb-3">
|
||||
|
|
@ -127,6 +151,7 @@ export const TeamProvisioningBanner = ({
|
|||
defaultLiveOutputOpen={false}
|
||||
onCancel={null}
|
||||
successMessage={readyMessage}
|
||||
successMessageSeverity={failedSpawnCount > 0 ? 'warning' : 'success'}
|
||||
onDismiss={() => setDismissed(true)}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ import {
|
|||
parseMessageReply,
|
||||
parseStructuredAgentMessage,
|
||||
} from '@renderer/utils/agentMessageFormatting';
|
||||
import {
|
||||
getBootstrapAcknowledgementDisplay,
|
||||
getBootstrapPromptDisplay,
|
||||
getSanitizedInboxMessageSummary,
|
||||
getSanitizedInboxMessageText,
|
||||
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import {
|
||||
|
|
@ -291,6 +297,80 @@ const NoiseRow = ({
|
|||
</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.
|
||||
// These patterns are kept only for legacy compatibility with old inbox/session rows;
|
||||
|
|
@ -452,6 +532,11 @@ export const ActivityItem = memo(
|
|||
}, [message.timestamp]);
|
||||
|
||||
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
|
||||
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
||||
// Highlight messages containing API errors
|
||||
|
|
@ -510,7 +595,10 @@ export const ActivityItem = memo(
|
|||
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
||||
const strippedText = useMemo(() => {
|
||||
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
|
||||
// Strip cross-team metadata tag (e.g. `<cross-team from="team.lead" depth="0" />\n`)
|
||||
// — 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
|
||||
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
}, [structured, message.text, isCrossTeamAny]);
|
||||
}, [structured, message, bootstrapDisplay, isCrossTeamAny]);
|
||||
const standaloneSlashCommand = useMemo(
|
||||
() => (strippedText ? parseStandaloneSlashCommand(strippedText) : null),
|
||||
[strippedText]
|
||||
|
|
@ -580,10 +668,12 @@ export const ActivityItem = memo(
|
|||
}
|
||||
if (crossTeamPreview) return crossTeamPreview;
|
||||
const s =
|
||||
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
|
||||
getSanitizedInboxMessageSummary(message) ||
|
||||
(structured ? getStructuredMessageSummary(structured) : '') ||
|
||||
'';
|
||||
if (s) return s;
|
||||
// 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 '';
|
||||
const oneLine = plain.replace(/\n+/g, ' ');
|
||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
||||
|
|
@ -592,10 +682,8 @@ export const ActivityItem = memo(
|
|||
isSlashCommandMessage,
|
||||
isSlashCommandResult,
|
||||
message.commandOutput,
|
||||
message.summary,
|
||||
message.text,
|
||||
message,
|
||||
slashCommandMeta,
|
||||
standaloneSlashCommand,
|
||||
structured,
|
||||
]);
|
||||
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 =
|
||||
structured && typeof structured.type === 'string'
|
||||
? getMessageTypeLabel(structured.type)
|
||||
|
|
@ -642,18 +757,10 @@ export const ActivityItem = memo(
|
|||
const subject = message.summary || autoSummary || `Task from ${message.from}`;
|
||||
const plainText = structured
|
||||
? JSON.stringify(structured, null, 2)
|
||||
: stripAgentBlocks(message.text);
|
||||
: getSanitizedInboxMessageText(message);
|
||||
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
|
||||
onCreateTask?.(subject, description);
|
||||
}, [
|
||||
autoSummary,
|
||||
message.from,
|
||||
message.summary,
|
||||
message.text,
|
||||
onCreateTask,
|
||||
structured,
|
||||
timestamp,
|
||||
]);
|
||||
}, [autoSummary, message.from, message.summary, message, onCreateTask, structured, timestamp]);
|
||||
|
||||
const isHeaderClickable = isManaged && canToggleCollapse;
|
||||
const showChevron = isHeaderClickable && !compactHeader;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
|
|
@ -44,6 +45,7 @@ import { OptionalSettingsSection } from './OptionalSettingsSection';
|
|||
import {
|
||||
createInitialProviderChecks,
|
||||
failIncompleteProviderChecks,
|
||||
getProvisioningProviderBackendSummary,
|
||||
getProvisioningFailureHint,
|
||||
ProvisioningProviderStatusList,
|
||||
shouldHideProvisioningProviderStatusList,
|
||||
|
|
@ -270,6 +272,9 @@ export const CreateTeamDialog = ({
|
|||
}: CreateTeamDialogProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
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) ──────────────────
|
||||
const {
|
||||
|
|
@ -460,12 +465,25 @@ export const CreateTeamDialog = ({
|
|||
new Set([
|
||||
selectedProviderId,
|
||||
...members.flatMap((member) =>
|
||||
member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : []
|
||||
isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||
),
|
||||
])
|
||||
);
|
||||
}, [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(() => {
|
||||
if (multimodelEnabled) {
|
||||
return;
|
||||
|
|
@ -481,6 +499,13 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
}, [members, multimodelEnabled, selectedProviderId, setMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || cliStatus || cliStatusLoading) {
|
||||
return;
|
||||
}
|
||||
void fetchCliStatus();
|
||||
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !canCreate || !launchTeam) {
|
||||
return;
|
||||
|
|
@ -523,6 +548,7 @@ export const CreateTeamDialog = ({
|
|||
for (const providerId of selectedMemberProviders) {
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: 'checking',
|
||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||
details: [],
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
|
|
@ -552,6 +578,7 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||
details: detailLines,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
|
|
@ -584,7 +611,15 @@ export const CreateTeamDialog = ({
|
|||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
||||
}, [
|
||||
open,
|
||||
canCreate,
|
||||
launchTeam,
|
||||
effectiveCwd,
|
||||
selectedProviderId,
|
||||
selectedMemberProviders,
|
||||
runtimeBackendSummaryByProvider,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -660,12 +695,8 @@ export const CreateTeamDialog = ({
|
|||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||
customRole: isCustom ? m.role : '',
|
||||
workflow: m.workflow,
|
||||
providerId: normalizeProviderForMode(m.providerId, multimodelEnabled),
|
||||
model:
|
||||
normalizeProviderForMode(m.providerId, multimodelEnabled) ===
|
||||
normalizeProviderForMode(m.providerId, true)
|
||||
? (m.model ?? '')
|
||||
: '',
|
||||
providerId: normalizeOptionalTeamProviderId(m.providerId),
|
||||
model: m.model ?? '',
|
||||
effort: m.effort,
|
||||
}),
|
||||
multimodelEnabled
|
||||
|
|
@ -777,9 +808,10 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
|
||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||
const teamNameInlineError = validateTeamNameInline(teamName);
|
||||
const isNameTakenByExistingTeam = existingTeamNames.includes(sanitizedTeamName);
|
||||
const isNameProvisioning =
|
||||
provisioningTeamNames.includes(sanitizedTeamName) &&
|
||||
!existingTeamNames.includes(sanitizedTeamName);
|
||||
provisioningTeamNames.includes(sanitizedTeamName) && !isNameTakenByExistingTeam;
|
||||
|
||||
const request = useMemo<TeamCreateRequest>(
|
||||
() => ({
|
||||
|
|
@ -815,6 +847,15 @@ export const CreateTeamDialog = ({
|
|||
customArgs,
|
||||
]
|
||||
);
|
||||
const requestValidation = useMemo(
|
||||
() => validateRequest(request, { requireCwd: launchTeam }),
|
||||
[request, launchTeam]
|
||||
);
|
||||
const hasCreateFormErrors =
|
||||
!!teamNameInlineError ||
|
||||
isNameTakenByExistingTeam ||
|
||||
isNameProvisioning ||
|
||||
!requestValidation.valid;
|
||||
|
||||
const internalArgs = useMemo(() => {
|
||||
const args: string[] = [];
|
||||
|
|
@ -1055,20 +1096,24 @@ export const CreateTeamDialog = ({
|
|||
id="team-name"
|
||||
className={cn(
|
||||
'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)]'
|
||||
)}
|
||||
value={teamName}
|
||||
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||
placeholder={suggestedTeamName}
|
||||
/>
|
||||
{allTakenTeamNames.includes(sanitizedTeamName) ? (
|
||||
{isNameTakenByExistingTeam ? (
|
||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||
{isNameProvisioning ? 'Team is currently launching' : 'Team name already exists'}
|
||||
Team name already exists
|
||||
</p>
|
||||
) : validateTeamNameInline(teamName) ? (
|
||||
) : teamNameInlineError ? (
|
||||
<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>
|
||||
) : fieldErrors.teamName ? (
|
||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||
|
|
@ -1391,7 +1436,7 @@ export const CreateTeamDialog = ({
|
|||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || !draftLoaded || isSubmitting}
|
||||
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import {
|
||||
getCurrentProvisioningProgressForTeam,
|
||||
isTeamProvisioningActive,
|
||||
|
|
@ -61,6 +62,7 @@ import { OptionalSettingsSection } from './OptionalSettingsSection';
|
|||
import {
|
||||
createInitialProviderChecks,
|
||||
failIncompleteProviderChecks,
|
||||
getProvisioningProviderBackendSummary,
|
||||
getProvisioningFailureHint,
|
||||
ProvisioningProviderStatusList,
|
||||
shouldHideProvisioningProviderStatusList,
|
||||
|
|
@ -68,7 +70,11 @@ import {
|
|||
type ProvisioningProviderCheck,
|
||||
} from './ProvisioningProviderStatusList';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||
import {
|
||||
computeEffectiveTeamModel,
|
||||
formatTeamModelSummary,
|
||||
TeamModelSelector,
|
||||
} from './TeamModelSelector';
|
||||
|
||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||
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
|
||||
// =============================================================================
|
||||
|
|
@ -163,6 +195,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const { open, onClose } = props;
|
||||
const { isLight } = useTheme();
|
||||
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 isSchedule = props.mode === 'schedule';
|
||||
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 prepareRequestSeqRef = useRef(0);
|
||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||
const previousLaunchParams = useStore((s) =>
|
||||
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
||||
);
|
||||
const members = isLaunch ? props.members : storeMembers;
|
||||
const [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
|
||||
|
||||
// Advanced CLI section state (with localStorage persistence)
|
||||
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
||||
|
|
@ -277,15 +316,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
new Set([
|
||||
selectedProviderId,
|
||||
...effectiveMemberDrafts.flatMap((member) =>
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? [member.providerId]
|
||||
: []
|
||||
isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||
),
|
||||
])
|
||||
),
|
||||
[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(() => {
|
||||
if (multimodelEnabled) {
|
||||
return;
|
||||
|
|
@ -305,6 +355,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
});
|
||||
}, [multimodelEnabled, selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || cliStatus || cliStatusLoading) {
|
||||
return;
|
||||
}
|
||||
void fetchCliStatus();
|
||||
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
|
||||
|
||||
// Schedule store actions
|
||||
const createSchedule = useStore((s) => s.createSchedule);
|
||||
const updateSchedule = useStore((s) => s.updateSchedule);
|
||||
|
|
@ -494,6 +551,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
? savedRequest.members
|
||||
: [];
|
||||
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
|
||||
const savedProviderId =
|
||||
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
|
||||
? savedRequest.providerId
|
||||
: savedRequest?.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: null;
|
||||
setSavedLaunchProviderId(savedProviderId);
|
||||
|
||||
setMembersDrafts(
|
||||
createMemberDraftsFromInputs(nextMembersSource).map((member) =>
|
||||
|
|
@ -536,6 +600,177 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
};
|
||||
}, [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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -588,6 +823,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
for (const providerId of selectedMemberProviders) {
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: 'checking',
|
||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||
details: [],
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
|
|
@ -615,6 +851,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}
|
||||
checks = updateProviderCheck(checks, providerId, {
|
||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
||||
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||
details: detailLines,
|
||||
});
|
||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||
|
|
@ -645,7 +882,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
||||
}, [
|
||||
open,
|
||||
isLaunch,
|
||||
effectiveCwd,
|
||||
selectedProviderId,
|
||||
selectedMemberProviders,
|
||||
runtimeBackendSummaryByProvider,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared effects: projects
|
||||
|
|
@ -819,6 +1063,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}
|
||||
return errors;
|
||||
}, [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
|
||||
|
|
@ -927,10 +1186,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSchedule) {
|
||||
setLocalError(err instanceof Error ? err.message : 'Failed to save schedule');
|
||||
const message =
|
||||
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 {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
|
@ -942,7 +1207,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isDisabled = isLaunch
|
||||
? isSubmitting || launchInFlight
|
||||
? isSubmitting ||
|
||||
launchInFlight ||
|
||||
validationErrors.length > 0 ||
|
||||
hasInvalidLaunchMemberNames ||
|
||||
hasDuplicateLaunchMemberNames
|
||||
: isSubmitting || validationErrors.length > 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1265,6 +1534,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
memberWarningById={memberRuntimeWarningById}
|
||||
softDeleteMembers
|
||||
/>
|
||||
|
||||
|
|
@ -1302,6 +1573,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<Checkbox
|
||||
id="clear-context"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
import type { TeamProviderId } from '@shared/types';
|
||||
import type { CliProviderStatus } from '@shared/types';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||
|
|
@ -8,6 +9,7 @@ export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' |
|
|||
export interface ProvisioningProviderCheck {
|
||||
providerId: TeamProviderId;
|
||||
status: ProvisioningProviderCheckStatus;
|
||||
backendSummary?: string | null;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
|
|
@ -29,10 +31,35 @@ export function createInitialProviderChecks(
|
|||
return providerIds.map((providerId) => ({
|
||||
providerId,
|
||||
status: 'pending',
|
||||
backendSummary: null,
|
||||
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(
|
||||
checks: ProvisioningProviderCheck[],
|
||||
providerId: TeamProviderId,
|
||||
|
|
@ -207,7 +234,9 @@ export function ProvisioningProviderStatusList({
|
|||
>
|
||||
<StatusIcon status={check.status} />
|
||||
<span>
|
||||
{getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)}
|
||||
{getProvisioningProviderLabel(check.providerId)}
|
||||
{check.backendSummary ? ` (${check.backendSummary})` : ''}:{' '}
|
||||
{getDisplayStatusText(check)}
|
||||
</span>
|
||||
</div>
|
||||
{visibleDetails.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -123,7 +123,22 @@ export function formatTeamModelSummary(
|
|||
const providerLabel = getTeamProviderLabel(providerId);
|
||||
const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
|
||||
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(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -250,7 +250,8 @@ export const KanbanTaskCard = memo(
|
|||
[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 = (
|
||||
<>
|
||||
{canDisplay && task.changePresence === 'has_changes' ? (
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ interface LeadModelRowProps {
|
|||
onLimitContextChange: (value: boolean) => void;
|
||||
syncModelsWithTeammates: boolean;
|
||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
warningText?: string | null;
|
||||
}
|
||||
|
||||
export const LeadModelRow = ({
|
||||
|
|
@ -41,6 +42,7 @@ export const LeadModelRow = ({
|
|||
onLimitContextChange,
|
||||
syncModelsWithTeammates,
|
||||
onSyncModelsWithTeammatesChange,
|
||||
warningText,
|
||||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
|
|
@ -102,6 +104,14 @@ export const LeadModelRow = ({
|
|||
</Button>
|
||||
</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 ? (
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<TeamModelSelector
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
|||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
LeadActivityState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamTaskWithKanban,
|
||||
|
|
@ -37,6 +38,7 @@ interface MemberCardProps {
|
|||
isRemoved?: boolean;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
spawnError?: string;
|
||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||
onOpenTask?: () => void;
|
||||
onOpenReviewTask?: () => void;
|
||||
onClick?: () => void;
|
||||
|
|
@ -58,6 +60,7 @@ export const MemberCard = ({
|
|||
isRemoved,
|
||||
spawnStatus,
|
||||
spawnError,
|
||||
spawnLivenessSource,
|
||||
onOpenTask,
|
||||
onOpenReviewTask,
|
||||
onClick,
|
||||
|
|
@ -79,6 +82,7 @@ export const MemberCard = ({
|
|||
const presenceLabel = getSpawnAwarePresenceLabel(
|
||||
member,
|
||||
spawnStatus,
|
||||
spawnLivenessSource,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ interface MemberDraftRowProps {
|
|||
modelLockReason?: string;
|
||||
isRemoved?: boolean;
|
||||
onRestore?: (id: string) => void;
|
||||
warningText?: string | null;
|
||||
}
|
||||
|
||||
export const MemberDraftRow = ({
|
||||
|
|
@ -81,6 +82,7 @@ export const MemberDraftRow = ({
|
|||
modelLockReason,
|
||||
isRemoved = false,
|
||||
onRestore,
|
||||
warningText,
|
||||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const memberColorSet = getTeamColorSet(
|
||||
|
|
@ -289,6 +291,14 @@ export const MemberDraftRow = ({
|
|||
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
|
||||
) : null}
|
||||
</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 ? (
|
||||
<div className="space-y-0.5 pl-3 md:col-span-3">
|
||||
<label
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
formatTeamModelSummary,
|
||||
getTeamEffortLabel,
|
||||
getTeamModelLabel,
|
||||
getTeamProviderLabel,
|
||||
|
|
@ -14,6 +15,7 @@ import { MemberCard } from './MemberCard';
|
|||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
LeadActivityState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamTaskWithKanban,
|
||||
|
|
@ -22,6 +24,7 @@ import type {
|
|||
export interface MemberSpawnEntry {
|
||||
status: MemberSpawnStatus;
|
||||
error?: string;
|
||||
livenessSource?: MemberSpawnLivenessSource;
|
||||
}
|
||||
|
||||
interface MemberListProps {
|
||||
|
|
@ -87,39 +90,11 @@ export const MemberList = ({
|
|||
|
||||
const buildRuntimeSummary = useCallback(
|
||||
(member: ResolvedTeamMember): string | undefined => {
|
||||
const hasMemberOverride = Boolean(member.providerId || member.model || member.effort);
|
||||
if (!hasMemberOverride && launchParams) {
|
||||
return undefined;
|
||||
}
|
||||
const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic';
|
||||
const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || '';
|
||||
const effectiveEffort = member.effort ?? launchParams?.effort;
|
||||
|
||||
const defaultProvider = launchParams?.providerId ?? 'anthropic';
|
||||
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;
|
||||
return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort);
|
||||
},
|
||||
[launchParams]
|
||||
);
|
||||
|
|
@ -161,6 +136,7 @@ export const MemberList = ({
|
|||
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
|
||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { Plus } from 'lucide-react';
|
||||
|
||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||
|
|
@ -31,7 +32,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
|
|||
if (role) obj.role = role;
|
||||
const workflow = getWorkflowForExport(d);
|
||||
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.effort) obj.effort = d.effort;
|
||||
return obj;
|
||||
|
|
@ -46,8 +47,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
|||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
||||
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
|
||||
const providerId: TeamProviderId =
|
||||
item.providerId === 'codex' || item.providerId === 'gemini' ? item.providerId : 'anthropic';
|
||||
const providerId = normalizeOptionalTeamProviderId(item.providerId);
|
||||
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
||||
const effort: EffortLevel | undefined =
|
||||
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
|
||||
|
|
@ -99,6 +99,7 @@ export interface MembersEditorSectionProps {
|
|||
forceInheritedModelSettings?: boolean;
|
||||
modelLockReason?: string;
|
||||
softDeleteMembers?: boolean;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
export const MembersEditorSection = ({
|
||||
|
|
@ -124,6 +125,7 @@ export const MembersEditorSection = ({
|
|||
forceInheritedModelSettings = false,
|
||||
modelLockReason,
|
||||
softDeleteMembers = false,
|
||||
memberWarningById,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
|
|
@ -310,6 +312,7 @@ export const MembersEditorSection = ({
|
|||
teamSuggestions={teamSuggestions}
|
||||
lockProviderModel={lockProviderModel}
|
||||
modelLockReason={modelLockReason}
|
||||
warningText={memberWarningById?.[member.id] ?? null}
|
||||
/>
|
||||
))}
|
||||
{softDeleteMembers && removedMembers.length > 0 ? (
|
||||
|
|
@ -348,6 +351,7 @@ export const MembersEditorSection = ({
|
|||
lockProviderModel
|
||||
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
|
||||
isRemoved
|
||||
warningText={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ interface TeamRosterEditorSectionProps {
|
|||
headerTop?: React.ReactNode;
|
||||
headerBottom?: React.ReactNode;
|
||||
softDeleteMembers?: boolean;
|
||||
leadWarningText?: string | null;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
}
|
||||
|
||||
export const TeamRosterEditorSection = ({
|
||||
|
|
@ -77,6 +79,8 @@ export const TeamRosterEditorSection = ({
|
|||
headerTop,
|
||||
headerBottom,
|
||||
softDeleteMembers = false,
|
||||
leadWarningText,
|
||||
memberWarningById,
|
||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||
return (
|
||||
<MembersEditorSection
|
||||
|
|
@ -115,10 +119,12 @@ export const TeamRosterEditorSection = ({
|
|||
onLimitContextChange={onLimitContextChange}
|
||||
syncModelsWithTeammates={syncModelsWithTeammates}
|
||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||
warningText={leadWarningText}
|
||||
/>
|
||||
{headerBottom}
|
||||
</div>
|
||||
}
|
||||
memberWarningById={memberWarningById}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -62,10 +63,7 @@ export function createMemberDraftsFromInputs(
|
|||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
workflow: member.workflow,
|
||||
providerId:
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: 'anthropic',
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
model: member.model ?? '',
|
||||
effort: normalizeDraftEffort(member.effort),
|
||||
removedAt: member.removedAt,
|
||||
|
|
@ -196,11 +194,8 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
const result: TeamProvisioningMemberInput = { name, role };
|
||||
const workflow = getWorkflowForExport(member);
|
||||
if (workflow) result.workflow = workflow;
|
||||
const providerId: TeamProviderId =
|
||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
||||
? member.providerId
|
||||
: 'anthropic';
|
||||
if (providerId !== 'anthropic') {
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
if (providerId) {
|
||||
result.providerId = providerId;
|
||||
}
|
||||
const model = member.model?.trim();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ClaudeLogsSection } from '../ClaudeLogsSection';
|
||||
import { MessagesPanel } from '../messages/MessagesPanel';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
|
|
@ -11,6 +12,9 @@ interface TeamSidebarRailProps {
|
|||
messagesPanelProps: SharedMessagesPanelProps;
|
||||
isResizing: boolean;
|
||||
onResizeMouseDown: MouseEventHandler<HTMLDivElement>;
|
||||
logsHeight: number;
|
||||
isLogsResizing: boolean;
|
||||
onLogsResizeMouseDown: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const TeamSidebarRail = ({
|
||||
|
|
@ -18,13 +22,39 @@ export const TeamSidebarRail = ({
|
|||
messagesPanelProps,
|
||||
isResizing,
|
||||
onResizeMouseDown,
|
||||
logsHeight,
|
||||
isLogsResizing,
|
||||
onLogsResizeMouseDown,
|
||||
}: 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 (
|
||||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||
<div className="shrink-0 overflow-hidden px-3">
|
||||
<ClaudeLogsSection teamName={teamName} position="sidebar" />
|
||||
<ClaudeLogsSection
|
||||
teamName={teamName}
|
||||
position="sidebar"
|
||||
sidebarViewerMaxHeight={logsHeight}
|
||||
onOpenChange={setLogsOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-[var(--color-text-muted)]/35 mx-3 h-px shrink-0" />
|
||||
{logsSeparator}
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessagesPanel position="sidebar" {...messagesPanelProps} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@
|
|||
|
||||
import { useStore } from '@renderer/store';
|
||||
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type { CliInstallationStatus, CliProviderId } from '@shared/types';
|
||||
|
||||
export function useCliInstaller(): {
|
||||
cliStatus: CliInstallationStatus | null;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
cliStatusError: string | null;
|
||||
installerState:
|
||||
| 'idle'
|
||||
|
|
@ -28,13 +29,19 @@ export function useCliInstaller(): {
|
|||
installerDetail: string | null;
|
||||
installerRawChunks: string[];
|
||||
completedVersion: string | null;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
options?: { silent?: boolean; epoch?: number }
|
||||
) => Promise<void>;
|
||||
invalidateCliStatus: () => Promise<void>;
|
||||
installCli: () => void;
|
||||
isBusy: boolean;
|
||||
} {
|
||||
const cliStatus = useStore((s) => s.cliStatus);
|
||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||
const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
|
||||
const cliStatusError = useStore((s) => s.cliStatusError);
|
||||
const installerState = useStore((s) => s.cliInstallerState);
|
||||
const downloadProgress = useStore((s) => s.cliDownloadProgress);
|
||||
|
|
@ -44,7 +51,9 @@ export function useCliInstaller(): {
|
|||
const installerDetail = useStore((s) => s.cliInstallerDetail);
|
||||
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks);
|
||||
const completedVersion = useStore((s) => s.cliCompletedVersion);
|
||||
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||
const fetchCliProviderStatus = useStore((s) => s.fetchCliProviderStatus);
|
||||
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
|
||||
const installCli = useStore((s) => s.installCli);
|
||||
|
||||
|
|
@ -53,6 +62,7 @@ export function useCliInstaller(): {
|
|||
return {
|
||||
cliStatus,
|
||||
cliStatusLoading,
|
||||
cliProviderStatusLoading,
|
||||
cliStatusError,
|
||||
installerState,
|
||||
downloadProgress,
|
||||
|
|
@ -62,7 +72,9 @@ export function useCliInstaller(): {
|
|||
installerDetail,
|
||||
installerRawChunks,
|
||||
completedVersion,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
fetchCliProviderStatus,
|
||||
invalidateCliStatus,
|
||||
installCli,
|
||||
isBusy,
|
||||
|
|
|
|||
|
|
@ -1,30 +1,35 @@
|
|||
/**
|
||||
* useResizablePanel - Reusable hook for mouse-based panel resizing.
|
||||
*
|
||||
* Extracted from the resize pattern in Sidebar.tsx.
|
||||
* Handles mousedown/mousemove/mouseup on document, cursor and userSelect overrides.
|
||||
*
|
||||
* @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
|
||||
* Supports both:
|
||||
* - horizontal resizing for left/right side panels
|
||||
* - vertical resizing for top/bottom stacked panels
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DEFAULT_MIN_WIDTH = 280;
|
||||
const DEFAULT_MAX_WIDTH = 500;
|
||||
const DEFAULT_MIN_HEIGHT = 120;
|
||||
const DEFAULT_MAX_HEIGHT = 520;
|
||||
|
||||
interface UseResizablePanelOptions {
|
||||
type HorizontalResizeOptions = {
|
||||
width: number;
|
||||
onWidthChange: (width: number) => void;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
};
|
||||
|
||||
type VerticalResizeOptions = {
|
||||
height: number;
|
||||
onHeightChange: (height: number) => void;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
side: 'top' | 'bottom';
|
||||
};
|
||||
|
||||
type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions;
|
||||
|
||||
interface ResizeHandleProps {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
|
|
@ -35,47 +40,61 @@ interface UseResizablePanelReturn {
|
|||
handleProps: ResizeHandleProps;
|
||||
}
|
||||
|
||||
export function useResizablePanel({
|
||||
width,
|
||||
onWidthChange,
|
||||
minWidth = DEFAULT_MIN_WIDTH,
|
||||
maxWidth = DEFAULT_MAX_WIDTH,
|
||||
side,
|
||||
}: UseResizablePanelOptions): UseResizablePanelReturn {
|
||||
function isVerticalOptions(options: UseResizablePanelOptions): options is VerticalResizeOptions {
|
||||
return options.side === 'top' || options.side === 'bottom';
|
||||
}
|
||||
|
||||
export function useResizablePanel(options: UseResizablePanelOptions): UseResizablePanelReturn {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const originRef = useRef(0);
|
||||
const isVertical = isVerticalOptions(options);
|
||||
|
||||
// Store the panel's left offset for 'left' side panels.
|
||||
// Updated on resize start so the formula stays correct if layout shifts.
|
||||
const panelLeftRef = useRef(0);
|
||||
|
||||
// Keep callbacks in refs to avoid stale closures in mousemove listener
|
||||
const onWidthChangeRef = useRef(onWidthChange);
|
||||
const minWidthRef = useRef(minWidth);
|
||||
const maxWidthRef = useRef(maxWidth);
|
||||
const sideRef = useRef(side);
|
||||
const onSizeChangeRef = useRef<(size: number) => void>(
|
||||
isVertical ? options.onHeightChange : options.onWidthChange
|
||||
);
|
||||
const minSizeRef = useRef(
|
||||
isVertical ? (options.minHeight ?? DEFAULT_MIN_HEIGHT) : (options.minWidth ?? DEFAULT_MIN_WIDTH)
|
||||
);
|
||||
const maxSizeRef = useRef(
|
||||
isVertical ? (options.maxHeight ?? DEFAULT_MAX_HEIGHT) : (options.maxWidth ?? DEFAULT_MAX_WIDTH)
|
||||
);
|
||||
const sideRef = useRef(options.side);
|
||||
|
||||
useEffect(() => {
|
||||
onWidthChangeRef.current = onWidthChange;
|
||||
minWidthRef.current = minWidth;
|
||||
maxWidthRef.current = maxWidth;
|
||||
sideRef.current = side;
|
||||
});
|
||||
sideRef.current = options.side;
|
||||
if (isVerticalOptions(options)) {
|
||||
onSizeChangeRef.current = options.onHeightChange;
|
||||
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(
|
||||
(e: MouseEvent) => {
|
||||
if (!isResizing) return;
|
||||
|
||||
let newWidth: number;
|
||||
if (sideRef.current === 'left') {
|
||||
// Panel on the left: width = cursor position - panel left edge
|
||||
newWidth = e.clientX - panelLeftRef.current;
|
||||
} else {
|
||||
// Panel on the right: width = viewport width - cursor position
|
||||
newWidth = window.innerWidth - e.clientX;
|
||||
let newSize: number;
|
||||
switch (sideRef.current) {
|
||||
case 'left':
|
||||
newSize = e.clientX - originRef.current;
|
||||
break;
|
||||
case 'right':
|
||||
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) {
|
||||
onWidthChangeRef.current(newWidth);
|
||||
if (newSize >= minSizeRef.current && newSize <= maxSizeRef.current) {
|
||||
onSizeChangeRef.current(newSize);
|
||||
}
|
||||
},
|
||||
[isResizing]
|
||||
|
|
@ -89,7 +108,7 @@ export function useResizablePanel({
|
|||
if (isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.cursor = isVertical ? 'row-resize' : 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
|
|
@ -99,20 +118,23 @@ export function useResizablePanel({
|
|||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
}, [isResizing, handleMouseMove, handleMouseUp]);
|
||||
}, [isResizing, handleMouseMove, handleMouseUp, isVertical]);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (side === 'left') {
|
||||
// Calculate the left edge of the panel from cursor position minus current width
|
||||
panelLeftRef.current = e.clientX - width;
|
||||
if (isVerticalOptions(options)) {
|
||||
if (options.side === 'top') {
|
||||
originRef.current = e.clientY - options.height;
|
||||
}
|
||||
} else if (options.side === 'left') {
|
||||
originRef.current = e.clientX - options.width;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
},
|
||||
[side, width]
|
||||
[options]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,12 @@ export function initializeNotificationListeners(): () => void {
|
|||
const isWindows = platform.toLowerCase().includes('win');
|
||||
const delayMs = isWindows ? 3000 : 0;
|
||||
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;
|
||||
}, delayMs);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,55 @@ import { api } from '@renderer/api';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { CliInstallationStatus } from '@shared/types';
|
||||
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const logger = createLogger('Store:cliInstaller');
|
||||
|
||||
/** Max log lines to keep in UI (reserved for future use) */
|
||||
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
|
||||
|
|
@ -22,6 +64,7 @@ export interface CliInstallerSlice {
|
|||
// State
|
||||
cliStatus: CliInstallationStatus | null;
|
||||
cliStatusLoading: boolean;
|
||||
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||
cliStatusError: string | null;
|
||||
cliInstallerState:
|
||||
| 'idle'
|
||||
|
|
@ -41,23 +84,33 @@ export interface CliInstallerSlice {
|
|||
cliCompletedVersion: string | null;
|
||||
|
||||
// Actions
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
options?: { silent?: boolean; epoch?: number }
|
||||
) => Promise<void>;
|
||||
invalidateCliStatus: () => Promise<void>;
|
||||
installCli: () => void;
|
||||
}
|
||||
|
||||
let cliStatusInFlight: Promise<void> | null = null;
|
||||
const cliProviderStatusInFlight = new Map<CliProviderId, Promise<void>>();
|
||||
let cliStatusEpoch = 0;
|
||||
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
||||
|
||||
// =============================================================================
|
||||
// Slice Creator
|
||||
// =============================================================================
|
||||
|
||||
export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstallerSlice> = (
|
||||
set
|
||||
set,
|
||||
get
|
||||
) => ({
|
||||
// Initial state
|
||||
cliStatus: null,
|
||||
cliStatusLoading: false,
|
||||
cliProviderStatusLoading: {},
|
||||
cliStatusError: null,
|
||||
cliInstallerState: 'idle',
|
||||
cliDownloadProgress: 0,
|
||||
|
|
@ -69,15 +122,92 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
cliInstallerRawChunks: [],
|
||||
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 () => {
|
||||
if (!api.cliInstaller) return;
|
||||
if (cliStatusInFlight) return cliStatusInFlight;
|
||||
|
||||
const epoch = ++cliStatusEpoch;
|
||||
cliStatusInFlight = (async () => {
|
||||
set({ cliStatusLoading: true, cliStatusError: null });
|
||||
try {
|
||||
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) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to check CLI status';
|
||||
logger.error('Failed to fetch CLI status:', error);
|
||||
|
|
@ -91,6 +221,102 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
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 () => {
|
||||
await api.cliInstaller?.invalidateStatus();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -762,8 +762,10 @@ export interface TeamSlice {
|
|||
// Messages panel UI state
|
||||
messagesPanelMode: 'sidebar' | 'inline';
|
||||
messagesPanelWidth: number;
|
||||
sidebarLogsHeight: number;
|
||||
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void;
|
||||
setMessagesPanelWidth: (width: number) => void;
|
||||
setSidebarLogsHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
// --- Per-team launch params persistence ---
|
||||
|
|
@ -998,8 +1000,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
// Messages panel UI state
|
||||
messagesPanelMode: 'sidebar' as const,
|
||||
messagesPanelWidth: 340,
|
||||
sidebarLogsHeight: 213,
|
||||
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }),
|
||||
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
|
||||
setSidebarLogsHeight: (height: number) => set({ sidebarLogsHeight: height }),
|
||||
|
||||
fetchBranches: async (paths: string[]) => {
|
||||
const entries = await Promise.all(
|
||||
|
|
@ -2381,10 +2385,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
|
||||
if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) {
|
||||
// Clear spawn statuses — provisioning is complete, members now tracked via normal status
|
||||
set((prev) => {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
229
src/renderer/utils/bootstrapPromptSanitizer.ts
Normal file
229
src/renderer/utils/bootstrapPromptSanitizer.ts
Normal 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 ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { isLeadMember } from '@shared/utils/leadDetection';
|
|||
|
||||
import type {
|
||||
LeadActivityState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
|
|
@ -98,9 +99,9 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
|
|||
|
||||
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||
offline: 'offline',
|
||||
waiting: 'waiting',
|
||||
spawning: 'spawning',
|
||||
online: 'online',
|
||||
waiting: 'awaiting heartbeat',
|
||||
spawning: 'starting',
|
||||
online: 'ready',
|
||||
error: 'spawn failed',
|
||||
};
|
||||
|
||||
|
|
@ -115,8 +116,20 @@ export function getSpawnAwareDotClass(
|
|||
isTeamProvisioning?: boolean,
|
||||
leadActivity?: LeadActivityState
|
||||
): string {
|
||||
if (spawnStatus && isTeamProvisioning) {
|
||||
return SPAWN_DOT_COLORS[spawnStatus];
|
||||
if (spawnStatus === 'error') {
|
||||
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);
|
||||
}
|
||||
|
|
@ -127,10 +140,23 @@ export function getSpawnAwareDotClass(
|
|||
export function getSpawnAwarePresenceLabel(
|
||||
member: ResolvedTeamMember,
|
||||
spawnStatus: MemberSpawnStatus | undefined,
|
||||
livenessSource: MemberSpawnLivenessSource | undefined,
|
||||
isTeamAlive?: boolean,
|
||||
isTeamProvisioning?: boolean,
|
||||
leadActivity?: LeadActivityState
|
||||
): 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) {
|
||||
return SPAWN_PRESENCE_LABELS[spawnStatus];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers';
|
||||
import { summarizeAgentToolInput } from '@shared/utils/toolSummary';
|
||||
|
||||
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')) {
|
||||
const input = item.tool.input as Record<string, unknown> | undefined;
|
||||
const desc =
|
||||
(item.tool.name === 'Agent' && input ? summarizeAgentToolInput(input, 80) : null) ||
|
||||
(typeof input?.description === 'string' && input.description) ||
|
||||
(typeof input?.prompt === 'string' && input.prompt.slice(0, 80)) ||
|
||||
'Subagent';
|
||||
pendingDescriptions.push(desc);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
import {
|
||||
getSanitizedInboxMessageSummary,
|
||||
getSanitizedInboxMessageText,
|
||||
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
|
@ -48,8 +52,8 @@ export function filterTeamMessages(
|
|||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter((m) => {
|
||||
const text = (m.text ?? '').toLowerCase();
|
||||
const summary = (m.summary ?? '').toLowerCase();
|
||||
const text = getSanitizedInboxMessageText(m).toLowerCase();
|
||||
const summary = getSanitizedInboxMessageSummary(m).toLowerCase();
|
||||
const from = (m.from ?? '').toLowerCase();
|
||||
const to = (m.to ?? '').toLowerCase();
|
||||
return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||
import { summarizeAgentToolInput } from '@shared/utils/toolSummary';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
case 'Agent': {
|
||||
const desc = input.description ?? input.prompt;
|
||||
return typeof desc === 'string' ? truncate(desc, 60) : 'Subagent';
|
||||
return summarizeAgentToolInput(input, 60);
|
||||
}
|
||||
|
||||
default: {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,25 @@ export type CliFlavor = 'claude' | 'free-code';
|
|||
|
||||
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 {
|
||||
providerId: CliProviderId;
|
||||
displayName: string;
|
||||
|
|
@ -39,6 +58,10 @@ export interface CliProviderStatus {
|
|||
teamLaunch: boolean;
|
||||
oneShot: boolean;
|
||||
};
|
||||
selectedBackendId?: string | null;
|
||||
resolvedBackendId?: string | null;
|
||||
availableBackends?: CliProviderBackendOption[];
|
||||
externalRuntimeDiagnostics?: CliExternalRuntimeDiagnostic[];
|
||||
backend?: {
|
||||
kind: string;
|
||||
label: string;
|
||||
|
|
@ -131,6 +154,8 @@ export interface CliInstallerProgress {
|
|||
export interface CliInstallerAPI {
|
||||
/** Get current CLI installation status */
|
||||
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. */
|
||||
install: () => Promise<void>;
|
||||
/** Invalidate cached status (forces fresh check on next getStatus) */
|
||||
|
|
|
|||
|
|
@ -321,6 +321,13 @@ export interface AppConfig {
|
|||
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
|
||||
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: {
|
||||
/** Whether to show timestamps in message views */
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@ export interface TeamSummary {
|
|||
deletedAt?: string;
|
||||
/** True when team.meta.json exists but config.json doesn't — provisioning failed before TeamCreate. */
|
||||
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';
|
||||
|
|
@ -434,9 +442,10 @@ export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
|
|||
|
||||
/**
|
||||
* 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
|
||||
* - 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)
|
||||
*/
|
||||
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||
|
|
@ -574,6 +583,8 @@ export interface MemberSpawnStatusesSnapshot {
|
|||
runId: string | null;
|
||||
}
|
||||
|
||||
export type MemberSpawnLivenessSource = 'heartbeat' | 'process';
|
||||
|
||||
export interface TeamChangeEvent {
|
||||
type:
|
||||
| 'config'
|
||||
|
|
@ -601,6 +612,12 @@ export interface MemberSpawnStatusEntry {
|
|||
status: MemberSpawnStatus;
|
||||
/** Error message when status === 'error'. */
|
||||
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. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
16
src/shared/utils/teamProvider.ts
Normal file
16
src/shared/utils/teamProvider.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -73,6 +73,75 @@ function truncateStr(str: string, max: number): string {
|
|||
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. */
|
||||
export function extractToolPreview(
|
||||
name: string,
|
||||
|
|
@ -95,10 +164,13 @@ export function extractToolPreview(
|
|||
case 'Agent':
|
||||
case 'Task':
|
||||
case 'TaskCreate':
|
||||
return typeof input.prompt === 'string'
|
||||
? input.prompt
|
||||
: typeof input.description === 'string'
|
||||
? input.description
|
||||
if (name === 'Agent') {
|
||||
return summarizeAgentToolInput(input, 80);
|
||||
}
|
||||
return typeof input.description === 'string'
|
||||
? input.description
|
||||
: typeof input.prompt === 'string'
|
||||
? truncateStr(input.prompt, 80)
|
||||
: undefined;
|
||||
case 'WebFetch':
|
||||
if (typeof input.url === 'string') {
|
||||
|
|
|
|||
|
|
@ -481,4 +481,34 @@ describe('TeamProvisioningService', () => {
|
|||
})
|
||||
).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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue