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) {
|
function resolveLeadSessionId(paths) {
|
||||||
const config = readTeamConfig(paths);
|
const config = readTeamConfig(paths);
|
||||||
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
|
return config && typeof config.leadSessionId === 'string' && config.leadSessionId.trim()
|
||||||
|
|
@ -459,6 +496,7 @@ module.exports = {
|
||||||
readMembersMeta,
|
readMembersMeta,
|
||||||
readTeamConfig,
|
readTeamConfig,
|
||||||
resolveTeamMembers,
|
resolveTeamMembers,
|
||||||
|
getCurrentRuntimeMemberIdentity,
|
||||||
resolveLeadSessionId,
|
resolveLeadSessionId,
|
||||||
saveTaskAttachmentFile,
|
saveTaskAttachmentFile,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ function isSameTaskMember(left, right, leadName) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeMemberRecord(base, overlay) {
|
||||||
|
return {
|
||||||
|
...(base && typeof base === 'object' ? base : {}),
|
||||||
|
...(overlay && typeof overlay === 'object' ? overlay : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function quoteMarkdown(text) {
|
function quoteMarkdown(text) {
|
||||||
return String(text)
|
return String(text)
|
||||||
.split('\n')
|
.split('\n')
|
||||||
|
|
@ -563,13 +570,14 @@ Failure to follow this protocol means the task board will show incorrect status.
|
||||||
* Context-free — does NOT follow the (context, ...) convention.
|
* Context-free — does NOT follow the (context, ...) convention.
|
||||||
*/
|
*/
|
||||||
function buildProcessProtocolText(teamName) {
|
function buildProcessProtocolText(teamName) {
|
||||||
return `BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
|
return `BACKGROUND SERVICE PROCESS REGISTRATION — this is ONLY for extra background services started by teammates (dev server, watcher, database, etc.). It is NOT a list of teammate agents themselves.
|
||||||
1. Launch with & to get PID:
|
1. Launch with & to get PID:
|
||||||
pnpm dev &
|
pnpm dev &
|
||||||
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
|
2. Register immediately with MCP tool process_register (--port and --url are optional, use when the process listens on a port):
|
||||||
{ teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" }
|
{ teamName: "${teamName}", pid: <PID>, label: "<description>", from: "<your-name>", port?: <PORT>, url?: "http://localhost:<PORT>", command?: "<command>" }
|
||||||
3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list:
|
3. VERIFY registration succeeded (MANDATORY — never skip this step) using MCP tool process_list:
|
||||||
{ teamName: "${teamName}" }
|
{ teamName: "${teamName}" }
|
||||||
|
process_list shows ONLY registered background services for the team. It does NOT show whether teammate agents themselves are alive.
|
||||||
4. When stopping a process, use MCP tool process_stop:
|
4. When stopping a process, use MCP tool process_stop:
|
||||||
{ teamName: "${teamName}", pid: <PID> }
|
{ teamName: "${teamName}", pid: <PID> }
|
||||||
5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister:
|
5. To fully remove a process record (e.g. after it has been stopped and is no longer needed), use MCP tool process_unregister:
|
||||||
|
|
@ -606,9 +614,43 @@ async function memberBriefing(context, memberName) {
|
||||||
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
|
if (resolved.removedNames && resolved.removedNames.has(requestedMemberKey)) {
|
||||||
throw new Error(`Member is removed from the team: ${requestedMemberName}`);
|
throw new Error(`Member is removed from the team: ${requestedMemberName}`);
|
||||||
}
|
}
|
||||||
const member =
|
let member =
|
||||||
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
||||||
null;
|
null;
|
||||||
|
if (!member) {
|
||||||
|
const runtimeIdentity = runtimeHelpers.getCurrentRuntimeMemberIdentity();
|
||||||
|
const runtimeAgentName = normalizeMemberName(runtimeIdentity && runtimeIdentity.agentName);
|
||||||
|
const runtimeAgentId = String((runtimeIdentity && runtimeIdentity.agentId) || '').trim().toLowerCase();
|
||||||
|
const runtimeTeamName = String((runtimeIdentity && runtimeIdentity.teamName) || '').trim().toLowerCase();
|
||||||
|
const requestedAgentId = `${requestedMemberKey}@${String(context.teamName || '').trim().toLowerCase()}`;
|
||||||
|
const isCurrentRuntimeMember =
|
||||||
|
requestedMemberKey &&
|
||||||
|
((runtimeAgentName && runtimeAgentName === requestedMemberKey) ||
|
||||||
|
(runtimeAgentId && runtimeAgentId === requestedAgentId)) &&
|
||||||
|
(!runtimeTeamName || runtimeTeamName === String(context.teamName || '').trim().toLowerCase());
|
||||||
|
if (isCurrentRuntimeMember) {
|
||||||
|
const configMembers = Array.isArray(config.members) ? config.members : [];
|
||||||
|
const configMember =
|
||||||
|
configMembers.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey) ||
|
||||||
|
null;
|
||||||
|
const metaMember =
|
||||||
|
Array.isArray(resolved.members)
|
||||||
|
? resolved.members.find((entry) => normalizeMemberName(entry && entry.name) === requestedMemberKey)
|
||||||
|
: null;
|
||||||
|
member = mergeMemberRecord(
|
||||||
|
{
|
||||||
|
name: requestedMemberName,
|
||||||
|
...(runtimeIdentity && runtimeIdentity.agentName
|
||||||
|
? { name: String(runtimeIdentity.agentName).trim() }
|
||||||
|
: {}),
|
||||||
|
...(typeof config.projectPath === 'string' && config.projectPath.trim()
|
||||||
|
? { cwd: config.projectPath.trim() }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
mergeMemberRecord(configMember || {}, metaMember || {})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!member) {
|
if (!member) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Member not found in team metadata or inboxes: ${requestedMemberName}`
|
`Member not found in team metadata or inboxes: ${requestedMemberName}`
|
||||||
|
|
@ -730,4 +772,4 @@ module.exports = {
|
||||||
updateTask: (context, taskRef, updater) =>
|
updateTask: (context, taskRef, updater) =>
|
||||||
taskStore.updateTask(context.paths, taskRef, updater),
|
taskStore.updateTask(context.paths, taskRef, updater),
|
||||||
updateTaskFields,
|
updateTaskFields,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ const toolContextSchema = {
|
||||||
export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'process_register',
|
name: 'process_register',
|
||||||
description: 'Register a running process for a team member',
|
description:
|
||||||
|
'Register a background service started by a teammate, such as a dev server, watcher, or database. This is not for teammate-agent liveness.',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
pid: z.number().int().positive(),
|
pid: z.number().int().positive(),
|
||||||
|
|
@ -51,7 +52,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'process_list',
|
name: 'process_list',
|
||||||
description: 'List registered team processes',
|
description:
|
||||||
|
'List registered background services for the team, such as dev servers, watchers, or databases. This does not show teammate-agent liveness.',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
|
|
@ -63,7 +65,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'process_unregister',
|
name: 'process_unregister',
|
||||||
description: 'Unregister a previously registered process',
|
description:
|
||||||
|
'Unregister a previously registered background service while keeping teammate-agent state separate.',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
pid: z.number().int().positive(),
|
pid: z.number().int().positive(),
|
||||||
|
|
@ -76,7 +79,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'process_stop',
|
name: 'process_stop',
|
||||||
description: 'Mark a registered process as stopped while preserving history',
|
description:
|
||||||
|
'Mark a registered background service as stopped while preserving history. This is not for stopping teammate agents.',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
pid: z.number().int().positive(),
|
pid: z.number().int().positive(),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ const toolContextSchema = {
|
||||||
claudeDir: z.string().min(1).optional(),
|
claudeDir: z.string().min(1).optional(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ALWAYS_LOAD_META = {
|
||||||
|
'anthropic/alwaysLoad': true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']);
|
||||||
|
|
||||||
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
|
/** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */
|
||||||
|
|
@ -468,6 +472,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'member_briefing',
|
name: 'member_briefing',
|
||||||
description: 'Get bootstrap briefing for a team member',
|
description: 'Get bootstrap briefing for a team member',
|
||||||
|
_meta: ALWAYS_LOAD_META,
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
memberName: z.string().min(1),
|
memberName: z.string().min(1),
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||||
import { setReviewMainWindow } from './ipc/review';
|
import { setReviewMainWindow } from './ipc/review';
|
||||||
import {
|
import {
|
||||||
ApiKeyService,
|
ApiKeyService,
|
||||||
|
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||||
ExtensionFacadeService,
|
ExtensionFacadeService,
|
||||||
GlamaMcpEnrichmentService,
|
GlamaMcpEnrichmentService,
|
||||||
McpCatalogAggregator,
|
McpCatalogAggregator,
|
||||||
|
|
@ -536,9 +537,6 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
if (match && teamDataService) {
|
if (match && teamDataService) {
|
||||||
const inboxName = match[1];
|
const inboxName = match[1];
|
||||||
|
|
||||||
// Mark member as online when their first inbox message arrives (spawn tracking).
|
|
||||||
teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName);
|
|
||||||
|
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.getLeadMemberName(teamName)
|
.getLeadMemberName(teamName)
|
||||||
.then((leadName) => {
|
.then((leadName) => {
|
||||||
|
|
@ -716,7 +714,7 @@ function reconfigureLocalContextForClaudeRoot(): void {
|
||||||
/**
|
/**
|
||||||
* Initializes all services.
|
* Initializes all services.
|
||||||
*/
|
*/
|
||||||
function initializeServices(): void {
|
async function initializeServices(): Promise<void> {
|
||||||
logger.info('Initializing services...');
|
logger.info('Initializing services...');
|
||||||
|
|
||||||
// Initialize SSH connection manager
|
// Initialize SSH connection manager
|
||||||
|
|
@ -839,6 +837,7 @@ function initializeServices(): void {
|
||||||
const pluginInstallService = new PluginInstallService(pluginCatalogService);
|
const pluginInstallService = new PluginInstallService(pluginCatalogService);
|
||||||
const mcpInstallService = new McpInstallService(mcpAggregator);
|
const mcpInstallService = new McpInstallService(mcpAggregator);
|
||||||
const apiKeyService = new ApiKeyService();
|
const apiKeyService = new ApiKeyService();
|
||||||
|
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||||
// warmup() and ensureInstalled() are deferred to after window creation
|
// warmup() and ensureInstalled() are deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
httpServer = new HttpServer();
|
httpServer = new HttpServer();
|
||||||
|
|
@ -1392,7 +1391,7 @@ function createWindow(): void {
|
||||||
/**
|
/**
|
||||||
* Application ready handler.
|
* Application ready handler.
|
||||||
*/
|
*/
|
||||||
void app.whenReady().then(() => {
|
void app.whenReady().then(async () => {
|
||||||
logger.info('App ready, initializing...');
|
logger.info('App ready, initializing...');
|
||||||
|
|
||||||
// Pre-warm interactive shell env cache (non-blocking).
|
// Pre-warm interactive shell env cache (non-blocking).
|
||||||
|
|
@ -1403,7 +1402,7 @@ void app.whenReady().then(() => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Initialize services first
|
// Initialize services first
|
||||||
initializeServices();
|
await initializeServices();
|
||||||
|
|
||||||
// Apply configuration settings
|
// Apply configuration settings
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CLI_INSTALLER_GET_STATUS,
|
CLI_INSTALLER_GET_STATUS,
|
||||||
|
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||||
|
|
@ -18,13 +19,19 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { CliInstallerService } from '../services';
|
import type { CliInstallerService } from '../services';
|
||||||
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
||||||
import type { CliInstallationStatus, IpcResult } from '@shared/types';
|
import type {
|
||||||
|
CliInstallationStatus,
|
||||||
|
CliProviderId,
|
||||||
|
CliProviderStatus,
|
||||||
|
IpcResult,
|
||||||
|
} from '@shared/types';
|
||||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||||
|
|
||||||
const logger = createLogger('IPC:cliInstaller');
|
const logger = createLogger('IPC:cliInstaller');
|
||||||
|
|
||||||
let service: CliInstallerService;
|
let service: CliInstallerService;
|
||||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||||
|
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
|
||||||
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null;
|
||||||
const STATUS_CACHE_TTL_MS = 5_000;
|
const STATUS_CACHE_TTL_MS = 5_000;
|
||||||
|
|
||||||
|
|
@ -40,6 +47,7 @@ export function initializeCliInstallerHandlers(installerService: CliInstallerSer
|
||||||
*/
|
*/
|
||||||
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
|
ipcMain.handle(CLI_INSTALLER_GET_STATUS, handleGetStatus);
|
||||||
|
ipcMain.handle(CLI_INSTALLER_GET_PROVIDER_STATUS, handleGetProviderStatus);
|
||||||
ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall);
|
ipcMain.handle(CLI_INSTALLER_INSTALL, handleInstall);
|
||||||
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
|
ipcMain.handle(CLI_INSTALLER_INVALIDATE_STATUS, handleInvalidateStatus);
|
||||||
|
|
||||||
|
|
@ -51,6 +59,7 @@ export function registerCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||||
*/
|
*/
|
||||||
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
|
ipcMain.removeHandler(CLI_INSTALLER_GET_STATUS);
|
||||||
|
ipcMain.removeHandler(CLI_INSTALLER_GET_PROVIDER_STATUS);
|
||||||
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
|
ipcMain.removeHandler(CLI_INSTALLER_INSTALL);
|
||||||
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
|
ipcMain.removeHandler(CLI_INSTALLER_INVALIDATE_STATUS);
|
||||||
|
|
||||||
|
|
@ -99,6 +108,58 @@ async function handleGetStatus(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
|
||||||
|
if (!cachedStatus || !providerStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextProviders = cachedStatus.value.providers.map((provider) =>
|
||||||
|
provider.providerId === providerStatus.providerId ? providerStatus : provider
|
||||||
|
);
|
||||||
|
const authenticatedProvider = nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||||
|
|
||||||
|
cachedStatus = {
|
||||||
|
value: {
|
||||||
|
...cachedStatus.value,
|
||||||
|
providers: nextProviders,
|
||||||
|
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||||
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
|
},
|
||||||
|
at: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetProviderStatus(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
providerId: CliProviderId
|
||||||
|
): Promise<IpcResult<CliProviderStatus | null>> {
|
||||||
|
try {
|
||||||
|
const inFlight = providerStatusInFlight.get(providerId);
|
||||||
|
if (inFlight) {
|
||||||
|
const status = await inFlight;
|
||||||
|
return { success: true, data: status };
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = service
|
||||||
|
.getProviderStatus(providerId)
|
||||||
|
.then((status) => {
|
||||||
|
patchCachedProviderStatus(status);
|
||||||
|
return status;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
providerStatusInFlight.delete(providerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
providerStatusInFlight.set(providerId, request);
|
||||||
|
const status = await request;
|
||||||
|
return { success: true, data: status };
|
||||||
|
} catch (error) {
|
||||||
|
const msg = getErrorMessage(error);
|
||||||
|
logger.error(`Error in cliInstaller:getProviderStatus(${providerId}):`, msg);
|
||||||
|
return { success: false, error: msg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> {
|
async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> {
|
||||||
try {
|
try {
|
||||||
await service.install();
|
await service.install();
|
||||||
|
|
@ -112,6 +173,7 @@ async function handleInstall(_event: IpcMainInvokeEvent): Promise<IpcResult<void
|
||||||
|
|
||||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||||
cachedStatus = null;
|
cachedStatus = null;
|
||||||
|
providerStatusInFlight.clear();
|
||||||
ClaudeBinaryResolver.clearCache();
|
ClaudeBinaryResolver.clearCache();
|
||||||
return { success: true, data: undefined };
|
return { success: true, data: undefined };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
HttpServerConfig,
|
HttpServerConfig,
|
||||||
NotificationConfig,
|
NotificationConfig,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
|
RuntimeConfig,
|
||||||
SshPersistConfig,
|
SshPersistConfig,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ interface ValidationFailure {
|
||||||
export type ConfigUpdateValidationResult =
|
export type ConfigUpdateValidationResult =
|
||||||
| ValidationSuccess<'notifications'>
|
| ValidationSuccess<'notifications'>
|
||||||
| ValidationSuccess<'general'>
|
| ValidationSuccess<'general'>
|
||||||
|
| ValidationSuccess<'runtime'>
|
||||||
| ValidationSuccess<'display'>
|
| ValidationSuccess<'display'>
|
||||||
| ValidationSuccess<'httpServer'>
|
| ValidationSuccess<'httpServer'>
|
||||||
| ValidationSuccess<'ssh'>
|
| ValidationSuccess<'ssh'>
|
||||||
|
|
@ -39,6 +41,7 @@ export type ConfigUpdateValidationResult =
|
||||||
const VALID_SECTIONS = new Set<ConfigSection>([
|
const VALID_SECTIONS = new Set<ConfigSection>([
|
||||||
'notifications',
|
'notifications',
|
||||||
'general',
|
'general',
|
||||||
|
'runtime',
|
||||||
'display',
|
'display',
|
||||||
'httpServer',
|
'httpServer',
|
||||||
'ssh',
|
'ssh',
|
||||||
|
|
@ -398,6 +401,60 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateRuntimeSection(data: unknown): ValidationSuccess<'runtime'> | ValidationFailure {
|
||||||
|
if (!isPlainObject(data)) {
|
||||||
|
return { valid: false, error: 'runtime update must be an object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Partial<RuntimeConfig> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (key !== 'providerBackends') {
|
||||||
|
return { valid: false, error: `runtime.${key} is not a valid setting` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
return { valid: false, error: 'runtime.providerBackends must be an object' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerBackends: Partial<RuntimeConfig['providerBackends']> = {};
|
||||||
|
|
||||||
|
for (const [providerId, backendId] of Object.entries(value)) {
|
||||||
|
if (providerId === 'gemini') {
|
||||||
|
if (backendId !== 'auto' && backendId !== 'api' && backendId !== 'cli-sdk') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'runtime.providerBackends.gemini must be one of: auto, api, cli-sdk',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
providerBackends.gemini = backendId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerId === 'codex') {
|
||||||
|
if (backendId !== 'auto' && backendId !== 'adapter') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'runtime.providerBackends.codex must be one of: auto, adapter',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
providerBackends.codex = backendId;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, error: `runtime.providerBackends.${providerId} is not supported` };
|
||||||
|
}
|
||||||
|
|
||||||
|
result.providerBackends = providerBackends as RuntimeConfig['providerBackends'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
section: 'runtime',
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure {
|
function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure {
|
||||||
if (!isPlainObject(data)) {
|
if (!isPlainObject(data)) {
|
||||||
return { valid: false, error: 'display update must be an object' };
|
return { valid: false, error: 'display update must be an object' };
|
||||||
|
|
@ -544,7 +601,7 @@ export function validateConfigUpdatePayload(
|
||||||
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
|
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: 'Section must be one of: notifications, general, display, httpServer, ssh',
|
error: 'Section must be one of: notifications, general, runtime, display, httpServer, ssh',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -553,6 +610,8 @@ export function validateConfigUpdatePayload(
|
||||||
return validateNotificationsSection(data);
|
return validateNotificationsSection(data);
|
||||||
case 'general':
|
case 'general':
|
||||||
return validateGeneralSection(data);
|
return validateGeneralSection(data);
|
||||||
|
case 'runtime':
|
||||||
|
return validateRuntimeSection(data);
|
||||||
case 'display':
|
case 'display':
|
||||||
return validateDisplaySection(data);
|
return validateDisplaySection(data);
|
||||||
case 'httpServer':
|
case 'httpServer':
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,10 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
import { GitHubStarsService } from '../services/extensions/catalog/GitHubStarsService';
|
||||||
|
|
||||||
import type { ApiKeyService } from '../services/extensions/apikeys/ApiKeyService';
|
import {
|
||||||
|
RUNTIME_MANAGED_API_KEY_ENV_VARS,
|
||||||
|
type ApiKeyService,
|
||||||
|
} from '../services/extensions/apikeys/ApiKeyService';
|
||||||
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
import type { ExtensionFacadeService } from '../services/extensions/ExtensionFacadeService';
|
||||||
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
import type { McpInstallService } from '../services/extensions/install/McpInstallService';
|
||||||
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
import type { PluginInstallService } from '../services/extensions/install/PluginInstallService';
|
||||||
|
|
@ -388,7 +391,12 @@ async function handleApiKeysSave(
|
||||||
): Promise<IpcResult<ApiKeyEntry>> {
|
): Promise<IpcResult<ApiKeyEntry>> {
|
||||||
return wrapHandler('apiKeysSave', () => {
|
return wrapHandler('apiKeysSave', () => {
|
||||||
if (!request) throw new Error('Request is required');
|
if (!request) throw new Error('Request is required');
|
||||||
return getApiKeyService().save(request);
|
return getApiKeyService()
|
||||||
|
.save(request)
|
||||||
|
.then(async (entry) => {
|
||||||
|
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -398,7 +406,11 @@ async function handleApiKeysDelete(
|
||||||
): Promise<IpcResult<void>> {
|
): Promise<IpcResult<void>> {
|
||||||
return wrapHandler('apiKeysDelete', () => {
|
return wrapHandler('apiKeysDelete', () => {
|
||||||
if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
|
if (typeof id !== 'string' || !id) throw new Error('Key ID is required');
|
||||||
return getApiKeyService().delete(id);
|
return getApiKeyService()
|
||||||
|
.delete(id)
|
||||||
|
.then(async () => {
|
||||||
|
await getApiKeyService().syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,10 +50,13 @@ const PBKDF2_ITERATIONS = 100_000;
|
||||||
const PBKDF2_KEY_BYTES = 32;
|
const PBKDF2_KEY_BYTES = 32;
|
||||||
const PBKDF2_SALT = 'claude-apikey-storage-v1';
|
const PBKDF2_SALT = 'claude-apikey-storage-v1';
|
||||||
|
|
||||||
|
export const RUNTIME_MANAGED_API_KEY_ENV_VARS = ['GEMINI_API_KEY'] as const;
|
||||||
|
|
||||||
export class ApiKeyService {
|
export class ApiKeyService {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
private cache: StoredApiKey[] | null = null;
|
private cache: StoredApiKey[] | null = null;
|
||||||
private aesKey: Buffer | null = null;
|
private aesKey: Buffer | null = null;
|
||||||
|
private readonly originalProcessEnv = new Map<string, string | undefined>();
|
||||||
|
|
||||||
constructor(claudeDir?: string) {
|
constructor(claudeDir?: string) {
|
||||||
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
|
const baseDir = claudeDir ?? path.join(os.homedir(), '.claude');
|
||||||
|
|
@ -163,6 +166,34 @@ export class ApiKeyService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncProcessEnv(envVarNames: readonly string[]): Promise<void> {
|
||||||
|
if (!envVarNames.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookups = await this.lookup([...envVarNames]);
|
||||||
|
const valueByEnv = new Map(lookups.map((entry) => [entry.envVarName, entry.value]));
|
||||||
|
|
||||||
|
for (const envVarName of envVarNames) {
|
||||||
|
if (!this.originalProcessEnv.has(envVarName)) {
|
||||||
|
this.originalProcessEnv.set(envVarName, process.env[envVarName]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = valueByEnv.get(envVarName);
|
||||||
|
if (nextValue && nextValue.trim().length > 0) {
|
||||||
|
process.env[envVarName] = nextValue;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalValue = this.originalProcessEnv.get(envVarName);
|
||||||
|
if (typeof originalValue === 'string' && originalValue.length > 0) {
|
||||||
|
process.env[envVarName] = originalValue;
|
||||||
|
} else {
|
||||||
|
delete process.env[envVarName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Encryption ──────────────────────────────────────────────────────────
|
// ── Encryption ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Extension services barrel export.
|
* Extension services barrel export.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { ApiKeyService } from './apikeys/ApiKeyService';
|
export { ApiKeyService, RUNTIME_MANAGED_API_KEY_ENV_VARS } from './apikeys/ApiKeyService';
|
||||||
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
export { GitHubStarsService } from './catalog/GitHubStarsService';
|
||||||
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
export { GlamaMcpEnrichmentService } from './catalog/GlamaMcpEnrichmentService';
|
||||||
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
export { McpCatalogAggregator } from './catalog/McpCatalogAggregator';
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,13 @@ import { ClaudeMultimodelBridgeService } from '../runtime/ClaudeMultimodelBridge
|
||||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||||
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor';
|
import { getConfiguredCliFlavor, getCliFlavorUiOptions } from '../team/cliFlavor';
|
||||||
|
|
||||||
import type { CliInstallationStatus, CliInstallerProgress, CliPlatform } from '@shared/types';
|
import type {
|
||||||
|
CliInstallationStatus,
|
||||||
|
CliInstallerProgress,
|
||||||
|
CliPlatform,
|
||||||
|
CliProviderId,
|
||||||
|
CliProviderStatus,
|
||||||
|
} from '@shared/types';
|
||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import type { IncomingMessage } from 'http';
|
import type { IncomingMessage } from 'http';
|
||||||
|
|
||||||
|
|
@ -131,6 +137,11 @@ function cloneCliInstallationStatus(status: CliInstallationStatus): CliInstallat
|
||||||
providers: status.providers.map((provider) => ({
|
providers: status.providers.map((provider) => ({
|
||||||
...provider,
|
...provider,
|
||||||
capabilities: { ...provider.capabilities },
|
capabilities: { ...provider.capabilities },
|
||||||
|
selectedBackendId: provider.selectedBackendId ?? null,
|
||||||
|
resolvedBackendId: provider.resolvedBackendId ?? null,
|
||||||
|
availableBackends: provider.availableBackends?.map((backend) => ({ ...backend })) ?? [],
|
||||||
|
externalRuntimeDiagnostics:
|
||||||
|
provider.externalRuntimeDiagnostics?.map((diagnostic) => ({ ...diagnostic })) ?? [],
|
||||||
backend: provider.backend ? { ...provider.backend } : null,
|
backend: provider.backend ? { ...provider.backend } : null,
|
||||||
models: [...provider.models],
|
models: [...provider.models],
|
||||||
})),
|
})),
|
||||||
|
|
@ -479,6 +490,23 @@ export class CliInstallerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getProviderStatus(providerId: CliProviderId): Promise<CliProviderStatus | null> {
|
||||||
|
await resolveInteractiveShellEnv();
|
||||||
|
|
||||||
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
|
if (!binaryPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flavor = getConfiguredCliFlavor();
|
||||||
|
if (flavor !== 'free-code') {
|
||||||
|
const fullStatus = await this.getStatus();
|
||||||
|
return fullStatus.providers.find((provider) => provider.providerId === providerId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.multimodelBridgeService.getProviderStatus(binaryPath, providerId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gathers CLI status information, mutating the provided result object.
|
* Gathers CLI status information, mutating the provided result object.
|
||||||
* Split from getStatus() to enable overall timeout via Promise.race —
|
* Split from getStatus() to enable overall timeout via Promise.race —
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,13 @@ export interface GeneralConfig {
|
||||||
telemetryEnabled: boolean;
|
telemetryEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
providerBackends: {
|
||||||
|
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||||
|
codex: 'auto' | 'adapter';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface DisplayConfig {
|
export interface DisplayConfig {
|
||||||
showTimestamps: boolean;
|
showTimestamps: boolean;
|
||||||
compactMode: boolean;
|
compactMode: boolean;
|
||||||
|
|
@ -247,6 +254,7 @@ export interface HttpServerConfig {
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
notifications: NotificationConfig;
|
notifications: NotificationConfig;
|
||||||
general: GeneralConfig;
|
general: GeneralConfig;
|
||||||
|
runtime: RuntimeConfig;
|
||||||
display: DisplayConfig;
|
display: DisplayConfig;
|
||||||
sessions: SessionsConfig;
|
sessions: SessionsConfig;
|
||||||
ssh: SshPersistConfig;
|
ssh: SshPersistConfig;
|
||||||
|
|
@ -299,6 +307,12 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
customProjectPaths: [],
|
customProjectPaths: [],
|
||||||
telemetryEnabled: true,
|
telemetryEnabled: true,
|
||||||
},
|
},
|
||||||
|
runtime: {
|
||||||
|
providerBackends: {
|
||||||
|
gemini: 'auto',
|
||||||
|
codex: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
compactMode: false,
|
compactMode: false,
|
||||||
|
|
@ -468,6 +482,12 @@ export class ConfigManager {
|
||||||
triggers: mergedTriggers,
|
triggers: mergedTriggers,
|
||||||
},
|
},
|
||||||
general: mergedGeneral,
|
general: mergedGeneral,
|
||||||
|
runtime: {
|
||||||
|
providerBackends: {
|
||||||
|
...DEFAULT_CONFIG.runtime.providerBackends,
|
||||||
|
...(loaded.runtime?.providerBackends ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
...DEFAULT_CONFIG.display,
|
...DEFAULT_CONFIG.display,
|
||||||
...(loaded.display ?? {}),
|
...(loaded.display ?? {}),
|
||||||
|
|
@ -540,10 +560,21 @@ export class ConfigManager {
|
||||||
section: K,
|
section: K,
|
||||||
data: Partial<AppConfig[K]>
|
data: Partial<AppConfig[K]>
|
||||||
): Partial<AppConfig[K]> {
|
): Partial<AppConfig[K]> {
|
||||||
if (section !== 'general') {
|
if (section !== 'general' && section !== 'runtime') {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section === 'runtime') {
|
||||||
|
const runtimeUpdate = data as Partial<RuntimeConfig>;
|
||||||
|
return {
|
||||||
|
...runtimeUpdate,
|
||||||
|
providerBackends: {
|
||||||
|
...this.config.runtime.providerBackends,
|
||||||
|
...runtimeUpdate.providerBackends,
|
||||||
|
},
|
||||||
|
} as unknown as Partial<AppConfig[K]>;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {
|
if (!Object.prototype.hasOwnProperty.call(data, 'claudeRootPath')) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@ import {
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
|
import { configManager } from '../infrastructure/ConfigManager';
|
||||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||||
|
import { applyConfiguredRuntimeBackendsEnv } from './providerRuntimeEnv';
|
||||||
|
|
||||||
const logger = createLogger('ClaudeMultimodelBridgeService');
|
const logger = createLogger('ClaudeMultimodelBridgeService');
|
||||||
|
|
||||||
|
|
@ -51,6 +53,53 @@ interface ProviderModelsCommandResponse {
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UnifiedRuntimeStatusResponse {
|
||||||
|
schemaVersion?: number;
|
||||||
|
providers?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
supported?: boolean;
|
||||||
|
authenticated?: boolean;
|
||||||
|
authMethod?: string | null;
|
||||||
|
verificationState?: 'verified' | 'unknown' | 'offline' | 'error';
|
||||||
|
canLoginFromUi?: boolean;
|
||||||
|
statusMessage?: string | null;
|
||||||
|
detailMessage?: string | null;
|
||||||
|
selectedBackendId?: string | null;
|
||||||
|
resolvedBackendId?: string | null;
|
||||||
|
availableBackends?: Array<{
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
selectable?: boolean;
|
||||||
|
recommended?: boolean;
|
||||||
|
available?: boolean;
|
||||||
|
statusMessage?: string | null;
|
||||||
|
detailMessage?: string | null;
|
||||||
|
}>;
|
||||||
|
externalRuntimeDiagnostics?: Array<{
|
||||||
|
id?: string;
|
||||||
|
label?: string;
|
||||||
|
detected?: boolean;
|
||||||
|
statusMessage?: string | null;
|
||||||
|
detailMessage?: string | null;
|
||||||
|
}>;
|
||||||
|
models?: Array<string | { id?: string; label?: string; description?: string }>;
|
||||||
|
capabilities?: {
|
||||||
|
teamLaunch?: boolean;
|
||||||
|
oneShot?: boolean;
|
||||||
|
};
|
||||||
|
backend?: {
|
||||||
|
kind?: string;
|
||||||
|
label?: string;
|
||||||
|
endpointLabel?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
authMethodDetail?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
|
const ORDERED_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
|
||||||
|
|
||||||
function extractJsonObject<T>(raw: string): T {
|
function extractJsonObject<T>(raw: string): T {
|
||||||
|
|
@ -83,6 +132,10 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat
|
||||||
teamLaunch: false,
|
teamLaunch: false,
|
||||||
oneShot: false,
|
oneShot: false,
|
||||||
},
|
},
|
||||||
|
selectedBackendId: null,
|
||||||
|
resolvedBackendId: null,
|
||||||
|
availableBackends: [],
|
||||||
|
externalRuntimeDiagnostics: [],
|
||||||
backend: null,
|
backend: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -117,11 +170,12 @@ export class ClaudeMultimodelBridgeService {
|
||||||
if (home) {
|
if (home) {
|
||||||
env.HOME = home;
|
env.HOME = home;
|
||||||
}
|
}
|
||||||
return env;
|
return applyConfiguredRuntimeBackendsEnv(env, configManager.getConfig().runtime);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
|
private buildProviderCliEnv(binaryPath: string, providerId: CliProviderId): NodeJS.ProcessEnv {
|
||||||
const env = { ...this.buildCliEnv(binaryPath) };
|
const env = { ...this.buildCliEnv(binaryPath) };
|
||||||
|
delete env.CLAUDE_CODE_ENTRY_PROVIDER;
|
||||||
delete env.CLAUDE_CODE_USE_OPENAI;
|
delete env.CLAUDE_CODE_USE_OPENAI;
|
||||||
delete env.CLAUDE_CODE_USE_BEDROCK;
|
delete env.CLAUDE_CODE_USE_BEDROCK;
|
||||||
delete env.CLAUDE_CODE_USE_VERTEX;
|
delete env.CLAUDE_CODE_USE_VERTEX;
|
||||||
|
|
@ -129,14 +183,116 @@ export class ClaudeMultimodelBridgeService {
|
||||||
delete env.CLAUDE_CODE_USE_GEMINI;
|
delete env.CLAUDE_CODE_USE_GEMINI;
|
||||||
|
|
||||||
if (providerId === 'codex') {
|
if (providerId === 'codex') {
|
||||||
env.CLAUDE_CODE_USE_OPENAI = '1';
|
env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
|
||||||
} else if (providerId === 'gemini') {
|
} else if (providerId === 'gemini') {
|
||||||
env.CLAUDE_CODE_USE_GEMINI = '1';
|
env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
|
||||||
}
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isUnifiedRuntimeUnsupported(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
return (
|
||||||
|
lower.includes('unknown command') ||
|
||||||
|
lower.includes('unknown option') ||
|
||||||
|
lower.includes('no such command') ||
|
||||||
|
lower.includes('did you mean') ||
|
||||||
|
lower.includes('runtime status')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapRuntimeProviderStatus(
|
||||||
|
providerId: CliProviderId,
|
||||||
|
runtimeStatus: NonNullable<UnifiedRuntimeStatusResponse['providers']>[string] | undefined
|
||||||
|
): CliProviderStatus {
|
||||||
|
const provider = createDefaultProviderStatus(providerId);
|
||||||
|
if (!runtimeStatus) {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...provider,
|
||||||
|
supported: runtimeStatus.supported === true,
|
||||||
|
authenticated: runtimeStatus.authenticated === true,
|
||||||
|
authMethod: runtimeStatus.authMethod ?? null,
|
||||||
|
verificationState: runtimeStatus.verificationState ?? 'unknown',
|
||||||
|
statusMessage: runtimeStatus.statusMessage ?? null,
|
||||||
|
canLoginFromUi: runtimeStatus.canLoginFromUi !== false,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: runtimeStatus.capabilities?.teamLaunch === true,
|
||||||
|
oneShot: runtimeStatus.capabilities?.oneShot === true,
|
||||||
|
},
|
||||||
|
selectedBackendId: runtimeStatus.selectedBackendId ?? null,
|
||||||
|
resolvedBackendId: runtimeStatus.resolvedBackendId ?? null,
|
||||||
|
availableBackends:
|
||||||
|
runtimeStatus.availableBackends?.map((backend) => ({
|
||||||
|
id: backend.id ?? 'unknown',
|
||||||
|
label: backend.label ?? backend.id ?? 'Unknown',
|
||||||
|
description: backend.description ?? '',
|
||||||
|
selectable: backend.selectable !== false,
|
||||||
|
recommended: backend.recommended === true,
|
||||||
|
available: backend.available === true,
|
||||||
|
statusMessage: backend.statusMessage ?? null,
|
||||||
|
detailMessage: backend.detailMessage ?? null,
|
||||||
|
})) ?? [],
|
||||||
|
externalRuntimeDiagnostics:
|
||||||
|
runtimeStatus.externalRuntimeDiagnostics?.map((diagnostic) => ({
|
||||||
|
id: diagnostic.id ?? 'unknown',
|
||||||
|
label: diagnostic.label ?? diagnostic.id ?? 'Unknown',
|
||||||
|
detected: diagnostic.detected === true,
|
||||||
|
statusMessage: diagnostic.statusMessage ?? null,
|
||||||
|
detailMessage: diagnostic.detailMessage ?? null,
|
||||||
|
})) ?? [],
|
||||||
|
models: extractModelIds(runtimeStatus.models),
|
||||||
|
backend: runtimeStatus.backend?.kind
|
||||||
|
? {
|
||||||
|
kind: runtimeStatus.backend.kind,
|
||||||
|
label: runtimeStatus.backend.label ?? runtimeStatus.backend.kind,
|
||||||
|
endpointLabel: runtimeStatus.backend.endpointLabel ?? null,
|
||||||
|
projectId: runtimeStatus.backend.projectId ?? null,
|
||||||
|
authMethodDetail: runtimeStatus.backend.authMethodDetail ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProviderStatus(
|
||||||
|
binaryPath: string,
|
||||||
|
providerId: CliProviderId
|
||||||
|
): Promise<CliProviderStatus> {
|
||||||
|
await resolveInteractiveShellEnv();
|
||||||
|
const env = this.buildCliEnv(binaryPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execCli(
|
||||||
|
binaryPath,
|
||||||
|
['runtime', 'status', '--json', '--provider', providerId],
|
||||||
|
{
|
||||||
|
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
|
env,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||||
|
return this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]);
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||||
|
logger.warn(
|
||||||
|
`Provider-scoped runtime status unavailable for ${providerId}, falling back to full probe: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const providers = await this.getProviderStatuses(binaryPath);
|
||||||
|
return (
|
||||||
|
providers.find((provider) => provider.providerId === providerId) ??
|
||||||
|
createDefaultProviderStatus(providerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
|
private async buildGeminiStatus(binaryPath: string): Promise<CliProviderStatus> {
|
||||||
const provider = createDefaultProviderStatus('gemini');
|
const provider = createDefaultProviderStatus('gemini');
|
||||||
const env = this.buildProviderCliEnv(binaryPath, 'gemini');
|
const env = this.buildProviderCliEnv(binaryPath, 'gemini');
|
||||||
|
|
@ -200,6 +356,27 @@ export class ClaudeMultimodelBridgeService {
|
||||||
await resolveInteractiveShellEnv();
|
await resolveInteractiveShellEnv();
|
||||||
const env = this.buildCliEnv(binaryPath);
|
const env = this.buildCliEnv(binaryPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execCli(binaryPath, ['runtime', 'status', '--json'], {
|
||||||
|
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||||
|
const providers = ORDERED_PROVIDER_IDS.map((providerId) =>
|
||||||
|
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId])
|
||||||
|
);
|
||||||
|
onUpdate?.(providers);
|
||||||
|
return providers;
|
||||||
|
} catch (error) {
|
||||||
|
if (!this.isUnifiedRuntimeUnsupported(error)) {
|
||||||
|
logger.warn(
|
||||||
|
`Unified runtime status unavailable, falling back to legacy probes: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [statusResult, modelsResult] = await Promise.allSettled([
|
const [statusResult, modelsResult] = await Promise.allSettled([
|
||||||
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
|
execCli(binaryPath, ['auth', 'status', '--json', '--provider', 'all'], {
|
||||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
export type GeminiGlobalConfig = {
|
export type GeminiGlobalConfig = {
|
||||||
geminiBackendPreference?: 'auto' | 'api' | 'cli';
|
geminiBackendPreference?: 'auto' | 'api' | 'cli' | 'cli-sdk';
|
||||||
geminiResolvedBackend?: 'api' | 'cli';
|
geminiResolvedBackend?: 'api' | 'cli' | 'cli-sdk';
|
||||||
geminiLastAuthMethod?: string;
|
geminiLastAuthMethod?: string;
|
||||||
geminiProjectId?: string;
|
geminiProjectId?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -11,11 +11,37 @@ export type GeminiGlobalConfig = {
|
||||||
export type GeminiRuntimeAuthState = {
|
export type GeminiRuntimeAuthState = {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
authMethod: string | null;
|
authMethod: string | null;
|
||||||
resolvedBackend: 'auto' | 'api' | 'cli';
|
resolvedBackend: 'auto' | 'api' | 'cli-sdk';
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
statusMessage: string | null;
|
statusMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeGeminiBackend(
|
||||||
|
value: string | null | undefined
|
||||||
|
): GeminiRuntimeAuthState['resolvedBackend'] {
|
||||||
|
if (value === 'api') return 'api';
|
||||||
|
if (value === 'cli' || value === 'cli-sdk') return 'cli-sdk';
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEffectiveGeminiBackend(
|
||||||
|
requestedBackend: GeminiRuntimeAuthState['resolvedBackend'],
|
||||||
|
authMethod: string | null,
|
||||||
|
hasGeminiApiKey: boolean,
|
||||||
|
hasAdcWithProject: boolean
|
||||||
|
): Exclude<GeminiRuntimeAuthState['resolvedBackend'], 'auto'> | 'auto' {
|
||||||
|
if (requestedBackend !== 'auto') {
|
||||||
|
return requestedBackend;
|
||||||
|
}
|
||||||
|
if (hasGeminiApiKey || hasAdcWithProject) {
|
||||||
|
return 'api';
|
||||||
|
}
|
||||||
|
if (authMethod === 'cli_oauth_personal') {
|
||||||
|
return 'cli-sdk';
|
||||||
|
}
|
||||||
|
return 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
export async function readGeminiGlobalConfig(
|
export async function readGeminiGlobalConfig(
|
||||||
env: NodeJS.ProcessEnv
|
env: NodeJS.ProcessEnv
|
||||||
): Promise<GeminiGlobalConfig | null> {
|
): Promise<GeminiGlobalConfig | null> {
|
||||||
|
|
@ -43,11 +69,11 @@ export async function resolveGeminiRuntimeAuth(
|
||||||
env: NodeJS.ProcessEnv
|
env: NodeJS.ProcessEnv
|
||||||
): Promise<GeminiRuntimeAuthState> {
|
): Promise<GeminiRuntimeAuthState> {
|
||||||
const config = await readGeminiGlobalConfig(env);
|
const config = await readGeminiGlobalConfig(env);
|
||||||
const resolvedBackend =
|
const resolvedBackend = normalizeGeminiBackend(
|
||||||
env.CLAUDE_CODE_GEMINI_BACKEND?.trim() ||
|
env.CLAUDE_CODE_GEMINI_BACKEND?.trim() ||
|
||||||
config?.geminiResolvedBackend?.trim() ||
|
config?.geminiResolvedBackend?.trim() ||
|
||||||
config?.geminiBackendPreference?.trim() ||
|
config?.geminiBackendPreference?.trim()
|
||||||
'auto';
|
);
|
||||||
const authMethod = config?.geminiLastAuthMethod?.trim() ?? null;
|
const authMethod = config?.geminiLastAuthMethod?.trim() ?? null;
|
||||||
const projectId =
|
const projectId =
|
||||||
env.GOOGLE_CLOUD_PROJECT?.trim() ||
|
env.GOOGLE_CLOUD_PROJECT?.trim() ||
|
||||||
|
|
@ -56,34 +82,41 @@ export async function resolveGeminiRuntimeAuth(
|
||||||
config?.geminiProjectId?.trim() ||
|
config?.geminiProjectId?.trim() ||
|
||||||
null;
|
null;
|
||||||
const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim());
|
const hasGeminiApiKey = Boolean(env.GEMINI_API_KEY?.trim());
|
||||||
|
const hasAdcWithProject = Boolean(
|
||||||
|
(authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId
|
||||||
|
);
|
||||||
|
const effectiveBackend = resolveEffectiveGeminiBackend(
|
||||||
|
resolvedBackend,
|
||||||
|
authMethod,
|
||||||
|
hasGeminiApiKey,
|
||||||
|
hasAdcWithProject
|
||||||
|
);
|
||||||
|
|
||||||
if (hasGeminiApiKey) {
|
if (hasGeminiApiKey) {
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
authMethod: 'api_key',
|
authMethod: 'api_key',
|
||||||
resolvedBackend:
|
resolvedBackend: effectiveBackend,
|
||||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
|
||||||
projectId,
|
projectId,
|
||||||
statusMessage: null,
|
statusMessage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((authMethod === 'adc_authorized_user' || authMethod === 'adc_service_account') && projectId) {
|
if (hasAdcWithProject) {
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
authMethod,
|
authMethod,
|
||||||
resolvedBackend:
|
resolvedBackend: effectiveBackend,
|
||||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
|
||||||
projectId,
|
projectId,
|
||||||
statusMessage: null,
|
statusMessage: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authMethod === 'cli_oauth_personal' && resolvedBackend === 'cli') {
|
if (authMethod === 'cli_oauth_personal' && effectiveBackend === 'cli-sdk') {
|
||||||
return {
|
return {
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
authMethod,
|
authMethod,
|
||||||
resolvedBackend: 'cli',
|
resolvedBackend: 'cli-sdk',
|
||||||
projectId,
|
projectId,
|
||||||
statusMessage: null,
|
statusMessage: null,
|
||||||
};
|
};
|
||||||
|
|
@ -93,19 +126,17 @@ export async function resolveGeminiRuntimeAuth(
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod,
|
authMethod,
|
||||||
resolvedBackend:
|
resolvedBackend: effectiveBackend,
|
||||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
|
||||||
projectId,
|
projectId,
|
||||||
statusMessage:
|
statusMessage:
|
||||||
'Gemini CLI OAuth was detected, but the active Gemini backend is not set to cli.',
|
'Gemini CLI OAuth was detected, but the active Gemini backend is not set to CLI SDK.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
authMethod,
|
authMethod,
|
||||||
resolvedBackend:
|
resolvedBackend,
|
||||||
resolvedBackend === 'api' || resolvedBackend === 'cli' ? resolvedBackend : 'auto',
|
|
||||||
projectId,
|
projectId,
|
||||||
statusMessage:
|
statusMessage:
|
||||||
'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',
|
'Gemini provider is not configured for runtime use. Set GEMINI_API_KEY or Google ADC credentials (plus GOOGLE_CLOUD_PROJECT when needed) and retry.',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import type { TeamProviderId } from '@shared/types';
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
|
import { ConfigManager } from '../infrastructure/ConfigManager';
|
||||||
|
|
||||||
const THIRD_PARTY_PROVIDER_ENV_KEYS = [
|
const THIRD_PARTY_PROVIDER_ENV_KEYS = [
|
||||||
|
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||||
'CLAUDE_CODE_USE_OPENAI',
|
'CLAUDE_CODE_USE_OPENAI',
|
||||||
'CLAUDE_CODE_USE_BEDROCK',
|
'CLAUDE_CODE_USE_BEDROCK',
|
||||||
'CLAUDE_CODE_USE_VERTEX',
|
'CLAUDE_CODE_USE_VERTEX',
|
||||||
|
|
@ -8,6 +11,24 @@ const THIRD_PARTY_PROVIDER_ENV_KEYS = [
|
||||||
'CLAUDE_CODE_USE_GEMINI',
|
'CLAUDE_CODE_USE_GEMINI',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const BACKEND_SELECTION_ENV_KEYS = [
|
||||||
|
'CLAUDE_CODE_GEMINI_BACKEND',
|
||||||
|
'CLAUDE_CODE_CODEX_BACKEND',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function applyConfiguredRuntimeBackendsEnv(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
runtimeConfig = ConfigManager.getInstance().getConfig().runtime
|
||||||
|
): NodeJS.ProcessEnv {
|
||||||
|
for (const key of BACKEND_SELECTION_ENV_KEYS) {
|
||||||
|
env[key] = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
env.CLAUDE_CODE_GEMINI_BACKEND = runtimeConfig.providerBackends.gemini;
|
||||||
|
env.CLAUDE_CODE_CODEX_BACKEND = runtimeConfig.providerBackends.codex;
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyProviderRuntimeEnv(
|
export function applyProviderRuntimeEnv(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
providerId: TeamProviderId | undefined
|
providerId: TeamProviderId | undefined
|
||||||
|
|
@ -20,9 +41,9 @@ export function applyProviderRuntimeEnv(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resolvedProvider === 'codex') {
|
if (resolvedProvider === 'codex') {
|
||||||
env.CLAUDE_CODE_USE_OPENAI = '1';
|
env.CLAUDE_CODE_ENTRY_PROVIDER = 'codex';
|
||||||
} else if (resolvedProvider === 'gemini') {
|
} else if (resolvedProvider === 'gemini') {
|
||||||
env.CLAUDE_CODE_USE_GEMINI = '1';
|
env.CLAUDE_CODE_ENTRY_PROVIDER = 'gemini';
|
||||||
}
|
}
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import { applyProviderRuntimeEnv } from '../runtime/providerRuntimeEnv';
|
import {
|
||||||
|
applyConfiguredRuntimeBackendsEnv,
|
||||||
|
applyProviderRuntimeEnv,
|
||||||
|
} from '../runtime/providerRuntimeEnv';
|
||||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
||||||
|
|
||||||
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types';
|
||||||
|
|
@ -103,7 +106,11 @@ export class ScheduledTaskExecutor {
|
||||||
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`);
|
||||||
|
|
||||||
const env = applyProviderRuntimeEnv(
|
const env = applyProviderRuntimeEnv(
|
||||||
{ ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined },
|
applyConfiguredRuntimeBackendsEnv({
|
||||||
|
...buildEnrichedEnv(binaryPath),
|
||||||
|
...shellEnv,
|
||||||
|
CLAUDECODE: undefined,
|
||||||
|
}),
|
||||||
request.config.providerId
|
request.config.providerId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -183,9 +183,11 @@ function getRepoLocalCliCandidates(): string[] {
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
const repoRoot = process.cwd();
|
||||||
return [
|
return [
|
||||||
|
// Prefer an already compiled repo-local binary when available.
|
||||||
|
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
|
||||||
|
// Fall back to launcher scripts for normal local development.
|
||||||
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'),
|
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli'),
|
||||||
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'),
|
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'cli-dev'),
|
||||||
path.resolve(repoRoot, '..', 'free-code-gemini-research', 'dist', 'cli'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,63 @@ const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full conf
|
||||||
const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
const PER_TEAM_READ_TIMEOUT_MS = 5_000;
|
||||||
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
const MAX_SESSION_HISTORY_IN_SUMMARY = 2000;
|
||||||
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200;
|
||||||
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
|
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||||
|
|
||||||
|
interface PartialLaunchStateSummary {
|
||||||
|
partialLaunchFailure: true;
|
||||||
|
expectedMemberCount: number;
|
||||||
|
confirmedMemberCount: number;
|
||||||
|
missingMembers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPartialLaunchStateSummary(
|
||||||
|
teamDir: string
|
||||||
|
): Promise<PartialLaunchStateSummary | null> {
|
||||||
|
const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE);
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(launchStatePath);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS);
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
state?: unknown;
|
||||||
|
expectedMembers?: unknown;
|
||||||
|
confirmedMembers?: unknown;
|
||||||
|
missingMembers?: unknown;
|
||||||
|
};
|
||||||
|
if (parsed.state !== 'partial_launch_failure') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const expectedMembers = Array.isArray(parsed.expectedMembers)
|
||||||
|
? parsed.expectedMembers.filter(
|
||||||
|
(name): name is string => typeof name === 'string' && name.trim().length > 0
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const confirmedMembers = Array.isArray(parsed.confirmedMembers)
|
||||||
|
? parsed.confirmedMembers.filter(
|
||||||
|
(name): name is string => typeof name === 'string' && name.trim().length > 0
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const missingMembers = Array.isArray(parsed.missingMembers)
|
||||||
|
? parsed.missingMembers.filter(
|
||||||
|
(name): name is string => typeof name === 'string' && name.trim().length > 0
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (expectedMembers.length === 0 || missingMembers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
partialLaunchFailure: true,
|
||||||
|
expectedMemberCount: expectedMembers.length,
|
||||||
|
confirmedMemberCount: confirmedMembers.length,
|
||||||
|
missingMembers,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function mapLimit<T, R>(
|
async function mapLimit<T, R>(
|
||||||
items: readonly T[],
|
items: readonly T[],
|
||||||
|
|
@ -132,6 +189,7 @@ export class TeamConfigReader {
|
||||||
|
|
||||||
private async readTeamSummary(teamsDir: string, teamName: string): Promise<TeamSummary | null> {
|
private async readTeamSummary(teamsDir: string, teamName: string): Promise<TeamSummary | null> {
|
||||||
const configPath = path.join(teamsDir, teamName, 'config.json');
|
const configPath = path.join(teamsDir, teamName, 'config.json');
|
||||||
|
const teamDir = path.join(teamsDir, teamName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let config: TeamConfig | null = null;
|
let config: TeamConfig | null = null;
|
||||||
|
|
@ -204,6 +262,8 @@ export class TeamConfigReader {
|
||||||
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
|
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
|
||||||
const memberMap = new Map<string, TeamSummaryMember>();
|
const memberMap = new Map<string, TeamSummaryMember>();
|
||||||
const removedKeys = new Set<string>();
|
const removedKeys = new Set<string>();
|
||||||
|
const expectedTeammateNames = new Set<string>();
|
||||||
|
const confirmedArtifactNames = new Set<string>();
|
||||||
|
|
||||||
const mergeMember = (m: TeamMember): void => {
|
const mergeMember = (m: TeamMember): void => {
|
||||||
const name = m.name?.trim();
|
const name = m.name?.trim();
|
||||||
|
|
@ -235,6 +295,7 @@ export class TeamConfigReader {
|
||||||
removedKeys.add(key);
|
removedKeys.add(key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
expectedTeammateNames.add(name);
|
||||||
mergeMember(member);
|
mergeMember(member);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -245,11 +306,28 @@ export class TeamConfigReader {
|
||||||
if (config && Array.isArray(config.members)) {
|
if (config && Array.isArray(config.members)) {
|
||||||
for (const member of config.members) {
|
for (const member of config.members) {
|
||||||
if (member && typeof member.name === 'string') {
|
if (member && typeof member.name === 'string') {
|
||||||
|
const name = member.name.trim();
|
||||||
|
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||||
|
confirmedArtifactNames.add(name);
|
||||||
|
}
|
||||||
mergeMember(member);
|
mergeMember(member);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inboxDir = path.join(teamDir, 'inboxes');
|
||||||
|
const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true });
|
||||||
|
for (const entry of inboxEntries) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
||||||
|
const inboxName = entry.name.slice(0, -'.json'.length).trim();
|
||||||
|
if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue;
|
||||||
|
confirmedArtifactNames.add(inboxName);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
// Defense: drop CLI auto-suffixed duplicates (alice-2) when base name exists.
|
||||||
const allNames = Array.from(memberMap.values()).map((m) => m.name);
|
const allNames = Array.from(memberMap.values()).map((m) => m.name);
|
||||||
const keepName = createCliAutoSuffixNameGuard(allNames);
|
const keepName = createCliAutoSuffixNameGuard(allNames);
|
||||||
|
|
@ -262,6 +340,29 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = Array.from(memberMap.values());
|
const members = Array.from(memberMap.values());
|
||||||
|
const partialLaunchState =
|
||||||
|
(await readPartialLaunchStateSummary(teamDir)) ??
|
||||||
|
(() => {
|
||||||
|
if (
|
||||||
|
!leadSessionId ||
|
||||||
|
expectedTeammateNames.size === 0 ||
|
||||||
|
confirmedArtifactNames.size === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const missingMembers = Array.from(expectedTeammateNames).filter(
|
||||||
|
(name) => !confirmedArtifactNames.has(name)
|
||||||
|
);
|
||||||
|
if (missingMembers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
partialLaunchFailure: true as const,
|
||||||
|
expectedMemberCount: expectedTeammateNames.size,
|
||||||
|
confirmedMemberCount: confirmedArtifactNames.size,
|
||||||
|
missingMembers,
|
||||||
|
};
|
||||||
|
})();
|
||||||
const summary: TeamSummary = {
|
const summary: TeamSummary = {
|
||||||
teamName,
|
teamName,
|
||||||
displayName,
|
displayName,
|
||||||
|
|
@ -276,6 +377,7 @@ export class TeamConfigReader {
|
||||||
...(projectPathHistory ? { projectPathHistory } : {}),
|
...(projectPathHistory ? { projectPathHistory } : {}),
|
||||||
...(sessionHistory ? { sessionHistory } : {}),
|
...(sessionHistory ? { sessionHistory } : {}),
|
||||||
...(deletedAt ? { deletedAt } : {}),
|
...(deletedAt ? { deletedAt } : {}),
|
||||||
|
...(partialLaunchState ?? {}),
|
||||||
};
|
};
|
||||||
return summary;
|
return summary;
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/ut
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
@ -589,6 +590,68 @@ export class TeamDataService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedup exact message copies that can appear as both live lead_process rows and
|
||||||
|
// their persisted inbox/sent-message counterpart. If the messageId is identical,
|
||||||
|
// keep a single row so the UI does not show the same SendMessage twice
|
||||||
|
// (for example "LIVE" plus the stored copy).
|
||||||
|
const duplicateMessageIds = new Set<string>();
|
||||||
|
const messageIdCounts = new Map<string, number>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
||||||
|
if (!id) continue;
|
||||||
|
const nextCount = (messageIdCounts.get(id) ?? 0) + 1;
|
||||||
|
messageIdCounts.set(id, nextCount);
|
||||||
|
if (nextCount > 1) duplicateMessageIds.add(id);
|
||||||
|
}
|
||||||
|
if (duplicateMessageIds.size > 0) {
|
||||||
|
const choosePreferredMessage = (
|
||||||
|
current: InboxMessage,
|
||||||
|
candidate: InboxMessage
|
||||||
|
): InboxMessage => {
|
||||||
|
const score = (msg: InboxMessage): number => {
|
||||||
|
let value = 0;
|
||||||
|
if (msg.source !== 'lead_process') value += 4;
|
||||||
|
if (msg.read === false) value += 2;
|
||||||
|
if (msg.relayOfMessageId) value += 1;
|
||||||
|
if (msg.summary) value += 1;
|
||||||
|
if (msg.to) value += 1;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const currentScore = score(current);
|
||||||
|
const candidateScore = score(candidate);
|
||||||
|
if (candidateScore !== currentScore) {
|
||||||
|
return candidateScore > currentScore ? candidate : current;
|
||||||
|
}
|
||||||
|
const currentTs = Date.parse(current.timestamp);
|
||||||
|
const candidateTs = Date.parse(candidate.timestamp);
|
||||||
|
if (
|
||||||
|
Number.isFinite(currentTs) &&
|
||||||
|
Number.isFinite(candidateTs) &&
|
||||||
|
candidateTs !== currentTs
|
||||||
|
) {
|
||||||
|
return candidateTs > currentTs ? candidate : current;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupedById = new Map<string, InboxMessage>();
|
||||||
|
const dedupedWithoutId: InboxMessage[] = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const id = typeof msg.messageId === 'string' ? msg.messageId.trim() : '';
|
||||||
|
if (!id) {
|
||||||
|
dedupedWithoutId.push(msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = dedupedById.get(id);
|
||||||
|
if (!existing) {
|
||||||
|
dedupedById.set(id, msg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
dedupedById.set(id, choosePreferredMessage(existing, msg));
|
||||||
|
}
|
||||||
|
messages = [...dedupedWithoutId, ...dedupedById.values()];
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
||||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||||
|
|
@ -1021,10 +1084,7 @@ export class TeamDataService {
|
||||||
name,
|
name,
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.trim() || undefined,
|
workflow: member.workflow?.trim() || undefined,
|
||||||
providerId:
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
|
||||||
? member.providerId
|
|
||||||
: undefined,
|
|
||||||
model: member.model?.trim() || undefined,
|
model: member.model?.trim() || undefined,
|
||||||
effort:
|
effort:
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
|
|
@ -1985,10 +2045,7 @@ export class TeamDataService {
|
||||||
})(),
|
})(),
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.trim() || undefined,
|
workflow: member.workflow?.trim() || undefined,
|
||||||
providerId:
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
|
||||||
? member.providerId
|
|
||||||
: undefined,
|
|
||||||
model: member.model?.trim() || undefined,
|
model: member.model?.trim() || undefined,
|
||||||
effort:
|
effort:
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -24,10 +25,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||||
providerId:
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
|
||||||
? member.providerId
|
|
||||||
: undefined,
|
|
||||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||||
effort:
|
effort:
|
||||||
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
member.effort === 'low' || member.effort === 'medium' || member.effort === 'high'
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -86,6 +86,9 @@ interface TaskReadDiag {
|
||||||
skipReasons: Record<string, number>;
|
skipReasons: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
|
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parsed JSON types (loose shapes from disk)
|
// Parsed JSON types (loose shapes from disk)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -316,6 +319,54 @@ function dropCliProvisionerMembers(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readPartialLaunchState(
|
||||||
|
teamsDir: string,
|
||||||
|
teamName: string
|
||||||
|
): Promise<{
|
||||||
|
partialLaunchFailure: true;
|
||||||
|
expectedMemberCount: number;
|
||||||
|
confirmedMemberCount: number;
|
||||||
|
missingMembers: string[];
|
||||||
|
} | null> {
|
||||||
|
const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE);
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(launchStatePath);
|
||||||
|
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) return null;
|
||||||
|
const raw = await fs.promises.readFile(launchStatePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
state?: unknown;
|
||||||
|
expectedMembers?: unknown;
|
||||||
|
confirmedMembers?: unknown;
|
||||||
|
missingMembers?: unknown;
|
||||||
|
};
|
||||||
|
if (parsed.state !== 'partial_launch_failure') return null;
|
||||||
|
const expectedMembers = Array.isArray(parsed.expectedMembers)
|
||||||
|
? parsed.expectedMembers.filter(
|
||||||
|
(name): name is string => typeof name === 'string' && name.trim().length > 0
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const confirmedMembers = Array.isArray(parsed.confirmedMembers)
|
||||||
|
? parsed.confirmedMembers.filter(
|
||||||
|
(name): name is string => typeof name === 'string' && name.trim().length > 0
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const missingMembers = Array.isArray(parsed.missingMembers)
|
||||||
|
? parsed.missingMembers.filter(
|
||||||
|
(name): name is string => typeof name === 'string' && name.trim().length > 0
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
if (expectedMembers.length === 0 || missingMembers.length === 0) return null;
|
||||||
|
return {
|
||||||
|
partialLaunchFailure: true,
|
||||||
|
expectedMemberCount: expectedMembers.length,
|
||||||
|
confirmedMemberCount: confirmedMembers.length,
|
||||||
|
missingMembers,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a draft team summary from team.meta.json when config.json is missing.
|
* Reads a draft team summary from team.meta.json when config.json is missing.
|
||||||
* Returns null if team.meta.json doesn't exist or is invalid.
|
* Returns null if team.meta.json doesn't exist or is invalid.
|
||||||
|
|
@ -482,6 +533,8 @@ async function listTeams(
|
||||||
|
|
||||||
const memberMap = new Map<string, { name: string; role?: string; color?: string }>();
|
const memberMap = new Map<string, { name: string; role?: string; color?: string }>();
|
||||||
const removedKeys = new Set<string>();
|
const removedKeys = new Set<string>();
|
||||||
|
const expectedTeammateNames = new Set<string>();
|
||||||
|
const confirmedArtifactNames = new Set<string>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json');
|
const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json');
|
||||||
|
|
@ -500,6 +553,7 @@ async function listTeams(
|
||||||
removedKeys.add(key);
|
removedKeys.add(key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
expectedTeammateNames.add(name);
|
||||||
mergeMember(member, memberMap, removedKeys);
|
mergeMember(member, memberMap, removedKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -511,15 +565,55 @@ async function listTeams(
|
||||||
if (config && Array.isArray(config.members)) {
|
if (config && Array.isArray(config.members)) {
|
||||||
for (const member of config.members as unknown[]) {
|
for (const member of config.members as unknown[]) {
|
||||||
if (isRawMember(member)) {
|
if (isRawMember(member)) {
|
||||||
|
const name = typeof member.name === 'string' ? member.name.trim() : '';
|
||||||
|
if (name && name !== 'user' && !isLeadMember(member)) {
|
||||||
|
confirmedArtifactNames.add(name);
|
||||||
|
}
|
||||||
mergeMember(member, memberMap, removedKeys);
|
mergeMember(member, memberMap, removedKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inboxDir = path.join(payload.teamsDir, teamName, 'inboxes');
|
||||||
|
const inboxEntries = await fs.promises.readdir(inboxDir, { withFileTypes: true });
|
||||||
|
for (const entry of inboxEntries) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
||||||
|
const inboxName = entry.name.slice(0, -'.json'.length).trim();
|
||||||
|
if (!inboxName || inboxName === 'user' || isLeadMember({ name: inboxName })) continue;
|
||||||
|
confirmedArtifactNames.add(inboxName);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
|
||||||
dropCliAutoSuffixedMembers(memberMap);
|
dropCliAutoSuffixedMembers(memberMap);
|
||||||
dropCliProvisionerMembers(memberMap);
|
dropCliProvisionerMembers(memberMap);
|
||||||
|
|
||||||
const members = Array.from(memberMap.values());
|
const members = Array.from(memberMap.values());
|
||||||
|
const partialLaunchState =
|
||||||
|
(await readPartialLaunchState(payload.teamsDir, teamName)) ??
|
||||||
|
(() => {
|
||||||
|
if (
|
||||||
|
!leadSessionId ||
|
||||||
|
expectedTeammateNames.size === 0 ||
|
||||||
|
confirmedArtifactNames.size === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const missingMembers = Array.from(expectedTeammateNames).filter(
|
||||||
|
(name) => !confirmedArtifactNames.has(name)
|
||||||
|
);
|
||||||
|
if (missingMembers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
partialLaunchFailure: true as const,
|
||||||
|
expectedMemberCount: expectedTeammateNames.size,
|
||||||
|
confirmedMemberCount: confirmedArtifactNames.size,
|
||||||
|
missingMembers,
|
||||||
|
};
|
||||||
|
})();
|
||||||
const summary = {
|
const summary = {
|
||||||
teamName,
|
teamName,
|
||||||
displayName,
|
displayName,
|
||||||
|
|
@ -534,6 +628,7 @@ async function listTeams(
|
||||||
...(projectPathHistory ? { projectPathHistory } : {}),
|
...(projectPathHistory ? { projectPathHistory } : {}),
|
||||||
...(sessionHistory ? { sessionHistory } : {}),
|
...(sessionHistory ? { sessionHistory } : {}),
|
||||||
...(deletedAt ? { deletedAt } : {}),
|
...(deletedAt ? { deletedAt } : {}),
|
||||||
|
...(partialLaunchState ?? {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const ms = nowMs() - t0;
|
const ms = nowMs() - t0;
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,9 @@ export const CROSS_TEAM_GET_OUTBOX = 'crossTeam:getOutbox';
|
||||||
/** Get CLI installation status */
|
/** Get CLI installation status */
|
||||||
export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus';
|
export const CLI_INSTALLER_GET_STATUS = 'cliInstaller:getStatus';
|
||||||
|
|
||||||
|
/** Get status for a single provider */
|
||||||
|
export const CLI_INSTALLER_GET_PROVIDER_STATUS = 'cliInstaller:getProviderStatus';
|
||||||
|
|
||||||
/** Start CLI install/update */
|
/** Start CLI install/update */
|
||||||
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
|
export const CLI_INSTALLER_INSTALL = 'cliInstaller:install';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
API_KEYS_STORAGE_STATUS,
|
API_KEYS_STORAGE_STATUS,
|
||||||
APP_RELAUNCH,
|
APP_RELAUNCH,
|
||||||
CLI_INSTALLER_GET_STATUS,
|
CLI_INSTALLER_GET_STATUS,
|
||||||
|
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
CLI_INSTALLER_PROGRESS,
|
CLI_INSTALLER_PROGRESS,
|
||||||
|
|
@ -1344,6 +1345,9 @@ const electronAPI: ElectronAPI = {
|
||||||
getStatus: async (): Promise<CliInstallationStatus> => {
|
getStatus: async (): Promise<CliInstallationStatus> => {
|
||||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
||||||
},
|
},
|
||||||
|
getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => {
|
||||||
|
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||||
|
},
|
||||||
install: async (): Promise<void> => {
|
install: async (): Promise<void> => {
|
||||||
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
|
return invokeIpcWithResult<void>(CLI_INSTALLER_INSTALL);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1076,6 +1076,7 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
authMethod: null,
|
authMethod: null,
|
||||||
providers: [],
|
providers: [],
|
||||||
}),
|
}),
|
||||||
|
getProviderStatus: async (): Promise<null> => null,
|
||||||
install: async (): Promise<void> => {
|
install: async (): Promise<void> => {
|
||||||
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
console.warn('[HttpAPIClient] CLI installer not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
DIFF_REMOVED_TEXT,
|
DIFF_REMOVED_TEXT,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
|
||||||
|
import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the input section based on tool type with theme-aware styling.
|
* Renders the input section based on tool type with theme-aware styling.
|
||||||
|
|
@ -99,6 +100,71 @@ export function renderInput(toolName: string, input: Record<string, unknown>): R
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special rendering for Agent tool - do not leak full bootstrap prompts in UI logs.
|
||||||
|
if (toolName === 'Agent') {
|
||||||
|
const details = getAgentToolDisplayDetails(input);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" style={{ color: COLOR_TEXT }}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
action
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap break-all">{details.action}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{details.teammateName && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
teammate
|
||||||
|
</div>
|
||||||
|
<div>{details.teammateName}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details.teamName && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
team
|
||||||
|
</div>
|
||||||
|
<div>{details.teamName}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details.runtime && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
runtime
|
||||||
|
</div>
|
||||||
|
<div>{details.runtime}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{details.subagentType && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs" style={{ color: COLOR_TEXT_MUTED }}>
|
||||||
|
type
|
||||||
|
</div>
|
||||||
|
<div>{details.subagentType}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded px-3 py-2 text-[11px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(250, 204, 21, 0.08)',
|
||||||
|
border: '1px solid rgba(250, 204, 21, 0.22)',
|
||||||
|
color: COLOR_TEXT_MUTED,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Startup instructions are hidden in the UI.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Default: key-value format with readable string values
|
// Default: key-value format with readable string values
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
|
<div className="space-y-2" style={{ color: COLOR_TEXT }}>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
|
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||||
|
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||||
|
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
|
|
@ -29,6 +32,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
SlidersHorizontal,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -167,6 +171,7 @@ const CliCheckingSpinner = ({
|
||||||
interface InstalledBannerProps {
|
interface InstalledBannerProps {
|
||||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>;
|
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>;
|
||||||
cliStatusLoading: boolean;
|
cliStatusLoading: boolean;
|
||||||
|
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||||
cliStatusError: string | null;
|
cliStatusError: string | null;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
multimodelEnabled: boolean;
|
multimodelEnabled: boolean;
|
||||||
|
|
@ -176,6 +181,8 @@ interface InstalledBannerProps {
|
||||||
onMultimodelToggle: (enabled: boolean) => void;
|
onMultimodelToggle: (enabled: boolean) => void;
|
||||||
onProviderLogin: (providerId: CliProviderId) => void;
|
onProviderLogin: (providerId: CliProviderId) => void;
|
||||||
onProviderLogout: (providerId: CliProviderId) => void;
|
onProviderLogout: (providerId: CliProviderId) => void;
|
||||||
|
onProviderManage: (providerId: CliProviderId) => void;
|
||||||
|
onProviderRefresh: (providerId: CliProviderId) => void;
|
||||||
variant: BannerVariant;
|
variant: BannerVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,35 +197,41 @@ function getProviderLabel(providerId: CliProviderId): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderTerminalCommand(providerId: CliProviderId): {
|
function getProviderTerminalCommand(provider: CliProviderStatus): {
|
||||||
args: string[];
|
args: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
if (providerId === 'gemini') {
|
if (provider.providerId === 'gemini') {
|
||||||
return {
|
return {
|
||||||
args: ['login'],
|
args: ['login'],
|
||||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
env: {
|
||||||
|
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||||
|
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
args: ['auth', 'login', '--provider', providerId],
|
args: ['auth', 'login', '--provider', provider.providerId],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
|
function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
||||||
args: string[];
|
args: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
if (providerId === 'gemini') {
|
if (provider.providerId === 'gemini') {
|
||||||
return {
|
return {
|
||||||
args: ['logout'],
|
args: ['logout'],
|
||||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
env: {
|
||||||
|
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||||
|
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
args: ['auth', 'logout', '--provider', providerId],
|
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,6 +291,40 @@ function ModelBadges({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderDetailSkeleton(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
<div
|
||||||
|
className="skeleton-shimmer h-3 rounded-sm"
|
||||||
|
style={{ width: '58%', backgroundColor: 'var(--skeleton-base)' }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="skeleton-shimmer h-6 rounded-md border"
|
||||||
|
style={{
|
||||||
|
width: index === 0 ? 56 : index === 1 ? 84 : index === 2 ? 72 : 96,
|
||||||
|
borderColor: 'var(--color-border-subtle)',
|
||||||
|
backgroundColor: 'var(--skeleton-base-dim)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||||
|
return (
|
||||||
|
providerLoading ||
|
||||||
|
(!provider.authenticated &&
|
||||||
|
provider.statusMessage === 'Checking...' &&
|
||||||
|
provider.models.length === 0 &&
|
||||||
|
provider.backend == null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatRuntimeLabel(
|
function formatRuntimeLabel(
|
||||||
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
|
cliStatus: NonNullable<ReturnType<typeof useCliInstaller>['cliStatus']>
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|
@ -301,12 +348,8 @@ function formatRuntimeAuthSummary(
|
||||||
) {
|
) {
|
||||||
return 'Checking providers...';
|
return 'Checking providers...';
|
||||||
}
|
}
|
||||||
const supportedProviders = cliStatus.providers.filter((provider) => provider.supported);
|
const denominator = cliStatus.providers.length;
|
||||||
const denominator =
|
const connected = cliStatus.providers.filter((provider) => provider.authenticated).length;
|
||||||
supportedProviders.length > 0 ? supportedProviders.length : cliStatus.providers.length;
|
|
||||||
const connected = (
|
|
||||||
supportedProviders.length > 0 ? supportedProviders : cliStatus.providers
|
|
||||||
).filter((provider) => provider.authenticated).length;
|
|
||||||
|
|
||||||
return `Providers: ${connected}/${denominator} connected`;
|
return `Providers: ${connected}/${denominator} connected`;
|
||||||
}
|
}
|
||||||
|
|
@ -334,48 +377,10 @@ function isCheckingMultimodelStatus(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLoadingMultimodelStatus(): CliInstallationStatus {
|
|
||||||
const providers: CliProviderStatus[] = [
|
|
||||||
{ providerId: 'anthropic' as const, displayName: 'Anthropic' },
|
|
||||||
{ providerId: 'codex' as const, displayName: 'Codex' },
|
|
||||||
{ providerId: 'gemini' as const, displayName: 'Gemini' },
|
|
||||||
].map((provider) => ({
|
|
||||||
...provider,
|
|
||||||
supported: false,
|
|
||||||
authenticated: false,
|
|
||||||
authMethod: null,
|
|
||||||
verificationState: 'unknown' as const,
|
|
||||||
statusMessage: 'Checking...',
|
|
||||||
models: [],
|
|
||||||
canLoginFromUi: true,
|
|
||||||
capabilities: {
|
|
||||||
teamLaunch: false,
|
|
||||||
oneShot: false,
|
|
||||||
},
|
|
||||||
backend: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
flavor: 'free-code',
|
|
||||||
displayName: 'free-code-gemini-research',
|
|
||||||
supportsSelfUpdate: false,
|
|
||||||
showVersionDetails: false,
|
|
||||||
showBinaryPath: false,
|
|
||||||
installed: true,
|
|
||||||
installedVersion: null,
|
|
||||||
binaryPath: null,
|
|
||||||
latestVersion: null,
|
|
||||||
updateAvailable: false,
|
|
||||||
authLoggedIn: false,
|
|
||||||
authStatusChecking: true,
|
|
||||||
authMethod: null,
|
|
||||||
providers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const InstalledBanner = ({
|
const InstalledBanner = ({
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
cliProviderStatusLoading,
|
||||||
cliStatusError,
|
cliStatusError,
|
||||||
isBusy,
|
isBusy,
|
||||||
multimodelEnabled,
|
multimodelEnabled,
|
||||||
|
|
@ -385,6 +390,8 @@ const InstalledBanner = ({
|
||||||
onMultimodelToggle,
|
onMultimodelToggle,
|
||||||
onProviderLogin,
|
onProviderLogin,
|
||||||
onProviderLogout,
|
onProviderLogout,
|
||||||
|
onProviderManage,
|
||||||
|
onProviderRefresh,
|
||||||
variant,
|
variant,
|
||||||
}: InstalledBannerProps): React.JSX.Element => {
|
}: InstalledBannerProps): React.JSX.Element => {
|
||||||
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
|
||||||
|
|
@ -499,48 +506,56 @@ const InstalledBanner = ({
|
||||||
{cliStatus.providers.map((provider) => {
|
{cliStatus.providers.map((provider) => {
|
||||||
const statusText = formatProviderStatus(provider);
|
const statusText = formatProviderStatus(provider);
|
||||||
const actionDisabled = isBusy || !cliStatus.binaryPath;
|
const actionDisabled = isBusy || !cliStatus.binaryPath;
|
||||||
|
const runtimeSummary = getProviderRuntimeBackendSummary(provider);
|
||||||
|
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
|
||||||
|
const showSkeleton = isProviderCardLoading(provider, providerLoading);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={provider.providerId}
|
key={provider.providerId}
|
||||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md px-2 py-2"
|
className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md px-2 py-2"
|
||||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.02)' }}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="col-span-2 flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
<div className="flex items-center gap-2">
|
||||||
{provider.displayName}
|
<span className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||||
</span>
|
{provider.displayName}
|
||||||
<span
|
|
||||||
className="text-xs"
|
|
||||||
style={{
|
|
||||||
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{statusText}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
|
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
|
||||||
>
|
|
||||||
{provider.backend?.label && (
|
|
||||||
<span>
|
|
||||||
Backend: {provider.backend.label}
|
|
||||||
{provider.backend.endpointLabel
|
|
||||||
? ` (${provider.backend.endpointLabel})`
|
|
||||||
: ''}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span
|
||||||
{provider.models.length === 0 && (
|
className="text-xs"
|
||||||
<span>Models unavailable for this runtime build</span>
|
style={{
|
||||||
|
color: provider.authenticated ? '#4ade80' : 'var(--color-text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{showSkeleton ? (
|
||||||
|
<ProviderDetailSkeleton />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="mt-1 flex min-h-[2.75rem] flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"
|
||||||
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
|
>
|
||||||
|
{provider.backend?.label && (
|
||||||
|
<span>
|
||||||
|
Backend: {provider.backend.label}
|
||||||
|
{provider.backend.endpointLabel
|
||||||
|
? ` (${provider.backend.endpointLabel})`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{runtimeSummary ? <span>Runtime: {runtimeSummary}</span> : null}
|
||||||
|
{provider.models.length === 0 && (
|
||||||
|
<span>Models unavailable for this runtime build</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex shrink-0 items-start gap-2">
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{provider.authenticated ? (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onProviderLogout(provider.providerId)}
|
onClick={() => onProviderManage(provider.providerId)}
|
||||||
disabled={actionDisabled}
|
disabled={actionDisabled}
|
||||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -548,39 +563,57 @@ const InstalledBanner = ({
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogOut className="size-3" />
|
<SlidersHorizontal className="size-3" />
|
||||||
Logout
|
Manage
|
||||||
</button>
|
</button>
|
||||||
) : provider.canLoginFromUi ? (
|
{provider.authenticated && provider.canLoginFromUi ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onProviderLogout(provider.providerId)}
|
||||||
|
disabled={actionDisabled}
|
||||||
|
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="size-3" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
) : provider.canLoginFromUi ? (
|
||||||
|
<button
|
||||||
|
onClick={() => onProviderLogin(provider.providerId)}
|
||||||
|
disabled={actionDisabled}
|
||||||
|
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogIn className="size-3" />
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
onClick={() => onProviderLogin(provider.providerId)}
|
onClick={() => onProviderRefresh(provider.providerId)}
|
||||||
disabled={actionDisabled}
|
disabled={cliStatusLoading || providerLoading}
|
||||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'var(--color-border)',
|
borderColor: 'var(--color-border)',
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
}}
|
}}
|
||||||
|
title={`Re-check ${provider.displayName}`}
|
||||||
>
|
>
|
||||||
<LogIn className="size-3" />
|
<RefreshCw
|
||||||
Login
|
className={
|
||||||
|
cliStatusLoading || providerLoading
|
||||||
|
? 'size-[11px] animate-spin'
|
||||||
|
: 'size-[11px]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
</div>
|
||||||
<button
|
|
||||||
onClick={onRefresh}
|
|
||||||
disabled={cliStatusLoading}
|
|
||||||
className="flex items-center gap-1 rounded-md border px-1.5 py-[3px] text-[10px] transition-colors hover:bg-white/5 disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--color-border)',
|
|
||||||
color: 'var(--color-text-secondary)',
|
|
||||||
}}
|
|
||||||
title={`Re-check ${provider.displayName}`}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={cliStatusLoading ? 'size-[11px] animate-spin' : 'size-[11px]'}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{provider.models.length > 0 && (
|
{!showSkeleton && provider.models.length > 0 && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<ModelBadges providerId={provider.providerId} models={provider.models} />
|
<ModelBadges providerId={provider.providerId} models={provider.models} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -605,6 +638,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
const {
|
const {
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
cliProviderStatusLoading,
|
||||||
cliStatusError,
|
cliStatusError,
|
||||||
installerState,
|
installerState,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
|
|
@ -614,7 +648,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
installerDetail,
|
installerDetail,
|
||||||
installerRawChunks,
|
installerRawChunks,
|
||||||
completedVersion,
|
completedVersion,
|
||||||
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
|
fetchCliProviderStatus,
|
||||||
invalidateCliStatus,
|
invalidateCliStatus,
|
||||||
installCli,
|
installCli,
|
||||||
isBusy,
|
isBusy,
|
||||||
|
|
@ -625,6 +661,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
providerId: CliProviderId;
|
providerId: CliProviderId;
|
||||||
action: 'login' | 'logout';
|
action: 'login' | 'logout';
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||||
|
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||||
|
|
@ -655,26 +693,34 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
}, [installCli]);
|
}, [installCli]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
|
if (multimodelEnabled) {
|
||||||
|
void bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
void fetchCliStatus();
|
void fetchCliStatus();
|
||||||
}, [fetchCliStatus]);
|
}, [bootstrapCliStatus, fetchCliStatus, multimodelEnabled]);
|
||||||
|
|
||||||
const handleMultimodelToggle = useCallback(
|
const handleMultimodelToggle = useCallback(
|
||||||
async (enabled: boolean) => {
|
async (enabled: boolean) => {
|
||||||
setIsSwitchingFlavor(true);
|
setIsSwitchingFlavor(true);
|
||||||
try {
|
try {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
|
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||||
cliStatusLoading: true,
|
cliStatusLoading: true,
|
||||||
cliStatusError: null,
|
cliStatusError: null,
|
||||||
});
|
});
|
||||||
await updateConfig('general', { multimodelEnabled: enabled });
|
await updateConfig('general', { multimodelEnabled: enabled });
|
||||||
await invalidateCliStatus();
|
await invalidateCliStatus();
|
||||||
await fetchCliStatus();
|
if (enabled) {
|
||||||
|
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
} else {
|
||||||
|
await fetchCliStatus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSwitchingFlavor(false);
|
setIsSwitchingFlavor(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchCliStatus, invalidateCliStatus, updateConfig]
|
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
const recheckAuthState = useCallback(() => {
|
const recheckAuthState = useCallback(() => {
|
||||||
|
|
@ -711,6 +757,40 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleProviderManage = useCallback((providerId: CliProviderId) => {
|
||||||
|
setManageProviderId(providerId);
|
||||||
|
setManageDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleProviderRefresh = useCallback(
|
||||||
|
(providerId: CliProviderId) => {
|
||||||
|
void fetchCliProviderStatus(providerId);
|
||||||
|
},
|
||||||
|
[fetchCliProviderStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleProviderBackendChange = useCallback(
|
||||||
|
async (providerId: CliProviderId, backendId: string) => {
|
||||||
|
if (providerId !== 'gemini' && providerId !== 'codex') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||||
|
gemini: 'auto' as const,
|
||||||
|
codex: 'auto' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateConfig('runtime', {
|
||||||
|
providerBackends: {
|
||||||
|
...currentBackends,
|
||||||
|
[providerId]: backendId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fetchCliProviderStatus(providerId);
|
||||||
|
},
|
||||||
|
[appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
|
||||||
|
);
|
||||||
|
|
||||||
if (!isElectron) return null;
|
if (!isElectron) return null;
|
||||||
|
|
||||||
// Determine variant for styling
|
// Determine variant for styling
|
||||||
|
|
@ -729,11 +809,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
|
|
||||||
const variant = getVariant();
|
const variant = getVariant();
|
||||||
const styles = VARIANT_STYLES[variant];
|
const styles = VARIANT_STYLES[variant];
|
||||||
const providerTerminalCommand = providerTerminal
|
const activeTerminalProvider = providerTerminal
|
||||||
? providerTerminal.action === 'login'
|
? (cliStatus?.providers.find(
|
||||||
? getProviderTerminalCommand(providerTerminal.providerId)
|
(provider) => provider.providerId === providerTerminal.providerId
|
||||||
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const providerTerminalCommand =
|
||||||
|
providerTerminal && activeTerminalProvider
|
||||||
|
? providerTerminal.action === 'login'
|
||||||
|
? getProviderTerminalCommand(activeTerminalProvider)
|
||||||
|
: getProviderTerminalLogoutCommand(activeTerminalProvider)
|
||||||
|
: null;
|
||||||
|
|
||||||
// ── Loading / fetch error state ────────────────────────────────────────
|
// ── Loading / fetch error state ────────────────────────────────────────
|
||||||
if (!cliStatus && installerState === 'idle') {
|
if (!cliStatus && installerState === 'idle') {
|
||||||
|
|
@ -794,8 +880,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
return (
|
return (
|
||||||
<InstalledBanner
|
<InstalledBanner
|
||||||
cliStatus={createLoadingMultimodelStatus()}
|
cliStatus={createLoadingMultimodelCliStatus()}
|
||||||
cliStatusLoading={cliStatusLoading}
|
cliStatusLoading={cliStatusLoading}
|
||||||
|
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
multimodelEnabled={multimodelEnabled}
|
||||||
|
|
@ -805,6 +892,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
onProviderManage={handleProviderManage}
|
||||||
|
onProviderRefresh={handleProviderRefresh}
|
||||||
variant="info"
|
variant="info"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -1072,7 +1161,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
setIsVerifyingAuth(true);
|
setIsVerifyingAuth(true);
|
||||||
try {
|
try {
|
||||||
await invalidateCliStatus();
|
await invalidateCliStatus();
|
||||||
await fetchCliStatus();
|
if (multimodelEnabled) {
|
||||||
|
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
} else {
|
||||||
|
await fetchCliStatus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsVerifyingAuth(false);
|
setIsVerifyingAuth(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1142,7 +1235,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await invalidateCliStatus();
|
await invalidateCliStatus();
|
||||||
await fetchCliStatus();
|
if (multimodelEnabled) {
|
||||||
|
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
} else {
|
||||||
|
await fetchCliStatus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsVerifyingAuth(false);
|
setIsVerifyingAuth(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1153,7 +1250,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await invalidateCliStatus();
|
await invalidateCliStatus();
|
||||||
await fetchCliStatus();
|
if (multimodelEnabled) {
|
||||||
|
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
} else {
|
||||||
|
await fetchCliStatus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsVerifyingAuth(false);
|
setIsVerifyingAuth(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1174,6 +1275,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
<InstalledBanner
|
<InstalledBanner
|
||||||
cliStatus={cliStatus}
|
cliStatus={cliStatus}
|
||||||
cliStatusLoading={cliStatusLoading}
|
cliStatusLoading={cliStatusLoading}
|
||||||
|
cliProviderStatusLoading={cliProviderStatusLoading}
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
multimodelEnabled={multimodelEnabled}
|
||||||
|
|
@ -1183,8 +1285,24 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
onProviderManage={handleProviderManage}
|
||||||
|
onProviderRefresh={handleProviderRefresh}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
/>
|
/>
|
||||||
|
{cliStatus && (
|
||||||
|
<ProviderRuntimeSettingsDialog
|
||||||
|
open={manageDialogOpen}
|
||||||
|
onOpenChange={setManageDialogOpen}
|
||||||
|
providers={cliStatus.providers}
|
||||||
|
initialProviderId={manageProviderId}
|
||||||
|
providerStatusLoading={cliProviderStatusLoading}
|
||||||
|
disabled={isBusy || cliStatusLoading || !cliStatus.binaryPath}
|
||||||
|
onSelectBackend={(providerId, backendId) => {
|
||||||
|
void handleProviderBackendChange(providerId, backendId);
|
||||||
|
}}
|
||||||
|
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{providerTerminal && cliStatus.binaryPath && (
|
{providerTerminal && cliStatus.binaryPath && (
|
||||||
<TerminalModal
|
<TerminalModal
|
||||||
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
title={`${cliStatus.displayName} ${providerTerminal.action === 'login' ? 'Login' : 'Logout'}: ${getProviderLabel(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
useNativeTitleBar: false,
|
||||||
telemetryEnabled: true,
|
telemetryEnabled: true,
|
||||||
},
|
},
|
||||||
|
runtime: {
|
||||||
|
providerBackends: {
|
||||||
|
gemini: 'auto',
|
||||||
|
codex: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
compactMode: false,
|
compactMode: false,
|
||||||
|
|
@ -334,6 +340,7 @@ export function useSettingsHandlers({
|
||||||
|
|
||||||
await api.config.update('notifications', defaultConfig.notifications);
|
await api.config.update('notifications', defaultConfig.notifications);
|
||||||
await api.config.update('general', defaultConfig.general);
|
await api.config.update('general', defaultConfig.general);
|
||||||
|
await api.config.update('runtime', defaultConfig.runtime);
|
||||||
const updatedConfig = await api.config.update('display', defaultConfig.display);
|
const updatedConfig = await api.config.update('display', defaultConfig.display);
|
||||||
setConfig(updatedConfig);
|
setConfig(updatedConfig);
|
||||||
setOptimisticConfig(updatedConfig);
|
setOptimisticConfig(updatedConfig);
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { isElectronMode } from '@renderer/api';
|
import { isElectronMode } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
|
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||||
|
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
|
||||||
import { formatBytes } from '@renderer/utils/formatters';
|
import { formatBytes } from '@renderer/utils/formatters';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -23,12 +26,13 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
SlidersHorizontal,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { SettingsSectionHeader } from '../components';
|
import { SettingsSectionHeader } from '../components';
|
||||||
|
|
||||||
import type { CliInstallationStatus, CliProviderId } from '@shared/types';
|
import type { CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
|
|
||||||
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
|
function formatModelBadgeLabel(providerId: CliProviderId, model: string): string {
|
||||||
if (providerId === 'anthropic') {
|
if (providerId === 'anthropic') {
|
||||||
|
|
@ -69,6 +73,40 @@ function ModelBadges({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ProviderDetailSkeleton(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="mt-1 space-y-2">
|
||||||
|
<div
|
||||||
|
className="skeleton-shimmer h-3 rounded-sm"
|
||||||
|
style={{ width: '58%', backgroundColor: 'var(--skeleton-base)' }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="skeleton-shimmer h-6 rounded-md border"
|
||||||
|
style={{
|
||||||
|
width: index === 0 ? 56 : index === 1 ? 84 : index === 2 ? 72 : 96,
|
||||||
|
borderColor: 'var(--color-border-subtle)',
|
||||||
|
backgroundColor: 'var(--skeleton-base-dim)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
|
||||||
|
return (
|
||||||
|
providerLoading ||
|
||||||
|
(!provider.authenticated &&
|
||||||
|
provider.statusMessage === 'Checking...' &&
|
||||||
|
provider.models.length === 0 &&
|
||||||
|
provider.backend == null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getProviderLabel(providerId: CliProviderId): string {
|
function getProviderLabel(providerId: CliProviderId): string {
|
||||||
switch (providerId) {
|
switch (providerId) {
|
||||||
case 'anthropic':
|
case 'anthropic':
|
||||||
|
|
@ -80,74 +118,41 @@ function getProviderLabel(providerId: CliProviderId): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderTerminalCommand(providerId: CliProviderId): {
|
function getProviderTerminalCommand(provider: CliProviderStatus): {
|
||||||
args: string[];
|
args: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
if (providerId === 'gemini') {
|
if (provider.providerId === 'gemini') {
|
||||||
return {
|
return {
|
||||||
args: ['login'],
|
args: ['login'],
|
||||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
env: {
|
||||||
|
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||||
|
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
args: ['auth', 'login', '--provider', providerId],
|
args: ['auth', 'login', '--provider', provider.providerId],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProviderTerminalLogoutCommand(providerId: CliProviderId): {
|
function getProviderTerminalLogoutCommand(provider: CliProviderStatus): {
|
||||||
args: string[];
|
args: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
if (providerId === 'gemini') {
|
if (provider.providerId === 'gemini') {
|
||||||
return {
|
return {
|
||||||
args: ['logout'],
|
args: ['logout'],
|
||||||
env: { CLAUDE_CODE_USE_GEMINI: '1' },
|
env: {
|
||||||
|
CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
|
||||||
|
CLAUDE_CODE_GEMINI_BACKEND: provider.selectedBackendId ?? 'auto',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
args: ['auth', 'logout', '--provider', providerId],
|
args: ['auth', 'logout', '--provider', provider.providerId],
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLoadingMultimodelStatus(): CliInstallationStatus {
|
|
||||||
const providers: Array<{ providerId: CliProviderId; displayName: string }> = [
|
|
||||||
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
|
||||||
{ providerId: 'codex', displayName: 'Codex' },
|
|
||||||
{ providerId: 'gemini', displayName: 'Gemini' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
flavor: 'free-code',
|
|
||||||
displayName: 'free-code-gemini-research',
|
|
||||||
supportsSelfUpdate: false,
|
|
||||||
showVersionDetails: false,
|
|
||||||
showBinaryPath: false,
|
|
||||||
installed: true,
|
|
||||||
installedVersion: null,
|
|
||||||
binaryPath: null,
|
|
||||||
latestVersion: null,
|
|
||||||
updateAvailable: false,
|
|
||||||
authLoggedIn: false,
|
|
||||||
authStatusChecking: true,
|
|
||||||
authMethod: null,
|
|
||||||
providers: providers.map((provider) => ({
|
|
||||||
...provider,
|
|
||||||
supported: false,
|
|
||||||
authenticated: false,
|
|
||||||
authMethod: null,
|
|
||||||
verificationState: 'unknown' as const,
|
|
||||||
statusMessage: 'Checking...',
|
|
||||||
models: [],
|
|
||||||
canLoginFromUi: true,
|
|
||||||
capabilities: {
|
|
||||||
teamLaunch: false,
|
|
||||||
oneShot: false,
|
|
||||||
},
|
|
||||||
backend: null,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,28 +168,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
downloadTotal,
|
downloadTotal,
|
||||||
installerError,
|
installerError,
|
||||||
completedVersion,
|
completedVersion,
|
||||||
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
|
fetchCliProviderStatus,
|
||||||
installCli,
|
installCli,
|
||||||
isBusy,
|
isBusy,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
cliProviderStatusLoading,
|
||||||
invalidateCliStatus,
|
invalidateCliStatus,
|
||||||
} = useCliInstaller();
|
} = useCliInstaller();
|
||||||
const [providerTerminal, setProviderTerminal] = useState<{
|
const [providerTerminal, setProviderTerminal] = useState<{
|
||||||
providerId: CliProviderId;
|
providerId: CliProviderId;
|
||||||
action: 'login' | 'logout';
|
action: 'login' | 'logout';
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||||
|
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||||
const effectiveCliStatus =
|
const effectiveCliStatus =
|
||||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||||
? createLoadingMultimodelStatus()
|
? createLoadingMultimodelCliStatus()
|
||||||
: cliStatus;
|
: cliStatus;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isElectron) {
|
if (isElectron) {
|
||||||
void fetchCliStatus();
|
if (!cliStatus) {
|
||||||
|
if (multimodelEnabled) {
|
||||||
|
void bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
} else {
|
||||||
|
void fetchCliStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isElectron, fetchCliStatus]);
|
}, [bootstrapCliStatus, cliStatus, fetchCliStatus, isElectron, multimodelEnabled]);
|
||||||
|
|
||||||
const handleInstall = useCallback(() => {
|
const handleInstall = useCallback(() => {
|
||||||
installCli();
|
installCli();
|
||||||
|
|
@ -213,6 +229,11 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleProviderManage = useCallback((providerId: CliProviderId) => {
|
||||||
|
setManageProviderId(providerId);
|
||||||
|
setManageDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const recheckStatus = useCallback(() => {
|
const recheckStatus = useCallback(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await invalidateCliStatus();
|
await invalidateCliStatus();
|
||||||
|
|
@ -225,18 +246,22 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
setIsSwitchingFlavor(true);
|
setIsSwitchingFlavor(true);
|
||||||
try {
|
try {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
cliStatus: enabled ? createLoadingMultimodelStatus() : null,
|
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||||
cliStatusLoading: true,
|
cliStatusLoading: true,
|
||||||
cliStatusError: null,
|
cliStatusError: null,
|
||||||
});
|
});
|
||||||
await updateConfig('general', { multimodelEnabled: enabled });
|
await updateConfig('general', { multimodelEnabled: enabled });
|
||||||
await invalidateCliStatus();
|
await invalidateCliStatus();
|
||||||
await fetchCliStatus();
|
if (enabled) {
|
||||||
|
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
} else {
|
||||||
|
await fetchCliStatus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsSwitchingFlavor(false);
|
setIsSwitchingFlavor(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[fetchCliStatus, invalidateCliStatus, updateConfig]
|
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, updateConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isElectron) return null;
|
if (!isElectron) return null;
|
||||||
|
|
@ -250,11 +275,39 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
|
? `${effectiveCliStatus.displayName} v${effectiveCliStatus.installedVersion ?? 'unknown'}`
|
||||||
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
|
: (effectiveCliStatus?.displayName ?? 'Claude CLI');
|
||||||
|
|
||||||
const providerTerminalCommand = providerTerminal
|
const activeTerminalProvider = providerTerminal
|
||||||
? providerTerminal.action === 'login'
|
? (effectiveCliStatus?.providers.find(
|
||||||
? getProviderTerminalCommand(providerTerminal.providerId)
|
(provider) => provider.providerId === providerTerminal.providerId
|
||||||
: getProviderTerminalLogoutCommand(providerTerminal.providerId)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const providerTerminalCommand =
|
||||||
|
providerTerminal && activeTerminalProvider
|
||||||
|
? providerTerminal.action === 'login'
|
||||||
|
? getProviderTerminalCommand(activeTerminalProvider)
|
||||||
|
: getProviderTerminalLogoutCommand(activeTerminalProvider)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleRuntimeBackendChange = useCallback(
|
||||||
|
async (providerId: CliProviderId, backendId: string) => {
|
||||||
|
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||||
|
gemini: 'auto' as const,
|
||||||
|
codex: 'auto' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (providerId !== 'gemini' && providerId !== 'codex') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateConfig('runtime', {
|
||||||
|
providerBackends: {
|
||||||
|
...currentBackends,
|
||||||
|
[providerId]: backendId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await fetchCliProviderStatus(providerId);
|
||||||
|
},
|
||||||
|
[appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
|
|
@ -381,94 +434,141 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
{effectiveCliStatus.providers.map((provider) => (
|
{effectiveCliStatus.providers.map((provider) => (
|
||||||
<div
|
<div
|
||||||
key={provider.providerId}
|
key={provider.providerId}
|
||||||
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
|
className="grid min-h-[132px] grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 rounded-md border px-3 py-2"
|
||||||
style={{
|
style={{
|
||||||
borderColor: 'var(--color-border-subtle)',
|
borderColor: 'var(--color-border-subtle)',
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
{(() => {
|
||||||
<div className="flex items-center gap-2 text-xs">
|
const providerLoading =
|
||||||
<span
|
cliProviderStatusLoading[provider.providerId] === true;
|
||||||
className="font-medium"
|
const showSkeleton = isProviderCardLoading(provider, providerLoading);
|
||||||
style={{ color: 'var(--color-text-secondary)' }}
|
const runtimeSummary = getProviderRuntimeBackendSummary(provider);
|
||||||
>
|
|
||||||
{provider.displayName}
|
return (
|
||||||
</span>
|
<>
|
||||||
<span
|
<div className="col-span-2 flex items-start justify-between gap-3">
|
||||||
style={{
|
<div className="min-w-0 flex-1">
|
||||||
color: provider.authenticated
|
<div className="flex items-center gap-2 text-xs">
|
||||||
? '#4ade80'
|
<span
|
||||||
: 'var(--color-text-muted)',
|
className="font-medium"
|
||||||
}}
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
>
|
>
|
||||||
{provider.authenticated
|
{provider.displayName}
|
||||||
? provider.authMethod
|
</span>
|
||||||
? `Authenticated via ${provider.authMethod}`
|
<span
|
||||||
: 'Authenticated'
|
style={{
|
||||||
: provider.statusMessage || 'Not connected'}
|
color: provider.authenticated
|
||||||
</span>
|
? '#4ade80'
|
||||||
</div>
|
: 'var(--color-text-muted)',
|
||||||
<div
|
}}
|
||||||
className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-[11px]"
|
>
|
||||||
style={{ color: 'var(--color-text-muted)' }}
|
{provider.authenticated
|
||||||
>
|
? provider.authMethod
|
||||||
{provider.backend?.label && (
|
? `Authenticated via ${provider.authMethod}`
|
||||||
<span>Backend: {provider.backend.label}</span>
|
: 'Authenticated'
|
||||||
)}
|
: provider.statusMessage || 'Not connected'}
|
||||||
{provider.models.length === 0 && (
|
</span>
|
||||||
<span>Models unavailable for this runtime build</span>
|
</div>
|
||||||
)}
|
{showSkeleton ? (
|
||||||
</div>
|
<ProviderDetailSkeleton />
|
||||||
</div>
|
) : (
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div
|
||||||
{provider.authenticated ? (
|
className="mt-1 flex min-h-[2.75rem] flex-wrap gap-x-3 gap-y-1 text-[11px]"
|
||||||
<button
|
style={{ color: 'var(--color-text-muted)' }}
|
||||||
type="button"
|
>
|
||||||
onClick={() => void handleProviderLogout(provider.providerId)}
|
{provider.backend?.label && (
|
||||||
disabled={!effectiveCliStatus.binaryPath}
|
<span>Backend: {provider.backend.label}</span>
|
||||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
)}
|
||||||
style={{
|
{runtimeSummary ? (
|
||||||
borderColor: 'var(--color-border)',
|
<span>Runtime: {runtimeSummary}</span>
|
||||||
color: 'var(--color-text-secondary)',
|
) : null}
|
||||||
}}
|
{provider.models.length === 0 && (
|
||||||
>
|
<span>Models unavailable for this runtime build</span>
|
||||||
<LogOut className="size-3" />
|
)}
|
||||||
Logout
|
</div>
|
||||||
</button>
|
)}
|
||||||
) : provider.canLoginFromUi ? (
|
</div>
|
||||||
<button
|
<div className="flex shrink-0 items-start gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
setProviderTerminal({
|
onClick={() => handleProviderManage(provider.providerId)}
|
||||||
providerId: provider.providerId,
|
disabled={!effectiveCliStatus.binaryPath}
|
||||||
action: 'login',
|
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
})
|
style={{
|
||||||
}
|
borderColor: 'var(--color-border)',
|
||||||
disabled={!effectiveCliStatus.binaryPath || !provider.canLoginFromUi}
|
color: 'var(--color-text-secondary)',
|
||||||
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
}}
|
||||||
style={{
|
>
|
||||||
borderColor: 'var(--color-border)',
|
<SlidersHorizontal className="size-3" />
|
||||||
color: 'var(--color-text-secondary)',
|
Manage
|
||||||
}}
|
</button>
|
||||||
>
|
{provider.authenticated && provider.canLoginFromUi ? (
|
||||||
<LogIn className="size-3" />
|
<button
|
||||||
Login
|
type="button"
|
||||||
</button>
|
onClick={() => void handleProviderLogout(provider.providerId)}
|
||||||
) : null}
|
disabled={!effectiveCliStatus.binaryPath}
|
||||||
</div>
|
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
{provider.models.length > 0 && (
|
style={{
|
||||||
<div className="col-span-2">
|
borderColor: 'var(--color-border)',
|
||||||
<ModelBadges
|
color: 'var(--color-text-secondary)',
|
||||||
providerId={provider.providerId}
|
}}
|
||||||
models={provider.models}
|
>
|
||||||
/>
|
<LogOut className="size-3" />
|
||||||
</div>
|
Logout
|
||||||
)}
|
</button>
|
||||||
|
) : provider.canLoginFromUi ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setProviderTerminal({
|
||||||
|
providerId: provider.providerId,
|
||||||
|
action: 'login',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!effectiveCliStatus.binaryPath || !provider.canLoginFromUi
|
||||||
|
}
|
||||||
|
className="flex items-center gap-1 rounded-md border px-2 py-[3px] text-[10px] font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderColor: 'var(--color-border)',
|
||||||
|
color: 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogIn className="size-3" />
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!showSkeleton && provider.models.length > 0 && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<ModelBadges
|
||||||
|
providerId={provider.providerId}
|
||||||
|
models={provider.models}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ProviderRuntimeSettingsDialog
|
||||||
|
open={manageDialogOpen}
|
||||||
|
onOpenChange={setManageDialogOpen}
|
||||||
|
providers={effectiveCliStatus.providers}
|
||||||
|
initialProviderId={manageProviderId}
|
||||||
|
providerStatusLoading={cliProviderStatusLoading}
|
||||||
|
disabled={!effectiveCliStatus.binaryPath || isBusy || cliStatusLoading}
|
||||||
|
onSelectBackend={(providerId, backendId) => {
|
||||||
|
void handleRuntimeBackendChange(providerId, backendId);
|
||||||
|
}}
|
||||||
|
onRefreshProvider={(providerId) => fetchCliProviderStatus(providerId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface ClaudeLogsPanelProps {
|
||||||
ctrl: ClaudeLogsController;
|
ctrl: ClaudeLogsController;
|
||||||
/** Maximum height class for the log viewer (e.g. "max-h-[213px]" for compact). */
|
/** Maximum height class for the log viewer (e.g. "max-h-[213px]" for compact). */
|
||||||
viewerClassName?: string;
|
viewerClassName?: string;
|
||||||
|
viewerMaxHeight?: number;
|
||||||
/** Extra className for the panel wrapper. */
|
/** Extra className for the panel wrapper. */
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -36,6 +37,7 @@ interface ClaudeLogsPanelProps {
|
||||||
export const ClaudeLogsPanel = ({
|
export const ClaudeLogsPanel = ({
|
||||||
ctrl,
|
ctrl,
|
||||||
viewerClassName,
|
viewerClassName,
|
||||||
|
viewerMaxHeight,
|
||||||
className,
|
className,
|
||||||
}: ClaudeLogsPanelProps): React.JSX.Element => {
|
}: ClaudeLogsPanelProps): React.JSX.Element => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -130,6 +132,7 @@ export const ClaudeLogsPanel = ({
|
||||||
order="newest-first"
|
order="newest-first"
|
||||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||||
className={cn('p-2', viewerClassName)}
|
className={cn('p-2', viewerClassName)}
|
||||||
|
style={viewerMaxHeight ? { maxHeight: `${viewerMaxHeight}px` } : undefined}
|
||||||
containerRefCallback={containerRefCallback}
|
containerRefCallback={containerRefCallback}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
viewerState={viewerState}
|
viewerState={viewerState}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ const PREVIEW_ICONS = {
|
||||||
interface ClaudeLogsSectionProps {
|
interface ClaudeLogsSectionProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
position?: 'sidebar' | 'inline';
|
position?: 'sidebar' | 'inline';
|
||||||
|
sidebarViewerMaxHeight?: number;
|
||||||
|
onOpenChange?: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,6 +72,8 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E
|
||||||
export const ClaudeLogsSection = ({
|
export const ClaudeLogsSection = ({
|
||||||
teamName,
|
teamName,
|
||||||
position = 'inline',
|
position = 'inline',
|
||||||
|
sidebarViewerMaxHeight,
|
||||||
|
onOpenChange,
|
||||||
}: ClaudeLogsSectionProps): React.JSX.Element => {
|
}: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||||
const ctrl = useClaudeLogsController(teamName);
|
const ctrl = useClaudeLogsController(teamName);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
@ -95,7 +99,7 @@ export const ClaudeLogsSection = ({
|
||||||
<>
|
<>
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
sectionId="claude-logs"
|
sectionId="claude-logs"
|
||||||
title="Claude logs"
|
title="Logs"
|
||||||
icon={null}
|
icon={null}
|
||||||
badge={ctrl.badge}
|
badge={ctrl.badge}
|
||||||
afterBadge={
|
afterBadge={
|
||||||
|
|
@ -119,9 +123,13 @@ export const ClaudeLogsSection = ({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined}
|
||||||
|
headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined}
|
||||||
headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'}
|
headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'}
|
||||||
headerExtra={sectionHeaderExtra}
|
headerExtra={sectionHeaderExtra}
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
contentWrapperClassName={isSidebar ? 'mt-0 pb-0' : undefined}
|
||||||
contentClassName="pt-0 [overflow-anchor:none]"
|
contentClassName="pt-0 [overflow-anchor:none]"
|
||||||
>
|
>
|
||||||
{/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */}
|
{/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */}
|
||||||
|
|
@ -131,7 +139,11 @@ export const ClaudeLogsSection = ({
|
||||||
Viewing in fullscreen mode
|
Viewing in fullscreen mode
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ClaudeLogsPanel ctrl={ctrl} viewerClassName="max-h-[213px]" />
|
<ClaudeLogsPanel
|
||||||
|
ctrl={ctrl}
|
||||||
|
viewerClassName="max-h-[213px]"
|
||||||
|
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ interface CliLogsRichViewProps {
|
||||||
/** Optional local search query override for inline highlighting */
|
/** Optional local search query override for inline highlighting */
|
||||||
searchQueryOverride?: string;
|
searchQueryOverride?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
/** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */
|
/** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */
|
||||||
footer?: React.ReactNode;
|
footer?: React.ReactNode;
|
||||||
|
|
||||||
|
|
@ -341,6 +342,7 @@ export const CliLogsRichView = ({
|
||||||
containerRefCallback,
|
containerRefCallback,
|
||||||
searchQueryOverride,
|
searchQueryOverride,
|
||||||
className,
|
className,
|
||||||
|
style,
|
||||||
footer,
|
footer,
|
||||||
viewerState: controlledState,
|
viewerState: controlledState,
|
||||||
onViewerStateChange,
|
onViewerStateChange,
|
||||||
|
|
@ -557,6 +559,7 @@ export const CliLogsRichView = ({
|
||||||
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={style}
|
||||||
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 p-3">
|
<div className="flex items-center gap-2 p-3">
|
||||||
|
|
@ -582,6 +585,7 @@ export const CliLogsRichView = ({
|
||||||
containerRefCallback?.(el);
|
containerRefCallback?.(el);
|
||||||
}}
|
}}
|
||||||
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
|
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
|
||||||
|
style={style}
|
||||||
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
onScroll={(e) => handleScrollEvent(e.currentTarget)}
|
||||||
>
|
>
|
||||||
{visibleEntries.map((entry) =>
|
{visibleEntries.map((entry) =>
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,14 @@ interface CollapsibleTeamSectionProps {
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
/** Extra classes applied to the content wrapper (e.g. padding). */
|
/** Extra classes applied to the content wrapper (e.g. padding). */
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
|
/** Extra classes for the outer content wrapper (e.g. remove default top/bottom gaps). */
|
||||||
|
contentWrapperClassName?: string;
|
||||||
/** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */
|
/** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */
|
||||||
headerClassName?: string;
|
headerClassName?: string;
|
||||||
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
|
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
|
||||||
headerContentClassName?: string;
|
headerContentClassName?: string;
|
||||||
|
/** Extra classes for the clickable header surface itself (e.g. override rounded corners). */
|
||||||
|
headerSurfaceClassName?: string;
|
||||||
/** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */
|
/** When true, children stay mounted (hidden via CSS) when collapsed. Useful when children drive header state (e.g. online indicators). */
|
||||||
keepMounted?: boolean;
|
keepMounted?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -53,8 +57,10 @@ export const CollapsibleTeamSection = ({
|
||||||
action,
|
action,
|
||||||
sectionId,
|
sectionId,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
contentWrapperClassName,
|
||||||
headerClassName,
|
headerClassName,
|
||||||
headerContentClassName,
|
headerContentClassName,
|
||||||
|
headerSurfaceClassName,
|
||||||
keepMounted,
|
keepMounted,
|
||||||
children,
|
children,
|
||||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||||
|
|
@ -88,7 +94,13 @@ export const CollapsibleTeamSection = ({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-xl bg-[var(--color-section-bg-open)] hover:bg-[var(--color-section-hover-open)]' : 'rounded-xl bg-[var(--color-section-bg)] hover:bg-[var(--color-section-hover)]'}`}
|
className={cn(
|
||||||
|
'absolute inset-0 z-0 cursor-pointer transition-colors',
|
||||||
|
isOpen
|
||||||
|
? 'rounded-t-xl bg-[var(--color-section-bg-open)] hover:bg-[var(--color-section-hover-open)]'
|
||||||
|
: 'rounded-xl bg-[var(--color-section-bg)] hover:bg-[var(--color-section-hover)]',
|
||||||
|
headerSurfaceClassName
|
||||||
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setOpen((prev) => {
|
setOpen((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
|
|
@ -138,14 +150,24 @@ export const CollapsibleTeamSection = ({
|
||||||
</div>
|
</div>
|
||||||
{keepMounted ? (
|
{keepMounted ? (
|
||||||
<div
|
<div
|
||||||
className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}
|
className={cn(
|
||||||
|
'mt-1.5 min-w-0 overflow-x-clip pb-2',
|
||||||
|
contentWrapperClassName,
|
||||||
|
contentClassName
|
||||||
|
)}
|
||||||
style={isOpen ? undefined : { display: 'none' }}
|
style={isOpen ? undefined : { display: 'none' }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
isOpen && (
|
isOpen && (
|
||||||
<div className={cn('mt-1.5 min-w-0 overflow-x-clip pb-2', contentClassName)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-1.5 min-w-0 overflow-x-clip pb-2',
|
||||||
|
contentWrapperClassName,
|
||||||
|
contentClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '../chat/viewers/MarkdownViewer';
|
||||||
|
|
||||||
|
|
@ -39,6 +39,8 @@ export interface ProvisioningProgressBlockProps {
|
||||||
onCancel?: (() => void) | null;
|
onCancel?: (() => void) | null;
|
||||||
/** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */
|
/** Success message shown inside the block header (e.g. "Team launched — all N teammates online") */
|
||||||
successMessage?: string | null;
|
successMessage?: string | null;
|
||||||
|
/** Visual tone for the status banner above the block. */
|
||||||
|
successMessageSeverity?: 'success' | 'warning';
|
||||||
/** Dismiss handler — renders an X button in the block header top-right */
|
/** Dismiss handler — renders an X button in the block header top-right */
|
||||||
onDismiss?: (() => void) | null;
|
onDismiss?: (() => void) | null;
|
||||||
/** ISO timestamp when provisioning started */
|
/** ISO timestamp when provisioning started */
|
||||||
|
|
@ -132,6 +134,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
loading = false,
|
loading = false,
|
||||||
onCancel,
|
onCancel,
|
||||||
successMessage,
|
successMessage,
|
||||||
|
successMessageSeverity = 'success',
|
||||||
onDismiss,
|
onDismiss,
|
||||||
startedAt,
|
startedAt,
|
||||||
pid,
|
pid,
|
||||||
|
|
@ -199,8 +202,21 @@ export const ProvisioningProgressBlock = ({
|
||||||
>
|
>
|
||||||
{successMessage ? (
|
{successMessage ? (
|
||||||
<div className="mb-1.5 flex items-center gap-2">
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
|
{successMessageSeverity === 'warning' ? (
|
||||||
<p className="flex-1 text-xs text-[var(--step-success-text)]">{successMessage}</p>
|
<AlertTriangle size={14} className="shrink-0 text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 size={14} className="shrink-0 text-[var(--step-done-text)]" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'flex-1 text-xs',
|
||||||
|
successMessageSeverity === 'warning'
|
||||||
|
? 'text-amber-400'
|
||||||
|
: 'text-[var(--step-success-text)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{successMessage}
|
||||||
|
</p>
|
||||||
{onDismiss ? (
|
{onDismiss ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -458,8 +458,10 @@ export const TeamDetailView = ({
|
||||||
launchParams,
|
launchParams,
|
||||||
messagesPanelMode,
|
messagesPanelMode,
|
||||||
messagesPanelWidth,
|
messagesPanelWidth,
|
||||||
|
sidebarLogsHeight,
|
||||||
setMessagesPanelMode,
|
setMessagesPanelMode,
|
||||||
setMessagesPanelWidth,
|
setMessagesPanelWidth,
|
||||||
|
setSidebarLogsHeight,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
data: s.selectedTeamData,
|
data: s.selectedTeamData,
|
||||||
|
|
@ -507,8 +509,10 @@ export const TeamDetailView = ({
|
||||||
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
|
launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined,
|
||||||
messagesPanelMode: s.messagesPanelMode,
|
messagesPanelMode: s.messagesPanelMode,
|
||||||
messagesPanelWidth: s.messagesPanelWidth,
|
messagesPanelWidth: s.messagesPanelWidth,
|
||||||
|
sidebarLogsHeight: s.sidebarLogsHeight,
|
||||||
setMessagesPanelMode: s.setMessagesPanelMode,
|
setMessagesPanelMode: s.setMessagesPanelMode,
|
||||||
setMessagesPanelWidth: s.setMessagesPanelWidth,
|
setMessagesPanelWidth: s.setMessagesPanelWidth,
|
||||||
|
setSidebarLogsHeight: s.setSidebarLogsHeight,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -533,6 +537,13 @@ export const TeamDetailView = ({
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
side: 'left',
|
side: 'left',
|
||||||
});
|
});
|
||||||
|
const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({
|
||||||
|
height: sidebarLogsHeight,
|
||||||
|
onHeightChange: setSidebarLogsHeight,
|
||||||
|
minHeight: 120,
|
||||||
|
maxHeight: 520,
|
||||||
|
side: 'top',
|
||||||
|
});
|
||||||
|
|
||||||
const toggleMessagesPanelMode = useCallback(() => {
|
const toggleMessagesPanelMode = useCallback(() => {
|
||||||
setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar');
|
setMessagesPanelMode(messagesPanelMode === 'sidebar' ? 'inline' : 'sidebar');
|
||||||
|
|
@ -554,17 +565,28 @@ export const TeamDetailView = ({
|
||||||
|
|
||||||
// Fetch initial spawn statuses when provisioning starts
|
// Fetch initial spawn statuses when provisioning starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTeamProvisioning && teamName) {
|
if (teamName && (isTeamProvisioning || memberSpawnStatuses == null)) {
|
||||||
void fetchMemberSpawnStatuses(teamName);
|
void fetchMemberSpawnStatuses(teamName);
|
||||||
}
|
}
|
||||||
}, [isTeamProvisioning, teamName, fetchMemberSpawnStatuses]);
|
}, [isTeamProvisioning, memberSpawnStatuses, teamName, fetchMemberSpawnStatuses]);
|
||||||
|
|
||||||
// Convert Record<string, MemberSpawnStatusEntry> → Map<string, MemberSpawnEntry>
|
// Convert Record<string, MemberSpawnStatusEntry> → Map<string, MemberSpawnEntry>
|
||||||
const memberSpawnStatusMap = useMemo(() => {
|
const memberSpawnStatusMap = useMemo(() => {
|
||||||
if (!memberSpawnStatuses) return undefined;
|
if (!memberSpawnStatuses) return undefined;
|
||||||
const map = new Map<string, { status: MemberSpawnStatusEntry['status']; error?: string }>();
|
const map = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
status: MemberSpawnStatusEntry['status'];
|
||||||
|
error?: string;
|
||||||
|
livenessSource?: MemberSpawnStatusEntry['livenessSource'];
|
||||||
|
}
|
||||||
|
>();
|
||||||
for (const [name, entry] of Object.entries(memberSpawnStatuses)) {
|
for (const [name, entry] of Object.entries(memberSpawnStatuses)) {
|
||||||
map.set(name, { status: entry.status, error: entry.error });
|
map.set(name, {
|
||||||
|
status: entry.status,
|
||||||
|
error: entry.error,
|
||||||
|
livenessSource: entry.livenessSource,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return map.size > 0 ? map : undefined;
|
return map.size > 0 ? map : undefined;
|
||||||
}, [memberSpawnStatuses]);
|
}, [memberSpawnStatuses]);
|
||||||
|
|
@ -632,6 +654,11 @@ export const TeamDetailView = ({
|
||||||
}
|
}
|
||||||
}, [kanbanFilterQuery, clearKanbanFilter]);
|
}, [kanbanFilterQuery, clearKanbanFilter]);
|
||||||
|
|
||||||
|
const currentTeamSummary = useMemo(
|
||||||
|
() => teams.find((team) => team.teamName === teamName) ?? null,
|
||||||
|
[teams, teamName]
|
||||||
|
);
|
||||||
|
|
||||||
// Load sessions for the team's project
|
// Load sessions for the team's project
|
||||||
const projectId = useMemo(
|
const projectId = useMemo(
|
||||||
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
|
() => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups),
|
||||||
|
|
@ -1303,6 +1330,9 @@ export const TeamDetailView = ({
|
||||||
messagesPanelProps={sharedMessagesPanelProps}
|
messagesPanelProps={sharedMessagesPanelProps}
|
||||||
isResizing={isMessagesPanelResizing}
|
isResizing={isMessagesPanelResizing}
|
||||||
onResizeMouseDown={messagesPanelHandleProps.onMouseDown}
|
onResizeMouseDown={messagesPanelHandleProps.onMouseDown}
|
||||||
|
logsHeight={sidebarLogsHeight}
|
||||||
|
isLogsResizing={isLogsPanelResizing}
|
||||||
|
onLogsResizeMouseDown={logsPanelHandleProps.onMouseDown}
|
||||||
/>
|
/>
|
||||||
</TeamSidebarPortalSource>
|
</TeamSidebarPortalSource>
|
||||||
</TeamSidebarHost>
|
</TeamSidebarHost>
|
||||||
|
|
@ -1382,27 +1412,6 @@ export const TeamDetailView = ({
|
||||||
Launching...
|
Launching...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{data.isAlive &&
|
|
||||||
launchParams?.model &&
|
|
||||||
(() => {
|
|
||||||
const MODEL_LABELS: Record<string, string> = {
|
|
||||||
default: 'Default',
|
|
||||||
opus: 'Opus 4.6',
|
|
||||||
sonnet: 'Sonnet 4.6',
|
|
||||||
haiku: 'Haiku 4.5',
|
|
||||||
};
|
|
||||||
const modelLabel = MODEL_LABELS[launchParams.model] ?? launchParams.model;
|
|
||||||
const effortLabel = launchParams.effort
|
|
||||||
? launchParams.effort.charAt(0).toUpperCase() + launchParams.effort.slice(1)
|
|
||||||
: '';
|
|
||||||
const limitLabel = launchParams.limitContext ? '200K' : '';
|
|
||||||
const parts = [modelLabel, effortLabel, limitLabel].filter(Boolean).join(' ');
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text-secondary)]">
|
|
||||||
{parts}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
|
@ -1542,7 +1551,11 @@ export const TeamDetailView = ({
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-xs">
|
<span className="flex items-center gap-1.5 text-xs">
|
||||||
<AlertTriangle size={14} className="shrink-0" />
|
<AlertTriangle size={14} className="shrink-0" />
|
||||||
Team is offline
|
{currentTeamSummary?.partialLaunchFailure
|
||||||
|
? currentTeamSummary.missingMembers?.length
|
||||||
|
? `Last launch failed partway — ${currentTeamSummary.missingMembers.length}/${currentTeamSummary.expectedMemberCount ?? currentTeamSummary.missingMembers.length} teammates did not join`
|
||||||
|
: 'Last launch failed partway'
|
||||||
|
: 'Team is offline'}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ function generateUniqueName(sourceName: string, existingNames: string[]): string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline';
|
type TeamStatus = 'active' | 'idle' | 'provisioning' | 'offline' | 'partial_failure';
|
||||||
|
|
||||||
function getRecentProjects(team: TeamSummary): string[] {
|
function getRecentProjects(team: TeamSummary): string[] {
|
||||||
const history = team.projectPathHistory;
|
const history = team.projectPathHistory;
|
||||||
|
|
@ -157,6 +157,7 @@ function renderTeamRecentPaths(
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTeamStatus(
|
function resolveTeamStatus(
|
||||||
|
team: TeamSummary,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
aliveTeams: string[],
|
aliveTeams: string[],
|
||||||
currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>,
|
currentProgress: ReturnType<typeof getCurrentProvisioningProgressForTeam>,
|
||||||
|
|
@ -173,6 +174,9 @@ function resolveTeamStatus(
|
||||||
) {
|
) {
|
||||||
return 'provisioning';
|
return 'provisioning';
|
||||||
}
|
}
|
||||||
|
if (team.partialLaunchFailure) {
|
||||||
|
return 'partial_failure';
|
||||||
|
}
|
||||||
return 'offline';
|
return 'offline';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,6 +210,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
||||||
Offline
|
Offline
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
case 'partial_failure':
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-400">
|
||||||
|
<span className="size-1.5 rounded-full bg-amber-400" />
|
||||||
|
Launch failed partway
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -364,12 +375,13 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
if (filter.selectedStatuses.size > 0) {
|
if (filter.selectedStatuses.size > 0) {
|
||||||
result = result.filter((t) => {
|
result = result.filter((t) => {
|
||||||
const status = resolveTeamStatus(
|
const status = resolveTeamStatus(
|
||||||
|
t,
|
||||||
t.teamName,
|
t.teamName,
|
||||||
aliveTeams,
|
aliveTeams,
|
||||||
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
|
getCurrentProvisioningProgressForTeam(provisioningState, t.teamName),
|
||||||
leadActivityByTeam
|
leadActivityByTeam
|
||||||
);
|
);
|
||||||
const isRunning = status !== 'offline';
|
const isRunning = status !== 'offline' && status !== 'partial_failure';
|
||||||
if (filter.selectedStatuses.has('running') && isRunning) return true;
|
if (filter.selectedStatuses.has('running') && isRunning) return true;
|
||||||
if (filter.selectedStatuses.has('offline') && !isRunning) return true;
|
if (filter.selectedStatuses.has('offline') && !isRunning) return true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -780,6 +792,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{activeFiltered.map((team) => {
|
{activeFiltered.map((team) => {
|
||||||
const status = resolveTeamStatus(
|
const status = resolveTeamStatus(
|
||||||
|
team,
|
||||||
team.teamName,
|
team.teamName,
|
||||||
aliveTeams,
|
aliveTeams,
|
||||||
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
|
getCurrentProvisioningProgressForTeam(provisioningState, team.teamName),
|
||||||
|
|
@ -837,24 +850,27 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
{status === 'offline' && team.projectPath && (
|
{(status === 'offline' || status === 'partial_failure') &&
|
||||||
<Tooltip>
|
team.projectPath && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<button
|
<TooltipTrigger asChild>
|
||||||
type="button"
|
<button
|
||||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
|
type="button"
|
||||||
onClick={(e) => handleLaunchTeam(team.teamName, team.projectPath, e)}
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
|
||||||
disabled={launchingTeamName === team.teamName}
|
onClick={(e) =>
|
||||||
aria-label="Launch team"
|
handleLaunchTeam(team.teamName, team.projectPath, e)
|
||||||
>
|
}
|
||||||
<Play size={14} fill="currentColor" />
|
disabled={launchingTeamName === team.teamName}
|
||||||
</button>
|
aria-label="Launch team"
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent side="bottom">
|
<Play size={14} fill="currentColor" />
|
||||||
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
|
</button>
|
||||||
</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="bottom">
|
||||||
)}
|
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{(status === 'active' || status === 'idle') && (
|
{(status === 'active' || status === 'idle') && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -908,6 +924,13 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
{team.description || 'No description'}
|
{team.description || 'No description'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{team.partialLaunchFailure ? (
|
||||||
|
<p className="mt-2 text-[11px] text-amber-400">
|
||||||
|
{team.missingMembers?.length
|
||||||
|
? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
|
||||||
|
: 'Last launch stopped before all teammates joined.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
{team.members && team.members.length > 0 ? (
|
{team.members && team.members.length > 0 ? (
|
||||||
renderMemberChips(team.members, isLight)
|
renderMemberChips(team.members, isLight)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
import { getCurrentProvisioningProgressForTeam } from '@renderer/store/slices/teamSlice';
|
||||||
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -16,11 +17,12 @@ interface TeamProvisioningBannerProps {
|
||||||
export const TeamProvisioningBanner = ({
|
export const TeamProvisioningBanner = ({
|
||||||
teamName,
|
teamName,
|
||||||
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
|
}: TeamProvisioningBannerProps): React.JSX.Element | null => {
|
||||||
const { progress, cancelProvisioning, teamMembers } = useStore(
|
const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
progress: getCurrentProvisioningProgressForTeam(s, teamName),
|
||||||
cancelProvisioning: s.cancelProvisioning,
|
cancelProvisioning: s.cancelProvisioning,
|
||||||
teamMembers: s.selectedTeamData?.members,
|
teamMembers: s.selectedTeamData?.members,
|
||||||
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
@ -102,15 +104,37 @@ export const TeamProvisioningBanner = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTeammatesOnline =
|
const teammates = (teamMembers ?? []).filter((member) => !isLeadMember(member));
|
||||||
teamMembers != null &&
|
const failedSpawnEntries = Object.entries(memberSpawnStatuses ?? {}).filter(
|
||||||
teamMembers.length > 0 &&
|
([, entry]) => entry.status === 'error'
|
||||||
teamMembers.every((m) => m.status === 'active' || m.status === 'idle');
|
);
|
||||||
|
const failedSpawnCount = failedSpawnEntries.length;
|
||||||
|
const heartbeatConfirmedCount = teammates.filter((member) => {
|
||||||
|
const entry = memberSpawnStatuses?.[member.name];
|
||||||
|
return entry?.status === 'online' && entry.livenessSource === 'heartbeat';
|
||||||
|
}).length;
|
||||||
|
const processOnlyAliveCount = teammates.filter((member) => {
|
||||||
|
const entry = memberSpawnStatuses?.[member.name];
|
||||||
|
return entry?.status === 'online' && entry.livenessSource === 'process';
|
||||||
|
}).length;
|
||||||
|
const awaitingHeartbeatCount = teammates.filter((member) => {
|
||||||
|
const entry = memberSpawnStatuses?.[member.name];
|
||||||
|
return entry?.status === 'waiting';
|
||||||
|
}).length;
|
||||||
|
const allTeammatesConfirmedAlive =
|
||||||
|
teammates.length > 0 && failedSpawnCount === 0 && heartbeatConfirmedCount === teammates.length;
|
||||||
|
|
||||||
if (isReady) {
|
if (isReady) {
|
||||||
const readyMessage = allTeammatesOnline
|
const readyMessage =
|
||||||
? `Team launched — all ${teamMembers.length} teammates online`
|
failedSpawnCount > 0
|
||||||
: 'Team launched — teammates may still be starting';
|
? `Launch finished with errors — ${failedSpawnCount}/${Math.max(teammates.length, failedSpawnCount)} teammates failed to start`
|
||||||
|
: teammates.length === 0
|
||||||
|
? 'Team launched — lead online'
|
||||||
|
: allTeammatesConfirmedAlive
|
||||||
|
? `Team launched — all ${teammates.length} teammates confirmed alive`
|
||||||
|
: processOnlyAliveCount > 0 || awaitingHeartbeatCount > 0
|
||||||
|
? `Team launched — ${heartbeatConfirmedCount}/${teammates.length} teammates confirmed alive${processOnlyAliveCount > 0 ? `, ${processOnlyAliveCount} runtime${processOnlyAliveCount === 1 ? '' : 's'} alive but bootstrap still pending` : ''}${awaitingHeartbeatCount > 0 ? `${processOnlyAliveCount > 0 ? ', ' : ', '}${awaitingHeartbeatCount} awaiting first heartbeat` : ''}`
|
||||||
|
: 'Team launched — teammate liveness is still being confirmed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|
@ -127,6 +151,7 @@ export const TeamProvisioningBanner = ({
|
||||||
defaultLiveOutputOpen={false}
|
defaultLiveOutputOpen={false}
|
||||||
onCancel={null}
|
onCancel={null}
|
||||||
successMessage={readyMessage}
|
successMessage={readyMessage}
|
||||||
|
successMessageSeverity={failedSpawnCount > 0 ? 'warning' : 'success'}
|
||||||
onDismiss={() => setDismissed(true)}
|
onDismiss={() => setDismissed(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ import {
|
||||||
parseMessageReply,
|
parseMessageReply,
|
||||||
parseStructuredAgentMessage,
|
parseStructuredAgentMessage,
|
||||||
} from '@renderer/utils/agentMessageFormatting';
|
} from '@renderer/utils/agentMessageFormatting';
|
||||||
|
import {
|
||||||
|
getBootstrapAcknowledgementDisplay,
|
||||||
|
getBootstrapPromptDisplay,
|
||||||
|
getSanitizedInboxMessageSummary,
|
||||||
|
getSanitizedInboxMessageText,
|
||||||
|
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||||
import {
|
import {
|
||||||
|
|
@ -291,6 +297,80 @@ const NoiseRow = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const BootstrapSystemRow = ({
|
||||||
|
senderName,
|
||||||
|
recipientName,
|
||||||
|
runtime,
|
||||||
|
senderColor,
|
||||||
|
recipientColor,
|
||||||
|
timestamp,
|
||||||
|
onMemberNameClick,
|
||||||
|
}: {
|
||||||
|
senderName: string;
|
||||||
|
recipientName: string;
|
||||||
|
runtime?: string;
|
||||||
|
senderColor?: string;
|
||||||
|
recipientColor?: string;
|
||||||
|
timestamp: string;
|
||||||
|
onMemberNameClick?: (memberName: string) => void;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.82 }}>
|
||||||
|
<span className="bg-sky-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-sky-300">
|
||||||
|
start
|
||||||
|
</span>
|
||||||
|
<MemberBadge name={senderName} color={senderColor} hideAvatar onClick={onMemberNameClick} />
|
||||||
|
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||||
|
<MemberBadge
|
||||||
|
name={recipientName}
|
||||||
|
color={recipientColor}
|
||||||
|
hideAvatar
|
||||||
|
onClick={onMemberNameClick}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{runtime || 'Starting teammate'}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BootstrapAcknowledgementRow = ({
|
||||||
|
senderName,
|
||||||
|
recipientName,
|
||||||
|
senderColor,
|
||||||
|
recipientColor,
|
||||||
|
timestamp,
|
||||||
|
onMemberNameClick,
|
||||||
|
}: {
|
||||||
|
senderName: string;
|
||||||
|
recipientName: string;
|
||||||
|
senderColor?: string;
|
||||||
|
recipientColor?: string;
|
||||||
|
timestamp: string;
|
||||||
|
onMemberNameClick?: (memberName: string) => void;
|
||||||
|
}): React.JSX.Element => (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.72 }}>
|
||||||
|
<span className="bg-emerald-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-emerald-300">
|
||||||
|
bootstrap
|
||||||
|
</span>
|
||||||
|
<MemberBadge name={senderName} color={senderColor} hideAvatar onClick={onMemberNameClick} />
|
||||||
|
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||||
|
<MemberBadge
|
||||||
|
name={recipientName}
|
||||||
|
color={recipientColor}
|
||||||
|
hideAvatar
|
||||||
|
onClick={onMemberNameClick}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
Bootstrap acknowledged
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{timestamp}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Detect historical system/automated messages that should be collapsed by default.
|
// Detect historical system/automated messages that should be collapsed by default.
|
||||||
// These patterns are kept only for legacy compatibility with old inbox/session rows;
|
// These patterns are kept only for legacy compatibility with old inbox/session rows;
|
||||||
|
|
@ -452,6 +532,11 @@ export const ActivityItem = memo(
|
||||||
}, [message.timestamp]);
|
}, [message.timestamp]);
|
||||||
|
|
||||||
const structured = parseStructuredAgentMessage(message.text);
|
const structured = parseStructuredAgentMessage(message.text);
|
||||||
|
const bootstrapDisplay = useMemo(() => getBootstrapPromptDisplay(message), [message]);
|
||||||
|
const bootstrapAcknowledgement = useMemo(
|
||||||
|
() => getBootstrapAcknowledgementDisplay(message),
|
||||||
|
[message]
|
||||||
|
);
|
||||||
// Only flag agent messages as rate-limited, not user's own quotes
|
// Only flag agent messages as rate-limited, not user's own quotes
|
||||||
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
||||||
// Highlight messages containing API errors
|
// Highlight messages containing API errors
|
||||||
|
|
@ -510,7 +595,10 @@ export const ActivityItem = memo(
|
||||||
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
||||||
const strippedText = useMemo(() => {
|
const strippedText = useMemo(() => {
|
||||||
if (structured) return null;
|
if (structured) return null;
|
||||||
let stripped = stripAgentBlocks(message.text).trim();
|
let stripped = getSanitizedInboxMessageText(message).trim();
|
||||||
|
if (!bootstrapDisplay) {
|
||||||
|
stripped = stripAgentBlocks(stripped).trim();
|
||||||
|
}
|
||||||
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
||||||
// Strip cross-team metadata tag (e.g. `<cross-team from="team.lead" depth="0" />\n`)
|
// Strip cross-team metadata tag (e.g. `<cross-team from="team.lead" depth="0" />\n`)
|
||||||
// — kept in stored text for CLI agents / durable artifacts.
|
// — kept in stored text for CLI agents / durable artifacts.
|
||||||
|
|
@ -519,7 +607,7 @@ export const ActivityItem = memo(
|
||||||
}
|
}
|
||||||
// Normalize literal \n from historical CLI-produced text to real newlines
|
// Normalize literal \n from historical CLI-produced text to real newlines
|
||||||
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||||
}, [structured, message.text, isCrossTeamAny]);
|
}, [structured, message, bootstrapDisplay, isCrossTeamAny]);
|
||||||
const standaloneSlashCommand = useMemo(
|
const standaloneSlashCommand = useMemo(
|
||||||
() => (strippedText ? parseStandaloneSlashCommand(strippedText) : null),
|
() => (strippedText ? parseStandaloneSlashCommand(strippedText) : null),
|
||||||
[strippedText]
|
[strippedText]
|
||||||
|
|
@ -580,10 +668,12 @@ export const ActivityItem = memo(
|
||||||
}
|
}
|
||||||
if (crossTeamPreview) return crossTeamPreview;
|
if (crossTeamPreview) return crossTeamPreview;
|
||||||
const s =
|
const s =
|
||||||
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
|
getSanitizedInboxMessageSummary(message) ||
|
||||||
|
(structured ? getStructuredMessageSummary(structured) : '') ||
|
||||||
|
'';
|
||||||
if (s) return s;
|
if (s) return s;
|
||||||
// Fallback: use the beginning of message text as preview for plain-text messages
|
// Fallback: use the beginning of message text as preview for plain-text messages
|
||||||
const plain = stripAgentBlocks(message.text).trim();
|
const plain = getSanitizedInboxMessageText(message).trim();
|
||||||
if (!plain) return '';
|
if (!plain) return '';
|
||||||
const oneLine = plain.replace(/\n+/g, ' ');
|
const oneLine = plain.replace(/\n+/g, ' ');
|
||||||
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
|
||||||
|
|
@ -592,10 +682,8 @@ export const ActivityItem = memo(
|
||||||
isSlashCommandMessage,
|
isSlashCommandMessage,
|
||||||
isSlashCommandResult,
|
isSlashCommandResult,
|
||||||
message.commandOutput,
|
message.commandOutput,
|
||||||
message.summary,
|
message,
|
||||||
message.text,
|
|
||||||
slashCommandMeta,
|
slashCommandMeta,
|
||||||
standaloneSlashCommand,
|
|
||||||
structured,
|
structured,
|
||||||
]);
|
]);
|
||||||
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
|
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
|
||||||
|
|
@ -632,6 +720,33 @@ export const ActivityItem = memo(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bootstrapDisplay) {
|
||||||
|
return (
|
||||||
|
<BootstrapSystemRow
|
||||||
|
senderName={senderName}
|
||||||
|
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
|
||||||
|
runtime={bootstrapDisplay.runtime}
|
||||||
|
senderColor={senderColor}
|
||||||
|
recipientColor={recipientColor}
|
||||||
|
timestamp={timestamp}
|
||||||
|
onMemberNameClick={onMemberNameClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bootstrapAcknowledgement) {
|
||||||
|
return (
|
||||||
|
<BootstrapAcknowledgementRow
|
||||||
|
senderName={senderName}
|
||||||
|
recipientName={message.to ?? 'lead'}
|
||||||
|
senderColor={senderColor}
|
||||||
|
recipientColor={recipientColor}
|
||||||
|
timestamp={timestamp}
|
||||||
|
onMemberNameClick={onMemberNameClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const messageType =
|
const messageType =
|
||||||
structured && typeof structured.type === 'string'
|
structured && typeof structured.type === 'string'
|
||||||
? getMessageTypeLabel(structured.type)
|
? getMessageTypeLabel(structured.type)
|
||||||
|
|
@ -642,18 +757,10 @@ export const ActivityItem = memo(
|
||||||
const subject = message.summary || autoSummary || `Task from ${message.from}`;
|
const subject = message.summary || autoSummary || `Task from ${message.from}`;
|
||||||
const plainText = structured
|
const plainText = structured
|
||||||
? JSON.stringify(structured, null, 2)
|
? JSON.stringify(structured, null, 2)
|
||||||
: stripAgentBlocks(message.text);
|
: getSanitizedInboxMessageText(message);
|
||||||
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
|
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
|
||||||
onCreateTask?.(subject, description);
|
onCreateTask?.(subject, description);
|
||||||
}, [
|
}, [autoSummary, message.from, message.summary, message, onCreateTask, structured, timestamp]);
|
||||||
autoSummary,
|
|
||||||
message.from,
|
|
||||||
message.summary,
|
|
||||||
message.text,
|
|
||||||
onCreateTask,
|
|
||||||
structured,
|
|
||||||
timestamp,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isHeaderClickable = isManaged && canToggleCollapse;
|
const isHeaderClickable = isManaged && canToggleCollapse;
|
||||||
const showChevron = isHeaderClickable && !compactHeader;
|
const showChevron = isHeaderClickable && !compactHeader;
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
|
|
@ -44,6 +45,7 @@ import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import {
|
import {
|
||||||
createInitialProviderChecks,
|
createInitialProviderChecks,
|
||||||
failIncompleteProviderChecks,
|
failIncompleteProviderChecks,
|
||||||
|
getProvisioningProviderBackendSummary,
|
||||||
getProvisioningFailureHint,
|
getProvisioningFailureHint,
|
||||||
ProvisioningProviderStatusList,
|
ProvisioningProviderStatusList,
|
||||||
shouldHideProvisioningProviderStatusList,
|
shouldHideProvisioningProviderStatusList,
|
||||||
|
|
@ -270,6 +272,9 @@ export const CreateTeamDialog = ({
|
||||||
}: CreateTeamDialogProps): React.JSX.Element => {
|
}: CreateTeamDialogProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||||
|
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||||
|
|
||||||
// ── Persisted draft state (survives tab navigation) ──────────────────
|
// ── Persisted draft state (survives tab navigation) ──────────────────
|
||||||
const {
|
const {
|
||||||
|
|
@ -460,12 +465,25 @@ export const CreateTeamDialog = ({
|
||||||
new Set([
|
new Set([
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
...members.flatMap((member) =>
|
...members.flatMap((member) =>
|
||||||
member.providerId === 'codex' || member.providerId === 'gemini' ? [member.providerId] : []
|
isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
|
}, [members, multimodelEnabled, selectedProviderId, soloTeam, syncModelsWithLead]);
|
||||||
|
|
||||||
|
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||||
|
const entries: Array<readonly [TeamProviderId, string | null]> = (
|
||||||
|
cliStatus?.providers ?? []
|
||||||
|
).map(
|
||||||
|
(provider) =>
|
||||||
|
[
|
||||||
|
provider.providerId as TeamProviderId,
|
||||||
|
getProvisioningProviderBackendSummary(provider),
|
||||||
|
] as const
|
||||||
|
);
|
||||||
|
return new Map<TeamProviderId, string | null>(entries);
|
||||||
|
}, [cliStatus?.providers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -481,6 +499,13 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}, [members, multimodelEnabled, selectedProviderId, setMembers]);
|
}, [members, multimodelEnabled, selectedProviderId, setMembers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || cliStatus || cliStatusLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchCliStatus();
|
||||||
|
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !canCreate || !launchTeam) {
|
if (!open || !canCreate || !launchTeam) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -523,6 +548,7 @@ export const CreateTeamDialog = ({
|
||||||
for (const providerId of selectedMemberProviders) {
|
for (const providerId of selectedMemberProviders) {
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
|
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||||
details: [],
|
details: [],
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
|
@ -552,6 +578,7 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
||||||
|
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||||
details: detailLines,
|
details: detailLines,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
|
@ -584,7 +611,15 @@ export const CreateTeamDialog = ({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [open, canCreate, launchTeam, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
}, [
|
||||||
|
open,
|
||||||
|
canCreate,
|
||||||
|
launchTeam,
|
||||||
|
effectiveCwd,
|
||||||
|
selectedProviderId,
|
||||||
|
selectedMemberProviders,
|
||||||
|
runtimeBackendSummaryByProvider,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|
@ -660,12 +695,8 @@ export const CreateTeamDialog = ({
|
||||||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||||
customRole: isCustom ? m.role : '',
|
customRole: isCustom ? m.role : '',
|
||||||
workflow: m.workflow,
|
workflow: m.workflow,
|
||||||
providerId: normalizeProviderForMode(m.providerId, multimodelEnabled),
|
providerId: normalizeOptionalTeamProviderId(m.providerId),
|
||||||
model:
|
model: m.model ?? '',
|
||||||
normalizeProviderForMode(m.providerId, multimodelEnabled) ===
|
|
||||||
normalizeProviderForMode(m.providerId, true)
|
|
||||||
? (m.model ?? '')
|
|
||||||
: '',
|
|
||||||
effort: m.effort,
|
effort: m.effort,
|
||||||
}),
|
}),
|
||||||
multimodelEnabled
|
multimodelEnabled
|
||||||
|
|
@ -777,9 +808,10 @@ export const CreateTeamDialog = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||||
|
const teamNameInlineError = validateTeamNameInline(teamName);
|
||||||
|
const isNameTakenByExistingTeam = existingTeamNames.includes(sanitizedTeamName);
|
||||||
const isNameProvisioning =
|
const isNameProvisioning =
|
||||||
provisioningTeamNames.includes(sanitizedTeamName) &&
|
provisioningTeamNames.includes(sanitizedTeamName) && !isNameTakenByExistingTeam;
|
||||||
!existingTeamNames.includes(sanitizedTeamName);
|
|
||||||
|
|
||||||
const request = useMemo<TeamCreateRequest>(
|
const request = useMemo<TeamCreateRequest>(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -815,6 +847,15 @@ export const CreateTeamDialog = ({
|
||||||
customArgs,
|
customArgs,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
const requestValidation = useMemo(
|
||||||
|
() => validateRequest(request, { requireCwd: launchTeam }),
|
||||||
|
[request, launchTeam]
|
||||||
|
);
|
||||||
|
const hasCreateFormErrors =
|
||||||
|
!!teamNameInlineError ||
|
||||||
|
isNameTakenByExistingTeam ||
|
||||||
|
isNameProvisioning ||
|
||||||
|
!requestValidation.valid;
|
||||||
|
|
||||||
const internalArgs = useMemo(() => {
|
const internalArgs = useMemo(() => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
@ -1055,20 +1096,24 @@ export const CreateTeamDialog = ({
|
||||||
id="team-name"
|
id="team-name"
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-8 text-xs',
|
'h-8 text-xs',
|
||||||
(fieldErrors.teamName || allTakenTeamNames.includes(sanitizedTeamName)) &&
|
(fieldErrors.teamName || teamNameInlineError || isNameTakenByExistingTeam) &&
|
||||||
'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]'
|
'border-[var(--field-error-border)] bg-[var(--field-error-bg)] focus-visible:ring-[var(--field-error-border)]'
|
||||||
)}
|
)}
|
||||||
value={teamName}
|
value={teamName}
|
||||||
onChange={(event) => handleTeamNameChange(event.target.value)}
|
onChange={(event) => handleTeamNameChange(event.target.value)}
|
||||||
placeholder={suggestedTeamName}
|
placeholder={suggestedTeamName}
|
||||||
/>
|
/>
|
||||||
{allTakenTeamNames.includes(sanitizedTeamName) ? (
|
{isNameTakenByExistingTeam ? (
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
{isNameProvisioning ? 'Team is currently launching' : 'Team name already exists'}
|
Team name already exists
|
||||||
</p>
|
</p>
|
||||||
) : validateTeamNameInline(teamName) ? (
|
) : teamNameInlineError ? (
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
{validateTeamNameInline(teamName)}
|
{teamNameInlineError}
|
||||||
|
</p>
|
||||||
|
) : isNameProvisioning ? (
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--warning-text)' }}>
|
||||||
|
A team with this name is currently launching
|
||||||
</p>
|
</p>
|
||||||
) : fieldErrors.teamName ? (
|
) : fieldErrors.teamName ? (
|
||||||
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
||||||
|
|
@ -1391,7 +1436,7 @@ export const CreateTeamDialog = ({
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={!canCreate || !draftLoaded || isSubmitting}
|
disabled={!canCreate || !draftLoaded || isSubmitting || hasCreateFormErrors}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
||||||
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import {
|
import {
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
isTeamProvisioningActive,
|
isTeamProvisioningActive,
|
||||||
|
|
@ -61,6 +62,7 @@ import { OptionalSettingsSection } from './OptionalSettingsSection';
|
||||||
import {
|
import {
|
||||||
createInitialProviderChecks,
|
createInitialProviderChecks,
|
||||||
failIncompleteProviderChecks,
|
failIncompleteProviderChecks,
|
||||||
|
getProvisioningProviderBackendSummary,
|
||||||
getProvisioningFailureHint,
|
getProvisioningFailureHint,
|
||||||
ProvisioningProviderStatusList,
|
ProvisioningProviderStatusList,
|
||||||
shouldHideProvisioningProviderStatusList,
|
shouldHideProvisioningProviderStatusList,
|
||||||
|
|
@ -68,7 +70,11 @@ import {
|
||||||
type ProvisioningProviderCheck,
|
type ProvisioningProviderCheck,
|
||||||
} from './ProvisioningProviderStatusList';
|
} from './ProvisioningProviderStatusList';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
import {
|
||||||
|
computeEffectiveTeamModel,
|
||||||
|
formatTeamModelSummary,
|
||||||
|
TeamModelSelector,
|
||||||
|
} from './TeamModelSelector';
|
||||||
|
|
||||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
|
@ -155,6 +161,32 @@ function getProviderLabel(providerId: TeamProviderId): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMemberDraftRuntime(
|
||||||
|
member: Pick<MemberDraft, 'providerId' | 'model' | 'effort'>,
|
||||||
|
inheritedProviderId: TeamProviderId,
|
||||||
|
inheritedModel: string,
|
||||||
|
inheritedEffort: EffortLevel | undefined
|
||||||
|
): { providerId: TeamProviderId; model: string; effort: EffortLevel | undefined } {
|
||||||
|
return {
|
||||||
|
providerId: member.providerId ?? inheritedProviderId,
|
||||||
|
model: member.model?.trim() || inheritedModel,
|
||||||
|
effort: member.effort ?? inheritedEffort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResolvedMemberRuntime(
|
||||||
|
member: Pick<ResolvedTeamMember, 'providerId' | 'model' | 'effort'>,
|
||||||
|
inheritedProviderId: TeamProviderId,
|
||||||
|
inheritedModel: string,
|
||||||
|
inheritedEffort: EffortLevel | undefined
|
||||||
|
): { providerId: TeamProviderId; model: string; effort: EffortLevel | undefined } {
|
||||||
|
return {
|
||||||
|
providerId: normalizeOptionalTeamProviderId(member.providerId) ?? inheritedProviderId,
|
||||||
|
model: member.model?.trim() || inheritedModel,
|
||||||
|
effort: member.effort ?? inheritedEffort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Component
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -163,6 +195,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const { open, onClose } = props;
|
const { open, onClose } = props;
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
|
||||||
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
|
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||||
|
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||||
const isLaunch = props.mode === 'launch';
|
const isLaunch = props.mode === 'launch';
|
||||||
const isSchedule = props.mode === 'schedule';
|
const isSchedule = props.mode === 'schedule';
|
||||||
const schedule = isSchedule ? (props.schedule ?? null) : null;
|
const schedule = isSchedule ? (props.schedule ?? null) : null;
|
||||||
|
|
@ -239,7 +274,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
const [prepareChecks, setPrepareChecks] = useState<ProvisioningProviderCheck[]>([]);
|
||||||
const prepareRequestSeqRef = useRef(0);
|
const prepareRequestSeqRef = useRef(0);
|
||||||
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
const storeMembers = useStore((s) => s.selectedTeamData?.members ?? []);
|
||||||
|
const previousLaunchParams = useStore((s) =>
|
||||||
|
effectiveTeamName ? s.launchParamsByTeam[effectiveTeamName] : undefined
|
||||||
|
);
|
||||||
const members = isLaunch ? props.members : storeMembers;
|
const members = isLaunch ? props.members : storeMembers;
|
||||||
|
const [savedLaunchProviderId, setSavedLaunchProviderId] = useState<TeamProviderId | null>(null);
|
||||||
|
|
||||||
// Advanced CLI section state (with localStorage persistence)
|
// Advanced CLI section state (with localStorage persistence)
|
||||||
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
const [worktreeEnabled, setWorktreeEnabledRaw] = useState(
|
||||||
|
|
@ -277,15 +316,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
new Set([
|
new Set([
|
||||||
selectedProviderId,
|
selectedProviderId,
|
||||||
...effectiveMemberDrafts.flatMap((member) =>
|
...effectiveMemberDrafts.flatMap((member) =>
|
||||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
isTeamProviderId(member.providerId) ? [member.providerId] : []
|
||||||
? [member.providerId]
|
|
||||||
: []
|
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
[effectiveMemberDrafts, multimodelEnabled, selectedProviderId]
|
[effectiveMemberDrafts, multimodelEnabled, selectedProviderId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const runtimeBackendSummaryByProvider = useMemo(() => {
|
||||||
|
const entries: Array<readonly [TeamProviderId, string | null]> = (
|
||||||
|
cliStatus?.providers ?? []
|
||||||
|
).map(
|
||||||
|
(provider) =>
|
||||||
|
[
|
||||||
|
provider.providerId as TeamProviderId,
|
||||||
|
getProvisioningProviderBackendSummary(provider),
|
||||||
|
] as const
|
||||||
|
);
|
||||||
|
return new Map<TeamProviderId, string | null>(entries);
|
||||||
|
}, [cliStatus?.providers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (multimodelEnabled) {
|
if (multimodelEnabled) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -305,6 +355,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
});
|
});
|
||||||
}, [multimodelEnabled, selectedProviderId]);
|
}, [multimodelEnabled, selectedProviderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || cliStatus || cliStatusLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void fetchCliStatus();
|
||||||
|
}, [open, cliStatus, cliStatusLoading, fetchCliStatus]);
|
||||||
|
|
||||||
// Schedule store actions
|
// Schedule store actions
|
||||||
const createSchedule = useStore((s) => s.createSchedule);
|
const createSchedule = useStore((s) => s.createSchedule);
|
||||||
const updateSchedule = useStore((s) => s.updateSchedule);
|
const updateSchedule = useStore((s) => s.updateSchedule);
|
||||||
|
|
@ -494,6 +551,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
? savedRequest.members
|
? savedRequest.members
|
||||||
: [];
|
: [];
|
||||||
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
|
const storedEffort = localStorage.getItem('team:lastSelectedEffort');
|
||||||
|
const savedProviderId =
|
||||||
|
savedRequest?.providerId === 'codex' || savedRequest?.providerId === 'gemini'
|
||||||
|
? savedRequest.providerId
|
||||||
|
: savedRequest?.providerId === 'anthropic'
|
||||||
|
? 'anthropic'
|
||||||
|
: null;
|
||||||
|
setSavedLaunchProviderId(savedProviderId);
|
||||||
|
|
||||||
setMembersDrafts(
|
setMembersDrafts(
|
||||||
createMemberDraftsFromInputs(nextMembersSource).map((member) =>
|
createMemberDraftsFromInputs(nextMembersSource).map((member) =>
|
||||||
|
|
@ -536,6 +600,177 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
};
|
};
|
||||||
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]);
|
}, [open, isLaunch, effectiveTeamName, members, multimodelEnabled]);
|
||||||
|
|
||||||
|
const previousProviderId = useMemo<TeamProviderId | null>(() => {
|
||||||
|
if (!isLaunch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const fromLaunchParams = previousLaunchParams?.providerId;
|
||||||
|
if (
|
||||||
|
fromLaunchParams === 'anthropic' ||
|
||||||
|
fromLaunchParams === 'codex' ||
|
||||||
|
fromLaunchParams === 'gemini'
|
||||||
|
) {
|
||||||
|
return fromLaunchParams;
|
||||||
|
}
|
||||||
|
return savedLaunchProviderId;
|
||||||
|
}, [isLaunch, previousLaunchParams?.providerId, savedLaunchProviderId]);
|
||||||
|
|
||||||
|
const providerChangeForcesFreshLeadContext = useMemo(() => {
|
||||||
|
if (!isLaunch || !previousProviderId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return previousProviderId !== selectedProviderId;
|
||||||
|
}, [isLaunch, previousProviderId, selectedProviderId]);
|
||||||
|
|
||||||
|
const effectiveLeadRuntimeModel = useMemo(
|
||||||
|
() => computeEffectiveTeamModel(selectedModel, limitContext, selectedProviderId) ?? '',
|
||||||
|
[selectedModel, limitContext, selectedProviderId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runtimeChangeNotes = useMemo(() => {
|
||||||
|
if (!isLaunch) {
|
||||||
|
return [] as Array<{ key: string; memberName: string; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notes: Array<{ key: string; memberName: string; message: string }> = [];
|
||||||
|
const previousLeadModel = previousLaunchParams?.model?.trim() || '';
|
||||||
|
const previousLeadEffort = previousLaunchParams?.effort;
|
||||||
|
const currentLeadDisplayModel = selectedModel.trim() || effectiveLeadRuntimeModel;
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousProviderId &&
|
||||||
|
(previousProviderId !== selectedProviderId ||
|
||||||
|
previousLeadModel !== currentLeadDisplayModel ||
|
||||||
|
(previousLeadEffort ?? '') !== ((selectedEffort as EffortLevel | '') || ''))
|
||||||
|
) {
|
||||||
|
notes.push({
|
||||||
|
key: 'lead',
|
||||||
|
memberName: 'lead',
|
||||||
|
message: `${formatTeamModelSummary(
|
||||||
|
selectedProviderId,
|
||||||
|
currentLeadDisplayModel,
|
||||||
|
(selectedEffort as EffortLevel) || undefined
|
||||||
|
)} instead of ${formatTeamModelSummary(
|
||||||
|
previousProviderId,
|
||||||
|
previousLeadModel,
|
||||||
|
previousLeadEffort
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMembersByName = new Map(
|
||||||
|
members.map((member) => [member.name.trim().toLowerCase(), member] as const)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const member of effectiveMemberDrafts) {
|
||||||
|
if (member.removedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = member.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousMember = previousMembersByName.get(name.toLowerCase());
|
||||||
|
if (!previousMember) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
providerId: currentProviderId,
|
||||||
|
model: currentModel,
|
||||||
|
effort: currentEffort,
|
||||||
|
} = resolveMemberDraftRuntime(
|
||||||
|
member,
|
||||||
|
selectedProviderId,
|
||||||
|
currentLeadDisplayModel,
|
||||||
|
(selectedEffort as EffortLevel) || undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
providerId: previousProvider,
|
||||||
|
model: previousModel,
|
||||||
|
effort: previousEffort,
|
||||||
|
} = resolveResolvedMemberRuntime(
|
||||||
|
previousMember,
|
||||||
|
previousProviderId ?? 'anthropic',
|
||||||
|
previousLeadModel,
|
||||||
|
previousLeadEffort
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
previousProvider === currentProviderId &&
|
||||||
|
previousModel === currentModel &&
|
||||||
|
(previousEffort ?? '') === (currentEffort ?? '')
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
notes.push({
|
||||||
|
key: `member:${name.toLowerCase()}`,
|
||||||
|
memberName: name,
|
||||||
|
message: `${formatTeamModelSummary(
|
||||||
|
currentProviderId,
|
||||||
|
currentModel,
|
||||||
|
currentEffort
|
||||||
|
)} instead of ${formatTeamModelSummary(previousProvider, previousModel, previousEffort)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notes;
|
||||||
|
}, [
|
||||||
|
isLaunch,
|
||||||
|
previousLaunchParams?.effort,
|
||||||
|
previousLaunchParams?.model,
|
||||||
|
previousProviderId,
|
||||||
|
selectedProviderId,
|
||||||
|
selectedModel,
|
||||||
|
effectiveLeadRuntimeModel,
|
||||||
|
selectedEffort,
|
||||||
|
members,
|
||||||
|
effectiveMemberDrafts,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runtimeChangeNoteByKey = useMemo(
|
||||||
|
() => new Map(runtimeChangeNotes.map((note) => [note.key, note.message] as const)),
|
||||||
|
[runtimeChangeNotes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const leadRuntimeWarningText = useMemo(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (providerChangeForcesFreshLeadContext && previousProviderId) {
|
||||||
|
parts.push(
|
||||||
|
`Provider changed from ${getProviderLabel(previousProviderId)} to ${getProviderLabel(selectedProviderId)}. The previous lead session will not be resumed and lead will start with a fresh context.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const runtimeChange = runtimeChangeNoteByKey.get('lead');
|
||||||
|
if (runtimeChange) {
|
||||||
|
parts.push(`Next launch will use ${runtimeChange}.`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? parts.join(' ') : null;
|
||||||
|
}, [
|
||||||
|
providerChangeForcesFreshLeadContext,
|
||||||
|
previousProviderId,
|
||||||
|
selectedProviderId,
|
||||||
|
runtimeChangeNoteByKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const memberRuntimeWarningById = useMemo(() => {
|
||||||
|
const warnings: Record<string, string> = {};
|
||||||
|
for (const member of effectiveMemberDrafts) {
|
||||||
|
const name = member.name.trim();
|
||||||
|
if (!name || member.removedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const note = runtimeChangeNoteByKey.get(`member:${name.toLowerCase()}`);
|
||||||
|
if (note) {
|
||||||
|
warnings[member.id] = `Next launch will use ${note}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnings;
|
||||||
|
}, [effectiveMemberDrafts, runtimeChangeNoteByKey]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Launch-only effects
|
// Launch-only effects
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -588,6 +823,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
for (const providerId of selectedMemberProviders) {
|
for (const providerId of selectedMemberProviders) {
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
|
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||||
details: [],
|
details: [],
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
|
@ -615,6 +851,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}
|
}
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
checks = updateProviderCheck(checks, providerId, {
|
||||||
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
status: !prepResult.ready ? 'failed' : detailLines.length > 0 ? 'notes' : 'ready',
|
||||||
|
backendSummary: runtimeBackendSummaryByProvider.get(providerId) ?? null,
|
||||||
details: detailLines,
|
details: detailLines,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
|
@ -645,7 +882,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, isLaunch, effectiveCwd, selectedProviderId, selectedMemberProviders]);
|
}, [
|
||||||
|
open,
|
||||||
|
isLaunch,
|
||||||
|
effectiveCwd,
|
||||||
|
selectedProviderId,
|
||||||
|
selectedMemberProviders,
|
||||||
|
runtimeBackendSummaryByProvider,
|
||||||
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared effects: projects
|
// Shared effects: projects
|
||||||
|
|
@ -819,6 +1063,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
}
|
}
|
||||||
return errors;
|
return errors;
|
||||||
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]);
|
}, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]);
|
||||||
|
const hasInvalidLaunchMemberNames = useMemo(
|
||||||
|
() =>
|
||||||
|
isLaunch &&
|
||||||
|
membersDrafts.some(
|
||||||
|
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
|
||||||
|
),
|
||||||
|
[isLaunch, membersDrafts]
|
||||||
|
);
|
||||||
|
const hasDuplicateLaunchMemberNames = useMemo(() => {
|
||||||
|
if (!isLaunch) return false;
|
||||||
|
const activeNames = membersDrafts
|
||||||
|
.map((member) => member.name.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
return new Set(activeNames).size !== activeNames.length;
|
||||||
|
}, [isLaunch, membersDrafts]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Error
|
// Error
|
||||||
|
|
@ -927,10 +1186,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isSchedule) {
|
const message =
|
||||||
setLocalError(err instanceof Error ? err.message : 'Failed to save schedule');
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: isSchedule
|
||||||
|
? 'Failed to save schedule'
|
||||||
|
: 'Failed to launch team';
|
||||||
|
setLocalError(message);
|
||||||
|
if (isLaunch) {
|
||||||
|
console.error('Failed to launch team from dialog:', err);
|
||||||
}
|
}
|
||||||
// launch errors shown via provisioningError prop
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -942,7 +1207,11 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const isDisabled = isLaunch
|
const isDisabled = isLaunch
|
||||||
? isSubmitting || launchInFlight
|
? isSubmitting ||
|
||||||
|
launchInFlight ||
|
||||||
|
validationErrors.length > 0 ||
|
||||||
|
hasInvalidLaunchMemberNames ||
|
||||||
|
hasDuplicateLaunchMemberNames
|
||||||
: isSubmitting || validationErrors.length > 0;
|
: isSubmitting || validationErrors.length > 0;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -1265,6 +1534,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
onLimitContextChange={setLimitContext}
|
onLimitContextChange={setLimitContext}
|
||||||
syncModelsWithTeammates={syncModelsWithLead}
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||||
|
leadWarningText={leadRuntimeWarningText}
|
||||||
|
memberWarningById={memberRuntimeWarningById}
|
||||||
softDeleteMembers
|
softDeleteMembers
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -1302,6 +1573,26 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{providerChangeForcesFreshLeadContext ? (
|
||||||
|
<div
|
||||||
|
className="rounded-md border px-3 py-2 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--warning-bg)',
|
||||||
|
borderColor: 'var(--warning-border)',
|
||||||
|
color: 'var(--warning-text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<p>
|
||||||
|
Provider changed from {getProviderLabel(previousProviderId!)} to{' '}
|
||||||
|
{getProviderLabel(selectedProviderId)}. The previous lead session will not
|
||||||
|
be resumed, and the lead will start with fresh context so the new runtime
|
||||||
|
is applied correctly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="clear-context"
|
id="clear-context"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { TeamProviderId } from '@shared/types';
|
import type { TeamProviderId } from '@shared/types';
|
||||||
|
import type { CliProviderStatus } from '@shared/types';
|
||||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
|
||||||
|
|
@ -8,6 +9,7 @@ export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' |
|
||||||
export interface ProvisioningProviderCheck {
|
export interface ProvisioningProviderCheck {
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
status: ProvisioningProviderCheckStatus;
|
status: ProvisioningProviderCheckStatus;
|
||||||
|
backendSummary?: string | null;
|
||||||
details: string[];
|
details: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,10 +31,35 @@ export function createInitialProviderChecks(
|
||||||
return providerIds.map((providerId) => ({
|
return providerIds.map((providerId) => ({
|
||||||
providerId,
|
providerId,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
backendSummary: null,
|
||||||
details: [],
|
details: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProvisioningProviderBackendSummary(
|
||||||
|
provider:
|
||||||
|
| Pick<
|
||||||
|
CliProviderStatus,
|
||||||
|
'selectedBackendId' | 'resolvedBackendId' | 'availableBackends' | 'backend'
|
||||||
|
>
|
||||||
|
| null
|
||||||
|
| undefined
|
||||||
|
): string | null {
|
||||||
|
if (!provider) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = provider.availableBackends ?? [];
|
||||||
|
const optionById = new Map(options.map((option) => [option.id, option.label]));
|
||||||
|
const effectiveBackendId = provider.resolvedBackendId ?? provider.selectedBackendId;
|
||||||
|
|
||||||
|
if (effectiveBackendId) {
|
||||||
|
return optionById.get(effectiveBackendId) ?? provider.backend?.label ?? effectiveBackendId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return provider.backend?.label ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function updateProviderCheck(
|
export function updateProviderCheck(
|
||||||
checks: ProvisioningProviderCheck[],
|
checks: ProvisioningProviderCheck[],
|
||||||
providerId: TeamProviderId,
|
providerId: TeamProviderId,
|
||||||
|
|
@ -207,7 +234,9 @@ export function ProvisioningProviderStatusList({
|
||||||
>
|
>
|
||||||
<StatusIcon status={check.status} />
|
<StatusIcon status={check.status} />
|
||||||
<span>
|
<span>
|
||||||
{getProvisioningProviderLabel(check.providerId)}: {getDisplayStatusText(check)}
|
{getProvisioningProviderLabel(check.providerId)}
|
||||||
|
{check.backendSummary ? ` (${check.backendSummary})` : ''}:{' '}
|
||||||
|
{getDisplayStatusText(check)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{visibleDetails.length > 0 ? (
|
{visibleDetails.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,22 @@ export function formatTeamModelSummary(
|
||||||
const providerLabel = getTeamProviderLabel(providerId);
|
const providerLabel = getTeamProviderLabel(providerId);
|
||||||
const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
|
const modelLabel = model.trim() ? getTeamModelLabel(model.trim()) : 'Default';
|
||||||
const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
|
const effortLabel = effort?.trim() ? getTeamEffortLabel(effort) : '';
|
||||||
return [providerLabel, modelLabel, effortLabel].filter(Boolean).join(' · ');
|
|
||||||
|
const normalizedProvider = providerLabel.trim().toLowerCase();
|
||||||
|
const normalizedModel = modelLabel.trim().toLowerCase();
|
||||||
|
const modelAlreadyCarriesProviderBrand =
|
||||||
|
modelLabel !== 'Default' &&
|
||||||
|
(normalizedModel.startsWith(normalizedProvider) ||
|
||||||
|
(providerId === 'anthropic' && normalizedModel.startsWith('claude')) ||
|
||||||
|
(providerId === 'codex' && normalizedModel.startsWith('codex')) ||
|
||||||
|
(providerId === 'codex' && normalizedModel.startsWith('gpt')) ||
|
||||||
|
(providerId === 'gemini' && normalizedModel.startsWith('gemini')));
|
||||||
|
|
||||||
|
const parts = modelAlreadyCarriesProviderBrand
|
||||||
|
? [modelLabel, effortLabel]
|
||||||
|
: [providerLabel, modelLabel, effortLabel];
|
||||||
|
|
||||||
|
return parts.filter(Boolean).join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,8 @@ export const KanbanTaskCard = memo(
|
||||||
[taskChangeRequestOptions, onViewChanges]
|
[taskChangeRequestOptions, onViewChanges]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isReviewManual = columnId === 'review' && !hasReviewers && !kanbanTaskState?.reviewer;
|
const effectiveReviewer = (kanbanTaskState?.reviewer ?? task.reviewer ?? '').trim();
|
||||||
|
const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0;
|
||||||
const metaActions = (
|
const metaActions = (
|
||||||
<>
|
<>
|
||||||
{canDisplay && task.changePresence === 'has_changes' ? (
|
{canDisplay && task.changePresence === 'has_changes' ? (
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ interface LeadModelRowProps {
|
||||||
onLimitContextChange: (value: boolean) => void;
|
onLimitContextChange: (value: boolean) => void;
|
||||||
syncModelsWithTeammates: boolean;
|
syncModelsWithTeammates: boolean;
|
||||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||||
|
warningText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LeadModelRow = ({
|
export const LeadModelRow = ({
|
||||||
|
|
@ -41,6 +42,7 @@ export const LeadModelRow = ({
|
||||||
onLimitContextChange,
|
onLimitContextChange,
|
||||||
syncModelsWithTeammates,
|
syncModelsWithTeammates,
|
||||||
onSyncModelsWithTeammatesChange,
|
onSyncModelsWithTeammatesChange,
|
||||||
|
warningText,
|
||||||
}: LeadModelRowProps): React.JSX.Element => {
|
}: LeadModelRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const [modelExpanded, setModelExpanded] = useState(false);
|
const [modelExpanded, setModelExpanded] = useState(false);
|
||||||
|
|
@ -102,6 +104,14 @@ export const LeadModelRow = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{warningText ? (
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<div className="bg-amber-500/8 ml-3 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||||
|
<p>{warningText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{modelExpanded ? (
|
{modelExpanded ? (
|
||||||
<div className="space-y-2 md:col-span-3">
|
<div className="space-y-2 md:col-span-3">
|
||||||
<TeamModelSelector
|
<TeamModelSelector
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
|
|
@ -37,6 +38,7 @@ interface MemberCardProps {
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
spawnStatus?: MemberSpawnStatus;
|
spawnStatus?: MemberSpawnStatus;
|
||||||
spawnError?: string;
|
spawnError?: string;
|
||||||
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
onOpenReviewTask?: () => void;
|
onOpenReviewTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
@ -58,6 +60,7 @@ export const MemberCard = ({
|
||||||
isRemoved,
|
isRemoved,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnError,
|
spawnError,
|
||||||
|
spawnLivenessSource,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onOpenReviewTask,
|
onOpenReviewTask,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
@ -79,6 +82,7 @@ export const MemberCard = ({
|
||||||
const presenceLabel = getSpawnAwarePresenceLabel(
|
const presenceLabel = getSpawnAwarePresenceLabel(
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
spawnLivenessSource,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ interface MemberDraftRowProps {
|
||||||
modelLockReason?: string;
|
modelLockReason?: string;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
onRestore?: (id: string) => void;
|
onRestore?: (id: string) => void;
|
||||||
|
warningText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDraftRow = ({
|
export const MemberDraftRow = ({
|
||||||
|
|
@ -81,6 +82,7 @@ export const MemberDraftRow = ({
|
||||||
modelLockReason,
|
modelLockReason,
|
||||||
isRemoved = false,
|
isRemoved = false,
|
||||||
onRestore,
|
onRestore,
|
||||||
|
warningText,
|
||||||
}: MemberDraftRowProps): React.JSX.Element => {
|
}: MemberDraftRowProps): React.JSX.Element => {
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const memberColorSet = getTeamColorSet(
|
const memberColorSet = getTeamColorSet(
|
||||||
|
|
@ -289,6 +291,14 @@ export const MemberDraftRow = ({
|
||||||
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
|
<div className="pl-1 text-[11px] text-[var(--color-text-muted)]">Removed</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{!isRemoved && warningText ? (
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<div className="bg-amber-500/8 ml-3 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||||
|
<p>{warningText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{showWorkflow && onWorkflowChange && workflowExpanded ? (
|
{showWorkflow && onWorkflowChange && workflowExpanded ? (
|
||||||
<div className="space-y-0.5 pl-3 md:col-span-3">
|
<div className="space-y-0.5 pl-3 md:col-span-3">
|
||||||
<label
|
<label
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
formatTeamModelSummary,
|
||||||
getTeamEffortLabel,
|
getTeamEffortLabel,
|
||||||
getTeamModelLabel,
|
getTeamModelLabel,
|
||||||
getTeamProviderLabel,
|
getTeamProviderLabel,
|
||||||
|
|
@ -14,6 +15,7 @@ import { MemberCard } from './MemberCard';
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
|
|
@ -22,6 +24,7 @@ import type {
|
||||||
export interface MemberSpawnEntry {
|
export interface MemberSpawnEntry {
|
||||||
status: MemberSpawnStatus;
|
status: MemberSpawnStatus;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
livenessSource?: MemberSpawnLivenessSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MemberListProps {
|
interface MemberListProps {
|
||||||
|
|
@ -87,39 +90,11 @@ export const MemberList = ({
|
||||||
|
|
||||||
const buildRuntimeSummary = useCallback(
|
const buildRuntimeSummary = useCallback(
|
||||||
(member: ResolvedTeamMember): string | undefined => {
|
(member: ResolvedTeamMember): string | undefined => {
|
||||||
const hasMemberOverride = Boolean(member.providerId || member.model || member.effort);
|
const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic';
|
||||||
if (!hasMemberOverride && launchParams) {
|
const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || '';
|
||||||
return undefined;
|
const effectiveEffort = member.effort ?? launchParams?.effort;
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProvider = launchParams?.providerId ?? 'anthropic';
|
return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort);
|
||||||
const memberProvider = member.providerId ?? defaultProvider;
|
|
||||||
const defaultModel = launchParams?.model?.trim() || '';
|
|
||||||
const memberModel = member.model?.trim() || '';
|
|
||||||
const defaultEffort = launchParams?.effort;
|
|
||||||
const memberEffort = member.effort;
|
|
||||||
|
|
||||||
const showProvider =
|
|
||||||
!launchParams || Boolean(member.providerId && memberProvider !== defaultProvider);
|
|
||||||
const showModel = !launchParams
|
|
||||||
? Boolean(memberModel)
|
|
||||||
: Boolean(memberModel && memberModel !== defaultModel);
|
|
||||||
const showEffort = !launchParams
|
|
||||||
? Boolean(memberEffort)
|
|
||||||
: Boolean(memberEffort && memberEffort !== defaultEffort);
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (showProvider) {
|
|
||||||
parts.push(getTeamProviderLabel(memberProvider));
|
|
||||||
}
|
|
||||||
if (showModel) {
|
|
||||||
parts.push(getTeamModelLabel(memberModel));
|
|
||||||
}
|
|
||||||
if (showEffort && memberEffort) {
|
|
||||||
parts.push(getTeamEffortLabel(memberEffort));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join(' · ') : undefined;
|
|
||||||
},
|
},
|
||||||
[launchParams]
|
[launchParams]
|
||||||
);
|
);
|
||||||
|
|
@ -161,6 +136,7 @@ export const MemberList = ({
|
||||||
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
|
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||||
|
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||||
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
onOpenTask={!isRemoved && currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||||
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
|
onOpenReviewTask={!isRemoved && reviewTask ? () => onOpenTask?.(reviewTask) : undefined}
|
||||||
onClick={() => onMemberClick?.(member)}
|
onClick={() => onMemberClick?.(member)}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||||
|
|
@ -31,7 +32,7 @@ function membersToJsonText(drafts: MemberDraft[]): string {
|
||||||
if (role) obj.role = role;
|
if (role) obj.role = role;
|
||||||
const workflow = getWorkflowForExport(d);
|
const workflow = getWorkflowForExport(d);
|
||||||
if (workflow) obj.workflow = workflow;
|
if (workflow) obj.workflow = workflow;
|
||||||
if (d.providerId && d.providerId !== 'anthropic') obj.providerId = d.providerId;
|
if (d.providerId) obj.providerId = d.providerId;
|
||||||
if (d.model?.trim()) obj.model = d.model.trim();
|
if (d.model?.trim()) obj.model = d.model.trim();
|
||||||
if (d.effort) obj.effort = d.effort;
|
if (d.effort) obj.effort = d.effort;
|
||||||
return obj;
|
return obj;
|
||||||
|
|
@ -46,8 +47,7 @@ function parseJsonToDrafts(text: string): MemberDraft[] {
|
||||||
const name = typeof item.name === 'string' ? item.name : '';
|
const name = typeof item.name === 'string' ? item.name : '';
|
||||||
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
||||||
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
|
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
|
||||||
const providerId: TeamProviderId =
|
const providerId = normalizeOptionalTeamProviderId(item.providerId);
|
||||||
item.providerId === 'codex' || item.providerId === 'gemini' ? item.providerId : 'anthropic';
|
|
||||||
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
||||||
const effort: EffortLevel | undefined =
|
const effort: EffortLevel | undefined =
|
||||||
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
|
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
|
||||||
|
|
@ -99,6 +99,7 @@ export interface MembersEditorSectionProps {
|
||||||
forceInheritedModelSettings?: boolean;
|
forceInheritedModelSettings?: boolean;
|
||||||
modelLockReason?: string;
|
modelLockReason?: string;
|
||||||
softDeleteMembers?: boolean;
|
softDeleteMembers?: boolean;
|
||||||
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MembersEditorSection = ({
|
export const MembersEditorSection = ({
|
||||||
|
|
@ -124,6 +125,7 @@ export const MembersEditorSection = ({
|
||||||
forceInheritedModelSettings = false,
|
forceInheritedModelSettings = false,
|
||||||
modelLockReason,
|
modelLockReason,
|
||||||
softDeleteMembers = false,
|
softDeleteMembers = false,
|
||||||
|
memberWarningById,
|
||||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||||
const [jsonText, setJsonText] = useState('');
|
const [jsonText, setJsonText] = useState('');
|
||||||
|
|
@ -310,6 +312,7 @@ export const MembersEditorSection = ({
|
||||||
teamSuggestions={teamSuggestions}
|
teamSuggestions={teamSuggestions}
|
||||||
lockProviderModel={lockProviderModel}
|
lockProviderModel={lockProviderModel}
|
||||||
modelLockReason={modelLockReason}
|
modelLockReason={modelLockReason}
|
||||||
|
warningText={memberWarningById?.[member.id] ?? null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{softDeleteMembers && removedMembers.length > 0 ? (
|
{softDeleteMembers && removedMembers.length > 0 ? (
|
||||||
|
|
@ -348,6 +351,7 @@ export const MembersEditorSection = ({
|
||||||
lockProviderModel
|
lockProviderModel
|
||||||
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
|
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
|
||||||
isRemoved
|
isRemoved
|
||||||
|
warningText={null}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ interface TeamRosterEditorSectionProps {
|
||||||
headerTop?: React.ReactNode;
|
headerTop?: React.ReactNode;
|
||||||
headerBottom?: React.ReactNode;
|
headerBottom?: React.ReactNode;
|
||||||
softDeleteMembers?: boolean;
|
softDeleteMembers?: boolean;
|
||||||
|
leadWarningText?: string | null;
|
||||||
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamRosterEditorSection = ({
|
export const TeamRosterEditorSection = ({
|
||||||
|
|
@ -77,6 +79,8 @@ export const TeamRosterEditorSection = ({
|
||||||
headerTop,
|
headerTop,
|
||||||
headerBottom,
|
headerBottom,
|
||||||
softDeleteMembers = false,
|
softDeleteMembers = false,
|
||||||
|
leadWarningText,
|
||||||
|
memberWarningById,
|
||||||
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
}: TeamRosterEditorSectionProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<MembersEditorSection
|
<MembersEditorSection
|
||||||
|
|
@ -115,10 +119,12 @@ export const TeamRosterEditorSection = ({
|
||||||
onLimitContextChange={onLimitContextChange}
|
onLimitContextChange={onLimitContextChange}
|
||||||
syncModelsWithTeammates={syncModelsWithTeammates}
|
syncModelsWithTeammates={syncModelsWithTeammates}
|
||||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||||
|
warningText={leadWarningText}
|
||||||
/>
|
/>
|
||||||
{headerBottom}
|
{headerBottom}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
memberWarningById={memberWarningById}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -62,10 +63,7 @@ export function createMemberDraftsFromInputs(
|
||||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||||
customRole: role && !isPreset ? role : '',
|
customRole: role && !isPreset ? role : '',
|
||||||
workflow: member.workflow,
|
workflow: member.workflow,
|
||||||
providerId:
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
|
||||||
? member.providerId
|
|
||||||
: 'anthropic',
|
|
||||||
model: member.model ?? '',
|
model: member.model ?? '',
|
||||||
effort: normalizeDraftEffort(member.effort),
|
effort: normalizeDraftEffort(member.effort),
|
||||||
removedAt: member.removedAt,
|
removedAt: member.removedAt,
|
||||||
|
|
@ -196,11 +194,8 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
||||||
const result: TeamProvisioningMemberInput = { name, role };
|
const result: TeamProvisioningMemberInput = { name, role };
|
||||||
const workflow = getWorkflowForExport(member);
|
const workflow = getWorkflowForExport(member);
|
||||||
if (workflow) result.workflow = workflow;
|
if (workflow) result.workflow = workflow;
|
||||||
const providerId: TeamProviderId =
|
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||||
member.providerId === 'codex' || member.providerId === 'gemini'
|
if (providerId) {
|
||||||
? member.providerId
|
|
||||||
: 'anthropic';
|
|
||||||
if (providerId !== 'anthropic') {
|
|
||||||
result.providerId = providerId;
|
result.providerId = providerId;
|
||||||
}
|
}
|
||||||
const model = member.model?.trim();
|
const model = member.model?.trim();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ClaudeLogsSection } from '../ClaudeLogsSection';
|
import { ClaudeLogsSection } from '../ClaudeLogsSection';
|
||||||
import { MessagesPanel } from '../messages/MessagesPanel';
|
import { MessagesPanel } from '../messages/MessagesPanel';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import type { MouseEventHandler } from 'react';
|
import type { MouseEventHandler } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
|
|
@ -11,6 +12,9 @@ interface TeamSidebarRailProps {
|
||||||
messagesPanelProps: SharedMessagesPanelProps;
|
messagesPanelProps: SharedMessagesPanelProps;
|
||||||
isResizing: boolean;
|
isResizing: boolean;
|
||||||
onResizeMouseDown: MouseEventHandler<HTMLDivElement>;
|
onResizeMouseDown: MouseEventHandler<HTMLDivElement>;
|
||||||
|
logsHeight: number;
|
||||||
|
isLogsResizing: boolean;
|
||||||
|
onLogsResizeMouseDown: MouseEventHandler<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TeamSidebarRail = ({
|
export const TeamSidebarRail = ({
|
||||||
|
|
@ -18,13 +22,39 @@ export const TeamSidebarRail = ({
|
||||||
messagesPanelProps,
|
messagesPanelProps,
|
||||||
isResizing,
|
isResizing,
|
||||||
onResizeMouseDown,
|
onResizeMouseDown,
|
||||||
|
logsHeight,
|
||||||
|
isLogsResizing,
|
||||||
|
onLogsResizeMouseDown,
|
||||||
}: TeamSidebarRailProps): React.JSX.Element => {
|
}: TeamSidebarRailProps): React.JSX.Element => {
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
|
const logsSeparator = logsOpen ? (
|
||||||
|
<div
|
||||||
|
className={`group relative h-3 shrink-0 cursor-row-resize ${isLogsResizing ? 'bg-blue-500/10' : ''}`}
|
||||||
|
onMouseDown={onLogsResizeMouseDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 transition-colors ${
|
||||||
|
isLogsResizing
|
||||||
|
? 'bg-blue-500'
|
||||||
|
: 'bg-[var(--color-text-muted)]/35 group-hover:bg-blue-500/90'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-[var(--color-text-muted)]/35 h-px shrink-0" />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
|
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||||
<div className="shrink-0 overflow-hidden px-3">
|
<div className="shrink-0 overflow-hidden px-3">
|
||||||
<ClaudeLogsSection teamName={teamName} position="sidebar" />
|
<ClaudeLogsSection
|
||||||
|
teamName={teamName}
|
||||||
|
position="sidebar"
|
||||||
|
sidebarViewerMaxHeight={logsHeight}
|
||||||
|
onOpenChange={setLogsOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[var(--color-text-muted)]/35 mx-3 h-px shrink-0" />
|
{logsSeparator}
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<MessagesPanel position="sidebar" {...messagesPanelProps} />
|
<MessagesPanel position="sidebar" {...messagesPanelProps} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,12 @@
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
import type { CliInstallationStatus } from '@shared/types';
|
import type { CliInstallationStatus, CliProviderId } from '@shared/types';
|
||||||
|
|
||||||
export function useCliInstaller(): {
|
export function useCliInstaller(): {
|
||||||
cliStatus: CliInstallationStatus | null;
|
cliStatus: CliInstallationStatus | null;
|
||||||
cliStatusLoading: boolean;
|
cliStatusLoading: boolean;
|
||||||
|
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||||
cliStatusError: string | null;
|
cliStatusError: string | null;
|
||||||
installerState:
|
installerState:
|
||||||
| 'idle'
|
| 'idle'
|
||||||
|
|
@ -28,13 +29,19 @@ export function useCliInstaller(): {
|
||||||
installerDetail: string | null;
|
installerDetail: string | null;
|
||||||
installerRawChunks: string[];
|
installerRawChunks: string[];
|
||||||
completedVersion: string | null;
|
completedVersion: string | null;
|
||||||
|
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
|
fetchCliProviderStatus: (
|
||||||
|
providerId: CliProviderId,
|
||||||
|
options?: { silent?: boolean; epoch?: number }
|
||||||
|
) => Promise<void>;
|
||||||
invalidateCliStatus: () => Promise<void>;
|
invalidateCliStatus: () => Promise<void>;
|
||||||
installCli: () => void;
|
installCli: () => void;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
} {
|
} {
|
||||||
const cliStatus = useStore((s) => s.cliStatus);
|
const cliStatus = useStore((s) => s.cliStatus);
|
||||||
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
|
||||||
|
const cliProviderStatusLoading = useStore((s) => s.cliProviderStatusLoading);
|
||||||
const cliStatusError = useStore((s) => s.cliStatusError);
|
const cliStatusError = useStore((s) => s.cliStatusError);
|
||||||
const installerState = useStore((s) => s.cliInstallerState);
|
const installerState = useStore((s) => s.cliInstallerState);
|
||||||
const downloadProgress = useStore((s) => s.cliDownloadProgress);
|
const downloadProgress = useStore((s) => s.cliDownloadProgress);
|
||||||
|
|
@ -44,7 +51,9 @@ export function useCliInstaller(): {
|
||||||
const installerDetail = useStore((s) => s.cliInstallerDetail);
|
const installerDetail = useStore((s) => s.cliInstallerDetail);
|
||||||
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks);
|
const installerRawChunks = useStore((s) => s.cliInstallerRawChunks);
|
||||||
const completedVersion = useStore((s) => s.cliCompletedVersion);
|
const completedVersion = useStore((s) => s.cliCompletedVersion);
|
||||||
|
const bootstrapCliStatus = useStore((s) => s.bootstrapCliStatus);
|
||||||
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
const fetchCliStatus = useStore((s) => s.fetchCliStatus);
|
||||||
|
const fetchCliProviderStatus = useStore((s) => s.fetchCliProviderStatus);
|
||||||
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
|
const invalidateCliStatus = useStore((s) => s.invalidateCliStatus);
|
||||||
const installCli = useStore((s) => s.installCli);
|
const installCli = useStore((s) => s.installCli);
|
||||||
|
|
||||||
|
|
@ -53,6 +62,7 @@ export function useCliInstaller(): {
|
||||||
return {
|
return {
|
||||||
cliStatus,
|
cliStatus,
|
||||||
cliStatusLoading,
|
cliStatusLoading,
|
||||||
|
cliProviderStatusLoading,
|
||||||
cliStatusError,
|
cliStatusError,
|
||||||
installerState,
|
installerState,
|
||||||
downloadProgress,
|
downloadProgress,
|
||||||
|
|
@ -62,7 +72,9 @@ export function useCliInstaller(): {
|
||||||
installerDetail,
|
installerDetail,
|
||||||
installerRawChunks,
|
installerRawChunks,
|
||||||
completedVersion,
|
completedVersion,
|
||||||
|
bootstrapCliStatus,
|
||||||
fetchCliStatus,
|
fetchCliStatus,
|
||||||
|
fetchCliProviderStatus,
|
||||||
invalidateCliStatus,
|
invalidateCliStatus,
|
||||||
installCli,
|
installCli,
|
||||||
isBusy,
|
isBusy,
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,35 @@
|
||||||
/**
|
/**
|
||||||
* useResizablePanel - Reusable hook for mouse-based panel resizing.
|
* useResizablePanel - Reusable hook for mouse-based panel resizing.
|
||||||
*
|
*
|
||||||
* Extracted from the resize pattern in Sidebar.tsx.
|
* Supports both:
|
||||||
* Handles mousedown/mousemove/mouseup on document, cursor and userSelect overrides.
|
* - horizontal resizing for left/right side panels
|
||||||
*
|
* - vertical resizing for top/bottom stacked panels
|
||||||
* @param options.width Current panel width (controlled)
|
|
||||||
* @param options.onWidthChange Callback when width changes during drag
|
|
||||||
* @param options.minWidth Minimum allowed width (default 280)
|
|
||||||
* @param options.maxWidth Maximum allowed width (default 500)
|
|
||||||
* @param options.side Which side the panel is on:
|
|
||||||
* 'left' → panel is on the left, resize handle on right edge
|
|
||||||
* 'right' → panel is on the right, resize handle on left edge
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
const DEFAULT_MIN_WIDTH = 280;
|
const DEFAULT_MIN_WIDTH = 280;
|
||||||
const DEFAULT_MAX_WIDTH = 500;
|
const DEFAULT_MAX_WIDTH = 500;
|
||||||
|
const DEFAULT_MIN_HEIGHT = 120;
|
||||||
|
const DEFAULT_MAX_HEIGHT = 520;
|
||||||
|
|
||||||
interface UseResizablePanelOptions {
|
type HorizontalResizeOptions = {
|
||||||
width: number;
|
width: number;
|
||||||
onWidthChange: (width: number) => void;
|
onWidthChange: (width: number) => void;
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
side: 'left' | 'right';
|
side: 'left' | 'right';
|
||||||
}
|
};
|
||||||
|
|
||||||
|
type VerticalResizeOptions = {
|
||||||
|
height: number;
|
||||||
|
onHeightChange: (height: number) => void;
|
||||||
|
minHeight?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
side: 'top' | 'bottom';
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseResizablePanelOptions = HorizontalResizeOptions | VerticalResizeOptions;
|
||||||
|
|
||||||
interface ResizeHandleProps {
|
interface ResizeHandleProps {
|
||||||
onMouseDown: (e: React.MouseEvent) => void;
|
onMouseDown: (e: React.MouseEvent) => void;
|
||||||
|
|
@ -35,47 +40,61 @@ interface UseResizablePanelReturn {
|
||||||
handleProps: ResizeHandleProps;
|
handleProps: ResizeHandleProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useResizablePanel({
|
function isVerticalOptions(options: UseResizablePanelOptions): options is VerticalResizeOptions {
|
||||||
width,
|
return options.side === 'top' || options.side === 'bottom';
|
||||||
onWidthChange,
|
}
|
||||||
minWidth = DEFAULT_MIN_WIDTH,
|
|
||||||
maxWidth = DEFAULT_MAX_WIDTH,
|
export function useResizablePanel(options: UseResizablePanelOptions): UseResizablePanelReturn {
|
||||||
side,
|
|
||||||
}: UseResizablePanelOptions): UseResizablePanelReturn {
|
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const originRef = useRef(0);
|
||||||
|
const isVertical = isVerticalOptions(options);
|
||||||
|
|
||||||
// Store the panel's left offset for 'left' side panels.
|
const onSizeChangeRef = useRef<(size: number) => void>(
|
||||||
// Updated on resize start so the formula stays correct if layout shifts.
|
isVertical ? options.onHeightChange : options.onWidthChange
|
||||||
const panelLeftRef = useRef(0);
|
);
|
||||||
|
const minSizeRef = useRef(
|
||||||
// Keep callbacks in refs to avoid stale closures in mousemove listener
|
isVertical ? (options.minHeight ?? DEFAULT_MIN_HEIGHT) : (options.minWidth ?? DEFAULT_MIN_WIDTH)
|
||||||
const onWidthChangeRef = useRef(onWidthChange);
|
);
|
||||||
const minWidthRef = useRef(minWidth);
|
const maxSizeRef = useRef(
|
||||||
const maxWidthRef = useRef(maxWidth);
|
isVertical ? (options.maxHeight ?? DEFAULT_MAX_HEIGHT) : (options.maxWidth ?? DEFAULT_MAX_WIDTH)
|
||||||
const sideRef = useRef(side);
|
);
|
||||||
|
const sideRef = useRef(options.side);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onWidthChangeRef.current = onWidthChange;
|
sideRef.current = options.side;
|
||||||
minWidthRef.current = minWidth;
|
if (isVerticalOptions(options)) {
|
||||||
maxWidthRef.current = maxWidth;
|
onSizeChangeRef.current = options.onHeightChange;
|
||||||
sideRef.current = side;
|
minSizeRef.current = options.minHeight ?? DEFAULT_MIN_HEIGHT;
|
||||||
});
|
maxSizeRef.current = options.maxHeight ?? DEFAULT_MAX_HEIGHT;
|
||||||
|
} else {
|
||||||
|
onSizeChangeRef.current = options.onWidthChange;
|
||||||
|
minSizeRef.current = options.minWidth ?? DEFAULT_MIN_WIDTH;
|
||||||
|
maxSizeRef.current = options.maxWidth ?? DEFAULT_MAX_WIDTH;
|
||||||
|
}
|
||||||
|
}, [options]);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (!isResizing) return;
|
if (!isResizing) return;
|
||||||
|
|
||||||
let newWidth: number;
|
let newSize: number;
|
||||||
if (sideRef.current === 'left') {
|
switch (sideRef.current) {
|
||||||
// Panel on the left: width = cursor position - panel left edge
|
case 'left':
|
||||||
newWidth = e.clientX - panelLeftRef.current;
|
newSize = e.clientX - originRef.current;
|
||||||
} else {
|
break;
|
||||||
// Panel on the right: width = viewport width - cursor position
|
case 'right':
|
||||||
newWidth = window.innerWidth - e.clientX;
|
newSize = window.innerWidth - e.clientX;
|
||||||
|
break;
|
||||||
|
case 'top':
|
||||||
|
newSize = e.clientY - originRef.current;
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
newSize = window.innerHeight - e.clientY;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newWidth >= minWidthRef.current && newWidth <= maxWidthRef.current) {
|
if (newSize >= minSizeRef.current && newSize <= maxSizeRef.current) {
|
||||||
onWidthChangeRef.current(newWidth);
|
onSizeChangeRef.current(newSize);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isResizing]
|
[isResizing]
|
||||||
|
|
@ -89,7 +108,7 @@ export function useResizablePanel({
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
document.body.style.cursor = 'col-resize';
|
document.body.style.cursor = isVertical ? 'row-resize' : 'col-resize';
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,20 +118,23 @@ export function useResizablePanel({
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = '';
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
};
|
};
|
||||||
}, [isResizing, handleMouseMove, handleMouseUp]);
|
}, [isResizing, handleMouseMove, handleMouseUp, isVertical]);
|
||||||
|
|
||||||
const onMouseDown = useCallback(
|
const onMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (side === 'left') {
|
if (isVerticalOptions(options)) {
|
||||||
// Calculate the left edge of the panel from cursor position minus current width
|
if (options.side === 'top') {
|
||||||
panelLeftRef.current = e.clientX - width;
|
originRef.current = e.clientY - options.height;
|
||||||
|
}
|
||||||
|
} else if (options.side === 'left') {
|
||||||
|
originRef.current = e.clientX - options.width;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
},
|
},
|
||||||
[side, width]
|
[options]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,12 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const isWindows = platform.toLowerCase().includes('win');
|
const isWindows = platform.toLowerCase().includes('win');
|
||||||
const delayMs = isWindows ? 3000 : 0;
|
const delayMs = isWindows ? 3000 : 0;
|
||||||
cliStatusTimer = setTimeout(() => {
|
cliStatusTimer = setTimeout(() => {
|
||||||
void useStore.getState().fetchCliStatus();
|
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
|
||||||
|
if (multimodelEnabled) {
|
||||||
|
void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });
|
||||||
|
} else {
|
||||||
|
void useStore.getState().fetchCliStatus();
|
||||||
|
}
|
||||||
cliStatusTimer = null;
|
cliStatusTimer = null;
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,55 @@ import { api } from '@renderer/api';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type { CliInstallationStatus } from '@shared/types';
|
import type { CliInstallationStatus, CliProviderId, CliProviderStatus } from '@shared/types';
|
||||||
import type { StateCreator } from 'zustand';
|
import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('Store:cliInstaller');
|
const logger = createLogger('Store:cliInstaller');
|
||||||
|
|
||||||
/** Max log lines to keep in UI (reserved for future use) */
|
/** Max log lines to keep in UI (reserved for future use) */
|
||||||
const _MAX_LOG_LINES = 50;
|
const _MAX_LOG_LINES = 50;
|
||||||
|
export const MULTIMODEL_PROVIDER_IDS: CliProviderId[] = ['anthropic', 'codex', 'gemini'];
|
||||||
|
|
||||||
|
export function createLoadingMultimodelCliStatus(): CliInstallationStatus {
|
||||||
|
const providers: CliProviderStatus[] = (
|
||||||
|
[
|
||||||
|
{ providerId: 'anthropic', displayName: 'Anthropic' },
|
||||||
|
{ providerId: 'codex', displayName: 'Codex' },
|
||||||
|
{ providerId: 'gemini', displayName: 'Gemini' },
|
||||||
|
] as const
|
||||||
|
).map((provider) => ({
|
||||||
|
...provider,
|
||||||
|
supported: false,
|
||||||
|
authenticated: false,
|
||||||
|
authMethod: null,
|
||||||
|
verificationState: 'unknown' as const,
|
||||||
|
statusMessage: 'Checking...',
|
||||||
|
models: [],
|
||||||
|
canLoginFromUi: true,
|
||||||
|
capabilities: {
|
||||||
|
teamLaunch: false,
|
||||||
|
oneShot: false,
|
||||||
|
},
|
||||||
|
backend: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
flavor: 'free-code',
|
||||||
|
displayName: 'free-code-gemini-research',
|
||||||
|
supportsSelfUpdate: false,
|
||||||
|
showVersionDetails: false,
|
||||||
|
showBinaryPath: false,
|
||||||
|
installed: true,
|
||||||
|
installedVersion: null,
|
||||||
|
binaryPath: null,
|
||||||
|
latestVersion: null,
|
||||||
|
updateAvailable: false,
|
||||||
|
authLoggedIn: false,
|
||||||
|
authStatusChecking: true,
|
||||||
|
authMethod: null,
|
||||||
|
providers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Slice Interface
|
// Slice Interface
|
||||||
|
|
@ -22,6 +64,7 @@ export interface CliInstallerSlice {
|
||||||
// State
|
// State
|
||||||
cliStatus: CliInstallationStatus | null;
|
cliStatus: CliInstallationStatus | null;
|
||||||
cliStatusLoading: boolean;
|
cliStatusLoading: boolean;
|
||||||
|
cliProviderStatusLoading: Partial<Record<CliProviderId, boolean>>;
|
||||||
cliStatusError: string | null;
|
cliStatusError: string | null;
|
||||||
cliInstallerState:
|
cliInstallerState:
|
||||||
| 'idle'
|
| 'idle'
|
||||||
|
|
@ -41,23 +84,33 @@ export interface CliInstallerSlice {
|
||||||
cliCompletedVersion: string | null;
|
cliCompletedVersion: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||||
fetchCliStatus: () => Promise<void>;
|
fetchCliStatus: () => Promise<void>;
|
||||||
|
fetchCliProviderStatus: (
|
||||||
|
providerId: CliProviderId,
|
||||||
|
options?: { silent?: boolean; epoch?: number }
|
||||||
|
) => Promise<void>;
|
||||||
invalidateCliStatus: () => Promise<void>;
|
invalidateCliStatus: () => Promise<void>;
|
||||||
installCli: () => void;
|
installCli: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let cliStatusInFlight: Promise<void> | null = null;
|
let cliStatusInFlight: Promise<void> | null = null;
|
||||||
|
const cliProviderStatusInFlight = new Map<CliProviderId, Promise<void>>();
|
||||||
|
let cliStatusEpoch = 0;
|
||||||
|
const cliProviderStatusSeq = new Map<CliProviderId, number>();
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Slice Creator
|
// Slice Creator
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstallerSlice> = (
|
export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstallerSlice> = (
|
||||||
set
|
set,
|
||||||
|
get
|
||||||
) => ({
|
) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
cliStatus: null,
|
cliStatus: null,
|
||||||
cliStatusLoading: false,
|
cliStatusLoading: false,
|
||||||
|
cliProviderStatusLoading: {},
|
||||||
cliStatusError: null,
|
cliStatusError: null,
|
||||||
cliInstallerState: 'idle',
|
cliInstallerState: 'idle',
|
||||||
cliDownloadProgress: 0,
|
cliDownloadProgress: 0,
|
||||||
|
|
@ -69,15 +122,92 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
cliInstallerRawChunks: [],
|
cliInstallerRawChunks: [],
|
||||||
cliCompletedVersion: null,
|
cliCompletedVersion: null,
|
||||||
|
|
||||||
|
bootstrapCliStatus: async (options) => {
|
||||||
|
if (!api.cliInstaller) return;
|
||||||
|
const multimodelEnabled = options?.multimodelEnabled ?? true;
|
||||||
|
if (!multimodelEnabled) {
|
||||||
|
return get().fetchCliStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const epoch = ++cliStatusEpoch;
|
||||||
|
const providerLoading = Object.fromEntries(
|
||||||
|
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true])
|
||||||
|
) as Partial<Record<CliProviderId, boolean>>;
|
||||||
|
|
||||||
|
set({
|
||||||
|
cliStatus: createLoadingMultimodelCliStatus(),
|
||||||
|
cliStatusLoading: true,
|
||||||
|
cliProviderStatusLoading: providerLoading,
|
||||||
|
cliStatusError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const metadata = await api.cliInstaller.getStatus();
|
||||||
|
set((state) => {
|
||||||
|
if (epoch !== cliStatusEpoch || !state.cliStatus) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cliStatus: {
|
||||||
|
...state.cliStatus,
|
||||||
|
flavor: metadata.flavor,
|
||||||
|
displayName: metadata.displayName,
|
||||||
|
supportsSelfUpdate: metadata.supportsSelfUpdate,
|
||||||
|
showVersionDetails: metadata.showVersionDetails,
|
||||||
|
showBinaryPath: metadata.showBinaryPath,
|
||||||
|
installed: metadata.installed,
|
||||||
|
installedVersion: metadata.installedVersion,
|
||||||
|
binaryPath: metadata.binaryPath,
|
||||||
|
latestVersion: metadata.latestVersion,
|
||||||
|
updateAvailable: metadata.updateAvailable,
|
||||||
|
authStatusChecking: state.cliStatus.providers.some(
|
||||||
|
(provider) => provider.statusMessage === 'Checking...'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to hydrate CLI metadata during provider-first bootstrap:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(
|
||||||
|
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
|
||||||
|
get().fetchCliProviderStatus(providerId, {
|
||||||
|
silent: false,
|
||||||
|
epoch,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (epoch === cliStatusEpoch) {
|
||||||
|
set({ cliStatusLoading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
fetchCliStatus: async () => {
|
fetchCliStatus: async () => {
|
||||||
if (!api.cliInstaller) return;
|
if (!api.cliInstaller) return;
|
||||||
if (cliStatusInFlight) return cliStatusInFlight;
|
if (cliStatusInFlight) return cliStatusInFlight;
|
||||||
|
|
||||||
|
const epoch = ++cliStatusEpoch;
|
||||||
cliStatusInFlight = (async () => {
|
cliStatusInFlight = (async () => {
|
||||||
set({ cliStatusLoading: true, cliStatusError: null });
|
set({ cliStatusLoading: true, cliStatusError: null });
|
||||||
try {
|
try {
|
||||||
const status = await api.cliInstaller.getStatus();
|
const status = await api.cliInstaller.getStatus();
|
||||||
set({ cliStatus: status });
|
if (epoch !== cliStatusEpoch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ cliStatus: status, cliProviderStatusLoading: {} });
|
||||||
|
for (const provider of status.providers) {
|
||||||
|
void get().fetchCliProviderStatus(provider.providerId, {
|
||||||
|
silent: true,
|
||||||
|
epoch,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to check CLI status';
|
const message = error instanceof Error ? error.message : 'Failed to check CLI status';
|
||||||
logger.error('Failed to fetch CLI status:', error);
|
logger.error('Failed to fetch CLI status:', error);
|
||||||
|
|
@ -91,6 +221,102 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
||||||
return cliStatusInFlight;
|
return cliStatusInFlight;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fetchCliProviderStatus: async (providerId, options) => {
|
||||||
|
if (!api.cliInstaller) return;
|
||||||
|
const inFlight = cliProviderStatusInFlight.get(providerId);
|
||||||
|
if (inFlight) return inFlight;
|
||||||
|
|
||||||
|
const requestEpoch = options?.epoch ?? cliStatusEpoch;
|
||||||
|
const requestSeq = (cliProviderStatusSeq.get(providerId) ?? 0) + 1;
|
||||||
|
const silent = options?.silent === true;
|
||||||
|
cliProviderStatusSeq.set(providerId, requestSeq);
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
if (!silent) {
|
||||||
|
set((state) => ({
|
||||||
|
cliStatusError: null,
|
||||||
|
cliProviderStatusLoading: {
|
||||||
|
...state.cliProviderStatusLoading,
|
||||||
|
[providerId]: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const providerStatus = await api.cliInstaller.getProviderStatus(providerId);
|
||||||
|
set((state) => {
|
||||||
|
const nextLoading = silent
|
||||||
|
? state.cliProviderStatusLoading
|
||||||
|
: {
|
||||||
|
...state.cliProviderStatusLoading,
|
||||||
|
[providerId]: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestEpoch !== cliStatusEpoch ||
|
||||||
|
cliProviderStatusSeq.get(providerId) !== requestSeq
|
||||||
|
) {
|
||||||
|
return { cliProviderStatusLoading: nextLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providerStatus || !state.cliStatus) {
|
||||||
|
return { cliProviderStatusLoading: nextLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasProvider = state.cliStatus.providers.some(
|
||||||
|
(provider) => provider.providerId === providerId
|
||||||
|
);
|
||||||
|
const nextProviders = hasProvider
|
||||||
|
? state.cliStatus.providers.map((provider) =>
|
||||||
|
provider.providerId === providerId ? providerStatus : provider
|
||||||
|
)
|
||||||
|
: [...state.cliStatus.providers, providerStatus];
|
||||||
|
const authenticatedProvider =
|
||||||
|
nextProviders.find((provider) => provider.authenticated) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
cliStatus: {
|
||||||
|
...state.cliStatus,
|
||||||
|
providers: nextProviders,
|
||||||
|
authLoggedIn: nextProviders.some((provider) => provider.authenticated),
|
||||||
|
authMethod: authenticatedProvider?.authMethod ?? null,
|
||||||
|
},
|
||||||
|
cliProviderStatusLoading: nextLoading,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : `Failed to refresh ${providerId} status`;
|
||||||
|
logger.error(`Failed to fetch ${providerId} CLI status:`, error);
|
||||||
|
set((state) => {
|
||||||
|
const nextLoading = silent
|
||||||
|
? state.cliProviderStatusLoading
|
||||||
|
: {
|
||||||
|
...state.cliProviderStatusLoading,
|
||||||
|
[providerId]: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
requestEpoch !== cliStatusEpoch ||
|
||||||
|
cliProviderStatusSeq.get(providerId) !== requestSeq
|
||||||
|
) {
|
||||||
|
return { cliProviderStatusLoading: nextLoading };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cliStatusError: message,
|
||||||
|
cliProviderStatusLoading: nextLoading,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
cliProviderStatusInFlight.delete(providerId);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
cliProviderStatusInFlight.set(providerId, request);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
|
||||||
invalidateCliStatus: async () => {
|
invalidateCliStatus: async () => {
|
||||||
await api.cliInstaller?.invalidateStatus();
|
await api.cliInstaller?.invalidateStatus();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -762,8 +762,10 @@ export interface TeamSlice {
|
||||||
// Messages panel UI state
|
// Messages panel UI state
|
||||||
messagesPanelMode: 'sidebar' | 'inline';
|
messagesPanelMode: 'sidebar' | 'inline';
|
||||||
messagesPanelWidth: number;
|
messagesPanelWidth: number;
|
||||||
|
sidebarLogsHeight: number;
|
||||||
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void;
|
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => void;
|
||||||
setMessagesPanelWidth: (width: number) => void;
|
setMessagesPanelWidth: (width: number) => void;
|
||||||
|
setSidebarLogsHeight: (height: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Per-team launch params persistence ---
|
// --- Per-team launch params persistence ---
|
||||||
|
|
@ -998,8 +1000,10 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Messages panel UI state
|
// Messages panel UI state
|
||||||
messagesPanelMode: 'sidebar' as const,
|
messagesPanelMode: 'sidebar' as const,
|
||||||
messagesPanelWidth: 340,
|
messagesPanelWidth: 340,
|
||||||
|
sidebarLogsHeight: 213,
|
||||||
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }),
|
setMessagesPanelMode: (mode: 'sidebar' | 'inline') => set({ messagesPanelMode: mode }),
|
||||||
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
|
setMessagesPanelWidth: (width: number) => set({ messagesPanelWidth: width }),
|
||||||
|
setSidebarLogsHeight: (height: number) => set({ sidebarLogsHeight: height }),
|
||||||
|
|
||||||
fetchBranches: async (paths: string[]) => {
|
fetchBranches: async (paths: string[]) => {
|
||||||
const entries = await Promise.all(
|
const entries = await Promise.all(
|
||||||
|
|
@ -2381,10 +2385,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) {
|
if (isCanonicalRun && TERMINAL_PROVISIONING_STATES.has(progress.state)) {
|
||||||
// Clear spawn statuses — provisioning is complete, members now tracked via normal status
|
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
const next = { ...prev.memberSpawnStatusesByTeam };
|
const next = { ...prev.memberSpawnStatusesByTeam };
|
||||||
delete next[progress.teamName];
|
const currentStatuses = next[progress.teamName];
|
||||||
|
if (!currentStatuses) {
|
||||||
|
return { memberSpawnStatusesByTeam: next };
|
||||||
|
}
|
||||||
|
if (progress.state === 'ready') {
|
||||||
|
next[progress.teamName] = currentStatuses;
|
||||||
|
return { memberSpawnStatusesByTeam: next };
|
||||||
|
}
|
||||||
|
const retainedStatuses = Object.fromEntries(
|
||||||
|
Object.entries(currentStatuses).filter(([, entry]) => entry.status === 'error')
|
||||||
|
);
|
||||||
|
if (Object.keys(retainedStatuses).length > 0) {
|
||||||
|
next[progress.teamName] = retainedStatuses;
|
||||||
|
} else {
|
||||||
|
delete next[progress.teamName];
|
||||||
|
}
|
||||||
return { memberSpawnStatusesByTeam: next };
|
return { memberSpawnStatusesByTeam: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
MemberStatus,
|
MemberStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
|
@ -98,9 +99,9 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
|
||||||
|
|
||||||
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
offline: 'offline',
|
offline: 'offline',
|
||||||
waiting: 'waiting',
|
waiting: 'awaiting heartbeat',
|
||||||
spawning: 'spawning',
|
spawning: 'starting',
|
||||||
online: 'online',
|
online: 'ready',
|
||||||
error: 'spawn failed',
|
error: 'spawn failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -115,8 +116,20 @@ export function getSpawnAwareDotClass(
|
||||||
isTeamProvisioning?: boolean,
|
isTeamProvisioning?: boolean,
|
||||||
leadActivity?: LeadActivityState
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
if (spawnStatus && isTeamProvisioning) {
|
if (spawnStatus === 'error') {
|
||||||
return SPAWN_DOT_COLORS[spawnStatus];
|
return SPAWN_DOT_COLORS.error;
|
||||||
|
}
|
||||||
|
if (spawnStatus === 'waiting') {
|
||||||
|
return SPAWN_DOT_COLORS.waiting;
|
||||||
|
}
|
||||||
|
if (spawnStatus === 'online') {
|
||||||
|
return SPAWN_DOT_COLORS.online;
|
||||||
|
}
|
||||||
|
if (spawnStatus === 'offline' && isTeamProvisioning) {
|
||||||
|
return SPAWN_DOT_COLORS.offline;
|
||||||
|
}
|
||||||
|
if (spawnStatus === 'spawning' && isTeamProvisioning) {
|
||||||
|
return SPAWN_DOT_COLORS.spawning;
|
||||||
}
|
}
|
||||||
return getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
return getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||||
}
|
}
|
||||||
|
|
@ -127,10 +140,23 @@ export function getSpawnAwareDotClass(
|
||||||
export function getSpawnAwarePresenceLabel(
|
export function getSpawnAwarePresenceLabel(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
|
livenessSource: MemberSpawnLivenessSource | undefined,
|
||||||
isTeamAlive?: boolean,
|
isTeamAlive?: boolean,
|
||||||
isTeamProvisioning?: boolean,
|
isTeamProvisioning?: boolean,
|
||||||
leadActivity?: LeadActivityState
|
leadActivity?: LeadActivityState
|
||||||
): string {
|
): string {
|
||||||
|
if (spawnStatus === 'error') {
|
||||||
|
return SPAWN_PRESENCE_LABELS.error;
|
||||||
|
}
|
||||||
|
if (spawnStatus === 'offline' && isTeamProvisioning) {
|
||||||
|
return 'waiting for Agent';
|
||||||
|
}
|
||||||
|
if (spawnStatus === 'waiting') {
|
||||||
|
return SPAWN_PRESENCE_LABELS.waiting;
|
||||||
|
}
|
||||||
|
if (spawnStatus === 'online' && livenessSource === 'process') {
|
||||||
|
return 'running';
|
||||||
|
}
|
||||||
if (spawnStatus && isTeamProvisioning) {
|
if (spawnStatus && isTeamProvisioning) {
|
||||||
return SPAWN_PRESENCE_LABELS[spawnStatus];
|
return SPAWN_PRESENCE_LABELS[spawnStatus];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers';
|
import { getToolSummary } from '@renderer/utils/toolRendering/toolSummaryHelpers';
|
||||||
|
import { summarizeAgentToolInput } from '@shared/utils/toolSummary';
|
||||||
|
|
||||||
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
|
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
|
||||||
|
|
||||||
|
|
@ -363,8 +364,8 @@ export function groupBySubagent(groups: StreamJsonGroup[]): StreamJsonEntry[] {
|
||||||
if (item.type === 'tool' && (item.tool.name === 'Agent' || item.tool.name === 'Task')) {
|
if (item.type === 'tool' && (item.tool.name === 'Agent' || item.tool.name === 'Task')) {
|
||||||
const input = item.tool.input as Record<string, unknown> | undefined;
|
const input = item.tool.input as Record<string, unknown> | undefined;
|
||||||
const desc =
|
const desc =
|
||||||
|
(item.tool.name === 'Agent' && input ? summarizeAgentToolInput(input, 80) : null) ||
|
||||||
(typeof input?.description === 'string' && input.description) ||
|
(typeof input?.description === 'string' && input.description) ||
|
||||||
(typeof input?.prompt === 'string' && input.prompt.slice(0, 80)) ||
|
|
||||||
'Subagent';
|
'Subagent';
|
||||||
pendingDescriptions.push(desc);
|
pendingDescriptions.push(desc);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import {
|
||||||
|
getSanitizedInboxMessageSummary,
|
||||||
|
getSanitizedInboxMessageText,
|
||||||
|
} from '@renderer/utils/bootstrapPromptSanitizer';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
@ -48,8 +52,8 @@ export function filterTeamMessages(
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
if (q) {
|
if (q) {
|
||||||
list = list.filter((m) => {
|
list = list.filter((m) => {
|
||||||
const text = (m.text ?? '').toLowerCase();
|
const text = getSanitizedInboxMessageText(m).toLowerCase();
|
||||||
const summary = (m.summary ?? '').toLowerCase();
|
const summary = getSanitizedInboxMessageSummary(m).toLowerCase();
|
||||||
const from = (m.from ?? '').toLowerCase();
|
const from = (m.from ?? '').toLowerCase();
|
||||||
const to = (m.to ?? '').toLowerCase();
|
const to = (m.to ?? '').toLowerCase();
|
||||||
return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q);
|
return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
|
import { summarizeAgentToolInput } from '@shared/utils/toolSummary';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncates a string to a maximum length with ellipsis.
|
* Truncates a string to a maximum length with ellipsis.
|
||||||
|
|
@ -249,8 +250,7 @@ export function getToolSummary(toolName: string, input: Record<string, unknown>)
|
||||||
return 'Delete team';
|
return 'Delete team';
|
||||||
|
|
||||||
case 'Agent': {
|
case 'Agent': {
|
||||||
const desc = input.description ?? input.prompt;
|
return summarizeAgentToolInput(input, 60);
|
||||||
return typeof desc === 'string' ? truncate(desc, 60) : 'Subagent';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,25 @@ export type CliFlavor = 'claude' | 'free-code';
|
||||||
|
|
||||||
export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
|
export type CliProviderId = 'anthropic' | 'codex' | 'gemini';
|
||||||
|
|
||||||
|
export interface CliProviderBackendOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
selectable: boolean;
|
||||||
|
recommended: boolean;
|
||||||
|
available: boolean;
|
||||||
|
statusMessage?: string | null;
|
||||||
|
detailMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliExternalRuntimeDiagnostic {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
detected: boolean;
|
||||||
|
statusMessage?: string | null;
|
||||||
|
detailMessage?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CliProviderStatus {
|
export interface CliProviderStatus {
|
||||||
providerId: CliProviderId;
|
providerId: CliProviderId;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
@ -39,6 +58,10 @@ export interface CliProviderStatus {
|
||||||
teamLaunch: boolean;
|
teamLaunch: boolean;
|
||||||
oneShot: boolean;
|
oneShot: boolean;
|
||||||
};
|
};
|
||||||
|
selectedBackendId?: string | null;
|
||||||
|
resolvedBackendId?: string | null;
|
||||||
|
availableBackends?: CliProviderBackendOption[];
|
||||||
|
externalRuntimeDiagnostics?: CliExternalRuntimeDiagnostic[];
|
||||||
backend?: {
|
backend?: {
|
||||||
kind: string;
|
kind: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -131,6 +154,8 @@ export interface CliInstallerProgress {
|
||||||
export interface CliInstallerAPI {
|
export interface CliInstallerAPI {
|
||||||
/** Get current CLI installation status */
|
/** Get current CLI installation status */
|
||||||
getStatus: () => Promise<CliInstallationStatus>;
|
getStatus: () => Promise<CliInstallationStatus>;
|
||||||
|
/** Get current runtime/auth status for a single provider */
|
||||||
|
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
||||||
/** Start install/update flow. Progress sent via onProgress events. */
|
/** Start install/update flow. Progress sent via onProgress events. */
|
||||||
install: () => Promise<void>;
|
install: () => Promise<void>;
|
||||||
/** Invalidate cached status (forces fresh check on next getStatus) */
|
/** Invalidate cached status (forces fresh check on next getStatus) */
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,13 @@ export interface AppConfig {
|
||||||
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
|
/** Send anonymous crash & performance telemetry (requires SENTRY_DSN at build time) */
|
||||||
telemetryEnabled: boolean;
|
telemetryEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
/** Runtime backend preferences for app-launched free-code sessions */
|
||||||
|
runtime: {
|
||||||
|
providerBackends: {
|
||||||
|
gemini: 'auto' | 'api' | 'cli-sdk';
|
||||||
|
codex: 'auto' | 'adapter';
|
||||||
|
};
|
||||||
|
};
|
||||||
/** Display and UI settings */
|
/** Display and UI settings */
|
||||||
display: {
|
display: {
|
||||||
/** Whether to show timestamps in message views */
|
/** Whether to show timestamps in message views */
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,14 @@ export interface TeamSummary {
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
/** True when team.meta.json exists but config.json doesn't — provisioning failed before TeamCreate. */
|
/** True when team.meta.json exists but config.json doesn't — provisioning failed before TeamCreate. */
|
||||||
pendingCreate?: boolean;
|
pendingCreate?: boolean;
|
||||||
|
/** True when the last launch partially succeeded (e.g. lead started, but not all teammates joined). */
|
||||||
|
partialLaunchFailure?: boolean;
|
||||||
|
/** Planned teammate count for the last persisted partial launch marker. */
|
||||||
|
expectedMemberCount?: number;
|
||||||
|
/** Confirmed teammate count from runtime artifacts/config for the last partial launch marker. */
|
||||||
|
confirmedMemberCount?: number;
|
||||||
|
/** Missing teammate names from the last partial launch marker. */
|
||||||
|
missingMembers?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
||||||
|
|
@ -434,9 +442,10 @@ export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn lifecycle status for a team member during team launch/reconnect.
|
* Spawn lifecycle status for a team member during team launch/reconnect.
|
||||||
* - offline: not yet spawned (no Agent tool_use seen)
|
* - offline: queued, Agent tool_use not sent yet
|
||||||
* - spawning: Agent tool_use sent, awaiting tool_result
|
* - spawning: Agent tool_use sent, awaiting tool_result
|
||||||
* - online: tool_result received, agent is active
|
* - waiting: teammate process accepted by runtime, awaiting first heartbeat/inbox signal
|
||||||
|
* - online: first heartbeat/inbox signal received
|
||||||
* - error: spawn failed (tool_result with error)
|
* - error: spawn failed (tool_result with error)
|
||||||
*/
|
*/
|
||||||
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
export type MemberSpawnStatus = 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||||
|
|
@ -574,6 +583,8 @@ export interface MemberSpawnStatusesSnapshot {
|
||||||
runId: string | null;
|
runId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MemberSpawnLivenessSource = 'heartbeat' | 'process';
|
||||||
|
|
||||||
export interface TeamChangeEvent {
|
export interface TeamChangeEvent {
|
||||||
type:
|
type:
|
||||||
| 'config'
|
| 'config'
|
||||||
|
|
@ -601,6 +612,12 @@ export interface MemberSpawnStatusEntry {
|
||||||
status: MemberSpawnStatus;
|
status: MemberSpawnStatus;
|
||||||
/** Error message when status === 'error'. */
|
/** Error message when status === 'error'. */
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/**
|
||||||
|
* Optional provenance for `online`.
|
||||||
|
* - heartbeat: teammate sent a real inbox/native message after bootstrap
|
||||||
|
* - process: runtime process is alive, but bootstrap/first reply is not yet confirmed
|
||||||
|
*/
|
||||||
|
livenessSource?: MemberSpawnLivenessSource;
|
||||||
/** ISO timestamp of the last status change. */
|
/** ISO timestamp of the last status change. */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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) + '...';
|
return str.length <= max ? str : str.slice(0, max) + '...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProviderName(providerId: string): string {
|
||||||
|
switch (providerId.trim().toLowerCase()) {
|
||||||
|
case 'anthropic':
|
||||||
|
return 'Anthropic';
|
||||||
|
case 'codex':
|
||||||
|
return 'Codex';
|
||||||
|
case 'gemini':
|
||||||
|
return 'Gemini';
|
||||||
|
default:
|
||||||
|
return providerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEffortName(effort: string): string {
|
||||||
|
const trimmed = effort.trim();
|
||||||
|
return trimmed ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentToolDisplayDetails {
|
||||||
|
action: string;
|
||||||
|
teammateName?: string;
|
||||||
|
teamName?: string;
|
||||||
|
runtime?: string;
|
||||||
|
subagentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentToolDisplayDetails(
|
||||||
|
input: Record<string, unknown>
|
||||||
|
): AgentToolDisplayDetails {
|
||||||
|
const teammateName = typeof input.name === 'string' ? input.name.trim() || undefined : undefined;
|
||||||
|
const teamName =
|
||||||
|
typeof input.team_name === 'string' ? input.team_name.trim() || undefined : undefined;
|
||||||
|
const description =
|
||||||
|
typeof input.description === 'string' ? input.description.trim() || undefined : undefined;
|
||||||
|
const provider =
|
||||||
|
typeof input.provider === 'string'
|
||||||
|
? formatProviderName(input.provider)
|
||||||
|
: typeof input.providerId === 'string'
|
||||||
|
? formatProviderName(input.providerId)
|
||||||
|
: undefined;
|
||||||
|
const model = typeof input.model === 'string' ? input.model.trim() || undefined : undefined;
|
||||||
|
const effort = typeof input.effort === 'string' ? formatEffortName(input.effort) : undefined;
|
||||||
|
const subagentType =
|
||||||
|
typeof input.subagent_type === 'string'
|
||||||
|
? input.subagent_type.trim() || undefined
|
||||||
|
: typeof input.subagentType === 'string'
|
||||||
|
? input.subagentType.trim() || undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const runtimeParts = [provider, model, effort].filter(
|
||||||
|
(part): part is string => typeof part === 'string' && part.length > 0
|
||||||
|
);
|
||||||
|
const runtime = runtimeParts.length > 0 ? runtimeParts.join(' · ') : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: description ?? (teammateName ? `Spawn teammate ${teammateName}` : 'Spawn subagent'),
|
||||||
|
teammateName,
|
||||||
|
teamName,
|
||||||
|
runtime,
|
||||||
|
subagentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeAgentToolInput(input: Record<string, unknown>, max = 60): string {
|
||||||
|
const details = getAgentToolDisplayDetails(input);
|
||||||
|
const text = details.runtime ? `${details.action} · ${details.runtime}` : details.action;
|
||||||
|
return truncateStr(text, max);
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract a short human-readable preview from tool_use input arguments. */
|
/** Extract a short human-readable preview from tool_use input arguments. */
|
||||||
export function extractToolPreview(
|
export function extractToolPreview(
|
||||||
name: string,
|
name: string,
|
||||||
|
|
@ -95,10 +164,13 @@ export function extractToolPreview(
|
||||||
case 'Agent':
|
case 'Agent':
|
||||||
case 'Task':
|
case 'Task':
|
||||||
case 'TaskCreate':
|
case 'TaskCreate':
|
||||||
return typeof input.prompt === 'string'
|
if (name === 'Agent') {
|
||||||
? input.prompt
|
return summarizeAgentToolInput(input, 80);
|
||||||
: typeof input.description === 'string'
|
}
|
||||||
? input.description
|
return typeof input.description === 'string'
|
||||||
|
? input.description
|
||||||
|
: typeof input.prompt === 'string'
|
||||||
|
? truncateStr(input.prompt, 80)
|
||||||
: undefined;
|
: undefined;
|
||||||
case 'WebFetch':
|
case 'WebFetch':
|
||||||
if (typeof input.url === 'string') {
|
if (typeof input.url === 'string') {
|
||||||
|
|
|
||||||
|
|
@ -481,4 +481,34 @@ describe('TeamProvisioningService', () => {
|
||||||
})
|
})
|
||||||
).toContain('but no logs for 2m is already unusual.');
|
).toContain('but no logs for 2m is already unusual.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('formats AskUserQuestion approvals with readable question text', () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(svc as any).formatToolApprovalBody('AskUserQuestion', {
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question:
|
||||||
|
'Я испытываю технические трудности с отправкой сообщений с помощью инструмента `SendMessage`.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toBe(
|
||||||
|
'Question: Я испытываю технические трудности с отправкой сообщений с помощью инструмента `SendMessage`.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats AskUserQuestion approvals with a compact multi-question summary', () => {
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(svc as any).formatToolApprovalBody('AskUserQuestion', {
|
||||||
|
questions: [
|
||||||
|
{ question: ' First question with extra spacing. ' },
|
||||||
|
{ question: 'Second question.' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
).toBe('Questions (2): First question with extra spacing.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue