From 95652c8990ae74e24750e04cb9b4c593d2ff8c8e Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 11:37:37 +0300
Subject: [PATCH 01/16] refactor(team): extract provisioning service policies
---
.../services/team/TeamProvisioningService.ts | 2173 +----------------
.../TeamProvisioningCliExitPresentation.ts | 257 ++
.../TeamProvisioningDirectRestart.ts | 188 ++
.../TeamProvisioningLaunchCompatibility.ts | 123 +
.../TeamProvisioningLaunchDiagnostics.ts | 197 ++
.../TeamProvisioningLaunchFailurePolicy.ts | 131 +
.../TeamProvisioningMemberIdentity.ts | 44 +
...TeamProvisioningMemberSpawnStatusPolicy.ts | 192 ++
...amProvisioningOpenCodeDiagnosticsPolicy.ts | 216 ++
...ovisioningOpenCodeRuntimeEvidencePolicy.ts | 620 +++++
.../TeamProvisioningRuntimeDiagnostics.ts | 172 ++
...eamProvisioningCliExitPresentation.test.ts | 135 +
.../TeamProvisioningDirectRestart.test.ts | 146 ++
...eamProvisioningLaunchCompatibility.test.ts | 75 +
.../TeamProvisioningLaunchDiagnostics.test.ts | 324 +++
...eamProvisioningLaunchFailurePolicy.test.ts | 113 +
.../TeamProvisioningMemberIdentity.test.ts | 54 +
...rovisioningMemberSpawnStatusPolicy.test.ts | 190 ++
...visioningOpenCodeDiagnosticsPolicy.test.ts | 129 +
...oningOpenCodeRuntimeEvidencePolicy.test.ts | 488 ++++
...TeamProvisioningRuntimeDiagnostics.test.ts | 213 ++
...amProvisioningServiceAuditWarnings.test.ts | 3 +-
22 files changed, 4133 insertions(+), 2050 deletions(-)
create mode 100644 src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
create mode 100644 src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts
create mode 100644 test/main/services/team/TeamProvisioningCliExitPresentation.test.ts
create mode 100644 test/main/services/team/TeamProvisioningDirectRestart.test.ts
create mode 100644 test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts
create mode 100644 test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts
create mode 100644 test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts
create mode 100644 test/main/services/team/TeamProvisioningMemberIdentity.test.ts
create mode 100644 test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts
create mode 100644 test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts
create mode 100644 test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
create mode 100644 test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 672aaaed..511754d1 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -18,7 +18,6 @@ import {
} from '@features/codex-runtime-profile/main';
import {
buildPlannedMemberLaneIdentity,
- fromProvisioningMembers,
isMixedOpenCodeSideLanePlan,
type TeamRuntimeLanePlan,
} from '@features/team-runtime-lanes';
@@ -154,9 +153,6 @@ import * as readline from 'readline';
import {
ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS,
type AnthropicTeamApiKeyHelperMaterial,
- CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV,
- CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER,
- CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV,
cleanupAnthropicTeamApiKeyHelperForTeam,
cleanupAnthropicTeamApiKeyHelperMaterial,
cleanupStaleAnthropicTeamApiKeyHelpers,
@@ -204,6 +200,56 @@ import {
writeDeterministicBootstrapSpecFile,
writeDeterministicBootstrapUserPromptFile,
} from './provisioning/TeamProvisioningBootstrapSpec';
+import {
+ buildCliExitFailurePresentation,
+ buildCombinedLogs,
+} from './provisioning/TeamProvisioningCliExitPresentation';
+import {
+ buildDirectTmuxRestartCommand,
+ hasAnthropicCompatibleAuthTokenEnv,
+ isInteractiveShellCommand,
+} from './provisioning/TeamProvisioningDirectRestart';
+import {
+ assertDeterministicBootstrapPrimaryMemberLimit,
+ assertOpenCodeNotLaunchedThroughLegacyProvisioning,
+ buildLargeDeterministicBootstrapWarning,
+ getMixedLaunchFallbackRecoveryError,
+ isPureOpenCodeProvisioningRequest,
+ mergeProvisioningWarnings,
+ type TeamLaunchCompatibilityReport,
+} from './provisioning/TeamProvisioningLaunchCompatibility';
+import {
+ buildLaunchDiagnosticsFromRun,
+ buildWorkspaceTrustPreflightLaunchDiagnostic,
+ mentionsProcessTableUnavailable,
+ mergeLaunchDiagnosticItem,
+} from './provisioning/TeamProvisioningLaunchDiagnostics';
+import {
+ deriveMemberLaunchState,
+ isAutoClearableLaunchFailureReason,
+ isNeverSpawnedDuringLaunchReason,
+} from './provisioning/TeamProvisioningLaunchFailurePolicy';
+import {
+ isOpenCodeOverlayMemberRemoved,
+ matchesExactTeamMemberName,
+ matchesMemberNameOrBase,
+ matchesObservedMemberNameForExpected,
+ matchesTeamMemberIdentity,
+ namesMatchCaseInsensitive,
+} from './provisioning/TeamProvisioningMemberIdentity';
+import {
+ buildRestartDuplicateUnconfirmedReason,
+ buildRestartGraceTimeoutReason,
+ buildRestartStillRunningReason,
+ createInitialMemberSpawnStatusEntry,
+ deriveTaskActivityPauseAt,
+ deriveTaskActivityResumeAt,
+ MEMBER_LAUNCH_GRACE_MS,
+ parseOptionalIsoMs,
+ shouldWarnOnMissingRegisteredMember,
+ shouldWarnOnUnreadableMemberAuditConfig,
+ summarizeMemberSpawnStatusRecord,
+} from './provisioning/TeamProvisioningMemberSpawnStatusPolicy';
import {
buildEffectiveTeamMemberSpec,
buildEffectiveTeamMemberSpecs,
@@ -212,6 +258,46 @@ import {
normalizeTeamProviderLike,
teamRequestIncludesCodexMember,
} from './provisioning/TeamProvisioningMemberSpecs';
+import {
+ boundOpenCodeAppManagedBriefingText,
+ filterStaleOpenCodeOverlayDiagnostics,
+ hasRealOpenCodeFailureDiagnostic,
+ hasRealOpenCodeLaunchDiagnostic,
+ hasStaleOpenCodeDiagnostics,
+ hasStaleOpenCodeSecondaryLaunchDiagnostic,
+ isFileLockTimeoutError,
+ isPersistedOpenCodeSecondaryLaneMember,
+ promoteOpenCodePersistedFailureReasonsFromDiagnostics,
+} from './provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy';
+import {
+ appendDiagnosticOnce,
+ buildOpenCodeSecondaryLaneTimingDiagnostic,
+ buildOpenCodeUncommittedBootstrapDiagnostic,
+ collectOpenCodeSecondaryLaneFailureDiagnostics,
+ createUnexpectedMixedSecondaryLaneFailureResult,
+ downgradeUncommittedOpenCodeBootstrapEvidence,
+ getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted,
+ hasOpenCodeRuntimeEntryHandle,
+ hasOpenCodeRuntimeHandle,
+ hasOpenCodeRuntimeLivenessMarker,
+ isBootstrapMemberEvidenceCurrentForMember,
+ isDefinitiveOpenCodePreLaunchFailure,
+ isExplicitLegacyOpenCodeBootstrap,
+ isMaterializedOpenCodeSessionId,
+ isRecoverableOpenCodeBootstrapPendingLaunchResult,
+ isRecoverableOpenCodeRuntimeEvidence,
+ isRecoverablePersistedOpenCodeRuntimeCandidate,
+ isRecoverablePersistedOpenCodeTerminalRuntimeCandidate,
+ MEMBER_BOOTSTRAP_STALL_MS,
+ normalizeIsoTimestamp,
+ normalizeRecoverableOpenCodeBootstrapPendingLaunchResult,
+ OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC,
+ promoteCommittedOpenCodeAppManagedBootstrapEvidence,
+ resolveOpenCodeBootstrapAcceptedAt,
+ selectOpenCodeSecondaryBootstrapStallDiagnostic,
+ shouldMarkPersistedOpenCodeBootstrapStalled,
+ summarizeRuntimeLaunchResultMembers,
+} from './provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy';
import {
buildDeterministicLaunchHydrationPrompt,
buildGeminiPostLaunchHydrationPrompt,
@@ -226,15 +312,31 @@ import {
getBootstrapTranscriptSuccessSource,
getCanonicalSendMessageFieldRule,
getCanonicalSendMessageToolRule,
- isBootstrapInstructionPrompt,
isBootstrapTranscriptContextText,
isTaskBoardSnapshotWorkCandidate,
normalizeMemberDiagnosticText,
shouldUseGeminiStagedLaunch,
} from './provisioning/TeamProvisioningPromptBuilders';
+import {
+ buildRuntimeLaunchWarning,
+ getAnthropicFastModeDefault,
+ getConfiguredRuntimeBackend,
+ getPromptSizeSummary,
+ getTeamProviderLabel,
+ logRuntimeLaunchSnapshot,
+} from './provisioning/TeamProvisioningRuntimeDiagnostics';
import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
export type { RuntimeBootstrapMemberMcpLaunchConfig } from './provisioning/TeamProvisioningBootstrapSpec';
+export { buildDirectTmuxRestartEnvAssignments } from './provisioning/TeamProvisioningDirectRestart';
+export {
+ getMixedLaunchFallbackRecoveryError,
+ getOpenCodeMixedProviderProvisioningError,
+} from './provisioning/TeamProvisioningLaunchCompatibility';
+export {
+ shouldWarnOnMissingRegisteredMember,
+ shouldWarnOnUnreadableMemberAuditConfig,
+} from './provisioning/TeamProvisioningMemberSpawnStatusPolicy';
export {
buildAddMemberSpawnMessage,
buildRestartMemberSpawnMessage,
@@ -715,7 +817,6 @@ const VERIFY_TIMEOUT_MS = 15_000;
const MCP_PREFLIGHT_INITIALIZE_TIMEOUT_MS = 45_000;
const PROVIDER_MODEL_LIST_TIMEOUT_MS = 30_000;
const PROVIDER_RUNTIME_STATUS_TIMEOUT_MS = 20_000;
-const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000;
function asRuntimeRecord(value: unknown): Record {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -794,10 +895,6 @@ function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata {
};
}
-function mentionsProcessTableUnavailable(value: string | undefined): boolean {
- return /\bprocess table\b.*\bunavailable\b/i.test(value ?? '');
-}
-
function buildRuntimeToolMetadataDiagnostics(metadata: RuntimeToolMetadata | undefined): string[] {
if (!metadata) {
return [];
@@ -1118,70 +1215,10 @@ const STALL_CHECK_INTERVAL_MS = 10_000;
const STALL_WARNING_THRESHOLD_MS = 20_000;
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS =
'TeamDelete,TodoWrite,TaskCreate,TaskUpdate,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop';
-const DIRECT_TMUX_RESTART_ENV_KEYS = [
- 'CLAUDE_CONFIG_DIR',
- 'CLAUDE_TEAM_CONTROL_URL',
- 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
- 'CLAUDE_CODE_USE_OPENAI',
- 'CLAUDE_CODE_USE_BEDROCK',
- 'CLAUDE_CODE_USE_VERTEX',
- 'CLAUDE_CODE_USE_FOUNDRY',
- 'CLAUDE_CODE_USE_GEMINI',
- 'CLAUDE_CODE_ENTRY_PROVIDER',
- 'CLAUDE_CODE_GEMINI_BACKEND',
- 'CLAUDE_CODE_CODEX_BACKEND',
- 'CODEX_HOME',
- CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV,
- CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV,
- 'ANTHROPIC_BASE_URL',
- 'ANTHROPIC_AWS_WORKSPACE_ID',
- 'ANTHROPIC_AWS_API_KEY',
- 'ANTHROPIC_API_KEY',
- 'ANTHROPIC_AUTH_TOKEN',
- 'GEMINI_BASE_URL',
- 'GEMINI_API_VERSION',
- 'GEMINI_API_KEY',
- 'CODEX_API_KEY',
- 'OPENAI_API_KEY',
- 'GOOGLE_APPLICATION_CREDENTIALS',
- 'GOOGLE_CLOUD_PROJECT',
- 'GOOGLE_CLOUD_PROJECT_ID',
- 'GCLOUD_PROJECT',
- 'HTTPS_PROXY',
- 'https_proxy',
- 'HTTP_PROXY',
- 'http_proxy',
- 'NO_PROXY',
- 'no_proxy',
- 'SSL_CERT_FILE',
- 'NODE_EXTRA_CA_CERTS',
- 'REQUESTS_CA_BUNDLE',
- 'CURL_CA_BUNDLE',
-] as const;
-const DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS = [
- 'CLAUDE_CODE_USE_OPENAI',
- 'CLAUDE_CODE_USE_BEDROCK',
- 'CLAUDE_CODE_USE_VERTEX',
- 'CLAUDE_CODE_USE_FOUNDRY',
- 'CLAUDE_CODE_USE_GEMINI',
- 'CLAUDE_CODE_ENTRY_PROVIDER',
-] as const;
-const INTERACTIVE_SHELL_COMMANDS = new Set([
- 'bash',
- 'zsh',
- 'sh',
- 'fish',
- 'nu',
- 'pwsh',
- 'powershell',
- 'cmd',
- 'cmd.exe',
-]);
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
const MEMBER_SPAWN_AUDIT_MIN_INTERVAL_MS = 1_500;
-const MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS = 10_000;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_send',
'cross_team_list_targets',
@@ -1301,96 +1338,6 @@ function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): st
return mergeJsonSettingsArgs([...providerArgs, ...args]);
}
-function shellQuote(value: string): string {
- if (value.length === 0) {
- return "''";
- }
- return `'${value.replace(/'/g, `'\\''`)}'`;
-}
-
-function isInteractiveShellCommand(command: string | undefined): boolean {
- const normalized = command?.trim().toLowerCase();
- if (!normalized) {
- return false;
- }
- return INTERACTIVE_SHELL_COMMANDS.has(path.basename(normalized));
-}
-
-function getDirectRestartEntryProvider(providerId: TeamProviderId): string {
- return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
-}
-
-export function buildDirectTmuxRestartEnvAssignments(
- env: NodeJS.ProcessEnv,
- providerId: TeamProviderId
-): string {
- const assignments = new Map();
- assignments.set('CLAUDECODE', '1');
- assignments.set('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1');
-
- for (const key of DIRECT_TMUX_RESTART_ENV_KEYS) {
- const value = env[key];
- if (typeof value === 'string' && value.length > 0) {
- assignments.set(key, value);
- }
- }
-
- for (const key of DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS) {
- assignments.set(key, '');
- }
- assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
- assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
- if (providerId === 'anthropic') {
- if (hasAnthropicCompatibleAuthTokenEnv(env)) {
- assignments.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL?.trim() ?? '');
- assignments.set('ANTHROPIC_AUTH_TOKEN', env.ANTHROPIC_AUTH_TOKEN?.trim() ?? '');
- if (!env.ANTHROPIC_API_KEY?.trim()) {
- assignments.set('ANTHROPIC_API_KEY', '');
- }
- } else if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) {
- assignments.set('ANTHROPIC_AUTH_TOKEN', '');
- }
- }
- if (
- providerId === 'anthropic' &&
- env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER
- ) {
- assignments.set(
- CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV,
- CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER
- );
- const settingsPath = env[CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV];
- if (typeof settingsPath === 'string') {
- assignments.set(CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, settingsPath);
- }
- for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) {
- assignments.set(key, '');
- }
- }
-
- return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ');
-}
-
-function buildDirectTmuxRestartCommand(input: {
- cwd: string;
- env: NodeJS.ProcessEnv;
- providerId: TeamProviderId;
- binaryPath: string;
- args: string[];
-}): string {
- const envAssignments = buildDirectTmuxRestartEnvAssignments(input.env, input.providerId);
- const command = [
- 'cd',
- shellQuote(input.cwd),
- '&&',
- 'env',
- envAssignments,
- shellQuote(input.binaryPath),
- ...input.args.map(shellQuote),
- ].join(' ');
- return `(${command}); __claude_teammate_exit=$?; printf '\\n__CLAUDE_TEAMMATE_EXIT__:%s\\n' "$__claude_teammate_exit"`;
-}
-
interface ProviderModelListCommandResponse {
schemaVersion?: number;
providers?: Record<
@@ -1640,12 +1587,6 @@ function hasAuthoritativeCodexLaunchCatalog(
);
}
-function getAnthropicFastModeDefault(): boolean {
- return (
- ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true
- );
-}
-
function resolveAnthropicSelectionFromFacts(params: {
selectedModel?: string;
limitContext?: boolean;
@@ -1845,274 +1786,6 @@ function resolveRequestedLaunchModel(params: {
return explicitModel ?? params.facts.defaultModel;
}
-function getTeamProviderLabel(providerId: TeamProviderId): string {
- switch (providerId) {
- case 'opencode':
- return 'OpenCode';
- case 'codex':
- return 'Codex';
- case 'gemini':
- return 'Gemini';
- case 'anthropic':
- default:
- return 'Anthropic';
- }
-}
-
-function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null {
- const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends;
- switch (providerId) {
- case 'opencode':
- return null;
- case 'gemini':
- return runtimeConfig.gemini;
- case 'codex':
- return migrateProviderBackendId('codex', runtimeConfig.codex) ?? 'codex-native';
- case 'anthropic':
- default:
- return null;
- }
-}
-
-function isOpenCodeLegacyProvisioningRequest(request: {
- providerId?: unknown;
- members?: readonly { providerId?: unknown; provider?: unknown }[];
-}): boolean {
- return (
- normalizeOptionalTeamProviderId(request.providerId) === 'opencode' ||
- (request.members ?? []).some(
- (member) =>
- normalizeOptionalTeamProviderId(member.providerId) === 'opencode' ||
- normalizeOptionalTeamProviderId(member.provider) === 'opencode'
- )
- );
-}
-
-function isPureOpenCodeProvisioningRequest(request: {
- providerId?: unknown;
- members?: readonly { providerId?: unknown; provider?: unknown }[];
-}): boolean {
- if (!isOpenCodeLegacyProvisioningRequest(request)) {
- return false;
- }
-
- const rootProviderId = normalizeOptionalTeamProviderId(request.providerId);
- if (rootProviderId && rootProviderId !== 'opencode') {
- return false;
- }
-
- return (request.members ?? []).every((member) => {
- const memberProviderId =
- normalizeOptionalTeamProviderId(member.providerId) ??
- normalizeOptionalTeamProviderId(member.provider);
- return !memberProviderId || memberProviderId === 'opencode';
- });
-}
-
-export function getOpenCodeMixedProviderProvisioningError(): string {
- return (
- 'This OpenCode mixed-team request is outside the current support scope. ' +
- 'Supported mixed teams keep the lead on Anthropic or Codex. OpenCode-led mixed teams still remain blocked in this phase.'
- );
-}
-
-export function getMixedLaunchFallbackRecoveryError(): string {
- return 'This old mixed team is missing stable member metadata. Open Edit Team and save the roster once before launching.';
-}
-
-type TeamLaunchCompatibilityLevel = 'ready' | 'repairable' | 'unsafe';
-type TeamLaunchCompatibilityRosterSource = 'members-meta' | 'config' | 'inboxes' | 'missing';
-type TeamLaunchCompatibilityRepairAction = 'materialize-members-meta';
-
-interface TeamLaunchCompatibilityReport {
- level: TeamLaunchCompatibilityLevel;
- rosterSource: TeamLaunchCompatibilityRosterSource;
- members: TeamCreateRequest['members'];
- warnings: string[];
- blockers: string[];
- repairAction?: TeamLaunchCompatibilityRepairAction;
-}
-
-function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: {
- providerId?: unknown;
- members?: readonly { providerId?: unknown; provider?: unknown }[];
-}): void {
- if (!isOpenCodeLegacyProvisioningRequest(request)) {
- return;
- }
- const lanePlan = fromProvisioningMembers(
- normalizeOptionalTeamProviderId(request.providerId),
- (request.members ?? []).map((member, index) => ({
- name: `member-${index + 1}`,
- providerId:
- normalizeOptionalTeamProviderId(member.providerId) ??
- normalizeOptionalTeamProviderId(member.provider),
- }))
- );
- if (!lanePlan.ok) {
- throw new Error(lanePlan.message || getOpenCodeMixedProviderProvisioningError());
- }
- if (!isPureOpenCodeProvisioningRequest(request)) {
- return;
- }
- throw new Error(
- 'OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path. ' +
- 'Use the gated OpenCode runtime adapter once production launch is enabled.'
- );
-}
-
-function mergeProvisioningWarnings(
- existing: string[] | undefined,
- nextWarning: string | null
-): string[] | undefined {
- if (!nextWarning) return existing;
- const merged = (existing ?? []).filter((warning) => warning !== nextWarning);
- merged.push(nextWarning);
- return merged.length > 0 ? merged : undefined;
-}
-
-const DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD = 8;
-const DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS = 20;
-
-function buildLargeDeterministicBootstrapWarning(memberCount: number): string | null {
- if (memberCount <= DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD) {
- return null;
- }
- return (
- `Large Codex team launch: ${memberCount} primary teammates will bootstrap in one runtime. ` +
- `Launches above ${DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD} teammates can be slower and more likely to hit provider rate limits or bootstrap timeouts.`
- );
-}
-
-function assertDeterministicBootstrapPrimaryMemberLimit(memberCount: number): void {
- if (memberCount <= DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS) {
- return;
- }
- throw new Error(
- `Codex deterministic bootstrap currently supports up to ${DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS} primary teammates; this team has ${memberCount}. Reduce primary teammates or move extra OpenCode members to secondary lanes.`
- );
-}
-
-function buildRuntimeLaunchWarning(
- request: Pick<
- TeamCreateRequest,
- 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode'
- >,
- env: NodeJS.ProcessEnv,
- options?: {
- geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
- promptSize?: PromptSizeSummary | null;
- expectedMembersCount?: number;
- }
-): string {
- const providerId = resolveTeamProviderId(request.providerId);
- const providerLabel = getTeamProviderLabel(providerId);
- const modelLabel = request.model?.trim() || 'default';
- const effortLabel = request.effort ?? 'default';
- const fastLabel =
- providerId === 'anthropic'
- ? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}`
- : providerId === 'codex'
- ? `, fast ${request.fastMode ?? 'inherit:off'}`
- : '';
- const backend =
- migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
- getConfiguredRuntimeBackend(providerId);
- const flags: string[] = [];
- if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI');
- if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI');
- if (env.CLAUDE_CODE_ENTRY_PROVIDER) {
- flags.push(`ENTRY_PROVIDER=${env.CLAUDE_CODE_ENTRY_PROVIDER}`);
- }
- if (env.CLAUDE_CODE_GEMINI_BACKEND) {
- flags.push(`GEMINI_BACKEND=${env.CLAUDE_CODE_GEMINI_BACKEND}`);
- }
- if (env.CLAUDE_CODE_CODEX_BACKEND) {
- flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`);
- }
- if (env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES === '1') {
- flags.push('FORCE_PROCESS_TEAMMATES');
- }
- const backendPart = backend ? `, backend ${backend}` : '';
- const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : '';
- const geminiAuth = options?.geminiRuntimeAuth;
- const authPart =
- providerId === 'gemini' && geminiAuth
- ? `, auth ${geminiAuth.authMethod ?? 'none'}/${geminiAuth.resolvedBackend}`
- : '';
- const promptSize = options?.promptSize;
- const promptPart = promptSize
- ? `, prompt ${promptSize.chars.toLocaleString('en-US')} chars/${promptSize.lines} lines`
- : '';
- const membersPart =
- typeof options?.expectedMembersCount === 'number'
- ? `, members ${options.expectedMembersCount}`
- : '';
- return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${fastLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`;
-}
-
-function logRuntimeLaunchSnapshot(
- teamName: string,
- claudePath: string,
- args: string[],
- request: Pick<
- TeamCreateRequest,
- 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode'
- >,
- env: NodeJS.ProcessEnv,
- options?: {
- geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
- promptSize?: PromptSizeSummary | null;
- expectedMembersCount?: number;
- launchIdentity?: ProviderModelLaunchIdentity | null;
- }
-): void {
- const providerId = resolveTeamProviderId(request.providerId);
- const snapshot = {
- providerId,
- providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null,
- model: request.model ?? null,
- effort: request.effort ?? null,
- fastMode: request.fastMode ?? null,
- configuredBackend:
- migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
- getConfiguredRuntimeBackend(providerId),
- promptSize: options?.promptSize ?? null,
- expectedMembersCount: options?.expectedMembersCount ?? null,
- launchIdentity: options?.launchIdentity ?? null,
- geminiRuntimeAuth:
- providerId === 'gemini'
- ? {
- authenticated: options?.geminiRuntimeAuth?.authenticated ?? null,
- authMethod: options?.geminiRuntimeAuth?.authMethod ?? null,
- resolvedBackend: options?.geminiRuntimeAuth?.resolvedBackend ?? null,
- projectId: options?.geminiRuntimeAuth?.projectId ?? null,
- statusMessage: options?.geminiRuntimeAuth?.statusMessage ?? null,
- }
- : null,
- env: {
- CLAUDE_CODE_USE_GEMINI: env.CLAUDE_CODE_USE_GEMINI ?? null,
- CLAUDE_CODE_USE_OPENAI: env.CLAUDE_CODE_USE_OPENAI ?? null,
- CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null,
- CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null,
- CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null,
- CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES ?? null,
- CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null,
- CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null,
- },
- args,
- claudePath,
- };
- logger.info(`[${teamName}] Launch runtime snapshot ${JSON.stringify(snapshot)}`);
-}
-
-function getPromptSizeSummary(prompt: string): PromptSizeSummary {
- return {
- chars: prompt.length,
- lines: prompt.length === 0 ? 0 : prompt.split(/\r?\n/g).length,
- };
-}
-
type TeamsBaseLocation = 'configured' | 'default';
type ValidConfigProbeResult =
@@ -2442,67 +2115,6 @@ interface MemberLifecycleOperation {
startedAtMs: number;
}
-function formatOpenCodeLaneTimingMs(value: number | null | undefined): string {
- return typeof value === 'number' && Number.isFinite(value)
- ? `${Math.max(0, Math.round(value))}ms`
- : 'n/a';
-}
-
-function appendDiagnosticOnce(diagnostics: readonly string[], diagnostic: string | null): string[] {
- if (!diagnostic || diagnostics.includes(diagnostic)) {
- return [...diagnostics];
- }
- return [...diagnostics, diagnostic];
-}
-
-function buildOpenCodeSecondaryLaneTimingDiagnostic(
- lane: MixedSecondaryRuntimeLaneState
-): string | null {
- if (
- typeof lane.queuedAtMs !== 'number' ||
- typeof lane.launchStartedAtMs !== 'number' ||
- typeof lane.launchFinishedAtMs !== 'number'
- ) {
- return null;
- }
- return [
- 'OpenCode secondary lane timing:',
- `member=${lane.member.name}`,
- `queueWaitMs=${formatOpenCodeLaneTimingMs(lane.launchStartedAtMs - lane.queuedAtMs)}`,
- `launchMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.launchStartedAtMs)}`,
- `totalMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.queuedAtMs)}`,
- ].join(' ');
-}
-
-function createUnexpectedMixedSecondaryLaneFailureResult(input: {
- runId: string;
- teamName: string;
- memberName: string;
- message: string;
-}): TeamRuntimeLaunchResult {
- return {
- runId: input.runId,
- teamName: input.teamName,
- launchPhase: 'finished',
- teamLaunchState: 'partial_failure',
- members: {
- [input.memberName]: {
- memberName: input.memberName,
- providerId: 'opencode',
- launchState: 'failed_to_start',
- agentToolAccepted: false,
- runtimeAlive: false,
- bootstrapConfirmed: false,
- hardFailure: true,
- hardFailureReason: input.message,
- diagnostics: [input.message],
- },
- },
- warnings: [],
- diagnostics: [input.message],
- };
-}
-
type LeadActivityState = 'active' | 'idle' | 'offline';
type ProvisioningAuthSource =
@@ -2522,32 +2134,6 @@ function isAnthropicDirectCredentialAuthSource(authSource: unknown): boolean {
return isAnthropicApiKeyBackedAuthSource(authSource) || authSource === 'anthropic_auth_token';
}
-function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
- const trimmed = baseUrl?.trim();
- if (!trimmed) {
- return false;
- }
-
- try {
- const url = new URL(trimmed);
- return (
- (url.protocol === 'http:' || url.protocol === 'https:') &&
- !url.username &&
- !url.password &&
- url.hostname !== 'api.anthropic.com' &&
- url.hostname !== 'api-staging.anthropic.com'
- );
- } catch {
- return false;
- }
-}
-
-function hasAnthropicCompatibleAuthTokenEnv(env: NodeJS.ProcessEnv): boolean {
- return Boolean(
- isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL) && env.ANTHROPIC_AUTH_TOKEN?.trim()
- );
-}
-
function buildAnthropicCrossProviderDirectAuthEnvPatch(
env: NodeJS.ProcessEnv,
authSource: ProvisioningAuthSource
@@ -2610,110 +2196,16 @@ interface CrossProviderMemberArgsResult {
usesAnthropicApiKeyHelper: boolean;
}
-interface PromptSizeSummary {
- chars: number;
- lines: number;
-}
-
-const MEMBER_LAUNCH_GRACE_MS = 120_000;
-const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
const OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS = {
acquireTimeoutMs: 45_000,
staleTimeoutMs: 60_000,
retryIntervalMs: 50,
} as const;
-export function shouldWarnOnUnreadableMemberAuditConfig(params: {
- nowMs: number;
- lastWarnAt: number;
- expectedMembers: readonly string[];
- memberSpawnStatuses: ReadonlyMap<
- string,
- Pick | undefined
- >;
-}): boolean {
- const { nowMs, lastWarnAt, expectedMembers, memberSpawnStatuses } = params;
- if (nowMs - lastWarnAt < MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS) {
- return false;
- }
- return expectedMembers.some((memberName) => {
- const current = memberSpawnStatuses.get(memberName);
- if (!current?.agentToolAccepted || typeof current.firstSpawnAcceptedAt !== 'string') {
- return false;
- }
- const acceptedAtMs = Date.parse(current.firstSpawnAcceptedAt);
- return Number.isFinite(acceptedAtMs) && nowMs - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
- });
-}
-
-export function shouldWarnOnMissingRegisteredMember(params: {
- nowMs: number;
- lastWarnAt: number;
- graceExpired: boolean;
-}): boolean {
- const { nowMs, lastWarnAt, graceExpired } = params;
- return graceExpired && nowMs - lastWarnAt >= MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS;
-}
-
function nowIso(): string {
return new Date().toISOString();
}
-function parseOptionalIsoMs(value: string | undefined): number {
- if (!value) return 0;
- const parsed = Date.parse(value);
- return Number.isFinite(parsed) ? parsed : 0;
-}
-
-function deriveTaskActivityPauseAt(previous: MemberSpawnStatusEntry, fallbackAt: string): string {
- const fallbackMs = parseOptionalIsoMs(fallbackAt);
- const explicitEvidenceMs = Math.max(
- parseOptionalIsoMs(previous.lastHeartbeatAt),
- parseOptionalIsoMs(previous.livenessLastCheckedAt)
- );
- const evidenceMs =
- explicitEvidenceMs > 0 ? explicitEvidenceMs : parseOptionalIsoMs(previous.updatedAt);
- if (evidenceMs <= 0 || fallbackMs <= 0) {
- return fallbackAt;
- }
- const boundedEvidenceMs = Math.min(evidenceMs, fallbackMs);
- const closeMs = Math.max(
- boundedEvidenceMs,
- Math.min(fallbackMs, boundedEvidenceMs + TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS)
- );
- return new Date(closeMs).toISOString();
-}
-
-function deriveTaskActivityResumeAt(
- previous: MemberSpawnStatusEntry,
- evidenceAt: string,
- fallbackAt: string
-): string {
- const fallbackMs = parseOptionalIsoMs(fallbackAt);
- const evidenceMs = parseOptionalIsoMs(evidenceAt);
- const previousUpdatedMs = parseOptionalIsoMs(previous.updatedAt);
- if (evidenceMs <= 0 || fallbackMs <= 0) {
- return fallbackAt;
- }
- if (previousUpdatedMs > 0 && evidenceMs < previousUpdatedMs) {
- return fallbackAt;
- }
- return new Date(Math.min(evidenceMs, fallbackMs)).toISOString();
-}
-
-function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
- const updatedAt = nowIso();
- return {
- status: 'offline',
- launchState: 'starting',
- agentToolAccepted: false,
- runtimeAlive: false,
- bootstrapConfirmed: false,
- hardFailure: false,
- updatedAt,
- };
-}
-
interface LiveTeamAgentRuntimeMetadata {
alive: boolean;
backendType?: TeamAgentRuntimeBackendType;
@@ -2736,372 +2228,12 @@ interface LiveTeamAgentRuntimeMetadata {
diagnostics?: string[];
}
-function isNeverSpawnedDuringLaunchReason(reason?: string): boolean {
- return reason?.trim() === 'Teammate was never spawned during launch.';
-}
-
-function collectRuntimeLaunchFailureDiagnostics(
- result: TeamRuntimeLaunchResult,
- memberName: string
-): string[] {
- const member = result.members[memberName];
- return [...(member?.diagnostics ?? []), member?.hardFailureReason, ...result.diagnostics].filter(
- (value): value is string => typeof value === 'string' && value.trim().length > 0
- );
-}
-
-function collectOpenCodeSecondaryLaneFailureDiagnostics(
- result: TeamRuntimeLaunchResult,
- memberName: string,
- prefixDiagnostics: readonly string[]
-): string[] {
- const diagnostics = [
- ...prefixDiagnostics,
- ...collectRuntimeLaunchFailureDiagnostics(result, memberName),
- ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
- return diagnostics.length > 0 ? diagnostics : ['OpenCode bridge reported member launch failure'];
-}
-
-function isReconciliableOpenCodeUnknownOutcome(diagnostics: readonly string[]): boolean {
- return diagnostics.some((diagnostic) =>
- /outcome must be reconciled before retry/i.test(diagnostic)
- );
-}
-
-function isDefinitiveOpenCodePreLaunchFailure(
- result: TeamRuntimeLaunchResult,
- memberName: string
-): boolean {
- const member = result.members[memberName];
- if (!member) {
- return false;
- }
- const hardFailed = member.launchState === 'failed_to_start' || member.hardFailure === true;
- if (!hardFailed) {
- return false;
- }
- const runtimeMaterialized =
- member.agentToolAccepted ||
- member.runtimeAlive ||
- member.bootstrapConfirmed ||
- (typeof member.sessionId === 'string' && member.sessionId.trim().length > 0) ||
- (typeof member.runtimePid === 'number' &&
- Number.isFinite(member.runtimePid) &&
- member.runtimePid > 0);
- if (runtimeMaterialized) {
- return false;
- }
- return !isReconciliableOpenCodeUnknownOutcome(
- collectRuntimeLaunchFailureDiagnostics(result, memberName)
- );
-}
-
-const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC =
- 'opencode_bootstrap_pending_after_materialized_session';
-const OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC =
- 'OpenCode app-managed bootstrap evidence is pending after materialized session.';
-
-function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean {
- if (typeof sessionId !== 'string') {
- return false;
- }
- const trimmed = sessionId.trim();
- return trimmed.length > 0 && !trimmed.startsWith('failed:');
-}
-
-function hasMaterializedOpenCodeRuntimeForBootstrap(
- member: TeamRuntimeMemberLaunchEvidence | undefined
-): member is TeamRuntimeMemberLaunchEvidence {
- if (!member) {
- return false;
- }
- if (isMaterializedOpenCodeSessionId(member.sessionId)) {
- return true;
- }
- return (
- hasOpenCodeRuntimeLivenessMarker(member) &&
- typeof member.runtimePid === 'number' &&
- Number.isFinite(member.runtimePid) &&
- member.runtimePid > 0
- );
-}
-
-function hasRecoverableOpenCodeBootstrapDiagnostic(diagnostics: readonly string[]): boolean {
- const text = diagnostics.join('\n').toLowerCase();
- if (!text) {
- return false;
- }
- if (hasRealOpenCodeFailureDiagnostic(text)) {
- return false;
- }
- return (
- text.includes('runtime_bootstrap_checkin') ||
- text.includes('member_briefing') ||
- text.includes('bootstrap mcp') ||
- text.includes('member_session_recorded') ||
- text.includes('not connected') ||
- text.includes('mcp not connected') ||
- text.includes('member_launch_reconcile_pending') ||
- text.includes('member_launch_preview_timeout')
- );
-}
-
-function isRecoverableOpenCodeBootstrapPendingLaunchResult(
- result: TeamRuntimeLaunchResult,
- memberName: string
-): boolean {
- const member = result.members[memberName];
- if (!hasMaterializedOpenCodeRuntimeForBootstrap(member)) {
- return false;
- }
- if (member.bootstrapConfirmed || member.launchState === 'confirmed_alive') {
- return false;
- }
- if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) {
- return false;
- }
- return hasRecoverableOpenCodeBootstrapDiagnostic(
- collectRuntimeLaunchFailureDiagnostics(result, memberName)
- );
-}
-
-function normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(
- result: TeamRuntimeLaunchResult,
- memberName: string,
- diagnostics: readonly string[]
-): TeamRuntimeLaunchResult {
- const member = result.members[memberName];
- if (!member) {
- return result;
- }
- const memberDiagnostics = Array.from(
- new Set([
- ...(member.diagnostics ?? []),
- OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC,
- isExplicitLegacyOpenCodeBootstrap(member)
- ? 'OpenCode runtime session materialized; waiting for runtime_bootstrap_checkin.'
- : OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC,
- ...diagnostics,
- ])
- );
- const normalizedMember: TeamRuntimeMemberLaunchEvidence = {
- ...member,
- launchState: 'runtime_pending_bootstrap',
- agentToolAccepted: true,
- runtimeAlive: true,
- bootstrapConfirmed: false,
- hardFailure: false,
- hardFailureReason: undefined,
- pendingPermissionRequestIds: undefined,
- livenessKind:
- member.livenessKind === 'confirmed_bootstrap'
- ? 'runtime_process'
- : (member.livenessKind ?? 'runtime_process'),
- runtimeDiagnostic:
- member.runtimeDiagnostic ??
- 'OpenCode runtime process detected; waiting for bootstrap check-in.',
- runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'info',
- diagnostics: memberDiagnostics,
- };
- const members = {
- ...result.members,
- [memberName]: normalizedMember,
- };
- const teamLaunchState = summarizeRuntimeLaunchResultMembers(members);
- return {
- ...result,
- launchPhase: teamLaunchState === 'clean_success' ? result.launchPhase : 'active',
- teamLaunchState,
- members,
- diagnostics: Array.from(new Set([...result.diagnostics, ...memberDiagnostics])),
- };
-}
-
-const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC =
- 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.';
-
-function buildOpenCodeUncommittedBootstrapDiagnostic(storage: {
- manifestEntryCount: number | null;
- manifestUpdatedAt: string | null;
- fileNames: string[];
-}): string[] {
- return [
- OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC,
- `OpenCode lane manifest entries: ${storage.manifestEntryCount ?? 0}`,
- ...(storage.manifestUpdatedAt
- ? [`OpenCode lane manifest updated at: ${storage.manifestUpdatedAt}`]
- : []),
- storage.fileNames.length > 0
- ? `OpenCode lane files: ${storage.fileNames.slice(0, 8).join(', ')}`
- : 'OpenCode lane files: none',
- ];
-}
-
-function downgradeUncommittedOpenCodeBootstrapEvidence(
- evidence: TeamRuntimeMemberLaunchEvidence,
- diagnostics: readonly string[]
-): TeamRuntimeMemberLaunchEvidence {
- const hasRuntimeHandle = hasOpenCodeRuntimeHandle(evidence);
- return {
- ...evidence,
- launchState: hasRuntimeHandle ? 'runtime_pending_bootstrap' : 'starting',
- agentToolAccepted: hasRuntimeHandle,
- runtimeAlive: false,
- bootstrapConfirmed: false,
- hardFailure: false,
- hardFailureReason: undefined,
- livenessKind: hasRuntimeHandle
- ? evidence.livenessKind === 'confirmed_bootstrap'
- ? 'runtime_process_candidate'
- : (evidence.livenessKind ?? 'runtime_process_candidate')
- : 'registered_only',
- runtimeDiagnostic: hasRuntimeHandle
- ? 'OpenCode runtime handle is present, but bootstrap evidence was not committed.'
- : 'OpenCode bootstrap confirmation was not committed to lane runtime evidence.',
- runtimeDiagnosticSeverity: 'warning',
- diagnostics: Array.from(new Set([...evidence.diagnostics, ...diagnostics])),
- };
-}
-
-function promoteCommittedOpenCodeAppManagedBootstrapEvidence(
- evidence: TeamRuntimeMemberLaunchEvidence
-): TeamRuntimeMemberLaunchEvidence {
- return {
- ...evidence,
- launchState: 'confirmed_alive',
- agentToolAccepted: true,
- runtimeAlive: true,
- bootstrapConfirmed: true,
- hardFailure: false,
- hardFailureReason: undefined,
- livenessKind: 'confirmed_bootstrap',
- runtimeDiagnostic:
- 'OpenCode app-managed bootstrap evidence was committed and read back by the desktop app.',
- runtimeDiagnosticSeverity: 'info',
- diagnostics: appendDiagnosticOnce(
- evidence.diagnostics,
- 'OpenCode app-managed bootstrap evidence committed and read back.'
- ),
- };
-}
-
-function summarizeRuntimeLaunchResultMembers(
- members: Record
-): TeamLaunchAggregateState {
- const values = Object.values(members);
- if (
- values.some((member) => member.launchState === 'failed_to_start' || member.hardFailure === true)
- ) {
- return 'partial_failure';
- }
- if (values.length > 0 && values.every((member) => member.launchState === 'confirmed_alive')) {
- return 'clean_success';
- }
- return 'partial_pending';
-}
-
-function hasOpenCodeRuntimeHandle(
- value:
- | Pick
- | Pick
- | undefined
-): boolean {
- if (!value) {
- return false;
- }
- const runtimePid =
- typeof value.runtimePid === 'number' &&
- Number.isFinite(value.runtimePid) &&
- value.runtimePid > 0;
- const runtimeSessionId = (value as { runtimeSessionId?: unknown }).runtimeSessionId;
- const runtimeEvidenceSessionId = (value as { sessionId?: unknown }).sessionId;
- const sessionId =
- (typeof runtimeSessionId === 'string' && runtimeSessionId.trim().length > 0) ||
- (typeof runtimeEvidenceSessionId === 'string' && runtimeEvidenceSessionId.trim().length > 0);
- return runtimePid || sessionId;
-}
-
-function hasOpenCodeRuntimeLivenessMarker(
- value: Pick | undefined
-): boolean {
- return (
- value?.livenessKind === 'runtime_process' ||
- value?.livenessKind === 'runtime_process_candidate' ||
- value?.livenessKind === 'permission_blocked'
- );
-}
-
-function hasOpenCodeRuntimeEntryHandle(
- value:
- | Pick
- | undefined
- | null
-): boolean {
- if (!value) {
- return false;
- }
- const pid = typeof value.pid === 'number' && Number.isFinite(value.pid) && value.pid > 0;
- const runtimePid =
- typeof value.runtimePid === 'number' &&
- Number.isFinite(value.runtimePid) &&
- value.runtimePid > 0;
- const runtimeSessionId =
- typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0;
- return pid || runtimePid || runtimeSessionId || hasOpenCodeRuntimeLivenessMarker(value);
-}
-
-function isRecoverablePersistedOpenCodeRuntimeCandidate(
- member: PersistedTeamLaunchMemberState | undefined | null
-): boolean {
- if (!member || member.skippedForLaunch) {
- return false;
- }
- if (
- member.providerId !== 'opencode' ||
- member.laneKind !== 'secondary' ||
- member.laneOwnerProviderId !== 'opencode' ||
- typeof member.laneId !== 'string' ||
- member.laneId.trim().length === 0
- ) {
- return false;
- }
- const hasPendingPermission = (member.pendingPermissionRequestIds?.length ?? 0) > 0;
- return (
- member.agentToolAccepted === true && (hasOpenCodeRuntimeHandle(member) || hasPendingPermission)
- );
-}
-
-function isPersistedOpenCodeSecondaryLaneMember(
- member: PersistedTeamLaunchMemberState | undefined | null
-): boolean {
- return (
- member?.providerId === 'opencode' &&
- member.laneKind === 'secondary' &&
- member.laneOwnerProviderId === 'opencode' &&
- typeof member.laneId === 'string' &&
- member.laneId.trim().length > 0
- );
-}
-
const OPENCODE_BOOTSTRAP_CHECKIN_RETRY_SENT_PREFIX = 'opencode_bootstrap_checkin_retry_prompt_sent';
-const OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC =
- 'OpenCode app-managed bootstrap evidence did not commit within 5 min.';
function getOpenCodeBootstrapCheckinRetryMarker(runId: string, runtimeSessionId: string): string {
return `${OPENCODE_BOOTSTRAP_CHECKIN_RETRY_SENT_PREFIX}:${runId}:${runtimeSessionId}`;
}
-function isExplicitLegacyOpenCodeBootstrap(
- value:
- | {
- bootstrapMode?: 'model_tool_checkin' | 'app_managed_context';
- }
- | undefined
- | null
-): boolean {
- return value?.bootstrapMode === 'model_tool_checkin';
-}
-
function resolveOpenCodeSecondaryLaneMemberEvidence(
lane: MixedSecondaryRuntimeLaneState | undefined,
memberName: string
@@ -3117,348 +2249,6 @@ function resolveOpenCodeSecondaryLaneMemberEvidence(
);
}
-const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN =
- /\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i;
-
-function normalizeIsoTimestamp(value: unknown): string | null {
- if (typeof value !== 'string') {
- return null;
- }
- const trimmed = value.trim();
- if (!trimmed) {
- return null;
- }
- const parsed = Date.parse(trimmed);
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
-}
-
-function selectEarliestIsoTimestamp(values: readonly unknown[]): string | undefined {
- let selected: { value: string; timeMs: number } | null = null;
- for (const value of values) {
- const normalized = normalizeIsoTimestamp(value);
- if (!normalized) {
- continue;
- }
- const timeMs = Date.parse(normalized);
- if (!selected || timeMs < selected.timeMs) {
- selected = { value: normalized, timeMs };
- }
- }
- return selected?.value;
-}
-
-function extractOpenCodeMemberSessionRecordedAt(
- diagnostics: readonly string[] | undefined
-): string[] {
- return (diagnostics ?? []).flatMap((diagnostic) => {
- const match = OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN.exec(diagnostic);
- return match?.[1] ? [match[1]] : [];
- });
-}
-
-function resolveOpenCodeBootstrapAcceptedAt(
- member: Pick
-): string | undefined {
- return selectEarliestIsoTimestamp([
- member.firstSpawnAcceptedAt,
- ...extractOpenCodeMemberSessionRecordedAt(member.diagnostics),
- ]);
-}
-
-function hasOpenCodeSecondaryFatalBootstrapDiagnostic(
- member: Pick<
- PersistedTeamLaunchMemberState,
- 'diagnostics' | 'runtimeDiagnostic' | 'hardFailureReason'
- >
-): boolean {
- const text = [member.runtimeDiagnostic, member.hardFailureReason, ...(member.diagnostics ?? [])]
- .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
- .join('\n')
- .toLowerCase();
- return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text);
-}
-
-function selectOpenCodeSecondaryBootstrapStallDiagnostic(
- values: readonly unknown[]
-): string | null {
- const normalizedValues = values
- .filter((value): value is string => typeof value === 'string')
- .map((value) => normalizeOpenCodePersistedFailureReason(value))
- .filter((value): value is string => typeof value === 'string' && value.length > 0);
-
- const runtimeCheckinDiagnostic = normalizedValues.find((value) =>
- value.toLowerCase().includes('runtime_bootstrap_checkin')
- );
- if (runtimeCheckinDiagnostic) {
- return runtimeCheckinDiagnostic;
- }
-
- const memberBriefingDiagnostic = normalizedValues.find((value) =>
- value.toLowerCase().includes('member_briefing')
- );
- if (memberBriefingDiagnostic) {
- return `${memberBriefingDiagnostic}; runtime_bootstrap_checkin did not complete after 5 min.`;
- }
-
- return null;
-}
-
-function getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted(
- member: PersistedTeamLaunchMemberState
-): string {
- if (!isExplicitLegacyOpenCodeBootstrap(member)) {
- return OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC;
- }
-
- const selected = selectOpenCodeSecondaryBootstrapStallDiagnostic([
- member.runtimeDiagnostic,
- ...(member.diagnostics ?? []),
- member.hardFailureReason,
- ]);
- if (selected) {
- return selected;
- }
-
- return 'OpenCode bootstrap did not complete runtime_bootstrap_checkin after 5 min.';
-}
-
-function shouldMarkPersistedOpenCodeBootstrapStalled(
- member: PersistedTeamLaunchMemberState,
- nowMs: number
-): boolean {
- if (!isPersistedOpenCodeSecondaryLaneMember(member)) {
- return false;
- }
- if (
- member.launchState !== 'runtime_pending_bootstrap' ||
- member.bootstrapConfirmed === true ||
- member.hardFailure === true ||
- member.skippedForLaunch === true ||
- (member.pendingPermissionRequestIds?.length ?? 0) > 0
- ) {
- return false;
- }
- if (hasOpenCodeSecondaryFatalBootstrapDiagnostic(member)) {
- return false;
- }
- const acceptedAt = resolveOpenCodeBootstrapAcceptedAt(member);
- const acceptedAtMs = acceptedAt ? Date.parse(acceptedAt) : NaN;
- if (!Number.isFinite(acceptedAtMs) || nowMs - acceptedAtMs < MEMBER_BOOTSTRAP_STALL_MS) {
- return false;
- }
- return (
- hasOpenCodeRuntimeHandle(member) ||
- hasOpenCodeRuntimeLivenessMarker(member) ||
- hasRecoverableOpenCodeBootstrapDiagnostic(
- [member.runtimeDiagnostic, ...(member.diagnostics ?? [])].filter(
- (value): value is string => typeof value === 'string'
- )
- )
- );
-}
-
-function namesMatchCaseInsensitive(left: string, right: string): boolean {
- return left.trim().toLowerCase() === right.trim().toLowerCase();
-}
-
-function isOpenCodeOverlayMemberRemoved(
- metaMembers: readonly { name?: string; removedAt?: unknown }[],
- memberName: string
-): boolean {
- return metaMembers.some(
- (member) =>
- typeof member.name === 'string' &&
- namesMatchCaseInsensitive(member.name, memberName) &&
- member.removedAt != null
- );
-}
-
-function hasStaleOpenCodeSecondaryLaunchDiagnostic(
- member: PersistedTeamLaunchMemberState
-): boolean {
- return hasStaleOpenCodeDiagnostics(getOpenCodeLaunchDiagnosticValues(member));
-}
-
-function hasRealOpenCodeLaunchDiagnostic(member: PersistedTeamLaunchMemberState): boolean {
- const text = getOpenCodeLaunchDiagnosticValues(member)
- .filter((value): value is string => typeof value === 'string')
- .join('\n')
- .toLowerCase();
- return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text);
-}
-
-function getOpenCodeLaunchDiagnosticValues(
- member: PersistedTeamLaunchMemberState
-): readonly unknown[] {
- return [member.hardFailureReason, member.runtimeDiagnostic, ...(member.diagnostics ?? [])];
-}
-
-function hasStaleOpenCodeDiagnostics(values: readonly unknown[] | undefined): boolean {
- const text = (values ?? [])
- .filter((value): value is string => typeof value === 'string')
- .join('\n')
- .toLowerCase();
- if (!text) {
- return false;
- }
- if (hasRealOpenCodeFailureDiagnostic(text)) {
- return false;
- }
- return (
- text.includes('no lane runtime evidence') ||
- text.includes('no runtime evidence') ||
- text.includes('runtime evidence was not committed') ||
- text.includes('no lane runtime evidence was committed') ||
- text.includes('registered runtime metadata without live process') ||
- text.includes('member has persisted runtime metadata only') ||
- text.includes('opencode bridge reported member launch failure') ||
- text.includes('file lock timeout') ||
- text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase())
- );
-}
-
-function isFileLockTimeoutError(error: unknown): boolean {
- const message = error instanceof Error ? error.message : String(error);
- return message.toLowerCase().includes('file lock timeout');
-}
-
-function hasRealOpenCodeFailureDiagnostic(text: string): boolean {
- return (
- /\bauth(?:entication|orization)?\b/.test(text) ||
- text.includes('api key') ||
- text.includes('unauthorized') ||
- text.includes('forbidden') ||
- text.includes('invalid_request') ||
- text.includes('model not found') ||
- text.includes('not found in live opencode catalog') ||
- text.includes('provider unavailable') ||
- text.includes('quota') ||
- text.includes('credits') ||
- text.includes('max_tokens') ||
- text.includes('rate limit') ||
- text.includes('member removed') ||
- text.includes('session conflict') ||
- text.includes('run tombstoned') ||
- text.includes('stop requested') ||
- text.includes('relaunch started')
- );
-}
-
-const OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON =
- 'OpenCode bridge reported member launch failure';
-const OPEN_CODE_SECRET_FLAG_PATTERN =
- /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
-const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Z0-9._~+/=-]+/gi;
-const OPEN_CODE_SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
-const OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS = 12_000;
-
-function normalizeOpenCodePersistedFailureReason(value: string | undefined): string | undefined {
- const trimmed = value?.replace(/\s+/g, ' ').trim();
- if (!trimmed) {
- return undefined;
- }
- return trimmed
- .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]')
- .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]')
- .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]');
-}
-
-function redactOpenCodeAppManagedContextText(value: string): string {
- return value
- .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]')
- .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]')
- .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]');
-}
-
-function boundOpenCodeAppManagedBriefingText(value: string): string {
- const normalized = redactOpenCodeAppManagedContextText(value.replace(/\r\n/g, '\n')).trim();
- if (normalized.length <= OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS) {
- return normalized;
- }
- return `${normalized.slice(0, OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS)}\n[truncated app-managed briefing]`;
-}
-
-function isGenericOpenCodePersistedFailureReason(value: string | undefined): boolean {
- const normalized = normalizeOpenCodePersistedFailureReason(value);
- return (
- normalized === OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON ||
- normalized?.startsWith(`${OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON}:`) === true ||
- normalized?.startsWith('OpenCode secondary lane timing:') === true ||
- normalized?.startsWith(
- 'OpenCode bridge reported ready without all required durable checkpoints:'
- ) === true ||
- normalized?.startsWith(
- 'OpenCode bridge reported ready before all expected members were confirmed:'
- ) === true ||
- normalized?.startsWith(
- 'OpenCode bootstrap MCP did not complete required tools before assistant response:'
- ) === true ||
- normalized?.startsWith('info:opencode_launch_member_timing:') === true ||
- normalized?.startsWith('info:opencode_launch_total_timing:') === true
- );
-}
-
-function selectOpenCodePersistedFailureReasonFromDiagnostics(
- member: PersistedTeamLaunchMemberState
-): string | undefined {
- if (!isPersistedOpenCodeSecondaryLaneMember(member)) {
- return undefined;
- }
- if (member.launchState !== 'failed_to_start' || member.hardFailure !== true) {
- return undefined;
- }
- if (!isGenericOpenCodePersistedFailureReason(member.hardFailureReason)) {
- return undefined;
- }
- for (const value of member.diagnostics ?? []) {
- const normalized = normalizeOpenCodePersistedFailureReason(value);
- if (!normalized || isGenericOpenCodePersistedFailureReason(normalized)) {
- continue;
- }
- return normalized;
- }
- return undefined;
-}
-
-function promoteOpenCodePersistedFailureReasonsFromDiagnostics(
- snapshot: PersistedTeamLaunchSnapshot | null
-): PersistedTeamLaunchSnapshot | null {
- if (!snapshot) {
- return null;
- }
- let changed = false;
- const members: Record = { ...snapshot.members };
- for (const [memberName, member] of Object.entries(snapshot.members)) {
- const promotedReason = selectOpenCodePersistedFailureReasonFromDiagnostics(member);
- if (!promotedReason || promotedReason === member.hardFailureReason) {
- continue;
- }
- members[memberName] = {
- ...member,
- hardFailureReason: promotedReason,
- runtimeDiagnostic:
- member.runtimeDiagnostic &&
- !isGenericOpenCodePersistedFailureReason(member.runtimeDiagnostic)
- ? member.runtimeDiagnostic
- : promotedReason,
- runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'error',
- };
- changed = true;
- }
- if (!changed) {
- return snapshot;
- }
- return createPersistedLaunchSnapshot({
- teamName: snapshot.teamName,
- expectedMembers: snapshot.expectedMembers,
- bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers,
- leadSessionId: snapshot.leadSessionId,
- launchPhase: snapshot.launchPhase,
- members,
- updatedAt: nowIso(),
- });
-}
-
function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
current: PersistedTeamLaunchMemberState;
previous: PersistedTeamLaunchMemberState | null;
@@ -3522,155 +2312,6 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: {
};
}
-function filterStaleOpenCodeOverlayDiagnostics(values: readonly string[] | undefined): string[] {
- return (values ?? []).filter((value) => !hasStaleOpenCodeDiagnostics([value]));
-}
-
-function isRecoverablePersistedOpenCodeTerminalRuntimeCandidate(
- member: PersistedTeamLaunchMemberState | undefined | null
-): boolean {
- return (
- isRecoverablePersistedOpenCodeRuntimeCandidate(member) &&
- member?.launchState === 'failed_to_start' &&
- member.hardFailure === true &&
- hasOpenCodeRuntimeHandle(member)
- );
-}
-
-function isRecoverableOpenCodeRuntimeEvidence(
- evidence: TeamRuntimeMemberLaunchEvidence | undefined | null
-): evidence is TeamRuntimeMemberLaunchEvidence {
- if (!evidence) {
- return false;
- }
- return (
- evidence.runtimeAlive === true ||
- evidence.bootstrapConfirmed === true ||
- (evidence.pendingPermissionRequestIds?.length ?? 0) > 0 ||
- hasOpenCodeRuntimeHandle(evidence) ||
- (evidence.agentToolAccepted === true && hasOpenCodeRuntimeLivenessMarker(evidence))
- );
-}
-
-function isLaunchGraceWindowFailureReason(reason?: string): boolean {
- return reason?.trim() === 'Teammate did not join within the launch grace window.';
-}
-
-function isConfigRegistrationFailureReason(reason?: string): boolean {
- return (
- reason?.trim() ===
- 'Teammate was not registered in config.json during launch. Persistent spawn failed.'
- );
-}
-
-function isOpenCodeBridgeLaunchFailureReason(reason?: string): boolean {
- return reason?.trim() === 'OpenCode bridge reported member launch failure';
-}
-
-function isRegisteredRuntimeMetadataFailureReason(reason?: string): boolean {
- return reason?.trim() === 'registered runtime metadata without live process';
-}
-
-function isProcessTableUnavailableFailureReason(reason?: string): boolean {
- const text = reason?.trim();
- if (!text || !mentionsProcessTableUnavailable(text)) {
- return false;
- }
- return (
- /^process table (?:is )?unavailable$/i.test(text) ||
- /^runtime pid could not be verified because process table (?:is )?unavailable$/i.test(text)
- );
-}
-
-function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null {
- const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim());
- const baseReason = match?.[1]?.trim();
- return baseReason && baseReason.length > 0 ? baseReason : null;
-}
-
-function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean {
- return (
- isNeverSpawnedDuringLaunchReason(reason) ||
- isLaunchGraceWindowFailureReason(reason) ||
- isConfigRegistrationFailureReason(reason) ||
- isRegisteredRuntimeMetadataFailureReason(reason) ||
- isOpenCodeBridgeLaunchFailureReason(reason) ||
- isBootstrapMcpResourceReadFailureReason(reason) ||
- isBootstrapCheckInTimeoutFailureReason(reason) ||
- isBootstrapInstructionPromptFailureReason(reason) ||
- isLaunchCleanupBootstrapIncompleteFailureReason(reason)
- );
-}
-
-function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean {
- const text = reason?.trim().toLowerCase() ?? '';
- return (
- text.includes('resources/read failed') &&
- text.includes('member_briefing') &&
- (text.includes('method not found') || text.includes('mcp error'))
- );
-}
-
-function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean {
- return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.';
-}
-
-function isBootstrapInstructionPromptFailureReason(reason?: string): boolean {
- return typeof reason === 'string' && isBootstrapInstructionPrompt(reason);
-}
-
-function isLaunchCleanupBootstrapIncompleteFailureReason(reason?: string): boolean {
- const text = reason?.trim();
- if (!text) {
- return false;
- }
- if (
- text === 'Launch ended before teammate bootstrap completed.' ||
- text === 'Deterministic bootstrap failed before teammate check-in.'
- ) {
- return true;
- }
- return (
- text.startsWith('Launch ended before teammate bootstrap completed. ') &&
- text.includes('Runtime process was alive after bootstrap failure')
- );
-}
-
-function isBootstrapMemberEvidenceCurrentForMember(
- current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string },
- bootstrapMember: Pick<
- PersistedTeamLaunchMemberState,
- 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'lastRuntimeAliveAt' | 'lastEvaluatedAt'
- >,
- evidenceKind: 'acceptance' | 'confirmation'
-): boolean {
- const bootstrapFirstSpawnAcceptedMs = Date.parse(bootstrapMember.firstSpawnAcceptedAt ?? '');
- const bootstrapLastEvaluatedMs = Date.parse(bootstrapMember.lastEvaluatedAt ?? '');
- const hasDurableBootstrapSpawnAcceptedAt =
- Number.isFinite(bootstrapFirstSpawnAcceptedMs) &&
- (!Number.isFinite(bootstrapLastEvaluatedMs) ||
- bootstrapFirstSpawnAcceptedMs <= bootstrapLastEvaluatedMs);
- const evidenceAt =
- evidenceKind === 'confirmation'
- ? (bootstrapMember.lastHeartbeatAt ??
- bootstrapMember.lastRuntimeAliveAt ??
- bootstrapMember.lastEvaluatedAt)
- : hasDurableBootstrapSpawnAcceptedAt
- ? bootstrapMember.firstSpawnAcceptedAt
- : bootstrapMember.lastEvaluatedAt;
- const evidenceMs = Date.parse(evidenceAt ?? '');
- if (!Number.isFinite(evidenceMs)) {
- return false;
- }
- const firstSpawnAcceptedMs = Date.parse(current.firstSpawnAcceptedAt ?? '');
- const lastEvaluatedMs = Date.parse(current.lastEvaluatedAt ?? '');
- const hasDurableSpawnBoundary =
- Number.isFinite(firstSpawnAcceptedMs) &&
- (!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs);
- const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
- return !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs;
-}
-
function isTmuxNoServerRunningError(error: unknown): boolean {
const text = error instanceof Error ? error.message : String(error ?? '');
return (
@@ -3679,109 +2320,6 @@ function isTmuxNoServerRunningError(error: unknown): boolean {
);
}
-function isAutoClearableLaunchFailureReason(reason?: string): boolean {
- const text = reason?.trim();
- if (!text) {
- return false;
- }
- const baseReason = stripProcessTableUnavailableDiagnosticSuffix(text);
- return (
- isBaseAutoClearableLaunchFailureReason(text) ||
- isProcessTableUnavailableFailureReason(text) ||
- (baseReason != null && isBaseAutoClearableLaunchFailureReason(baseReason))
- );
-}
-
-function summarizeMemberSpawnStatusRecord(
- expectedMembers: readonly string[],
- statuses: Record
-): PersistedTeamLaunchSummary {
- let confirmedCount = 0;
- let pendingCount = 0;
- let failedCount = 0;
- let skippedCount = 0;
- let runtimeAlivePendingCount = 0;
- let shellOnlyPendingCount = 0;
- let runtimeProcessPendingCount = 0;
- let runtimeCandidatePendingCount = 0;
- let noRuntimePendingCount = 0;
- let permissionPendingCount = 0;
- const memberNames = Array.from(new Set([...expectedMembers, ...Object.keys(statuses)]));
-
- for (const memberName of memberNames) {
- const entry = statuses[memberName];
- if (!entry) {
- pendingCount += 1;
- continue;
- }
- if (entry.launchState === 'confirmed_alive') {
- confirmedCount += 1;
- continue;
- }
- if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
- skippedCount += 1;
- continue;
- }
- if (entry.launchState === 'failed_to_start') {
- failedCount += 1;
- continue;
- }
- pendingCount += 1;
- if (entry.runtimeAlive) {
- runtimeAlivePendingCount += 1;
- }
- if (entry.launchState === 'runtime_pending_permission') {
- permissionPendingCount += 1;
- }
- if (entry.livenessKind === 'shell_only') {
- shellOnlyPendingCount += 1;
- } else if (entry.livenessKind === 'runtime_process') {
- runtimeProcessPendingCount += 1;
- } else if (entry.livenessKind === 'runtime_process_candidate') {
- runtimeCandidatePendingCount += 1;
- } else if (
- entry.livenessKind === 'not_found' ||
- entry.livenessKind === 'stale_metadata' ||
- entry.livenessKind === 'registered_only'
- ) {
- noRuntimePendingCount += 1;
- }
- }
-
- return {
- confirmedCount,
- pendingCount,
- failedCount,
- skippedCount,
- runtimeAlivePendingCount,
- shellOnlyPendingCount,
- runtimeProcessPendingCount,
- runtimeCandidatePendingCount,
- noRuntimePendingCount,
- permissionPendingCount,
- };
-}
-
-function buildRestartStillRunningReason(memberName: string): string {
- return (
- `Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
- `to be active. The requested settings may not have been applied.`
- );
-}
-
-function buildRestartDuplicateUnconfirmedReason(memberName: string, rawReason?: string): string {
- const suffix = rawReason?.trim()
- ? ` Agent returned duplicate_skipped with unrecognized reason "${rawReason.trim()}".`
- : ' Agent returned duplicate_skipped without a reason.';
- return (
- `Restart for teammate "${memberName}" could not be confirmed and may not have applied.` + suffix
- );
-}
-
-function buildRestartGraceTimeoutReason(memberName: string): string {
- return `Teammate "${memberName}" did not rejoin within the restart grace window.`;
-}
-
interface PendingMemberRestartContext {
requestedAt: string;
desired: Pick<
@@ -3802,30 +2340,6 @@ function normalizeTeamAgentRuntimeBackendType(
return normalized ? 'process' : undefined;
}
-function matchesMemberNameOrBase(candidateName: string, memberName: string): boolean {
- if (candidateName === memberName) {
- return true;
- }
- const parsed = parseNumericSuffixName(candidateName);
- return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName;
-}
-
-function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean {
- return (
- matchesMemberNameOrBase(leftName, rightName) || matchesMemberNameOrBase(rightName, leftName)
- );
-}
-
-function matchesObservedMemberNameForExpected(observedName: string, expectedName: string): boolean {
- return matchesMemberNameOrBase(observedName, expectedName);
-}
-
-function matchesExactTeamMemberName(candidateName: string, memberName: string): boolean {
- const left = candidateName.trim().toLowerCase();
- const right = memberName.trim().toLowerCase();
- return left.length > 0 && left === right;
-}
-
interface MemberSpawnInboxCursor {
timestamp: string;
messageId: string;
@@ -3952,32 +2466,6 @@ export function shouldAcceptDeterministicBootstrapEvent(params: {
return { accept: true, nextSeq: params.lastSeq };
}
-function deriveMemberLaunchState(entry: {
- agentToolAccepted?: boolean;
- runtimeAlive?: boolean;
- bootstrapConfirmed?: boolean;
- hardFailure?: boolean;
- skippedForLaunch?: boolean;
- pendingPermissionRequestIds?: string[];
-}): MemberLaunchState {
- if (entry.skippedForLaunch) {
- return 'skipped_for_launch';
- }
- if (entry.hardFailure) {
- return 'failed_to_start';
- }
- if (entry.bootstrapConfirmed) {
- return 'confirmed_alive';
- }
- if ((entry.pendingPermissionRequestIds?.length ?? 0) > 0) {
- return 'runtime_pending_permission';
- }
- if (entry.runtimeAlive || entry.agentToolAccepted) {
- return 'runtime_pending_bootstrap';
- }
- return 'starting';
-}
-
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -4263,202 +2751,6 @@ function updateProgress(
return run.progress;
}
-function buildLaunchDiagnosticsFromRun(
- run: ProvisioningRun
-): TeamLaunchDiagnosticItem[] | undefined {
- const memberSpawnStatuses = run.memberSpawnStatuses;
- if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) {
- return undefined;
- }
- const observedAt = nowIso();
- const items: TeamLaunchDiagnosticItem[] = [];
- for (const [memberName, entry] of memberSpawnStatuses.entries()) {
- if (entry.launchState === 'confirmed_alive') {
- items.push({
- id: `${memberName}:bootstrap_confirmed`,
- memberName,
- severity: 'info',
- code: 'bootstrap_confirmed',
- label: `${memberName} - bootstrap confirmed`,
- observedAt,
- });
- continue;
- }
- if (entry.launchState === 'failed_to_start') {
- items.push({
- id: `${memberName}:bootstrap_stalled`,
- memberName,
- severity: 'error',
- code: 'bootstrap_stalled',
- label: `${memberName} - failed to start`,
- detail: entry.hardFailureReason ?? entry.error,
- observedAt,
- });
- continue;
- }
- if (entry.launchState === 'runtime_pending_permission') {
- items.push({
- id: `${memberName}:permission_pending`,
- memberName,
- severity: 'warning',
- code: 'permission_pending',
- label: `${memberName} - awaiting permission`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- continue;
- }
- if (entry.bootstrapStalled === true) {
- items.push({
- id: `${memberName}:bootstrap_stalled`,
- memberName,
- severity: 'warning',
- code: 'bootstrap_stalled',
- label: `${memberName} - bootstrap stalled`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- continue;
- }
- if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) {
- items.push({
- id: `${memberName}:process_table_unavailable`,
- memberName,
- severity: 'warning',
- code: 'process_table_unavailable',
- label: `${memberName} - process table unavailable`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- continue;
- }
- if (entry.livenessKind === 'shell_only') {
- items.push({
- id: `${memberName}:tmux_shell_only`,
- memberName,
- severity: 'warning',
- code: 'tmux_shell_only',
- label: `${memberName} - shell only`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- continue;
- }
- if (entry.livenessKind === 'runtime_process_candidate') {
- items.push({
- id: `${memberName}:runtime_process_candidate`,
- memberName,
- severity: 'warning',
- code: 'runtime_process_candidate',
- label: `${memberName} - bootstrap unconfirmed`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- continue;
- }
- if (entry.livenessKind === 'runtime_process') {
- items.push({
- id: `${memberName}:runtime_process_detected`,
- memberName,
- severity: 'info',
- code: 'runtime_process_detected',
- label: `${memberName} - waiting for bootstrap`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- continue;
- }
- if (
- entry.livenessKind === 'registered_only' ||
- entry.livenessKind === 'stale_metadata' ||
- entry.livenessKind === 'not_found'
- ) {
- items.push({
- id: `${memberName}:runtime_not_found`,
- memberName,
- severity: 'warning',
- code: 'runtime_not_found',
- label: `${memberName} - waiting for runtime`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- continue;
- }
- if (entry.agentToolAccepted) {
- items.push({
- id: `${memberName}:spawn_accepted`,
- memberName,
- severity: 'info',
- code: 'spawn_accepted',
- label: `${memberName} - spawn accepted`,
- detail: entry.runtimeDiagnostic,
- observedAt,
- });
- }
- }
- return items.length > 0 ? items : undefined;
-}
-
-function buildWorkspaceTrustPreflightLaunchDiagnostic(
- execution: WorkspaceTrustExecutionResult
-): TeamLaunchDiagnosticItem | null {
- if (execution.status === 'cancelled') {
- return null;
- }
-
- const severity =
- execution.status === 'blocked'
- ? 'error'
- : execution.status === 'soft_failed'
- ? 'warning'
- : 'info';
- const label =
- execution.status === 'blocked'
- ? 'Workspace trust preflight blocked launch'
- : execution.status === 'soft_failed'
- ? 'Workspace trust preflight could not verify trust'
- : 'Workspace trust preflight completed';
- const detail =
- execution.errorMessage?.trim() ||
- execution.errorCode?.trim() ||
- execution.evidence?.find((item) => item.trim().length > 0)?.trim();
-
- return {
- id: 'workspace-trust:preflight',
- severity,
- code: 'workspace_trust_preflight',
- label,
- ...(detail ? { detail } : {}),
- observedAt: nowIso(),
- };
-}
-
-function mergeLaunchDiagnosticItem(
- items: readonly TeamLaunchDiagnosticItem[] | undefined,
- item: TeamLaunchDiagnosticItem
-): TeamLaunchDiagnosticItem[] {
- return [...(items ?? []).filter((candidate) => candidate.id !== item.id), item];
-}
-
-function buildCombinedLogs(
- stdoutBuffer: string | undefined,
- stderrBuffer: string | undefined
-): string {
- const stdoutTrimmed = (stdoutBuffer ?? '').trim();
- const stderrTrimmed = (stderrBuffer ?? '').trim();
-
- if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) {
- return '';
- }
- if (stdoutTrimmed.length > 0 && stderrTrimmed.length === 0) {
- return stdoutTrimmed;
- }
- if (stdoutTrimmed.length === 0 && stderrTrimmed.length > 0) {
- return stderrTrimmed;
- }
- return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n');
-}
-
interface AgentTeamsMcpConfigEntry {
command?: unknown;
args?: unknown;
@@ -4674,231 +2966,6 @@ function emitLogsProgress(run: ProvisioningRun): void {
run.onProgress(run.progress);
}
-type CliLogStream = 'stdout' | 'stderr' | 'unknown';
-
-interface CliLogLine {
- stream: CliLogStream;
- text: string;
-}
-
-interface CliExitFailurePresentation {
- message?: string;
- error: string;
-}
-
-const USER_FACING_CLI_NOISE_TEXT_PATTERN =
- /additionalContext|skill_flow|EXTREMELY_IMPORTANT|superpowers:using-superpowers|TodoWrite|Skill tool|Invoke Skill tool|Might any skill apply|relevant or requested skills BEFORE|hook_response|hook_started|hook_progress/i;
-
-const USER_FACING_STDOUT_ERROR_PATTERN =
- /\b(error|failed|failure|fatal|exception|traceback|uncaught|unauthorized|forbidden|quota|rate limit|not authenticated|invalid api key|token refresh failed|warning)\b|please run \/login/i;
-
-function parseCliLogLinesFromText(text: string): CliLogLine[] {
- const lines: CliLogLine[] = [];
- let currentStream: CliLogStream = 'unknown';
- for (const rawLine of text.split(/\r?\n/)) {
- const trimmed = rawLine.trim();
- if (!trimmed) {
- continue;
- }
- if (trimmed === '[stdout]') {
- currentStream = 'stdout';
- continue;
- }
- if (trimmed === '[stderr]') {
- currentStream = 'stderr';
- continue;
- }
- lines.push({ stream: currentStream, text: trimmed });
- }
- return lines;
-}
-
-function getCliLogLinesForUserFacingError(run: ProvisioningRun): CliLogLine[] {
- const lineHistory = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : [];
- const lines = lineHistory.length > 0 ? parseCliLogLinesFromText(lineHistory.join('\n')) : [];
- const combinedBufferLines = parseCliLogLinesFromText(
- buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer)
- );
-
- if (lines.length === 0) {
- return combinedBufferLines;
- }
-
- // `claudeLogLines` stores complete newline-delimited lines. Add raw ring-buffer
- // lines as a fallback only when they contain user-facing material that may be
- // sitting in a final partial stderr/stdout line at process close.
- const seen = new Set(lines.map((line) => `${line.stream}:${line.text}`));
- for (const line of combinedBufferLines) {
- const key = `${line.stream}:${line.text}`;
- if (!seen.has(key) && isPotentiallyUserFacingCliLine(line)) {
- lines.push(line);
- seen.add(key);
- }
- }
- return lines;
-}
-
-function isNoiseCliLine(text: string): boolean {
- return USER_FACING_CLI_NOISE_TEXT_PATTERN.test(text);
-}
-
-function isPotentiallyUserFacingCliLine(line: CliLogLine): boolean {
- if (isNoiseCliLine(line.text)) {
- return false;
- }
- if (line.stream === 'stderr') {
- return true;
- }
- return USER_FACING_STDOUT_ERROR_PATTERN.test(line.text);
-}
-
-function extractStringField(value: unknown, key: string): string | undefined {
- if (!value || typeof value !== 'object') {
- return undefined;
- }
- const raw = (value as Record)[key];
- return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined;
-}
-
-function extractStructuredCliError(parsed: Record): string | undefined {
- const type = typeof parsed.type === 'string' ? parsed.type : undefined;
- const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : undefined;
-
- if (type === 'system') {
- if (subtype === 'team_bootstrap' && parsed.event === 'failed') {
- return extractStringField(parsed, 'reason');
- }
- if (subtype === 'init' || subtype?.startsWith('hook_')) {
- return undefined;
- }
- return undefined;
- }
-
- if (type === 'result') {
- const result = parsed.result;
- const resultSubtype = subtype ?? extractStringField(result, 'subtype');
- if (resultSubtype === 'success' || parsed.outcome === 'success') {
- return undefined;
- }
- if (resultSubtype === 'error' || resultSubtype?.startsWith('error_')) {
- return (
- extractStringField(parsed, 'error') ??
- extractStringField(result, 'error') ??
- extractStringField(parsed, 'result')
- );
- }
- return undefined;
- }
-
- if (type === 'error') {
- return extractStringField(parsed, 'error') ?? extractStringField(parsed, 'message');
- }
-
- return undefined;
-}
-
-function buildSanitizedCliExitError(run: ProvisioningRun): string | undefined {
- const errorLines: string[] = [];
- for (const line of getCliLogLinesForUserFacingError(run)) {
- if (!line.text || isNoiseCliLine(line.text)) {
- continue;
- }
- try {
- const parsed = JSON.parse(line.text) as Record;
- const structuredError = extractStructuredCliError(parsed);
- if (structuredError && !isNoiseCliLine(structuredError)) {
- errorLines.push(structuredError);
- }
- continue;
- } catch {
- // Non-JSON stderr/plain CLI errors are handled below.
- }
-
- if (isPotentiallyUserFacingCliLine(line)) {
- errorLines.push(line.text);
- }
- }
-
- const deduped = [...new Set(errorLines.map((line) => line.trim()).filter(Boolean))];
- if (deduped.length === 0) {
- return undefined;
- }
- return deduped.join('\n').slice(-4000);
-}
-
-function formatPendingBootstrapMemberNames(run: ProvisioningRun): string {
- const pending = run.expectedMembers.filter((name) => {
- const status = run.memberSpawnStatuses.get(name);
- return status?.bootstrapConfirmed !== true;
- });
- const names = pending.length > 0 ? pending : run.expectedMembers;
- if (names.length === 0) {
- return 'unknown';
- }
- const visible = names.slice(0, 6);
- const suffix = names.length > visible.length ? ` and ${names.length - visible.length} more` : '';
- return `${visible.join(', ')}${suffix}`;
-}
-
-function buildDeterministicBootstrapExitFailure(run: ProvisioningRun): CliExitFailurePresentation {
- if (!run.lastDeterministicBootstrapEvent) {
- return {
- message: 'Launch bootstrap was not confirmed',
- error:
- 'Codex runtime exited before deterministic team bootstrap started. No team_bootstrap event was received.',
- };
- }
-
- if (!run.deterministicBootstrapMemberSpawnSeen) {
- const lastStage = run.lastDeterministicBootstrapPhase
- ? `${run.lastDeterministicBootstrapEvent}/${run.lastDeterministicBootstrapPhase}`
- : run.lastDeterministicBootstrapEvent;
- return {
- message: 'Launch bootstrap was not confirmed',
- error: `Codex runtime exited during deterministic team bootstrap before teammate spawning started. Last bootstrap event: ${lastStage}.`,
- };
- }
-
- return {
- message: 'Launch bootstrap was not confirmed',
- error: `Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: ${formatPendingBootstrapMemberNames(run)}.`,
- };
-}
-
-function buildCliExitFailurePresentation(
- run: ProvisioningRun,
- code: number | null
-): CliExitFailurePresentation {
- const trimmed = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer).trim();
- const cliCommandLabel = getConfiguredCliCommandLabel();
- if (trimmed.length > 0) {
- if (trimmed.toLowerCase().includes('please run /login')) {
- return {
- error:
- `${cliCommandLabel} reports it is not authenticated ("Please run /login"). ` +
- 'Run the CLI in a normal terminal and complete login, then retry. ' +
- 'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.',
- };
- }
- const sanitized = buildSanitizedCliExitError(run);
- if (sanitized) {
- return { error: sanitized };
- }
- }
-
- if (run.deterministicBootstrap) {
- return buildDeterministicBootstrapExitFailure(run);
- }
-
- if (code === 1) {
- return {
- error: `${cliCommandLabel} exited with code 1 without user-facing stdout/stderr. Typical causes: missing auth/onboarding, interactive TTY requirements, or an early bootstrap/runtime crash. Check \`~/.claude/debug/latest\` for the real stack and retry.`,
- };
- }
-
- return { error: `${cliCommandLabel} exited with code ${code ?? 'unknown'}` };
-}
-
interface CachedProbeResult {
cacheKey: string;
claudePath: string;
@@ -21311,7 +19378,7 @@ export class TeamProvisioningService {
promptSize,
expectedMembersCount: effectiveMemberSpecs.length,
});
- logRuntimeLaunchSnapshot(request.teamName, claudePath, spawnArgs, request, shellEnv, {
+ logRuntimeLaunchSnapshot(logger, request.teamName, claudePath, spawnArgs, request, shellEnv, {
geminiRuntimeAuth,
promptSize,
expectedMembersCount: effectiveMemberSpecs.length,
@@ -22628,12 +20695,20 @@ export class TeamProvisioningService {
promptSize,
expectedMembersCount: effectiveMemberSpecs.length,
});
- logRuntimeLaunchSnapshot(request.teamName, claudePath, finalLaunchArgs, request, shellEnv, {
- geminiRuntimeAuth,
- promptSize,
- expectedMembersCount: effectiveMemberSpecs.length,
- launchIdentity,
- });
+ logRuntimeLaunchSnapshot(
+ logger,
+ request.teamName,
+ claudePath,
+ finalLaunchArgs,
+ request,
+ shellEnv,
+ {
+ geminiRuntimeAuth,
+ promptSize,
+ expectedMembersCount: effectiveMemberSpecs.length,
+ launchIdentity,
+ }
+ );
// Deterministic bootstrap launches fresh because --team-bootstrap-spec and
// --resume are not a supported orchestrator combination.
emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn');
@@ -35423,7 +33498,9 @@ export class TeamProvisioningService {
return;
}
- const failurePresentation = buildCliExitFailurePresentation(run, code);
+ const failurePresentation = buildCliExitFailurePresentation(run, code, {
+ cliCommandLabel: getConfiguredCliCommandLabel(),
+ });
const runtimeFailureLabel = getRunRuntimeFailureLabel(run);
const progress = updateProgress(
run,
diff --git a/src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts b/src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts
new file mode 100644
index 00000000..4a6c9ffa
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningCliExitPresentation.ts
@@ -0,0 +1,257 @@
+type CliLogStream = 'stdout' | 'stderr' | 'unknown';
+
+interface CliLogLine {
+ stream: CliLogStream;
+ text: string;
+}
+
+export interface CliExitFailurePresentation {
+ message?: string;
+ error: string;
+}
+
+export interface CliExitPresentationRun {
+ stdoutBuffer: string;
+ stderrBuffer: string;
+ claudeLogLines?: string[];
+ deterministicBootstrap: boolean;
+ lastDeterministicBootstrapEvent?: string;
+ lastDeterministicBootstrapPhase?: string;
+ deterministicBootstrapMemberSpawnSeen: boolean;
+ expectedMembers: string[];
+ memberSpawnStatuses: ReadonlyMap;
+}
+
+const USER_FACING_CLI_NOISE_TEXT_PATTERN =
+ /additionalContext|skill_flow|EXTREMELY_IMPORTANT|superpowers:using-superpowers|TodoWrite|Skill tool|Invoke Skill tool|Might any skill apply|relevant or requested skills BEFORE|hook_response|hook_started|hook_progress/i;
+
+const USER_FACING_STDOUT_ERROR_PATTERN =
+ /\b(error|failed|failure|fatal|exception|traceback|uncaught|unauthorized|forbidden|quota|rate limit|not authenticated|invalid api key|token refresh failed|warning)\b|please run \/login/i;
+
+export function buildCombinedLogs(
+ stdoutBuffer: string | undefined,
+ stderrBuffer: string | undefined
+): string {
+ const stdoutTrimmed = (stdoutBuffer ?? '').trim();
+ const stderrTrimmed = (stderrBuffer ?? '').trim();
+
+ if (stdoutTrimmed.length === 0 && stderrTrimmed.length === 0) {
+ return '';
+ }
+ if (stdoutTrimmed.length > 0 && stderrTrimmed.length === 0) {
+ return stdoutTrimmed;
+ }
+ if (stdoutTrimmed.length === 0 && stderrTrimmed.length > 0) {
+ return stderrTrimmed;
+ }
+ return [`[stdout]`, stdoutTrimmed, '', `[stderr]`, stderrTrimmed].join('\n');
+}
+
+export function parseCliLogLinesFromText(text: string): CliLogLine[] {
+ const lines: CliLogLine[] = [];
+ let currentStream: CliLogStream = 'unknown';
+ for (const rawLine of text.split(/\r?\n/)) {
+ const trimmed = rawLine.trim();
+ if (!trimmed) {
+ continue;
+ }
+ if (trimmed === '[stdout]') {
+ currentStream = 'stdout';
+ continue;
+ }
+ if (trimmed === '[stderr]') {
+ currentStream = 'stderr';
+ continue;
+ }
+ lines.push({ stream: currentStream, text: trimmed });
+ }
+ return lines;
+}
+
+function getCliLogLinesForUserFacingError(run: CliExitPresentationRun): CliLogLine[] {
+ const lineHistory = Array.isArray(run.claudeLogLines) ? run.claudeLogLines : [];
+ const lines = lineHistory.length > 0 ? parseCliLogLinesFromText(lineHistory.join('\n')) : [];
+ const combinedBufferLines = parseCliLogLinesFromText(
+ buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer)
+ );
+
+ if (lines.length === 0) {
+ return combinedBufferLines;
+ }
+
+ // claudeLogLines stores complete newline-delimited lines. Add raw ring-buffer
+ // lines as a fallback only when they contain user-facing material that may be
+ // sitting in a final partial stderr/stdout line at process close.
+ const seen = new Set(lines.map((line) => `${line.stream}:${line.text}`));
+ for (const line of combinedBufferLines) {
+ const key = `${line.stream}:${line.text}`;
+ if (!seen.has(key) && isPotentiallyUserFacingCliLine(line)) {
+ lines.push(line);
+ seen.add(key);
+ }
+ }
+ return lines;
+}
+
+function isNoiseCliLine(text: string): boolean {
+ return USER_FACING_CLI_NOISE_TEXT_PATTERN.test(text);
+}
+
+function isPotentiallyUserFacingCliLine(line: CliLogLine): boolean {
+ if (isNoiseCliLine(line.text)) {
+ return false;
+ }
+ if (line.stream === 'stderr') {
+ return true;
+ }
+ return USER_FACING_STDOUT_ERROR_PATTERN.test(line.text);
+}
+
+function extractStringField(value: unknown, key: string): string | undefined {
+ if (!value || typeof value !== 'object') {
+ return undefined;
+ }
+ const raw = (value as Record)[key];
+ return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : undefined;
+}
+
+function extractStructuredCliError(parsed: Record): string | undefined {
+ const type = typeof parsed.type === 'string' ? parsed.type : undefined;
+ const subtype = typeof parsed.subtype === 'string' ? parsed.subtype : undefined;
+
+ if (type === 'system') {
+ if (subtype === 'team_bootstrap' && parsed.event === 'failed') {
+ return extractStringField(parsed, 'reason');
+ }
+ if (subtype === 'init' || subtype?.startsWith('hook_')) {
+ return undefined;
+ }
+ return undefined;
+ }
+
+ if (type === 'result') {
+ const result = parsed.result;
+ const resultSubtype = subtype ?? extractStringField(result, 'subtype');
+ if (resultSubtype === 'success' || parsed.outcome === 'success') {
+ return undefined;
+ }
+ if (resultSubtype === 'error' || resultSubtype?.startsWith('error_')) {
+ return (
+ extractStringField(parsed, 'error') ??
+ extractStringField(result, 'error') ??
+ extractStringField(parsed, 'result')
+ );
+ }
+ return undefined;
+ }
+
+ if (type === 'error') {
+ return extractStringField(parsed, 'error') ?? extractStringField(parsed, 'message');
+ }
+
+ return undefined;
+}
+
+export function buildSanitizedCliExitError(run: CliExitPresentationRun): string | undefined {
+ const errorLines: string[] = [];
+ for (const line of getCliLogLinesForUserFacingError(run)) {
+ if (!line.text || isNoiseCliLine(line.text)) {
+ continue;
+ }
+ try {
+ const parsed = JSON.parse(line.text) as Record;
+ const structuredError = extractStructuredCliError(parsed);
+ if (structuredError && !isNoiseCliLine(structuredError)) {
+ errorLines.push(structuredError);
+ }
+ continue;
+ } catch {
+ // Non-JSON stderr/plain CLI errors are handled below.
+ }
+
+ if (isPotentiallyUserFacingCliLine(line)) {
+ errorLines.push(line.text);
+ }
+ }
+
+ const deduped = [...new Set(errorLines.map((line) => line.trim()).filter(Boolean))];
+ if (deduped.length === 0) {
+ return undefined;
+ }
+ return deduped.join('\n').slice(-4000);
+}
+
+export function formatPendingBootstrapMemberNames(run: CliExitPresentationRun): string {
+ const pending = run.expectedMembers.filter((name) => {
+ const status = run.memberSpawnStatuses.get(name);
+ return status?.bootstrapConfirmed !== true;
+ });
+ const names = pending.length > 0 ? pending : run.expectedMembers;
+ if (names.length === 0) {
+ return 'unknown';
+ }
+ const visible = names.slice(0, 6);
+ const suffix = names.length > visible.length ? ` and ${names.length - visible.length} more` : '';
+ return `${visible.join(', ')}${suffix}`;
+}
+
+export function buildDeterministicBootstrapExitFailure(
+ run: CliExitPresentationRun
+): CliExitFailurePresentation {
+ if (!run.lastDeterministicBootstrapEvent) {
+ return {
+ message: 'Launch bootstrap was not confirmed',
+ error:
+ 'Codex runtime exited before deterministic team bootstrap started. No team_bootstrap event was received.',
+ };
+ }
+
+ if (!run.deterministicBootstrapMemberSpawnSeen) {
+ const lastStage = run.lastDeterministicBootstrapPhase
+ ? `${run.lastDeterministicBootstrapEvent}/${run.lastDeterministicBootstrapPhase}`
+ : run.lastDeterministicBootstrapEvent;
+ return {
+ message: 'Launch bootstrap was not confirmed',
+ error: `Codex runtime exited during deterministic team bootstrap before teammate spawning started. Last bootstrap event: ${lastStage}.`,
+ };
+ }
+
+ return {
+ message: 'Launch bootstrap was not confirmed',
+ error: `Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: ${formatPendingBootstrapMemberNames(run)}.`,
+ };
+}
+
+export function buildCliExitFailurePresentation(
+ run: CliExitPresentationRun,
+ code: number | null,
+ options: { cliCommandLabel: string }
+): CliExitFailurePresentation {
+ const trimmed = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer).trim();
+ if (trimmed.length > 0) {
+ if (trimmed.toLowerCase().includes('please run /login')) {
+ return {
+ error:
+ `${options.cliCommandLabel} reports it is not authenticated ("Please run /login"). ` +
+ 'Run the CLI in a normal terminal and complete login, then retry. ' +
+ 'For automation/headless use, set `ANTHROPIC_API_KEY` for `-p` mode.',
+ };
+ }
+ const sanitized = buildSanitizedCliExitError(run);
+ if (sanitized) {
+ return { error: sanitized };
+ }
+ }
+
+ if (run.deterministicBootstrap) {
+ return buildDeterministicBootstrapExitFailure(run);
+ }
+
+ if (code === 1) {
+ return {
+ error: `${options.cliCommandLabel} exited with code 1 without user-facing stdout/stderr. Typical causes: missing auth/onboarding, interactive TTY requirements, or an early bootstrap/runtime crash. Check \`~/.claude/debug/latest\` for the real stack and retry.`,
+ };
+ }
+
+ return { error: `${options.cliCommandLabel} exited with code ${code ?? 'unknown'}` };
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts
new file mode 100644
index 00000000..51ab1e90
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningDirectRestart.ts
@@ -0,0 +1,188 @@
+import * as path from 'path';
+
+import {
+ ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS,
+ CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV,
+ CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER,
+ CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV,
+} from '../../runtime/anthropicTeamApiKeyHelper';
+
+import type { TeamProviderId } from '@shared/types';
+
+const DIRECT_TMUX_RESTART_ENV_KEYS = [
+ 'CLAUDE_CONFIG_DIR',
+ 'CLAUDE_TEAM_CONTROL_URL',
+ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
+ 'CLAUDE_CODE_USE_OPENAI',
+ 'CLAUDE_CODE_USE_BEDROCK',
+ 'CLAUDE_CODE_USE_VERTEX',
+ 'CLAUDE_CODE_USE_FOUNDRY',
+ 'CLAUDE_CODE_USE_GEMINI',
+ 'CLAUDE_CODE_ENTRY_PROVIDER',
+ 'CLAUDE_CODE_GEMINI_BACKEND',
+ 'CLAUDE_CODE_CODEX_BACKEND',
+ 'CODEX_HOME',
+ CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV,
+ CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV,
+ 'ANTHROPIC_BASE_URL',
+ 'ANTHROPIC_AWS_WORKSPACE_ID',
+ 'ANTHROPIC_AWS_API_KEY',
+ 'ANTHROPIC_API_KEY',
+ 'ANTHROPIC_AUTH_TOKEN',
+ 'GEMINI_BASE_URL',
+ 'GEMINI_API_VERSION',
+ 'GEMINI_API_KEY',
+ 'CODEX_API_KEY',
+ 'OPENAI_API_KEY',
+ 'GOOGLE_APPLICATION_CREDENTIALS',
+ 'GOOGLE_CLOUD_PROJECT',
+ 'GOOGLE_CLOUD_PROJECT_ID',
+ 'GCLOUD_PROJECT',
+ 'HTTPS_PROXY',
+ 'https_proxy',
+ 'HTTP_PROXY',
+ 'http_proxy',
+ 'NO_PROXY',
+ 'no_proxy',
+ 'SSL_CERT_FILE',
+ 'NODE_EXTRA_CA_CERTS',
+ 'REQUESTS_CA_BUNDLE',
+ 'CURL_CA_BUNDLE',
+] as const;
+
+const DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS = [
+ 'CLAUDE_CODE_USE_OPENAI',
+ 'CLAUDE_CODE_USE_BEDROCK',
+ 'CLAUDE_CODE_USE_VERTEX',
+ 'CLAUDE_CODE_USE_FOUNDRY',
+ 'CLAUDE_CODE_USE_GEMINI',
+ 'CLAUDE_CODE_ENTRY_PROVIDER',
+] as const;
+
+const INTERACTIVE_SHELL_COMMANDS = new Set([
+ 'bash',
+ 'zsh',
+ 'sh',
+ 'fish',
+ 'nu',
+ 'pwsh',
+ 'powershell',
+ 'cmd',
+ 'cmd.exe',
+]);
+
+export function shellQuote(value: string): string {
+ if (value.length === 0) {
+ return "''";
+ }
+ return `'${value.replace(/'/g, `'\\''`)}'`;
+}
+
+export function isInteractiveShellCommand(command: string | undefined): boolean {
+ const normalized = command?.trim().toLowerCase();
+ if (!normalized) {
+ return false;
+ }
+ return INTERACTIVE_SHELL_COMMANDS.has(path.basename(normalized));
+}
+
+function getDirectRestartEntryProvider(providerId: TeamProviderId): string {
+ return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
+}
+
+export function isAnthropicCompatibleBaseUrl(baseUrl?: string | null): boolean {
+ const trimmed = baseUrl?.trim();
+ if (!trimmed) {
+ return false;
+ }
+
+ try {
+ const url = new URL(trimmed);
+ return (
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
+ !url.username &&
+ !url.password &&
+ url.hostname !== 'api.anthropic.com' &&
+ url.hostname !== 'api-staging.anthropic.com'
+ );
+ } catch {
+ return false;
+ }
+}
+
+export function hasAnthropicCompatibleAuthTokenEnv(env: NodeJS.ProcessEnv): boolean {
+ return Boolean(
+ isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL) && env.ANTHROPIC_AUTH_TOKEN?.trim()
+ );
+}
+
+export function buildDirectTmuxRestartEnvAssignments(
+ env: NodeJS.ProcessEnv,
+ providerId: TeamProviderId
+): string {
+ const assignments = new Map();
+ assignments.set('CLAUDECODE', '1');
+ assignments.set('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1');
+
+ for (const key of DIRECT_TMUX_RESTART_ENV_KEYS) {
+ const value = env[key];
+ if (typeof value === 'string' && value.length > 0) {
+ assignments.set(key, value);
+ }
+ }
+
+ for (const key of DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS) {
+ assignments.set(key, '');
+ }
+ assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
+ assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
+ if (providerId === 'anthropic') {
+ if (hasAnthropicCompatibleAuthTokenEnv(env)) {
+ assignments.set('ANTHROPIC_BASE_URL', env.ANTHROPIC_BASE_URL?.trim() ?? '');
+ assignments.set('ANTHROPIC_AUTH_TOKEN', env.ANTHROPIC_AUTH_TOKEN?.trim() ?? '');
+ if (!env.ANTHROPIC_API_KEY?.trim()) {
+ assignments.set('ANTHROPIC_API_KEY', '');
+ }
+ } else if (!isAnthropicCompatibleBaseUrl(env.ANTHROPIC_BASE_URL)) {
+ assignments.set('ANTHROPIC_AUTH_TOKEN', '');
+ }
+ }
+ if (
+ providerId === 'anthropic' &&
+ env[CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV] === CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER
+ ) {
+ assignments.set(
+ CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_ENV,
+ CLAUDE_TEAM_ANTHROPIC_AUTH_MODE_API_KEY_HELPER
+ );
+ const settingsPath = env[CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV];
+ if (typeof settingsPath === 'string') {
+ assignments.set(CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH_ENV, settingsPath);
+ }
+ for (const key of ANTHROPIC_HELPER_MODE_COMPETING_AUTH_ENV_KEYS) {
+ assignments.set(key, '');
+ }
+ }
+
+ return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ');
+}
+
+export function buildDirectTmuxRestartCommand(input: {
+ cwd: string;
+ env: NodeJS.ProcessEnv;
+ providerId: TeamProviderId;
+ binaryPath: string;
+ args: string[];
+}): string {
+ const envAssignments = buildDirectTmuxRestartEnvAssignments(input.env, input.providerId);
+ const command = [
+ 'cd',
+ shellQuote(input.cwd),
+ '&&',
+ 'env',
+ envAssignments,
+ shellQuote(input.binaryPath),
+ ...input.args.map(shellQuote),
+ ].join(' ');
+ return `(${command}); __claude_teammate_exit=$?; printf '\\n__CLAUDE_TEAMMATE_EXIT__:%s\\n' "$__claude_teammate_exit"`;
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts
new file mode 100644
index 00000000..19217836
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningLaunchCompatibility.ts
@@ -0,0 +1,123 @@
+import { fromProvisioningMembers } from '@features/team-runtime-lanes';
+import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
+
+import type { TeamCreateRequest } from '@shared/types';
+
+export type TeamLaunchCompatibilityLevel = 'ready' | 'repairable' | 'unsafe';
+export type TeamLaunchCompatibilityRosterSource = 'members-meta' | 'config' | 'inboxes' | 'missing';
+export type TeamLaunchCompatibilityRepairAction = 'materialize-members-meta';
+
+export interface TeamLaunchCompatibilityReport {
+ level: TeamLaunchCompatibilityLevel;
+ rosterSource: TeamLaunchCompatibilityRosterSource;
+ members: TeamCreateRequest['members'];
+ warnings: string[];
+ blockers: string[];
+ repairAction?: TeamLaunchCompatibilityRepairAction;
+}
+
+export function isOpenCodeLegacyProvisioningRequest(request: {
+ providerId?: unknown;
+ members?: readonly { providerId?: unknown; provider?: unknown }[];
+}): boolean {
+ return (
+ normalizeOptionalTeamProviderId(request.providerId) === 'opencode' ||
+ (request.members ?? []).some(
+ (member) =>
+ normalizeOptionalTeamProviderId(member.providerId) === 'opencode' ||
+ normalizeOptionalTeamProviderId(member.provider) === 'opencode'
+ )
+ );
+}
+
+export function isPureOpenCodeProvisioningRequest(request: {
+ providerId?: unknown;
+ members?: readonly { providerId?: unknown; provider?: unknown }[];
+}): boolean {
+ if (!isOpenCodeLegacyProvisioningRequest(request)) {
+ return false;
+ }
+
+ const rootProviderId = normalizeOptionalTeamProviderId(request.providerId);
+ if (rootProviderId && rootProviderId !== 'opencode') {
+ return false;
+ }
+
+ return (request.members ?? []).every((member) => {
+ const memberProviderId =
+ normalizeOptionalTeamProviderId(member.providerId) ??
+ normalizeOptionalTeamProviderId(member.provider);
+ return !memberProviderId || memberProviderId === 'opencode';
+ });
+}
+
+export function getOpenCodeMixedProviderProvisioningError(): string {
+ return (
+ 'This OpenCode mixed-team request is outside the current support scope. ' +
+ 'Supported mixed teams keep the lead on Anthropic or Codex. OpenCode-led mixed teams still remain blocked in this phase.'
+ );
+}
+
+export function getMixedLaunchFallbackRecoveryError(): string {
+ return 'This old mixed team is missing stable member metadata. Open Edit Team and save the roster once before launching.';
+}
+
+export function assertOpenCodeNotLaunchedThroughLegacyProvisioning(request: {
+ providerId?: unknown;
+ members?: readonly { providerId?: unknown; provider?: unknown }[];
+}): void {
+ if (!isOpenCodeLegacyProvisioningRequest(request)) {
+ return;
+ }
+ const lanePlan = fromProvisioningMembers(
+ normalizeOptionalTeamProviderId(request.providerId),
+ (request.members ?? []).map((member, index) => ({
+ name: `member-${index + 1}`,
+ providerId:
+ normalizeOptionalTeamProviderId(member.providerId) ??
+ normalizeOptionalTeamProviderId(member.provider),
+ }))
+ );
+ if (!lanePlan.ok) {
+ throw new Error(lanePlan.message || getOpenCodeMixedProviderProvisioningError());
+ }
+ if (!isPureOpenCodeProvisioningRequest(request)) {
+ return;
+ }
+ throw new Error(
+ 'OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path. ' +
+ 'Use the gated OpenCode runtime adapter once production launch is enabled.'
+ );
+}
+
+export function mergeProvisioningWarnings(
+ existing: string[] | undefined,
+ nextWarning: string | null
+): string[] | undefined {
+ if (!nextWarning) return existing;
+ const merged = (existing ?? []).filter((warning) => warning !== nextWarning);
+ merged.push(nextWarning);
+ return merged.length > 0 ? merged : undefined;
+}
+
+const DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD = 8;
+const DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS = 20;
+
+export function buildLargeDeterministicBootstrapWarning(memberCount: number): string | null {
+ if (memberCount <= DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD) {
+ return null;
+ }
+ return (
+ `Large Codex team launch: ${memberCount} primary teammates will bootstrap in one runtime. ` +
+ `Launches above ${DETERMINISTIC_BOOTSTRAP_LARGE_TEAM_WARNING_THRESHOLD} teammates can be slower and more likely to hit provider rate limits or bootstrap timeouts.`
+ );
+}
+
+export function assertDeterministicBootstrapPrimaryMemberLimit(memberCount: number): void {
+ if (memberCount <= DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS) {
+ return;
+ }
+ throw new Error(
+ `Codex deterministic bootstrap currently supports up to ${DETERMINISTIC_BOOTSTRAP_MAX_PRIMARY_MEMBERS} primary teammates; this team has ${memberCount}. Reduce primary teammates or move extra OpenCode members to secondary lanes.`
+ );
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts
new file mode 100644
index 00000000..4eb1290f
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningLaunchDiagnostics.ts
@@ -0,0 +1,197 @@
+import type { WorkspaceTrustExecutionResult } from '@features/workspace-trust/main';
+import type { MemberSpawnStatusEntry, TeamLaunchDiagnosticItem } from '@shared/types';
+
+export interface TeamProvisioningLaunchDiagnosticsRun {
+ isLaunch: boolean;
+ memberSpawnStatuses?: ReadonlyMap | null;
+}
+
+interface LaunchDiagnosticsClockOptions {
+ nowIso?: () => string;
+}
+
+const defaultNowIso = (): string => new Date().toISOString();
+
+export function mentionsProcessTableUnavailable(value: string | undefined): boolean {
+ return /\bprocess table\b.*\bunavailable\b/i.test(value ?? '');
+}
+
+export function buildLaunchDiagnosticsFromRun(
+ run: TeamProvisioningLaunchDiagnosticsRun,
+ options: LaunchDiagnosticsClockOptions = {}
+): TeamLaunchDiagnosticItem[] | undefined {
+ const memberSpawnStatuses = run.memberSpawnStatuses;
+ if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) {
+ return undefined;
+ }
+
+ const observedAt = (options.nowIso ?? defaultNowIso)();
+ const items: TeamLaunchDiagnosticItem[] = [];
+ for (const [memberName, entry] of memberSpawnStatuses.entries()) {
+ if (entry.launchState === 'confirmed_alive') {
+ items.push({
+ id: `${memberName}:bootstrap_confirmed`,
+ memberName,
+ severity: 'info',
+ code: 'bootstrap_confirmed',
+ label: `${memberName} - bootstrap confirmed`,
+ observedAt,
+ });
+ continue;
+ }
+ if (entry.launchState === 'failed_to_start') {
+ items.push({
+ id: `${memberName}:bootstrap_stalled`,
+ memberName,
+ severity: 'error',
+ code: 'bootstrap_stalled',
+ label: `${memberName} - failed to start`,
+ detail: entry.hardFailureReason ?? entry.error,
+ observedAt,
+ });
+ continue;
+ }
+ if (entry.launchState === 'runtime_pending_permission') {
+ items.push({
+ id: `${memberName}:permission_pending`,
+ memberName,
+ severity: 'warning',
+ code: 'permission_pending',
+ label: `${memberName} - awaiting permission`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ continue;
+ }
+ if (entry.bootstrapStalled === true) {
+ items.push({
+ id: `${memberName}:bootstrap_stalled`,
+ memberName,
+ severity: 'warning',
+ code: 'bootstrap_stalled',
+ label: `${memberName} - bootstrap stalled`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ continue;
+ }
+ if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) {
+ items.push({
+ id: `${memberName}:process_table_unavailable`,
+ memberName,
+ severity: 'warning',
+ code: 'process_table_unavailable',
+ label: `${memberName} - process table unavailable`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ continue;
+ }
+ if (entry.livenessKind === 'shell_only') {
+ items.push({
+ id: `${memberName}:tmux_shell_only`,
+ memberName,
+ severity: 'warning',
+ code: 'tmux_shell_only',
+ label: `${memberName} - shell only`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ continue;
+ }
+ if (entry.livenessKind === 'runtime_process_candidate') {
+ items.push({
+ id: `${memberName}:runtime_process_candidate`,
+ memberName,
+ severity: 'warning',
+ code: 'runtime_process_candidate',
+ label: `${memberName} - bootstrap unconfirmed`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ continue;
+ }
+ if (entry.livenessKind === 'runtime_process') {
+ items.push({
+ id: `${memberName}:runtime_process_detected`,
+ memberName,
+ severity: 'info',
+ code: 'runtime_process_detected',
+ label: `${memberName} - waiting for bootstrap`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ continue;
+ }
+ if (
+ entry.livenessKind === 'registered_only' ||
+ entry.livenessKind === 'stale_metadata' ||
+ entry.livenessKind === 'not_found'
+ ) {
+ items.push({
+ id: `${memberName}:runtime_not_found`,
+ memberName,
+ severity: 'warning',
+ code: 'runtime_not_found',
+ label: `${memberName} - waiting for runtime`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ continue;
+ }
+ if (entry.agentToolAccepted) {
+ items.push({
+ id: `${memberName}:spawn_accepted`,
+ memberName,
+ severity: 'info',
+ code: 'spawn_accepted',
+ label: `${memberName} - spawn accepted`,
+ detail: entry.runtimeDiagnostic,
+ observedAt,
+ });
+ }
+ }
+ return items.length > 0 ? items : undefined;
+}
+
+export function buildWorkspaceTrustPreflightLaunchDiagnostic(
+ execution: WorkspaceTrustExecutionResult,
+ options: LaunchDiagnosticsClockOptions = {}
+): TeamLaunchDiagnosticItem | null {
+ if (execution.status === 'cancelled') {
+ return null;
+ }
+
+ const severity =
+ execution.status === 'blocked'
+ ? 'error'
+ : execution.status === 'soft_failed'
+ ? 'warning'
+ : 'info';
+ const label =
+ execution.status === 'blocked'
+ ? 'Workspace trust preflight blocked launch'
+ : execution.status === 'soft_failed'
+ ? 'Workspace trust preflight could not verify trust'
+ : 'Workspace trust preflight completed';
+ const detail =
+ execution.errorMessage?.trim() ||
+ execution.errorCode?.trim() ||
+ execution.evidence?.find((item) => item.trim().length > 0)?.trim();
+
+ return {
+ id: 'workspace-trust:preflight',
+ severity,
+ code: 'workspace_trust_preflight',
+ label,
+ ...(detail ? { detail } : {}),
+ observedAt: (options.nowIso ?? defaultNowIso)(),
+ };
+}
+
+export function mergeLaunchDiagnosticItem(
+ items: readonly TeamLaunchDiagnosticItem[] | undefined,
+ item: TeamLaunchDiagnosticItem
+): TeamLaunchDiagnosticItem[] {
+ return [...(items ?? []).filter((candidate) => candidate.id !== item.id), item];
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts
new file mode 100644
index 00000000..58165db5
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy.ts
@@ -0,0 +1,131 @@
+import { mentionsProcessTableUnavailable } from './TeamProvisioningLaunchDiagnostics';
+import { isBootstrapInstructionPrompt } from './TeamProvisioningPromptBuilders';
+
+import type { MemberLaunchState } from '@shared/types';
+
+export function isNeverSpawnedDuringLaunchReason(reason?: string): boolean {
+ return reason?.trim() === 'Teammate was never spawned during launch.';
+}
+
+export function isLaunchGraceWindowFailureReason(reason?: string): boolean {
+ return reason?.trim() === 'Teammate did not join within the launch grace window.';
+}
+
+export function isConfigRegistrationFailureReason(reason?: string): boolean {
+ return (
+ reason?.trim() ===
+ 'Teammate was not registered in config.json during launch. Persistent spawn failed.'
+ );
+}
+
+export function isOpenCodeBridgeLaunchFailureReason(reason?: string): boolean {
+ return reason?.trim() === 'OpenCode bridge reported member launch failure';
+}
+
+export function isRegisteredRuntimeMetadataFailureReason(reason?: string): boolean {
+ return reason?.trim() === 'registered runtime metadata without live process';
+}
+
+export function isProcessTableUnavailableFailureReason(reason?: string): boolean {
+ const text = reason?.trim();
+ if (!text || !mentionsProcessTableUnavailable(text)) {
+ return false;
+ }
+ return (
+ /^process table (?:is )?unavailable$/i.test(text) ||
+ /^runtime pid could not be verified because process table (?:is )?unavailable$/i.test(text)
+ );
+}
+
+export function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null {
+ const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim());
+ const baseReason = match?.[1]?.trim();
+ return baseReason && baseReason.length > 0 ? baseReason : null;
+}
+
+function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean {
+ return (
+ isNeverSpawnedDuringLaunchReason(reason) ||
+ isLaunchGraceWindowFailureReason(reason) ||
+ isConfigRegistrationFailureReason(reason) ||
+ isRegisteredRuntimeMetadataFailureReason(reason) ||
+ isOpenCodeBridgeLaunchFailureReason(reason) ||
+ isBootstrapMcpResourceReadFailureReason(reason) ||
+ isBootstrapCheckInTimeoutFailureReason(reason) ||
+ isBootstrapInstructionPromptFailureReason(reason) ||
+ isLaunchCleanupBootstrapIncompleteFailureReason(reason)
+ );
+}
+
+export function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean {
+ const text = reason?.trim().toLowerCase() ?? '';
+ return (
+ text.includes('resources/read failed') &&
+ text.includes('member_briefing') &&
+ (text.includes('method not found') || text.includes('mcp error'))
+ );
+}
+
+export function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean {
+ return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.';
+}
+
+export function isBootstrapInstructionPromptFailureReason(reason?: string): boolean {
+ return typeof reason === 'string' && isBootstrapInstructionPrompt(reason);
+}
+
+export function isLaunchCleanupBootstrapIncompleteFailureReason(reason?: string): boolean {
+ const text = reason?.trim();
+ if (!text) {
+ return false;
+ }
+ if (
+ text === 'Launch ended before teammate bootstrap completed.' ||
+ text === 'Deterministic bootstrap failed before teammate check-in.'
+ ) {
+ return true;
+ }
+ return (
+ text.startsWith('Launch ended before teammate bootstrap completed. ') &&
+ text.includes('Runtime process was alive after bootstrap failure')
+ );
+}
+
+export function isAutoClearableLaunchFailureReason(reason?: string): boolean {
+ const text = reason?.trim();
+ if (!text) {
+ return false;
+ }
+ const baseReason = stripProcessTableUnavailableDiagnosticSuffix(text);
+ return (
+ isBaseAutoClearableLaunchFailureReason(text) ||
+ isProcessTableUnavailableFailureReason(text) ||
+ (baseReason != null && isBaseAutoClearableLaunchFailureReason(baseReason))
+ );
+}
+
+export function deriveMemberLaunchState(entry: {
+ agentToolAccepted?: boolean;
+ runtimeAlive?: boolean;
+ bootstrapConfirmed?: boolean;
+ hardFailure?: boolean;
+ skippedForLaunch?: boolean;
+ pendingPermissionRequestIds?: string[];
+}): MemberLaunchState {
+ if (entry.skippedForLaunch) {
+ return 'skipped_for_launch';
+ }
+ if (entry.hardFailure) {
+ return 'failed_to_start';
+ }
+ if (entry.bootstrapConfirmed) {
+ return 'confirmed_alive';
+ }
+ if ((entry.pendingPermissionRequestIds?.length ?? 0) > 0) {
+ return 'runtime_pending_permission';
+ }
+ if (entry.runtimeAlive || entry.agentToolAccepted) {
+ return 'runtime_pending_bootstrap';
+ }
+ return 'starting';
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts b/src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts
new file mode 100644
index 00000000..e793440c
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningMemberIdentity.ts
@@ -0,0 +1,44 @@
+import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
+
+export function matchesMemberNameOrBase(candidateName: string, memberName: string): boolean {
+ if (candidateName === memberName) {
+ return true;
+ }
+ const parsed = parseNumericSuffixName(candidateName);
+ return parsed !== null && parsed.suffix >= 2 && parsed.base === memberName;
+}
+
+export function matchesTeamMemberIdentity(leftName: string, rightName: string): boolean {
+ return (
+ matchesMemberNameOrBase(leftName, rightName) || matchesMemberNameOrBase(rightName, leftName)
+ );
+}
+
+export function matchesObservedMemberNameForExpected(
+ observedName: string,
+ expectedName: string
+): boolean {
+ return matchesMemberNameOrBase(observedName, expectedName);
+}
+
+export function matchesExactTeamMemberName(candidateName: string, memberName: string): boolean {
+ const left = candidateName.trim().toLowerCase();
+ const right = memberName.trim().toLowerCase();
+ return left.length > 0 && left === right;
+}
+
+export function namesMatchCaseInsensitive(left: string, right: string): boolean {
+ return left.trim().toLowerCase() === right.trim().toLowerCase();
+}
+
+export function isOpenCodeOverlayMemberRemoved(
+ metaMembers: readonly { name?: string; removedAt?: unknown }[],
+ memberName: string
+): boolean {
+ return metaMembers.some(
+ (member) =>
+ typeof member.name === 'string' &&
+ namesMatchCaseInsensitive(member.name, memberName) &&
+ member.removedAt != null
+ );
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts b/src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts
new file mode 100644
index 00000000..898de6d0
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy.ts
@@ -0,0 +1,192 @@
+import type { MemberSpawnStatusEntry, PersistedTeamLaunchSummary } from '@shared/types';
+
+export const TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS = 5_000;
+export const MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS = 10_000;
+export const MEMBER_LAUNCH_GRACE_MS = 120_000;
+
+export function shouldWarnOnUnreadableMemberAuditConfig(params: {
+ nowMs: number;
+ lastWarnAt: number;
+ expectedMembers: readonly string[];
+ memberSpawnStatuses: ReadonlyMap<
+ string,
+ Pick | undefined
+ >;
+}): boolean {
+ const { nowMs, lastWarnAt, expectedMembers, memberSpawnStatuses } = params;
+ if (nowMs - lastWarnAt < MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS) {
+ return false;
+ }
+ return expectedMembers.some((memberName) => {
+ const current = memberSpawnStatuses.get(memberName);
+ if (!current?.agentToolAccepted || typeof current.firstSpawnAcceptedAt !== 'string') {
+ return false;
+ }
+ const acceptedAtMs = Date.parse(current.firstSpawnAcceptedAt);
+ return Number.isFinite(acceptedAtMs) && nowMs - acceptedAtMs >= MEMBER_LAUNCH_GRACE_MS;
+ });
+}
+
+export function shouldWarnOnMissingRegisteredMember(params: {
+ nowMs: number;
+ lastWarnAt: number;
+ graceExpired: boolean;
+}): boolean {
+ const { nowMs, lastWarnAt, graceExpired } = params;
+ return graceExpired && nowMs - lastWarnAt >= MEMBER_SPAWN_AUDIT_WARNING_THROTTLE_MS;
+}
+
+function nowIso(): string {
+ return new Date().toISOString();
+}
+
+export function parseOptionalIsoMs(value: string | undefined): number {
+ if (!value) return 0;
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+export function deriveTaskActivityPauseAt(
+ previous: MemberSpawnStatusEntry,
+ fallbackAt: string
+): string {
+ const fallbackMs = parseOptionalIsoMs(fallbackAt);
+ const explicitEvidenceMs = Math.max(
+ parseOptionalIsoMs(previous.lastHeartbeatAt),
+ parseOptionalIsoMs(previous.livenessLastCheckedAt)
+ );
+ const evidenceMs =
+ explicitEvidenceMs > 0 ? explicitEvidenceMs : parseOptionalIsoMs(previous.updatedAt);
+ if (evidenceMs <= 0 || fallbackMs <= 0) {
+ return fallbackAt;
+ }
+ const boundedEvidenceMs = Math.min(evidenceMs, fallbackMs);
+ const closeMs = Math.max(
+ boundedEvidenceMs,
+ Math.min(fallbackMs, boundedEvidenceMs + TASK_ACTIVITY_RUNTIME_PAUSE_GRACE_MS)
+ );
+ return new Date(closeMs).toISOString();
+}
+
+export function deriveTaskActivityResumeAt(
+ previous: MemberSpawnStatusEntry,
+ evidenceAt: string,
+ fallbackAt: string
+): string {
+ const fallbackMs = parseOptionalIsoMs(fallbackAt);
+ const evidenceMs = parseOptionalIsoMs(evidenceAt);
+ const previousUpdatedMs = parseOptionalIsoMs(previous.updatedAt);
+ if (evidenceMs <= 0 || fallbackMs <= 0) {
+ return fallbackAt;
+ }
+ if (previousUpdatedMs > 0 && evidenceMs < previousUpdatedMs) {
+ return fallbackAt;
+ }
+ return new Date(Math.min(evidenceMs, fallbackMs)).toISOString();
+}
+
+export function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
+ const updatedAt = nowIso();
+ return {
+ status: 'offline',
+ launchState: 'starting',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ updatedAt,
+ };
+}
+
+export function summarizeMemberSpawnStatusRecord(
+ expectedMembers: readonly string[],
+ statuses: Record
+): PersistedTeamLaunchSummary {
+ let confirmedCount = 0;
+ let pendingCount = 0;
+ let failedCount = 0;
+ let skippedCount = 0;
+ let runtimeAlivePendingCount = 0;
+ let shellOnlyPendingCount = 0;
+ let runtimeProcessPendingCount = 0;
+ let runtimeCandidatePendingCount = 0;
+ let noRuntimePendingCount = 0;
+ let permissionPendingCount = 0;
+ const memberNames = Array.from(new Set([...expectedMembers, ...Object.keys(statuses)]));
+
+ for (const memberName of memberNames) {
+ const entry = statuses[memberName];
+ if (!entry) {
+ pendingCount += 1;
+ continue;
+ }
+ if (entry.launchState === 'confirmed_alive') {
+ confirmedCount += 1;
+ continue;
+ }
+ if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
+ skippedCount += 1;
+ continue;
+ }
+ if (entry.launchState === 'failed_to_start') {
+ failedCount += 1;
+ continue;
+ }
+ pendingCount += 1;
+ if (entry.runtimeAlive) {
+ runtimeAlivePendingCount += 1;
+ }
+ if (entry.launchState === 'runtime_pending_permission') {
+ permissionPendingCount += 1;
+ }
+ if (entry.livenessKind === 'shell_only') {
+ shellOnlyPendingCount += 1;
+ } else if (entry.livenessKind === 'runtime_process') {
+ runtimeProcessPendingCount += 1;
+ } else if (entry.livenessKind === 'runtime_process_candidate') {
+ runtimeCandidatePendingCount += 1;
+ } else if (
+ entry.livenessKind === 'not_found' ||
+ entry.livenessKind === 'stale_metadata' ||
+ entry.livenessKind === 'registered_only'
+ ) {
+ noRuntimePendingCount += 1;
+ }
+ }
+
+ return {
+ confirmedCount,
+ pendingCount,
+ failedCount,
+ skippedCount,
+ runtimeAlivePendingCount,
+ shellOnlyPendingCount,
+ runtimeProcessPendingCount,
+ runtimeCandidatePendingCount,
+ noRuntimePendingCount,
+ permissionPendingCount,
+ };
+}
+
+export function buildRestartStillRunningReason(memberName: string): string {
+ return (
+ `Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
+ `to be active. The requested settings may not have been applied.`
+ );
+}
+
+export function buildRestartDuplicateUnconfirmedReason(
+ memberName: string,
+ rawReason?: string
+): string {
+ const suffix = rawReason?.trim()
+ ? ` Agent returned duplicate_skipped with unrecognized reason "${rawReason.trim()}".`
+ : ' Agent returned duplicate_skipped without a reason.';
+ return (
+ `Restart for teammate "${memberName}" could not be confirmed and may not have applied.` + suffix
+ );
+}
+
+export function buildRestartGraceTimeoutReason(memberName: string): string {
+ return `Teammate "${memberName}" did not rejoin within the restart grace window.`;
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts
new file mode 100644
index 00000000..f6bf6720
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy.ts
@@ -0,0 +1,216 @@
+import { createPersistedLaunchSnapshot } from '../TeamLaunchStateEvaluator';
+
+import type { PersistedTeamLaunchMemberState, PersistedTeamLaunchSnapshot } from '@shared/types';
+
+export const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC =
+ 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.';
+
+const OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON =
+ 'OpenCode bridge reported member launch failure';
+const OPEN_CODE_SECRET_FLAG_PATTERN =
+ /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
+const OPEN_CODE_BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Z0-9._~+/=-]+/gi;
+const OPEN_CODE_SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
+const OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS = 12_000;
+
+function nowIso(): string {
+ return new Date().toISOString();
+}
+
+export function isPersistedOpenCodeSecondaryLaneMember(
+ member: PersistedTeamLaunchMemberState | undefined | null
+): boolean {
+ return (
+ member?.providerId === 'opencode' &&
+ member.laneKind === 'secondary' &&
+ member.laneOwnerProviderId === 'opencode' &&
+ typeof member.laneId === 'string' &&
+ member.laneId.trim().length > 0
+ );
+}
+
+export function hasStaleOpenCodeSecondaryLaunchDiagnostic(
+ member: PersistedTeamLaunchMemberState
+): boolean {
+ return hasStaleOpenCodeDiagnostics(getOpenCodeLaunchDiagnosticValues(member));
+}
+
+export function hasRealOpenCodeLaunchDiagnostic(member: PersistedTeamLaunchMemberState): boolean {
+ const text = getOpenCodeLaunchDiagnosticValues(member)
+ .filter((value): value is string => typeof value === 'string')
+ .join('\n')
+ .toLowerCase();
+ return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text);
+}
+
+export function getOpenCodeLaunchDiagnosticValues(
+ member: PersistedTeamLaunchMemberState
+): readonly unknown[] {
+ return [member.hardFailureReason, member.runtimeDiagnostic, ...(member.diagnostics ?? [])];
+}
+
+export function hasStaleOpenCodeDiagnostics(values: readonly unknown[] | undefined): boolean {
+ const text = (values ?? [])
+ .filter((value): value is string => typeof value === 'string')
+ .join('\n')
+ .toLowerCase();
+ if (!text) {
+ return false;
+ }
+ if (hasRealOpenCodeFailureDiagnostic(text)) {
+ return false;
+ }
+ return (
+ text.includes('no lane runtime evidence') ||
+ text.includes('no runtime evidence') ||
+ text.includes('runtime evidence was not committed') ||
+ text.includes('no lane runtime evidence was committed') ||
+ text.includes('registered runtime metadata without live process') ||
+ text.includes('member has persisted runtime metadata only') ||
+ text.includes('opencode bridge reported member launch failure') ||
+ text.includes('file lock timeout') ||
+ text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase())
+ );
+}
+
+export function isFileLockTimeoutError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.toLowerCase().includes('file lock timeout');
+}
+
+export function hasRealOpenCodeFailureDiagnostic(text: string): boolean {
+ return (
+ /\bauth(?:entication|orization)?\b/.test(text) ||
+ text.includes('api key') ||
+ text.includes('unauthorized') ||
+ text.includes('forbidden') ||
+ text.includes('invalid_request') ||
+ text.includes('model not found') ||
+ text.includes('not found in live opencode catalog') ||
+ text.includes('provider unavailable') ||
+ text.includes('quota') ||
+ text.includes('credits') ||
+ text.includes('max_tokens') ||
+ text.includes('rate limit') ||
+ text.includes('member removed') ||
+ text.includes('session conflict') ||
+ text.includes('run tombstoned') ||
+ text.includes('stop requested') ||
+ text.includes('relaunch started')
+ );
+}
+
+export function normalizeOpenCodePersistedFailureReason(
+ value: string | undefined
+): string | undefined {
+ const trimmed = value?.replace(/\s+/g, ' ').trim();
+ if (!trimmed) {
+ return undefined;
+ }
+ return trimmed
+ .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]')
+ .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]')
+ .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]');
+}
+
+export function redactOpenCodeAppManagedContextText(value: string): string {
+ return value
+ .replace(OPEN_CODE_SECRET_FLAG_PATTERN, '$1[redacted]')
+ .replace(OPEN_CODE_BEARER_TOKEN_PATTERN, 'Bearer [redacted]')
+ .replace(OPEN_CODE_SECRET_KEY_PATTERN, '[redacted-api-key]');
+}
+
+export function boundOpenCodeAppManagedBriefingText(value: string): string {
+ const normalized = redactOpenCodeAppManagedContextText(value.replace(/\r\n/g, '\n')).trim();
+ if (normalized.length <= OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS) {
+ return normalized;
+ }
+ return `${normalized.slice(0, OPEN_CODE_APP_MANAGED_BRIEFING_MAX_CHARS)}\n[truncated app-managed briefing]`;
+}
+
+export function isGenericOpenCodePersistedFailureReason(value: string | undefined): boolean {
+ const normalized = normalizeOpenCodePersistedFailureReason(value);
+ return (
+ normalized === OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON ||
+ normalized?.startsWith(`${OPEN_CODE_GENERIC_MEMBER_LAUNCH_FAILURE_REASON}:`) === true ||
+ normalized?.startsWith('OpenCode secondary lane timing:') === true ||
+ normalized?.startsWith(
+ 'OpenCode bridge reported ready without all required durable checkpoints:'
+ ) === true ||
+ normalized?.startsWith(
+ 'OpenCode bridge reported ready before all expected members were confirmed:'
+ ) === true ||
+ normalized?.startsWith(
+ 'OpenCode bootstrap MCP did not complete required tools before assistant response:'
+ ) === true ||
+ normalized?.startsWith('info:opencode_launch_member_timing:') === true ||
+ normalized?.startsWith('info:opencode_launch_total_timing:') === true
+ );
+}
+
+export function selectOpenCodePersistedFailureReasonFromDiagnostics(
+ member: PersistedTeamLaunchMemberState
+): string | undefined {
+ if (!isPersistedOpenCodeSecondaryLaneMember(member)) {
+ return undefined;
+ }
+ if (member.launchState !== 'failed_to_start' || member.hardFailure !== true) {
+ return undefined;
+ }
+ if (!isGenericOpenCodePersistedFailureReason(member.hardFailureReason)) {
+ return undefined;
+ }
+ for (const value of member.diagnostics ?? []) {
+ const normalized = normalizeOpenCodePersistedFailureReason(value);
+ if (!normalized || isGenericOpenCodePersistedFailureReason(normalized)) {
+ continue;
+ }
+ return normalized;
+ }
+ return undefined;
+}
+
+export function promoteOpenCodePersistedFailureReasonsFromDiagnostics(
+ snapshot: PersistedTeamLaunchSnapshot | null
+): PersistedTeamLaunchSnapshot | null {
+ if (!snapshot) {
+ return null;
+ }
+ let changed = false;
+ const members: Record = { ...snapshot.members };
+ for (const [memberName, member] of Object.entries(snapshot.members)) {
+ const promotedReason = selectOpenCodePersistedFailureReasonFromDiagnostics(member);
+ if (!promotedReason || promotedReason === member.hardFailureReason) {
+ continue;
+ }
+ members[memberName] = {
+ ...member,
+ hardFailureReason: promotedReason,
+ runtimeDiagnostic:
+ member.runtimeDiagnostic &&
+ !isGenericOpenCodePersistedFailureReason(member.runtimeDiagnostic)
+ ? member.runtimeDiagnostic
+ : promotedReason,
+ runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'error',
+ };
+ changed = true;
+ }
+ if (!changed) {
+ return snapshot;
+ }
+ return createPersistedLaunchSnapshot({
+ teamName: snapshot.teamName,
+ expectedMembers: snapshot.expectedMembers,
+ bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers,
+ leadSessionId: snapshot.leadSessionId,
+ launchPhase: snapshot.launchPhase,
+ members,
+ updatedAt: nowIso(),
+ });
+}
+
+export function filterStaleOpenCodeOverlayDiagnostics(
+ values: readonly string[] | undefined
+): string[] {
+ return (values ?? []).filter((value) => !hasStaleOpenCodeDiagnostics([value]));
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
new file mode 100644
index 00000000..30ce8949
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
@@ -0,0 +1,620 @@
+import {
+ hasRealOpenCodeFailureDiagnostic,
+ isPersistedOpenCodeSecondaryLaneMember,
+ normalizeOpenCodePersistedFailureReason,
+ OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC,
+} from './TeamProvisioningOpenCodeDiagnosticsPolicy';
+
+import type {
+ TeamRuntimeLaunchResult,
+ TeamRuntimeMemberLaunchEvidence,
+} from '../runtime/TeamRuntimeAdapter';
+import type {
+ PersistedTeamLaunchMemberState,
+ TeamAgentRuntimeEntry,
+ TeamLaunchAggregateState,
+} from '@shared/types';
+
+export const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
+export const OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC =
+ 'OpenCode app-managed bootstrap evidence did not commit within 5 min.';
+export const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC =
+ 'opencode_bootstrap_pending_after_materialized_session';
+export const OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC =
+ 'OpenCode app-managed bootstrap evidence is pending after materialized session.';
+
+const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN =
+ /\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i;
+
+export function formatOpenCodeLaneTimingMs(value: number | null | undefined): string {
+ return typeof value === 'number' && Number.isFinite(value)
+ ? `${Math.max(0, Math.round(value))}ms`
+ : 'n/a';
+}
+
+export function appendDiagnosticOnce(
+ diagnostics: readonly string[],
+ diagnostic: string | null
+): string[] {
+ if (!diagnostic || diagnostics.includes(diagnostic)) {
+ return [...diagnostics];
+ }
+ return [...diagnostics, diagnostic];
+}
+
+export function buildOpenCodeSecondaryLaneTimingDiagnostic(lane: {
+ member: { name: string };
+ queuedAtMs?: number;
+ launchStartedAtMs?: number;
+ launchFinishedAtMs?: number;
+}): string | null {
+ if (
+ typeof lane.queuedAtMs !== 'number' ||
+ typeof lane.launchStartedAtMs !== 'number' ||
+ typeof lane.launchFinishedAtMs !== 'number'
+ ) {
+ return null;
+ }
+ return [
+ 'OpenCode secondary lane timing:',
+ `member=${lane.member.name}`,
+ `queueWaitMs=${formatOpenCodeLaneTimingMs(lane.launchStartedAtMs - lane.queuedAtMs)}`,
+ `launchMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.launchStartedAtMs)}`,
+ `totalMs=${formatOpenCodeLaneTimingMs(lane.launchFinishedAtMs - lane.queuedAtMs)}`,
+ ].join(' ');
+}
+
+export function createUnexpectedMixedSecondaryLaneFailureResult(input: {
+ runId: string;
+ teamName: string;
+ memberName: string;
+ message: string;
+}): TeamRuntimeLaunchResult {
+ return {
+ runId: input.runId,
+ teamName: input.teamName,
+ launchPhase: 'finished',
+ teamLaunchState: 'partial_failure',
+ members: {
+ [input.memberName]: {
+ memberName: input.memberName,
+ providerId: 'opencode',
+ launchState: 'failed_to_start',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: input.message,
+ diagnostics: [input.message],
+ },
+ },
+ warnings: [],
+ diagnostics: [input.message],
+ };
+}
+
+export function isExplicitLegacyOpenCodeBootstrap(
+ value:
+ | {
+ bootstrapMode?: 'model_tool_checkin' | 'app_managed_context';
+ }
+ | undefined
+ | null
+): boolean {
+ return value?.bootstrapMode === 'model_tool_checkin';
+}
+
+export function hasRecoverableOpenCodeBootstrapDiagnostic(diagnostics: readonly string[]): boolean {
+ const text = diagnostics.join('\n').toLowerCase();
+ if (!text) {
+ return false;
+ }
+ if (hasRealOpenCodeFailureDiagnostic(text)) {
+ return false;
+ }
+ return (
+ text.includes('runtime_bootstrap_checkin') ||
+ text.includes('member_briefing') ||
+ text.includes('bootstrap mcp') ||
+ text.includes('member_session_recorded') ||
+ text.includes('not connected') ||
+ text.includes('mcp not connected') ||
+ text.includes('member_launch_reconcile_pending') ||
+ text.includes('member_launch_preview_timeout')
+ );
+}
+
+export function collectRuntimeLaunchFailureDiagnostics(
+ result: TeamRuntimeLaunchResult,
+ memberName: string
+): string[] {
+ const member = result.members[memberName];
+ return [...(member?.diagnostics ?? []), member?.hardFailureReason, ...result.diagnostics].filter(
+ (value): value is string => typeof value === 'string' && value.trim().length > 0
+ );
+}
+
+export function collectOpenCodeSecondaryLaneFailureDiagnostics(
+ result: TeamRuntimeLaunchResult,
+ memberName: string,
+ prefixDiagnostics: readonly string[]
+): string[] {
+ const diagnostics = [
+ ...prefixDiagnostics,
+ ...collectRuntimeLaunchFailureDiagnostics(result, memberName),
+ ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
+ return diagnostics.length > 0 ? diagnostics : ['OpenCode bridge reported member launch failure'];
+}
+
+export function isReconciliableOpenCodeUnknownOutcome(diagnostics: readonly string[]): boolean {
+ return diagnostics.some((diagnostic) =>
+ /outcome must be reconciled before retry/i.test(diagnostic)
+ );
+}
+
+export function isDefinitiveOpenCodePreLaunchFailure(
+ result: TeamRuntimeLaunchResult,
+ memberName: string
+): boolean {
+ const member = result.members[memberName];
+ if (!member) {
+ return false;
+ }
+ const hardFailed = member.launchState === 'failed_to_start' || member.hardFailure === true;
+ if (!hardFailed) {
+ return false;
+ }
+ const runtimeMaterialized =
+ member.agentToolAccepted ||
+ member.runtimeAlive ||
+ member.bootstrapConfirmed ||
+ (typeof member.sessionId === 'string' && member.sessionId.trim().length > 0) ||
+ (typeof member.runtimePid === 'number' &&
+ Number.isFinite(member.runtimePid) &&
+ member.runtimePid > 0);
+ if (runtimeMaterialized) {
+ return false;
+ }
+ return !isReconciliableOpenCodeUnknownOutcome(
+ collectRuntimeLaunchFailureDiagnostics(result, memberName)
+ );
+}
+
+export function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean {
+ if (typeof sessionId !== 'string') {
+ return false;
+ }
+ const trimmed = sessionId.trim();
+ return trimmed.length > 0 && !trimmed.startsWith('failed:');
+}
+
+export function hasMaterializedOpenCodeRuntimeForBootstrap(
+ member: TeamRuntimeMemberLaunchEvidence | undefined
+): member is TeamRuntimeMemberLaunchEvidence {
+ if (!member) {
+ return false;
+ }
+ if (isMaterializedOpenCodeSessionId(member.sessionId)) {
+ return true;
+ }
+ return (
+ hasOpenCodeRuntimeLivenessMarker(member) &&
+ typeof member.runtimePid === 'number' &&
+ Number.isFinite(member.runtimePid) &&
+ member.runtimePid > 0
+ );
+}
+
+export function isRecoverableOpenCodeBootstrapPendingLaunchResult(
+ result: TeamRuntimeLaunchResult,
+ memberName: string
+): boolean {
+ const member = result.members[memberName];
+ if (!hasMaterializedOpenCodeRuntimeForBootstrap(member)) {
+ return false;
+ }
+ if (member.bootstrapConfirmed || member.launchState === 'confirmed_alive') {
+ return false;
+ }
+ if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) {
+ return false;
+ }
+ return hasRecoverableOpenCodeBootstrapDiagnostic(
+ collectRuntimeLaunchFailureDiagnostics(result, memberName)
+ );
+}
+
+export function summarizeRuntimeLaunchResultMembers(
+ members: Record
+): TeamLaunchAggregateState {
+ const values = Object.values(members);
+ if (
+ values.some((member) => member.launchState === 'failed_to_start' || member.hardFailure === true)
+ ) {
+ return 'partial_failure';
+ }
+ if (values.length > 0 && values.every((member) => member.launchState === 'confirmed_alive')) {
+ return 'clean_success';
+ }
+ return 'partial_pending';
+}
+
+export function normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(
+ result: TeamRuntimeLaunchResult,
+ memberName: string,
+ diagnostics: readonly string[]
+): TeamRuntimeLaunchResult {
+ const member = result.members[memberName];
+ if (!member) {
+ return result;
+ }
+ const memberDiagnostics = Array.from(
+ new Set([
+ ...(member.diagnostics ?? []),
+ OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC,
+ isExplicitLegacyOpenCodeBootstrap(member)
+ ? 'OpenCode runtime session materialized; waiting for runtime_bootstrap_checkin.'
+ : OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC,
+ ...diagnostics,
+ ])
+ );
+ const normalizedMember: TeamRuntimeMemberLaunchEvidence = {
+ ...member,
+ launchState: 'runtime_pending_bootstrap',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ hardFailureReason: undefined,
+ pendingPermissionRequestIds: undefined,
+ livenessKind:
+ member.livenessKind === 'confirmed_bootstrap'
+ ? 'runtime_process'
+ : (member.livenessKind ?? 'runtime_process'),
+ runtimeDiagnostic:
+ member.runtimeDiagnostic ??
+ 'OpenCode runtime process detected; waiting for bootstrap check-in.',
+ runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'info',
+ diagnostics: memberDiagnostics,
+ };
+ const members = {
+ ...result.members,
+ [memberName]: normalizedMember,
+ };
+ const teamLaunchState = summarizeRuntimeLaunchResultMembers(members);
+ return {
+ ...result,
+ launchPhase: teamLaunchState === 'clean_success' ? result.launchPhase : 'active',
+ teamLaunchState,
+ members,
+ diagnostics: Array.from(new Set([...result.diagnostics, ...memberDiagnostics])),
+ };
+}
+
+export function buildOpenCodeUncommittedBootstrapDiagnostic(storage: {
+ manifestEntryCount: number | null;
+ manifestUpdatedAt: string | null;
+ fileNames: string[];
+}): string[] {
+ return [
+ OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC,
+ `OpenCode lane manifest entries: ${storage.manifestEntryCount ?? 0}`,
+ ...(storage.manifestUpdatedAt
+ ? [`OpenCode lane manifest updated at: ${storage.manifestUpdatedAt}`]
+ : []),
+ storage.fileNames.length > 0
+ ? `OpenCode lane files: ${storage.fileNames.slice(0, 8).join(', ')}`
+ : 'OpenCode lane files: none',
+ ];
+}
+
+export function downgradeUncommittedOpenCodeBootstrapEvidence(
+ evidence: TeamRuntimeMemberLaunchEvidence,
+ diagnostics: readonly string[]
+): TeamRuntimeMemberLaunchEvidence {
+ const hasRuntimeHandle = hasOpenCodeRuntimeHandle(evidence);
+ return {
+ ...evidence,
+ launchState: hasRuntimeHandle ? 'runtime_pending_bootstrap' : 'starting',
+ agentToolAccepted: hasRuntimeHandle,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ hardFailureReason: undefined,
+ livenessKind: hasRuntimeHandle
+ ? evidence.livenessKind === 'confirmed_bootstrap'
+ ? 'runtime_process_candidate'
+ : (evidence.livenessKind ?? 'runtime_process_candidate')
+ : 'registered_only',
+ runtimeDiagnostic: hasRuntimeHandle
+ ? 'OpenCode runtime handle is present, but bootstrap evidence was not committed.'
+ : 'OpenCode bootstrap confirmation was not committed to lane runtime evidence.',
+ runtimeDiagnosticSeverity: 'warning',
+ diagnostics: Array.from(new Set([...evidence.diagnostics, ...diagnostics])),
+ };
+}
+
+export function promoteCommittedOpenCodeAppManagedBootstrapEvidence(
+ evidence: TeamRuntimeMemberLaunchEvidence
+): TeamRuntimeMemberLaunchEvidence {
+ return {
+ ...evidence,
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ hardFailureReason: undefined,
+ livenessKind: 'confirmed_bootstrap',
+ runtimeDiagnostic:
+ 'OpenCode app-managed bootstrap evidence was committed and read back by the desktop app.',
+ runtimeDiagnosticSeverity: 'info',
+ diagnostics: appendDiagnosticOnce(
+ evidence.diagnostics,
+ 'OpenCode app-managed bootstrap evidence committed and read back.'
+ ),
+ };
+}
+
+export function hasOpenCodeRuntimeHandle(
+ value:
+ | Pick
+ | Pick
+ | undefined
+): boolean {
+ if (!value) {
+ return false;
+ }
+ const runtimePid =
+ typeof value.runtimePid === 'number' &&
+ Number.isFinite(value.runtimePid) &&
+ value.runtimePid > 0;
+ const runtimeSessionId = (value as { runtimeSessionId?: unknown }).runtimeSessionId;
+ const runtimeEvidenceSessionId = (value as { sessionId?: unknown }).sessionId;
+ const sessionId =
+ (typeof runtimeSessionId === 'string' && runtimeSessionId.trim().length > 0) ||
+ (typeof runtimeEvidenceSessionId === 'string' && runtimeEvidenceSessionId.trim().length > 0);
+ return runtimePid || sessionId;
+}
+
+export function hasOpenCodeRuntimeLivenessMarker(
+ value: Pick | undefined
+): boolean {
+ return (
+ value?.livenessKind === 'runtime_process' ||
+ value?.livenessKind === 'runtime_process_candidate' ||
+ value?.livenessKind === 'permission_blocked'
+ );
+}
+
+export function hasOpenCodeRuntimeEntryHandle(
+ value:
+ | Pick
+ | undefined
+ | null
+): boolean {
+ if (!value) {
+ return false;
+ }
+ const pid = typeof value.pid === 'number' && Number.isFinite(value.pid) && value.pid > 0;
+ const runtimePid =
+ typeof value.runtimePid === 'number' &&
+ Number.isFinite(value.runtimePid) &&
+ value.runtimePid > 0;
+ const runtimeSessionId =
+ typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0;
+ return pid || runtimePid || runtimeSessionId || hasOpenCodeRuntimeLivenessMarker(value);
+}
+
+export function isRecoverablePersistedOpenCodeRuntimeCandidate(
+ member: PersistedTeamLaunchMemberState | undefined | null
+): boolean {
+ if (!member || member.skippedForLaunch) {
+ return false;
+ }
+ if (!isPersistedOpenCodeSecondaryLaneMember(member)) {
+ return false;
+ }
+ const hasPendingPermission = (member.pendingPermissionRequestIds?.length ?? 0) > 0;
+ return (
+ member.agentToolAccepted === true && (hasOpenCodeRuntimeHandle(member) || hasPendingPermission)
+ );
+}
+
+export function normalizeIsoTimestamp(value: unknown): string | null {
+ if (typeof value !== 'string') {
+ return null;
+ }
+ const trimmed = value.trim();
+ if (!trimmed) {
+ return null;
+ }
+ const parsed = Date.parse(trimmed);
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
+}
+
+function selectEarliestIsoTimestamp(values: readonly unknown[]): string | undefined {
+ let selected: { value: string; timeMs: number } | null = null;
+ for (const value of values) {
+ const normalized = normalizeIsoTimestamp(value);
+ if (!normalized) {
+ continue;
+ }
+ const timeMs = Date.parse(normalized);
+ if (!selected || timeMs < selected.timeMs) {
+ selected = { value: normalized, timeMs };
+ }
+ }
+ return selected?.value;
+}
+
+function extractOpenCodeMemberSessionRecordedAt(
+ diagnostics: readonly string[] | undefined
+): string[] {
+ return (diagnostics ?? []).flatMap((diagnostic) => {
+ const match = OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN.exec(diagnostic);
+ return match?.[1] ? [match[1]] : [];
+ });
+}
+
+export function resolveOpenCodeBootstrapAcceptedAt(
+ member: Pick
+): string | undefined {
+ return selectEarliestIsoTimestamp([
+ member.firstSpawnAcceptedAt,
+ ...extractOpenCodeMemberSessionRecordedAt(member.diagnostics),
+ ]);
+}
+
+function hasOpenCodeSecondaryFatalBootstrapDiagnostic(
+ member: Pick<
+ PersistedTeamLaunchMemberState,
+ 'diagnostics' | 'runtimeDiagnostic' | 'hardFailureReason'
+ >
+): boolean {
+ const text = [member.runtimeDiagnostic, member.hardFailureReason, ...(member.diagnostics ?? [])]
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
+ .join('\n')
+ .toLowerCase();
+ return text.length > 0 && hasRealOpenCodeFailureDiagnostic(text);
+}
+
+export function selectOpenCodeSecondaryBootstrapStallDiagnostic(
+ values: readonly unknown[]
+): string | null {
+ const normalizedValues = values
+ .filter((value): value is string => typeof value === 'string')
+ .map((value) => normalizeOpenCodePersistedFailureReason(value))
+ .filter((value): value is string => typeof value === 'string' && value.length > 0);
+
+ const runtimeCheckinDiagnostic = normalizedValues.find((value) =>
+ value.toLowerCase().includes('runtime_bootstrap_checkin')
+ );
+ if (runtimeCheckinDiagnostic) {
+ return runtimeCheckinDiagnostic;
+ }
+
+ const memberBriefingDiagnostic = normalizedValues.find((value) =>
+ value.toLowerCase().includes('member_briefing')
+ );
+ if (memberBriefingDiagnostic) {
+ return `${memberBriefingDiagnostic}; runtime_bootstrap_checkin did not complete after 5 min.`;
+ }
+
+ return null;
+}
+
+export function getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted(
+ member: PersistedTeamLaunchMemberState
+): string {
+ if (!isExplicitLegacyOpenCodeBootstrap(member)) {
+ return OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC;
+ }
+
+ const selected = selectOpenCodeSecondaryBootstrapStallDiagnostic([
+ member.runtimeDiagnostic,
+ ...(member.diagnostics ?? []),
+ member.hardFailureReason,
+ ]);
+ if (selected) {
+ return selected;
+ }
+
+ return 'OpenCode bootstrap did not complete runtime_bootstrap_checkin after 5 min.';
+}
+
+export function shouldMarkPersistedOpenCodeBootstrapStalled(
+ member: PersistedTeamLaunchMemberState,
+ nowMs: number
+): boolean {
+ if (!isPersistedOpenCodeSecondaryLaneMember(member)) {
+ return false;
+ }
+ if (
+ member.launchState !== 'runtime_pending_bootstrap' ||
+ member.bootstrapConfirmed === true ||
+ member.hardFailure === true ||
+ member.skippedForLaunch === true ||
+ (member.pendingPermissionRequestIds?.length ?? 0) > 0
+ ) {
+ return false;
+ }
+ if (hasOpenCodeSecondaryFatalBootstrapDiagnostic(member)) {
+ return false;
+ }
+ const acceptedAt = resolveOpenCodeBootstrapAcceptedAt(member);
+ const acceptedAtMs = acceptedAt ? Date.parse(acceptedAt) : NaN;
+ if (!Number.isFinite(acceptedAtMs) || nowMs - acceptedAtMs < MEMBER_BOOTSTRAP_STALL_MS) {
+ return false;
+ }
+ return (
+ hasOpenCodeRuntimeHandle(member) ||
+ hasOpenCodeRuntimeLivenessMarker(member) ||
+ hasRecoverableOpenCodeBootstrapDiagnostic(
+ [member.runtimeDiagnostic, ...(member.diagnostics ?? [])].filter(
+ (value): value is string => typeof value === 'string'
+ )
+ )
+ );
+}
+
+export function isRecoverablePersistedOpenCodeTerminalRuntimeCandidate(
+ member: PersistedTeamLaunchMemberState | undefined | null
+): boolean {
+ return (
+ isRecoverablePersistedOpenCodeRuntimeCandidate(member) &&
+ member?.launchState === 'failed_to_start' &&
+ member.hardFailure === true &&
+ hasOpenCodeRuntimeHandle(member)
+ );
+}
+
+export function isRecoverableOpenCodeRuntimeEvidence(
+ evidence: TeamRuntimeMemberLaunchEvidence | undefined | null
+): evidence is TeamRuntimeMemberLaunchEvidence {
+ if (!evidence) {
+ return false;
+ }
+ return (
+ evidence.runtimeAlive === true ||
+ evidence.bootstrapConfirmed === true ||
+ (evidence.pendingPermissionRequestIds?.length ?? 0) > 0 ||
+ hasOpenCodeRuntimeHandle(evidence) ||
+ (evidence.agentToolAccepted === true && hasOpenCodeRuntimeLivenessMarker(evidence))
+ );
+}
+
+export function isBootstrapMemberEvidenceCurrentForMember(
+ current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string },
+ bootstrapMember: Pick<
+ PersistedTeamLaunchMemberState,
+ 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'lastRuntimeAliveAt' | 'lastEvaluatedAt'
+ >,
+ evidenceKind: 'acceptance' | 'confirmation'
+): boolean {
+ const bootstrapFirstSpawnAcceptedMs = Date.parse(bootstrapMember.firstSpawnAcceptedAt ?? '');
+ const bootstrapLastEvaluatedMs = Date.parse(bootstrapMember.lastEvaluatedAt ?? '');
+ const hasDurableBootstrapSpawnAcceptedAt =
+ Number.isFinite(bootstrapFirstSpawnAcceptedMs) &&
+ (!Number.isFinite(bootstrapLastEvaluatedMs) ||
+ bootstrapFirstSpawnAcceptedMs <= bootstrapLastEvaluatedMs);
+ const evidenceAt =
+ evidenceKind === 'confirmation'
+ ? (bootstrapMember.lastHeartbeatAt ??
+ bootstrapMember.lastRuntimeAliveAt ??
+ bootstrapMember.lastEvaluatedAt)
+ : hasDurableBootstrapSpawnAcceptedAt
+ ? bootstrapMember.firstSpawnAcceptedAt
+ : bootstrapMember.lastEvaluatedAt;
+ const evidenceMs = Date.parse(evidenceAt ?? '');
+ if (!Number.isFinite(evidenceMs)) {
+ return false;
+ }
+ const firstSpawnAcceptedMs = Date.parse(current.firstSpawnAcceptedAt ?? '');
+ const lastEvaluatedMs = Date.parse(current.lastEvaluatedAt ?? '');
+ const hasDurableSpawnBoundary =
+ Number.isFinite(firstSpawnAcceptedMs) &&
+ (!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs);
+ const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
+ return !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs;
+}
diff --git a/src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts b/src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts
new file mode 100644
index 00000000..1b53bc35
--- /dev/null
+++ b/src/main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics.ts
@@ -0,0 +1,172 @@
+import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
+import { migrateProviderBackendId } from '@shared/utils/providerBackend';
+
+import { resolveTeamProviderId } from '../../runtime/providerRuntimeEnv';
+
+import type { GeminiRuntimeAuthState } from '../../runtime/geminiRuntimeAuth';
+import type { ProviderModelLaunchIdentity, TeamCreateRequest, TeamProviderId } from '@shared/types';
+
+export interface PromptSizeSummary {
+ chars: number;
+ lines: number;
+}
+
+export interface RuntimeLaunchLogger {
+ info(message: string): void;
+}
+
+export function getAnthropicFastModeDefault(): boolean {
+ return (
+ ConfigManager.getInstance().getConfig().providerConnections.anthropic.fastModeDefault === true
+ );
+}
+
+export function getTeamProviderLabel(providerId: TeamProviderId): string {
+ switch (providerId) {
+ case 'opencode':
+ return 'OpenCode';
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ case 'anthropic':
+ default:
+ return 'Anthropic';
+ }
+}
+
+export function getConfiguredRuntimeBackend(providerId: TeamProviderId): string | null {
+ const runtimeConfig = ConfigManager.getInstance().getConfig().runtime.providerBackends;
+ switch (providerId) {
+ case 'opencode':
+ return null;
+ case 'gemini':
+ return runtimeConfig.gemini;
+ case 'codex':
+ return migrateProviderBackendId('codex', runtimeConfig.codex) ?? 'codex-native';
+ case 'anthropic':
+ default:
+ return null;
+ }
+}
+
+export function buildRuntimeLaunchWarning(
+ request: Pick<
+ TeamCreateRequest,
+ 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode'
+ >,
+ env: NodeJS.ProcessEnv,
+ options?: {
+ geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
+ promptSize?: PromptSizeSummary | null;
+ expectedMembersCount?: number;
+ }
+): string {
+ const providerId = resolveTeamProviderId(request.providerId);
+ const providerLabel = getTeamProviderLabel(providerId);
+ const modelLabel = request.model?.trim() || 'default';
+ const effortLabel = request.effort ?? 'default';
+ const fastLabel =
+ providerId === 'anthropic'
+ ? `, fast ${request.fastMode ?? (getAnthropicFastModeDefault() ? 'inherit:on' : 'inherit:off')}`
+ : providerId === 'codex'
+ ? `, fast ${request.fastMode ?? 'inherit:off'}`
+ : '';
+ const backend =
+ migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
+ getConfiguredRuntimeBackend(providerId);
+ const flags: string[] = [];
+ if (env.CLAUDE_CODE_USE_GEMINI === '1') flags.push('USE_GEMINI');
+ if (env.CLAUDE_CODE_USE_OPENAI === '1') flags.push('USE_OPENAI');
+ if (env.CLAUDE_CODE_ENTRY_PROVIDER) {
+ flags.push(`ENTRY_PROVIDER=${env.CLAUDE_CODE_ENTRY_PROVIDER}`);
+ }
+ if (env.CLAUDE_CODE_GEMINI_BACKEND) {
+ flags.push(`GEMINI_BACKEND=${env.CLAUDE_CODE_GEMINI_BACKEND}`);
+ }
+ if (env.CLAUDE_CODE_CODEX_BACKEND) {
+ flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`);
+ }
+ if (env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES === '1') {
+ flags.push('FORCE_PROCESS_TEAMMATES');
+ }
+ const backendPart = backend ? `, backend ${backend}` : '';
+ const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : '';
+ const geminiAuth = options?.geminiRuntimeAuth;
+ const authPart =
+ providerId === 'gemini' && geminiAuth
+ ? `, auth ${geminiAuth.authMethod ?? 'none'}/${geminiAuth.resolvedBackend}`
+ : '';
+ const promptSize = options?.promptSize;
+ const promptPart = promptSize
+ ? `, prompt ${promptSize.chars.toLocaleString('en-US')} chars/${promptSize.lines} lines`
+ : '';
+ const membersPart =
+ typeof options?.expectedMembersCount === 'number'
+ ? `, members ${options.expectedMembersCount}`
+ : '';
+ return `Launch runtime: ${providerLabel} · ${modelLabel} · ${effortLabel}${fastLabel}${backendPart}${authPart}${promptPart}${membersPart}${flagsPart}`;
+}
+
+export function logRuntimeLaunchSnapshot(
+ logger: RuntimeLaunchLogger,
+ teamName: string,
+ claudePath: string,
+ args: string[],
+ request: Pick<
+ TeamCreateRequest,
+ 'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode'
+ >,
+ env: NodeJS.ProcessEnv,
+ options?: {
+ geminiRuntimeAuth?: GeminiRuntimeAuthState | null;
+ promptSize?: PromptSizeSummary | null;
+ expectedMembersCount?: number;
+ launchIdentity?: ProviderModelLaunchIdentity | null;
+ }
+): void {
+ const providerId = resolveTeamProviderId(request.providerId);
+ const snapshot = {
+ providerId,
+ providerBackendId: migrateProviderBackendId(providerId, request.providerBackendId) ?? null,
+ model: request.model ?? null,
+ effort: request.effort ?? null,
+ fastMode: request.fastMode ?? null,
+ configuredBackend:
+ migrateProviderBackendId(providerId, request.providerBackendId?.trim()) ||
+ getConfiguredRuntimeBackend(providerId),
+ promptSize: options?.promptSize ?? null,
+ expectedMembersCount: options?.expectedMembersCount ?? null,
+ launchIdentity: options?.launchIdentity ?? null,
+ geminiRuntimeAuth:
+ providerId === 'gemini'
+ ? {
+ authenticated: options?.geminiRuntimeAuth?.authenticated ?? null,
+ authMethod: options?.geminiRuntimeAuth?.authMethod ?? null,
+ resolvedBackend: options?.geminiRuntimeAuth?.resolvedBackend ?? null,
+ projectId: options?.geminiRuntimeAuth?.projectId ?? null,
+ statusMessage: options?.geminiRuntimeAuth?.statusMessage ?? null,
+ }
+ : null,
+ env: {
+ CLAUDE_CODE_USE_GEMINI: env.CLAUDE_CODE_USE_GEMINI ?? null,
+ CLAUDE_CODE_USE_OPENAI: env.CLAUDE_CODE_USE_OPENAI ?? null,
+ CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null,
+ CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null,
+ CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null,
+ CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES ?? null,
+ CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null,
+ CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null,
+ },
+ args,
+ claudePath,
+ };
+ logger.info(`[${teamName}] Launch runtime snapshot ${JSON.stringify(snapshot)}`);
+}
+
+export function getPromptSizeSummary(prompt: string): PromptSizeSummary {
+ return {
+ chars: prompt.length,
+ lines: prompt.length === 0 ? 0 : prompt.split(/\r?\n/g).length,
+ };
+}
diff --git a/test/main/services/team/TeamProvisioningCliExitPresentation.test.ts b/test/main/services/team/TeamProvisioningCliExitPresentation.test.ts
new file mode 100644
index 00000000..abc9d4b4
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningCliExitPresentation.test.ts
@@ -0,0 +1,135 @@
+import {
+ buildCliExitFailurePresentation,
+ buildCombinedLogs,
+ buildDeterministicBootstrapExitFailure,
+ buildSanitizedCliExitError,
+ type CliExitPresentationRun,
+ formatPendingBootstrapMemberNames,
+ parseCliLogLinesFromText,
+} from '@main/services/team/provisioning/TeamProvisioningCliExitPresentation';
+import { describe, expect, it } from 'vitest';
+
+function run(overrides: Partial = {}): CliExitPresentationRun {
+ return {
+ stdoutBuffer: '',
+ stderrBuffer: '',
+ claudeLogLines: [],
+ deterministicBootstrap: false,
+ deterministicBootstrapMemberSpawnSeen: false,
+ expectedMembers: [],
+ memberSpawnStatuses: new Map(),
+ ...overrides,
+ };
+}
+
+describe('TeamProvisioningCliExitPresentation', () => {
+ it('combines stdout and stderr with stable stream markers', () => {
+ expect(buildCombinedLogs('', '')).toBe('');
+ expect(buildCombinedLogs(' stdout only ', '')).toBe('stdout only');
+ expect(buildCombinedLogs('', ' stderr only ')).toBe('stderr only');
+ expect(buildCombinedLogs('out', 'err')).toBe('[stdout]\nout\n\n[stderr]\nerr');
+ });
+
+ it('parses stream markers and ignores blank log lines', () => {
+ expect(parseCliLogLinesFromText('[stdout]\nhello\n\n[stderr]\nboom')).toEqual([
+ { stream: 'stdout', text: 'hello' },
+ { stream: 'stderr', text: 'boom' },
+ ]);
+ });
+
+ it('extracts structured CLI errors while filtering setup noise', () => {
+ const sanitized = buildSanitizedCliExitError(
+ run({
+ claudeLogLines: [
+ '[stdout]',
+ '{"type":"system","subtype":"init","message":"ignore"}',
+ '[stderr]',
+ '{"type":"error","message":"Invalid API key"}',
+ '{"type":"result","subtype":"error","error":"Quota exceeded"}',
+ 'TodoWrite hook_progress should stay hidden',
+ 'plain stderr failure',
+ ],
+ })
+ );
+
+ expect(sanitized).toBe('Invalid API key\nQuota exceeded\nplain stderr failure');
+ });
+
+ it('adds final partial buffer errors when line history already exists', () => {
+ expect(
+ buildSanitizedCliExitError(
+ run({
+ claudeLogLines: ['[stderr]', 'first failure'],
+ stderrBuffer: 'first failure\nsecond failure without newline',
+ })
+ )
+ ).toBe('first failure\nsecond failure without newline');
+ });
+
+ it('reports login guidance before generic sanitized errors', () => {
+ expect(
+ buildCliExitFailurePresentation(
+ run({ stderrBuffer: 'Please run /login to authenticate' }),
+ 1,
+ { cliCommandLabel: 'Claude CLI' }
+ ).error
+ ).toContain('Claude CLI reports it is not authenticated');
+ });
+
+ it('formats deterministic bootstrap failures by observed stage', () => {
+ expect(
+ buildDeterministicBootstrapExitFailure(run({ deterministicBootstrap: true })).error
+ ).toContain('before deterministic team bootstrap started');
+
+ expect(
+ buildDeterministicBootstrapExitFailure(
+ run({
+ deterministicBootstrap: true,
+ lastDeterministicBootstrapEvent: 'team_bootstrap',
+ lastDeterministicBootstrapPhase: 'planning',
+ })
+ ).error
+ ).toContain('Last bootstrap event: team_bootstrap/planning');
+ });
+
+ it('summarizes pending bootstrap members with a stable cap', () => {
+ expect(
+ formatPendingBootstrapMemberNames(
+ run({
+ expectedMembers: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'],
+ memberSpawnStatuses: new Map([
+ ['a', { bootstrapConfirmed: true }],
+ ['b', { bootstrapConfirmed: false }],
+ ]),
+ })
+ )
+ ).toBe('b, c, d, e, f, g and 1 more');
+ });
+
+ it('falls back to deterministic or generic exit presentations when logs are not useful', () => {
+ expect(
+ buildCliExitFailurePresentation(
+ run({
+ deterministicBootstrap: true,
+ lastDeterministicBootstrapEvent: 'team_bootstrap',
+ deterministicBootstrapMemberSpawnSeen: true,
+ expectedMembers: ['lead', 'worker'],
+ memberSpawnStatuses: new Map([['lead', { bootstrapConfirmed: true }]]),
+ }),
+ 1,
+ { cliCommandLabel: 'Codex runtime' }
+ )
+ ).toEqual({
+ message: 'Launch bootstrap was not confirmed',
+ error:
+ 'Bootstrap was not confirmed before the Codex runtime exited. Pending teammates: worker.',
+ });
+
+ expect(
+ buildCliExitFailurePresentation(run(), 1, { cliCommandLabel: 'Claude CLI' }).error
+ ).toContain('Claude CLI exited with code 1 without user-facing stdout/stderr');
+ expect(
+ buildCliExitFailurePresentation(run(), null, { cliCommandLabel: 'Claude CLI' }).error
+ ).toBe('Claude CLI exited with code unknown');
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningDirectRestart.test.ts b/test/main/services/team/TeamProvisioningDirectRestart.test.ts
new file mode 100644
index 00000000..b1fee3a8
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningDirectRestart.test.ts
@@ -0,0 +1,146 @@
+import {
+ buildDirectTmuxRestartCommand,
+ buildDirectTmuxRestartEnvAssignments,
+ hasAnthropicCompatibleAuthTokenEnv,
+ isAnthropicCompatibleBaseUrl,
+ isInteractiveShellCommand,
+ shellQuote,
+} from '@main/services/team/provisioning/TeamProvisioningDirectRestart';
+import { describe, expect, it } from 'vitest';
+
+describe('TeamProvisioningDirectRestart', () => {
+ it('quotes shell values without losing apostrophes or empty strings', () => {
+ expect(shellQuote('')).toBe("''");
+ expect(shellQuote('/tmp/demo path')).toBe("'/tmp/demo path'");
+ expect(shellQuote("worker's path")).toBe("'worker'\\''s path'");
+ });
+
+ it('detects interactive shell pane commands by basename', () => {
+ expect(isInteractiveShellCommand('/bin/zsh')).toBe(true);
+ expect(isInteractiveShellCommand(' FISH ')).toBe(true);
+ expect(isInteractiveShellCommand('node')).toBe(false);
+ expect(isInteractiveShellCommand(undefined)).toBe(false);
+ });
+
+ it('classifies Anthropic-compatible base URLs without accepting first-party or credential URLs', () => {
+ expect(isAnthropicCompatibleBaseUrl('http://localhost:1234')).toBe(true);
+ expect(isAnthropicCompatibleBaseUrl('https://proxy.example.test')).toBe(true);
+ expect(isAnthropicCompatibleBaseUrl('https://api.anthropic.com')).toBe(false);
+ expect(isAnthropicCompatibleBaseUrl('https://api-staging.anthropic.com')).toBe(false);
+ expect(isAnthropicCompatibleBaseUrl('http://token@localhost:1234')).toBe(false);
+ expect(isAnthropicCompatibleBaseUrl('not a url')).toBe(false);
+ expect(isAnthropicCompatibleBaseUrl('')).toBe(false);
+ });
+
+ it('requires both compatible base URL and auth token for compatible auth token env', () => {
+ expect(
+ hasAnthropicCompatibleAuthTokenEnv({
+ ANTHROPIC_BASE_URL: 'http://localhost:1234',
+ ANTHROPIC_AUTH_TOKEN: 'local-token',
+ })
+ ).toBe(true);
+ expect(
+ hasAnthropicCompatibleAuthTokenEnv({
+ ANTHROPIC_BASE_URL: 'http://localhost:1234',
+ ANTHROPIC_AUTH_TOKEN: ' ',
+ })
+ ).toBe(false);
+ expect(
+ hasAnthropicCompatibleAuthTokenEnv({
+ ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
+ ANTHROPIC_AUTH_TOKEN: 'stale-token',
+ })
+ ).toBe(false);
+ });
+
+ it('preserves provider-specific direct restart env while resetting provider selection flags', () => {
+ const assignments = buildDirectTmuxRestartEnvAssignments(
+ {
+ CODEX_HOME: '/tmp/codex home',
+ CLAUDE_CODE_USE_GEMINI: '1',
+ CLAUDE_CODE_ENTRY_PROVIDER: 'gemini',
+ CLAUDE_CODE_CODEX_BACKEND: 'codex-native',
+ },
+ 'codex'
+ );
+
+ expect(assignments).toContain("CLAUDECODE='1'");
+ expect(assignments).toContain("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS='1'");
+ expect(assignments).toContain("CODEX_HOME='/tmp/codex home'");
+ expect(assignments).toContain("CLAUDE_CODE_USE_GEMINI=''");
+ expect(assignments).toContain("CLAUDE_CODE_ENTRY_PROVIDER='codex'");
+ expect(assignments).toContain("CLAUDE_CODE_CODEX_BACKEND='codex-native'");
+ expect(assignments).toContain("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST='1'");
+ });
+
+ it('preserves Anthropic-compatible tokens but blanks stale first-party auth tokens', () => {
+ const compatibleAssignments = buildDirectTmuxRestartEnvAssignments(
+ {
+ ANTHROPIC_BASE_URL: ' http://localhost:1234 ',
+ ANTHROPIC_AUTH_TOKEN: ' local-token ',
+ ANTHROPIC_API_KEY: '',
+ },
+ 'anthropic'
+ );
+
+ expect(compatibleAssignments).toContain("ANTHROPIC_BASE_URL='http://localhost:1234'");
+ expect(compatibleAssignments).toContain("ANTHROPIC_AUTH_TOKEN='local-token'");
+ expect(compatibleAssignments).toContain("ANTHROPIC_API_KEY=''");
+
+ const firstPartyAssignments = buildDirectTmuxRestartEnvAssignments(
+ {
+ ANTHROPIC_BASE_URL: 'https://api.anthropic.com',
+ ANTHROPIC_AUTH_TOKEN: 'stale-token',
+ },
+ 'anthropic'
+ );
+
+ expect(firstPartyAssignments).toContain("ANTHROPIC_BASE_URL='https://api.anthropic.com'");
+ expect(firstPartyAssignments).toContain("ANTHROPIC_AUTH_TOKEN=''");
+ expect(firstPartyAssignments).not.toContain('stale-token');
+ });
+
+ it('blanks competing Anthropic helper auth carriers for direct restart helper mode', () => {
+ const assignments = buildDirectTmuxRestartEnvAssignments(
+ {
+ CLAUDE_TEAM_ANTHROPIC_AUTH_MODE: 'api_key_helper',
+ CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH:
+ '/tmp/team-runtime-auth/demo/runtime-settings-anthropic.json',
+ ANTHROPIC_API_KEY: 'sk-ant-direct-restart-should-not-leak',
+ ANTHROPIC_AUTH_TOKEN: 'direct-restart-token-should-not-leak',
+ CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR: '3',
+ CLAUDE_CODE_OAUTH_TOKEN: 'direct-restart-oauth-token-should-not-leak',
+ CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR: '4',
+ },
+ 'anthropic'
+ );
+
+ expect(assignments).toContain("CLAUDE_TEAM_ANTHROPIC_AUTH_MODE='api_key_helper'");
+ expect(assignments).toContain(
+ "CLAUDE_TEAM_ANTHROPIC_API_KEY_HELPER_SETTINGS_PATH='/tmp/team-runtime-auth/demo/runtime-settings-anthropic.json'"
+ );
+ expect(assignments).toContain("ANTHROPIC_API_KEY=''");
+ expect(assignments).toContain("ANTHROPIC_AUTH_TOKEN=''");
+ expect(assignments).toContain("CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR=''");
+ expect(assignments).toContain("CLAUDE_CODE_OAUTH_TOKEN=''");
+ expect(assignments).toContain("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR=''");
+ expect(assignments).not.toContain('sk-ant-direct-restart-should-not-leak');
+ expect(assignments).not.toContain('direct-restart-token-should-not-leak');
+ expect(assignments).not.toContain('direct-restart-oauth-token-should-not-leak');
+ });
+
+ it('builds a restart command that preserves cwd, binary and args quoting', () => {
+ const command = buildDirectTmuxRestartCommand({
+ cwd: '/tmp/team work',
+ env: { CODEX_HOME: '/tmp/codex' },
+ providerId: 'codex',
+ binaryPath: '/usr/local/bin/claude',
+ args: ['--model', "gpt worker's model"],
+ });
+
+ expect(command).toContain("cd '/tmp/team work' && env");
+ expect(command).toContain("CODEX_HOME='/tmp/codex'");
+ expect(command).toContain("'/usr/local/bin/claude' '--model' 'gpt worker'\\''s model'");
+ expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__:%s');
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts b/test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts
new file mode 100644
index 00000000..89a67b78
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningLaunchCompatibility.test.ts
@@ -0,0 +1,75 @@
+import {
+ assertDeterministicBootstrapPrimaryMemberLimit,
+ assertOpenCodeNotLaunchedThroughLegacyProvisioning,
+ buildLargeDeterministicBootstrapWarning,
+ getMixedLaunchFallbackRecoveryError,
+ getOpenCodeMixedProviderProvisioningError,
+ isPureOpenCodeProvisioningRequest,
+ mergeProvisioningWarnings,
+} from '@main/services/team/provisioning/TeamProvisioningLaunchCompatibility';
+import { describe, expect, it } from 'vitest';
+
+describe('TeamProvisioningLaunchCompatibility', () => {
+ it('classifies pure OpenCode legacy provisioning requests', () => {
+ expect(
+ isPureOpenCodeProvisioningRequest({
+ providerId: 'opencode',
+ members: [{ providerId: 'opencode' }, {}],
+ })
+ ).toBe(true);
+
+ expect(
+ isPureOpenCodeProvisioningRequest({
+ providerId: 'codex',
+ members: [{ providerId: 'opencode' }],
+ })
+ ).toBe(false);
+ });
+
+ it('blocks pure OpenCode legacy stream-json launch but allows supported side-lane mixed teams', () => {
+ expect(() =>
+ assertOpenCodeNotLaunchedThroughLegacyProvisioning({
+ providerId: 'opencode',
+ members: [{ providerId: 'opencode' }],
+ })
+ ).toThrow('OpenCode team launch is not enabled in the legacy Claude stream-json provisioning path');
+
+ expect(() =>
+ assertOpenCodeNotLaunchedThroughLegacyProvisioning({
+ providerId: 'codex',
+ members: [{ providerId: 'opencode' }, { providerId: 'codex' }],
+ })
+ ).not.toThrow();
+ });
+
+ it('keeps unsupported OpenCode-led mixed teams blocked with planner diagnostics', () => {
+ expect(() =>
+ assertOpenCodeNotLaunchedThroughLegacyProvisioning({
+ providerId: 'opencode',
+ members: [{ providerId: 'anthropic' }, { providerId: 'opencode' }],
+ })
+ ).toThrow('Mixed teams with an OpenCode lead are not supported');
+ });
+
+ it('deduplicates provisioning warnings while preserving latest ordering', () => {
+ expect(mergeProvisioningWarnings(undefined, null)).toBeUndefined();
+ expect(mergeProvisioningWarnings(['alpha', 'beta'], 'alpha')).toEqual(['beta', 'alpha']);
+ expect(mergeProvisioningWarnings(['alpha'], 'beta')).toEqual(['alpha', 'beta']);
+ });
+
+ it('bounds deterministic bootstrap team size warnings and hard limits', () => {
+ expect(buildLargeDeterministicBootstrapWarning(8)).toBeNull();
+ expect(buildLargeDeterministicBootstrapWarning(9)).toContain('Large Codex team launch: 9');
+ expect(() => assertDeterministicBootstrapPrimaryMemberLimit(20)).not.toThrow();
+ expect(() => assertDeterministicBootstrapPrimaryMemberLimit(21)).toThrow(
+ 'supports up to 20 primary teammates'
+ );
+ });
+
+ it('keeps user-facing compatibility error copy stable', () => {
+ expect(getOpenCodeMixedProviderProvisioningError()).toContain(
+ 'outside the current support scope'
+ );
+ expect(getMixedLaunchFallbackRecoveryError()).toContain('missing stable member metadata');
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts b/test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts
new file mode 100644
index 00000000..d05f9957
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningLaunchDiagnostics.test.ts
@@ -0,0 +1,324 @@
+import {
+ buildLaunchDiagnosticsFromRun,
+ buildWorkspaceTrustPreflightLaunchDiagnostic,
+ mentionsProcessTableUnavailable,
+ mergeLaunchDiagnosticItem,
+} from '@main/services/team/provisioning/TeamProvisioningLaunchDiagnostics';
+import { describe, expect, it } from 'vitest';
+
+import type { WorkspaceTrustExecutionResult } from '@features/workspace-trust/main';
+import type { MemberSpawnStatusEntry, TeamLaunchDiagnosticItem } from '@shared/types';
+
+const NOW = '2026-05-24T00:00:00.000Z';
+const nowIso = () => NOW;
+
+function spawnEntry(overrides: Partial): MemberSpawnStatusEntry {
+ return {
+ status: 'waiting',
+ launchState: 'starting',
+ updatedAt: NOW,
+ ...overrides,
+ };
+}
+
+function buildRun(entries: Array<[string, Partial]>, isLaunch = true) {
+ return {
+ isLaunch,
+ memberSpawnStatuses: new Map(
+ entries.map(([memberName, entry]) => [memberName, spawnEntry(entry)])
+ ),
+ };
+}
+
+function workspaceTrustExecution(
+ overrides: Partial
+): WorkspaceTrustExecutionResult {
+ return {
+ id: 'claude-pty-workspace-trust',
+ provider: 'claude',
+ status: 'ok',
+ workspaceIds: ['workspace-1'],
+ ...overrides,
+ };
+}
+
+describe('TeamProvisioningLaunchDiagnostics', () => {
+ it('skips non-launch and empty launch runs', () => {
+ expect(buildLaunchDiagnosticsFromRun(buildRun([], false), { nowIso })).toBeUndefined();
+ expect(buildLaunchDiagnosticsFromRun(buildRun([]), { nowIso })).toBeUndefined();
+ expect(
+ buildLaunchDiagnosticsFromRun({ isLaunch: true, memberSpawnStatuses: undefined }, { nowIso })
+ ).toBeUndefined();
+ });
+
+ it('projects member spawn status entries into launch diagnostics without changing priority order', () => {
+ const diagnostics = buildLaunchDiagnosticsFromRun(
+ buildRun([
+ ['Lead', { launchState: 'confirmed_alive' }],
+ [
+ 'WorkerA',
+ {
+ launchState: 'failed_to_start',
+ error: 'fallback error',
+ hardFailureReason: 'hard failure reason',
+ agentToolAccepted: true,
+ },
+ ],
+ [
+ 'WorkerB',
+ {
+ launchState: 'runtime_pending_permission',
+ runtimeDiagnostic: 'waiting for user approval',
+ },
+ ],
+ [
+ 'WorkerC',
+ {
+ bootstrapStalled: true,
+ runtimeDiagnostic: 'bootstrap deadline exceeded',
+ livenessKind: 'runtime_process',
+ },
+ ],
+ [
+ 'WorkerD',
+ {
+ runtimeDiagnostic: 'process table temporarily unavailable',
+ livenessKind: 'shell_only',
+ },
+ ],
+ ['WorkerE', { livenessKind: 'shell_only', runtimeDiagnostic: 'tmux pane only' }],
+ [
+ 'WorkerF',
+ {
+ livenessKind: 'runtime_process_candidate',
+ runtimeDiagnostic: 'process found but bootstrap missing',
+ },
+ ],
+ ['WorkerG', { livenessKind: 'runtime_process', runtimeDiagnostic: 'process found' }],
+ ['WorkerH', { livenessKind: 'registered_only', runtimeDiagnostic: 'registered only' }],
+ ['WorkerI', { livenessKind: 'stale_metadata', runtimeDiagnostic: 'stale metadata' }],
+ ['WorkerJ', { livenessKind: 'not_found', runtimeDiagnostic: 'no runtime' }],
+ ['WorkerK', { agentToolAccepted: true, runtimeDiagnostic: 'spawn accepted' }],
+ ['WorkerL', {}],
+ ]),
+ { nowIso }
+ );
+
+ expect(diagnostics).toEqual([
+ {
+ id: 'Lead:bootstrap_confirmed',
+ memberName: 'Lead',
+ severity: 'info',
+ code: 'bootstrap_confirmed',
+ label: 'Lead - bootstrap confirmed',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerA:bootstrap_stalled',
+ memberName: 'WorkerA',
+ severity: 'error',
+ code: 'bootstrap_stalled',
+ label: 'WorkerA - failed to start',
+ detail: 'hard failure reason',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerB:permission_pending',
+ memberName: 'WorkerB',
+ severity: 'warning',
+ code: 'permission_pending',
+ label: 'WorkerB - awaiting permission',
+ detail: 'waiting for user approval',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerC:bootstrap_stalled',
+ memberName: 'WorkerC',
+ severity: 'warning',
+ code: 'bootstrap_stalled',
+ label: 'WorkerC - bootstrap stalled',
+ detail: 'bootstrap deadline exceeded',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerD:process_table_unavailable',
+ memberName: 'WorkerD',
+ severity: 'warning',
+ code: 'process_table_unavailable',
+ label: 'WorkerD - process table unavailable',
+ detail: 'process table temporarily unavailable',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerE:tmux_shell_only',
+ memberName: 'WorkerE',
+ severity: 'warning',
+ code: 'tmux_shell_only',
+ label: 'WorkerE - shell only',
+ detail: 'tmux pane only',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerF:runtime_process_candidate',
+ memberName: 'WorkerF',
+ severity: 'warning',
+ code: 'runtime_process_candidate',
+ label: 'WorkerF - bootstrap unconfirmed',
+ detail: 'process found but bootstrap missing',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerG:runtime_process_detected',
+ memberName: 'WorkerG',
+ severity: 'info',
+ code: 'runtime_process_detected',
+ label: 'WorkerG - waiting for bootstrap',
+ detail: 'process found',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerH:runtime_not_found',
+ memberName: 'WorkerH',
+ severity: 'warning',
+ code: 'runtime_not_found',
+ label: 'WorkerH - waiting for runtime',
+ detail: 'registered only',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerI:runtime_not_found',
+ memberName: 'WorkerI',
+ severity: 'warning',
+ code: 'runtime_not_found',
+ label: 'WorkerI - waiting for runtime',
+ detail: 'stale metadata',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerJ:runtime_not_found',
+ memberName: 'WorkerJ',
+ severity: 'warning',
+ code: 'runtime_not_found',
+ label: 'WorkerJ - waiting for runtime',
+ detail: 'no runtime',
+ observedAt: NOW,
+ },
+ {
+ id: 'WorkerK:spawn_accepted',
+ memberName: 'WorkerK',
+ severity: 'info',
+ code: 'spawn_accepted',
+ label: 'WorkerK - spawn accepted',
+ detail: 'spawn accepted',
+ observedAt: NOW,
+ },
+ ]);
+ });
+
+ it('uses failed launch error when hard failure reason is absent', () => {
+ expect(
+ buildLaunchDiagnosticsFromRun(
+ buildRun([['Worker', { launchState: 'failed_to_start', error: 'spawn failed' }]]),
+ { nowIso }
+ )?.[0]
+ ).toMatchObject({
+ id: 'Worker:bootstrap_stalled',
+ detail: 'spawn failed',
+ });
+ });
+
+ it('recognizes process table unavailable diagnostics case-insensitively', () => {
+ expect(mentionsProcessTableUnavailable('Process table is currently unavailable')).toBe(true);
+ expect(mentionsProcessTableUnavailable('process runtime unavailable')).toBe(false);
+ expect(mentionsProcessTableUnavailable(undefined)).toBe(false);
+ });
+
+ it('builds workspace trust launch diagnostics for blocked, soft-failed, and completed preflight results', () => {
+ expect(
+ buildWorkspaceTrustPreflightLaunchDiagnostic(
+ workspaceTrustExecution({
+ status: 'blocked',
+ errorCode: 'workspace_trust_required',
+ errorMessage: ' trust must be accepted ',
+ evidence: ['fallback evidence'],
+ }),
+ { nowIso }
+ )
+ ).toEqual({
+ id: 'workspace-trust:preflight',
+ severity: 'error',
+ code: 'workspace_trust_preflight',
+ label: 'Workspace trust preflight blocked launch',
+ detail: 'trust must be accepted',
+ observedAt: NOW,
+ });
+
+ expect(
+ buildWorkspaceTrustPreflightLaunchDiagnostic(
+ workspaceTrustExecution({
+ status: 'soft_failed',
+ errorCode: 'workspace_trust_probe_failed',
+ evidence: ['fallback evidence'],
+ }),
+ { nowIso }
+ )
+ ).toMatchObject({
+ severity: 'warning',
+ label: 'Workspace trust preflight could not verify trust',
+ detail: 'workspace_trust_probe_failed',
+ });
+
+ expect(
+ buildWorkspaceTrustPreflightLaunchDiagnostic(
+ workspaceTrustExecution({
+ status: 'ok',
+ evidence: [' ', ' trusted from state probe '],
+ }),
+ { nowIso }
+ )
+ ).toMatchObject({
+ severity: 'info',
+ label: 'Workspace trust preflight completed',
+ detail: 'trusted from state probe',
+ });
+ });
+
+ it('omits cancelled workspace trust launch diagnostics', () => {
+ expect(
+ buildWorkspaceTrustPreflightLaunchDiagnostic(
+ workspaceTrustExecution({ status: 'cancelled' }),
+ { nowIso }
+ )
+ ).toBeNull();
+ });
+
+ it('merges launch diagnostic items by id without mutating existing diagnostics', () => {
+ const existing: TeamLaunchDiagnosticItem[] = [
+ {
+ id: 'old',
+ severity: 'info',
+ code: 'spawn_accepted',
+ label: 'Old',
+ observedAt: NOW,
+ },
+ {
+ id: 'same',
+ severity: 'warning',
+ code: 'runtime_not_found',
+ label: 'Previous',
+ observedAt: NOW,
+ },
+ ];
+ const replacement: TeamLaunchDiagnosticItem = {
+ id: 'same',
+ severity: 'error',
+ code: 'bootstrap_stalled',
+ label: 'Replacement',
+ observedAt: NOW,
+ };
+
+ expect(mergeLaunchDiagnosticItem(existing, replacement)).toEqual([existing[0], replacement]);
+ expect(existing[1].label).toBe('Previous');
+ expect(mergeLaunchDiagnosticItem(undefined, replacement)).toEqual([replacement]);
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts b/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts
new file mode 100644
index 00000000..a98a03f8
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningLaunchFailurePolicy.test.ts
@@ -0,0 +1,113 @@
+import {
+ deriveMemberLaunchState,
+ isAutoClearableLaunchFailureReason,
+ isBootstrapCheckInTimeoutFailureReason,
+ isBootstrapInstructionPromptFailureReason,
+ isBootstrapMcpResourceReadFailureReason,
+ isConfigRegistrationFailureReason,
+ isLaunchCleanupBootstrapIncompleteFailureReason,
+ isLaunchGraceWindowFailureReason,
+ isNeverSpawnedDuringLaunchReason,
+ isOpenCodeBridgeLaunchFailureReason,
+ isProcessTableUnavailableFailureReason,
+ isRegisteredRuntimeMetadataFailureReason,
+ stripProcessTableUnavailableDiagnosticSuffix,
+} from '@main/services/team/provisioning/TeamProvisioningLaunchFailurePolicy';
+import { describe, expect, it } from 'vitest';
+
+describe('TeamProvisioningLaunchFailurePolicy', () => {
+ it('recognizes exact launch failure reasons that are safe to auto-clear', () => {
+ expect(isNeverSpawnedDuringLaunchReason(' Teammate was never spawned during launch. ')).toBe(
+ true
+ );
+ expect(
+ isLaunchGraceWindowFailureReason('Teammate did not join within the launch grace window.')
+ ).toBe(true);
+ expect(
+ isConfigRegistrationFailureReason(
+ 'Teammate was not registered in config.json during launch. Persistent spawn failed.'
+ )
+ ).toBe(true);
+ expect(isOpenCodeBridgeLaunchFailureReason('OpenCode bridge reported member launch failure')).toBe(
+ true
+ );
+ expect(
+ isRegisteredRuntimeMetadataFailureReason('registered runtime metadata without live process')
+ ).toBe(true);
+ });
+
+ it('recognizes bootstrap-specific failure reasons without accepting unrelated text', () => {
+ expect(
+ isBootstrapMcpResourceReadFailureReason(
+ 'resources/read failed for member_briefing: MCP error method not found'
+ )
+ ).toBe(true);
+ expect(isBootstrapMcpResourceReadFailureReason('resources/read failed for other resource')).toBe(
+ false
+ );
+ expect(
+ isBootstrapCheckInTimeoutFailureReason(
+ 'Teammate was registered but did not bootstrap-confirm before timeout.'
+ )
+ ).toBe(true);
+ expect(
+ isBootstrapInstructionPromptFailureReason(
+ 'You are bootstrapping into team atlas. Your first action is to call the MCP tool member_briefing.'
+ )
+ ).toBe(true);
+ expect(
+ isLaunchCleanupBootstrapIncompleteFailureReason(
+ 'Launch ended before teammate bootstrap completed. Runtime process was alive after bootstrap failure'
+ )
+ ).toBe(true);
+ });
+
+ it('handles process-table unavailable reasons and suffixes conservatively', () => {
+ expect(isProcessTableUnavailableFailureReason('process table unavailable')).toBe(true);
+ expect(
+ isProcessTableUnavailableFailureReason(
+ 'runtime pid could not be verified because process table is unavailable'
+ )
+ ).toBe(true);
+ expect(isProcessTableUnavailableFailureReason('runtime failed; process table unavailable')).toBe(
+ false
+ );
+ expect(
+ stripProcessTableUnavailableDiagnosticSuffix(
+ 'Teammate did not join within the launch grace window.; process table unavailable'
+ )
+ ).toBe('Teammate did not join within the launch grace window.');
+ });
+
+ it('keeps auto-clear policy narrow but accepts known recoverable suffixes', () => {
+ expect(
+ isAutoClearableLaunchFailureReason('Teammate was never spawned during launch.')
+ ).toBe(true);
+ expect(isAutoClearableLaunchFailureReason('process table is unavailable')).toBe(true);
+ expect(
+ isAutoClearableLaunchFailureReason(
+ 'Teammate did not join within the launch grace window.; process table unavailable'
+ )
+ ).toBe(true);
+ expect(isAutoClearableLaunchFailureReason('model not found')).toBe(false);
+ expect(isAutoClearableLaunchFailureReason(undefined)).toBe(false);
+ });
+
+ it('derives member launch state by the existing precedence order', () => {
+ expect(deriveMemberLaunchState({ skippedForLaunch: true, hardFailure: true })).toBe(
+ 'skipped_for_launch'
+ );
+ expect(deriveMemberLaunchState({ hardFailure: true, bootstrapConfirmed: true })).toBe(
+ 'failed_to_start'
+ );
+ expect(deriveMemberLaunchState({ bootstrapConfirmed: true })).toBe('confirmed_alive');
+ expect(deriveMemberLaunchState({ pendingPermissionRequestIds: ['req-1'] })).toBe(
+ 'runtime_pending_permission'
+ );
+ expect(deriveMemberLaunchState({ runtimeAlive: true })).toBe('runtime_pending_bootstrap');
+ expect(deriveMemberLaunchState({ agentToolAccepted: true })).toBe(
+ 'runtime_pending_bootstrap'
+ );
+ expect(deriveMemberLaunchState({})).toBe('starting');
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningMemberIdentity.test.ts b/test/main/services/team/TeamProvisioningMemberIdentity.test.ts
new file mode 100644
index 00000000..a03b0b43
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningMemberIdentity.test.ts
@@ -0,0 +1,54 @@
+import {
+ isOpenCodeOverlayMemberRemoved,
+ matchesExactTeamMemberName,
+ matchesMemberNameOrBase,
+ matchesObservedMemberNameForExpected,
+ matchesTeamMemberIdentity,
+ namesMatchCaseInsensitive,
+} from '@main/services/team/provisioning/TeamProvisioningMemberIdentity';
+import { describe, expect, it } from 'vitest';
+
+describe('TeamProvisioningMemberIdentity', () => {
+ it('matches a member name against its auto-suffixed variants', () => {
+ expect(matchesMemberNameOrBase('Builder', 'Builder')).toBe(true);
+ expect(matchesMemberNameOrBase('Builder-2', 'Builder')).toBe(true);
+ expect(matchesMemberNameOrBase('Builder-10', 'Builder')).toBe(true);
+ expect(matchesMemberNameOrBase('Builder-1', 'Builder')).toBe(false);
+ expect(matchesMemberNameOrBase('Builder 2', 'Builder')).toBe(false);
+ expect(matchesMemberNameOrBase('Builder-2', 'builder')).toBe(false);
+ expect(matchesMemberNameOrBase('Reviewer-2', 'Builder')).toBe(false);
+ });
+
+ it('matches team member identity in either direction', () => {
+ expect(matchesTeamMemberIdentity('Builder-2', 'Builder')).toBe(true);
+ expect(matchesTeamMemberIdentity('Builder', 'Builder-2')).toBe(true);
+ expect(matchesTeamMemberIdentity('Builder-2', 'Builder-3')).toBe(false);
+ });
+
+ it('keeps observed-name matching one-directional', () => {
+ expect(matchesObservedMemberNameForExpected('Builder-2', 'Builder')).toBe(true);
+ expect(matchesObservedMemberNameForExpected('Builder', 'Builder-2')).toBe(false);
+ });
+
+ it('matches exact team member names case-insensitively after trimming', () => {
+ expect(matchesExactTeamMemberName(' Builder ', 'builder')).toBe(true);
+ expect(matchesExactTeamMemberName('', 'builder')).toBe(false);
+ expect(matchesExactTeamMemberName('Builder-2', 'Builder')).toBe(false);
+ });
+
+ it('detects removed OpenCode overlay members case-insensitively', () => {
+ expect(namesMatchCaseInsensitive(' Builder ', 'builder')).toBe(true);
+ expect(namesMatchCaseInsensitive('Builder-2', 'Builder')).toBe(false);
+ expect(
+ isOpenCodeOverlayMemberRemoved(
+ [
+ { name: 'Reviewer', removedAt: undefined },
+ { name: ' Builder ', removedAt: 123 },
+ ],
+ 'builder'
+ )
+ ).toBe(true);
+ expect(isOpenCodeOverlayMemberRemoved([{ name: 'Builder' }], 'builder')).toBe(false);
+ expect(isOpenCodeOverlayMemberRemoved([{ removedAt: 123 }], 'builder')).toBe(false);
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts b/test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts
new file mode 100644
index 00000000..f425ba1c
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningMemberSpawnStatusPolicy.test.ts
@@ -0,0 +1,190 @@
+import {
+ buildRestartDuplicateUnconfirmedReason,
+ buildRestartGraceTimeoutReason,
+ buildRestartStillRunningReason,
+ createInitialMemberSpawnStatusEntry,
+ deriveTaskActivityPauseAt,
+ deriveTaskActivityResumeAt,
+ MEMBER_LAUNCH_GRACE_MS,
+ parseOptionalIsoMs,
+ shouldWarnOnMissingRegisteredMember,
+ shouldWarnOnUnreadableMemberAuditConfig,
+ summarizeMemberSpawnStatusRecord,
+} from '@main/services/team/provisioning/TeamProvisioningMemberSpawnStatusPolicy';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { MemberSpawnStatusEntry } from '@shared/types';
+
+function makeStatus(overrides: Partial = {}): MemberSpawnStatusEntry {
+ return {
+ status: 'offline',
+ launchState: 'starting',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+describe('TeamProvisioningMemberSpawnStatusPolicy', () => {
+ it('warns about unreadable audit config only after throttle and launch grace windows', () => {
+ const acceptedAt = '2026-01-01T00:00:00.000Z';
+ const nowMs = Date.parse(acceptedAt) + MEMBER_LAUNCH_GRACE_MS;
+ const memberSpawnStatuses = new Map([
+ ['Builder', { agentToolAccepted: true, firstSpawnAcceptedAt: acceptedAt }],
+ ]);
+
+ expect(
+ shouldWarnOnUnreadableMemberAuditConfig({
+ nowMs,
+ lastWarnAt: nowMs - 9_999,
+ expectedMembers: ['Builder'],
+ memberSpawnStatuses,
+ })
+ ).toBe(false);
+ expect(
+ shouldWarnOnUnreadableMemberAuditConfig({
+ nowMs,
+ lastWarnAt: 0,
+ expectedMembers: ['Builder'],
+ memberSpawnStatuses,
+ })
+ ).toBe(true);
+ expect(
+ shouldWarnOnUnreadableMemberAuditConfig({
+ nowMs,
+ lastWarnAt: 0,
+ expectedMembers: ['Reviewer'],
+ memberSpawnStatuses,
+ })
+ ).toBe(false);
+ });
+
+ it('warns about missing registered members only after grace expiry and throttle', () => {
+ expect(
+ shouldWarnOnMissingRegisteredMember({
+ nowMs: 20_000,
+ lastWarnAt: 0,
+ graceExpired: false,
+ })
+ ).toBe(false);
+ expect(
+ shouldWarnOnMissingRegisteredMember({
+ nowMs: 20_000,
+ lastWarnAt: 15_000,
+ graceExpired: true,
+ })
+ ).toBe(false);
+ expect(
+ shouldWarnOnMissingRegisteredMember({
+ nowMs: 20_000,
+ lastWarnAt: 0,
+ graceExpired: true,
+ })
+ ).toBe(true);
+ });
+
+ it('derives bounded task activity pause and resume timestamps', () => {
+ expect(parseOptionalIsoMs(undefined)).toBe(0);
+ expect(parseOptionalIsoMs('not-a-date')).toBe(0);
+ expect(parseOptionalIsoMs('2026-01-01T00:00:00.000Z')).toBe(
+ Date.parse('2026-01-01T00:00:00.000Z')
+ );
+ expect(
+ deriveTaskActivityPauseAt(
+ makeStatus({ lastHeartbeatAt: '2026-01-01T00:00:00.000Z' }),
+ '2026-01-01T00:00:10.000Z'
+ )
+ ).toBe('2026-01-01T00:00:05.000Z');
+ expect(
+ deriveTaskActivityPauseAt(
+ makeStatus({ lastHeartbeatAt: 'not-a-date' }),
+ '2026-01-01T00:00:10.000Z'
+ )
+ ).toBe('2026-01-01T00:00:05.000Z');
+ expect(
+ deriveTaskActivityPauseAt(
+ makeStatus({ updatedAt: 'not-a-date' }),
+ '2026-01-01T00:00:10.000Z'
+ )
+ ).toBe('2026-01-01T00:00:10.000Z');
+ expect(
+ deriveTaskActivityResumeAt(
+ makeStatus({ updatedAt: '2026-01-01T00:00:05.000Z' }),
+ '2026-01-01T00:00:06.000Z',
+ '2026-01-01T00:00:10.000Z'
+ )
+ ).toBe('2026-01-01T00:00:06.000Z');
+ expect(
+ deriveTaskActivityResumeAt(
+ makeStatus({ updatedAt: '2026-01-01T00:00:05.000Z' }),
+ '2026-01-01T00:00:04.000Z',
+ '2026-01-01T00:00:10.000Z'
+ )
+ ).toBe('2026-01-01T00:00:10.000Z');
+ });
+
+ it('creates initial member spawn statuses with the current timestamp', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-02T03:04:05.000Z'));
+ try {
+ expect(createInitialMemberSpawnStatusEntry()).toEqual({
+ status: 'offline',
+ launchState: 'starting',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ updatedAt: '2026-01-02T03:04:05.000Z',
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('summarizes member spawn statuses across launch states and liveness kinds', () => {
+ expect(
+ summarizeMemberSpawnStatusRecord(['Confirmed', 'Missing'], {
+ Confirmed: makeStatus({ launchState: 'confirmed_alive' }),
+ Skipped: makeStatus({ launchState: 'skipped_for_launch' }),
+ Failed: makeStatus({ launchState: 'failed_to_start' }),
+ Permission: makeStatus({
+ launchState: 'runtime_pending_permission',
+ runtimeAlive: true,
+ }),
+ Shell: makeStatus({ livenessKind: 'shell_only' }),
+ Runtime: makeStatus({ livenessKind: 'runtime_process' }),
+ Candidate: makeStatus({ livenessKind: 'runtime_process_candidate' }),
+ MissingRuntime: makeStatus({ livenessKind: 'registered_only' }),
+ })
+ ).toEqual({
+ confirmedCount: 1,
+ pendingCount: 6,
+ failedCount: 1,
+ skippedCount: 1,
+ runtimeAlivePendingCount: 1,
+ shellOnlyPendingCount: 1,
+ runtimeProcessPendingCount: 1,
+ runtimeCandidatePendingCount: 1,
+ noRuntimePendingCount: 1,
+ permissionPendingCount: 1,
+ });
+ });
+
+ it('builds restart status reasons without changing message text', () => {
+ expect(buildRestartStillRunningReason('Builder')).toContain(
+ 'previous runtime still appears to be active'
+ );
+ expect(buildRestartDuplicateUnconfirmedReason('Builder')).toContain(
+ 'duplicate_skipped without a reason'
+ );
+ expect(buildRestartDuplicateUnconfirmedReason('Builder', 'still running')).toContain(
+ 'unrecognized reason "still running"'
+ );
+ expect(buildRestartGraceTimeoutReason('Builder')).toBe(
+ 'Teammate "Builder" did not rejoin within the restart grace window.'
+ );
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts
new file mode 100644
index 00000000..abb3a23d
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningOpenCodeDiagnosticsPolicy.test.ts
@@ -0,0 +1,129 @@
+import {
+ boundOpenCodeAppManagedBriefingText,
+ filterStaleOpenCodeOverlayDiagnostics,
+ hasRealOpenCodeFailureDiagnostic,
+ hasRealOpenCodeLaunchDiagnostic,
+ hasStaleOpenCodeDiagnostics,
+ isFileLockTimeoutError,
+ isGenericOpenCodePersistedFailureReason,
+ isPersistedOpenCodeSecondaryLaneMember,
+ normalizeOpenCodePersistedFailureReason,
+ promoteOpenCodePersistedFailureReasonsFromDiagnostics,
+ selectOpenCodePersistedFailureReasonFromDiagnostics,
+} from '@main/services/team/provisioning/TeamProvisioningOpenCodeDiagnosticsPolicy';
+import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { PersistedTeamLaunchMemberState } from '@shared/types';
+
+function makeMember(
+ overrides: Partial = {}
+): PersistedTeamLaunchMemberState {
+ return {
+ name: 'Builder',
+ providerId: 'opencode',
+ laneKind: 'secondary',
+ laneOwnerProviderId: 'opencode',
+ laneId: 'opencode-secondary',
+ launchState: 'failed_to_start',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: 'OpenCode bridge reported member launch failure',
+ lastEvaluatedAt: '2026-01-01T00:00:00.000Z',
+ ...overrides,
+ };
+}
+
+describe('TeamProvisioningOpenCodeDiagnosticsPolicy', () => {
+ it('recognizes only persisted OpenCode secondary lane members', () => {
+ expect(isPersistedOpenCodeSecondaryLaneMember(makeMember())).toBe(true);
+ expect(isPersistedOpenCodeSecondaryLaneMember(makeMember({ laneId: ' ' }))).toBe(false);
+ expect(isPersistedOpenCodeSecondaryLaneMember(makeMember({ laneKind: 'primary' }))).toBe(false);
+ expect(isPersistedOpenCodeSecondaryLaneMember(makeMember({ providerId: undefined }))).toBe(
+ false
+ );
+ });
+
+ it('keeps stale OpenCode diagnostics separate from real launch failures', () => {
+ expect(hasStaleOpenCodeDiagnostics(['No lane runtime evidence was committed'])).toBe(true);
+ expect(hasStaleOpenCodeDiagnostics(['OpenCode bridge reported member launch failure'])).toBe(
+ true
+ );
+ expect(hasStaleOpenCodeDiagnostics(['model not found in live OpenCode catalog'])).toBe(false);
+ expect(hasRealOpenCodeFailureDiagnostic('provider unavailable: quota exceeded')).toBe(true);
+ expect(
+ hasRealOpenCodeLaunchDiagnostic(
+ makeMember({ runtimeDiagnostic: 'OpenCode bridge reported member launch failure' })
+ )
+ ).toBe(false);
+ expect(hasRealOpenCodeLaunchDiagnostic(makeMember({ hardFailureReason: 'model not found' }))).toBe(
+ true
+ );
+ });
+
+ it('redacts secrets and bounds app-managed briefing text', () => {
+ const normalized = normalizeOpenCodePersistedFailureReason(
+ ' failed --api-key sk-abcdefghijklmnopqrstuvwxyz Bearer ABC123._+/=- '
+ );
+ expect(normalized).toBe('failed --api-key [redacted] Bearer [redacted]');
+ expect(isGenericOpenCodePersistedFailureReason('OpenCode bridge reported member launch failure')).toBe(
+ true
+ );
+
+ const longBriefing = `${'x'.repeat(12_005)} --token secret-token`;
+ const bounded = boundOpenCodeAppManagedBriefingText(longBriefing);
+ expect(bounded.length).toBeGreaterThan(12_000);
+ expect(bounded.length).toBeLessThanOrEqual(12_040);
+ expect(bounded.endsWith('\n[truncated app-managed briefing]')).toBe(true);
+ expect(bounded).not.toContain('secret-token');
+ });
+
+ it('promotes generic persisted failure reasons from specific diagnostics', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-02-03T04:05:06.000Z'));
+ try {
+ const generic = makeMember({
+ diagnostics: [
+ 'OpenCode secondary lane timing: 100ms',
+ 'model not found in live OpenCode catalog',
+ ],
+ });
+ expect(selectOpenCodePersistedFailureReasonFromDiagnostics(generic)).toBe(
+ 'model not found in live OpenCode catalog'
+ );
+
+ const snapshot = createPersistedLaunchSnapshot({
+ teamName: 'demo',
+ expectedMembers: ['Builder'],
+ launchPhase: 'finished',
+ members: { Builder: generic },
+ updatedAt: '2026-01-01T00:00:00.000Z',
+ });
+ const promoted = promoteOpenCodePersistedFailureReasonsFromDiagnostics(snapshot);
+ expect(promoted?.members.Builder.hardFailureReason).toBe(
+ 'model not found in live OpenCode catalog'
+ );
+ expect(promoted?.members.Builder.runtimeDiagnostic).toBe(
+ 'model not found in live OpenCode catalog'
+ );
+ expect(promoted?.updatedAt).toBe('2026-02-03T04:05:06.000Z');
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('filters stale overlay diagnostics and recognizes file lock timeouts', () => {
+ expect(
+ filterStaleOpenCodeOverlayDiagnostics([
+ 'No runtime evidence was committed',
+ 'model not found',
+ ])
+ ).toEqual(['model not found']);
+ expect(isFileLockTimeoutError(new Error('File lock timeout while reading manifest'))).toBe(
+ true
+ );
+ expect(isFileLockTimeoutError('other failure')).toBe(false);
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
new file mode 100644
index 00000000..a226818b
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
@@ -0,0 +1,488 @@
+import {
+ appendDiagnosticOnce,
+ buildOpenCodeSecondaryLaneTimingDiagnostic,
+ buildOpenCodeUncommittedBootstrapDiagnostic,
+ collectOpenCodeSecondaryLaneFailureDiagnostics,
+ collectRuntimeLaunchFailureDiagnostics,
+ createUnexpectedMixedSecondaryLaneFailureResult,
+ downgradeUncommittedOpenCodeBootstrapEvidence,
+ formatOpenCodeLaneTimingMs,
+ getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted,
+ hasMaterializedOpenCodeRuntimeForBootstrap,
+ hasOpenCodeRuntimeEntryHandle,
+ hasOpenCodeRuntimeHandle,
+ hasOpenCodeRuntimeLivenessMarker,
+ hasRecoverableOpenCodeBootstrapDiagnostic,
+ isBootstrapMemberEvidenceCurrentForMember,
+ isDefinitiveOpenCodePreLaunchFailure,
+ isExplicitLegacyOpenCodeBootstrap,
+ isMaterializedOpenCodeSessionId,
+ isRecoverableOpenCodeBootstrapPendingLaunchResult,
+ isRecoverableOpenCodeRuntimeEvidence,
+ isRecoverablePersistedOpenCodeRuntimeCandidate,
+ isRecoverablePersistedOpenCodeTerminalRuntimeCandidate,
+ MEMBER_BOOTSTRAP_STALL_MS,
+ normalizeIsoTimestamp,
+ normalizeRecoverableOpenCodeBootstrapPendingLaunchResult,
+ OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC,
+ OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC,
+ OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC,
+ promoteCommittedOpenCodeAppManagedBootstrapEvidence,
+ resolveOpenCodeBootstrapAcceptedAt,
+ selectOpenCodeSecondaryBootstrapStallDiagnostic,
+ shouldMarkPersistedOpenCodeBootstrapStalled,
+ summarizeRuntimeLaunchResultMembers,
+} from '@main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy';
+import { describe, expect, it } from 'vitest';
+
+import type {
+ TeamRuntimeLaunchResult,
+ TeamRuntimeMemberLaunchEvidence,
+} from '@main/services/team/runtime/TeamRuntimeAdapter';
+import type { PersistedTeamLaunchMemberState } from '@shared/types';
+
+const acceptedAt = '2026-01-01T00:00:00.000Z';
+const stalledAtMs = Date.parse(acceptedAt) + MEMBER_BOOTSTRAP_STALL_MS + 1;
+
+function makePersisted(
+ overrides: Partial = {}
+): PersistedTeamLaunchMemberState {
+ return {
+ name: 'Builder',
+ providerId: 'opencode',
+ laneKind: 'secondary',
+ laneOwnerProviderId: 'opencode',
+ laneId: 'opencode-secondary',
+ launchState: 'runtime_pending_bootstrap',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ firstSpawnAcceptedAt: acceptedAt,
+ lastEvaluatedAt: acceptedAt,
+ ...overrides,
+ };
+}
+
+function makeEvidence(
+ overrides: Partial = {}
+): TeamRuntimeMemberLaunchEvidence {
+ return {
+ memberName: 'Builder',
+ providerId: 'opencode',
+ launchState: 'starting',
+ agentToolAccepted: false,
+ runtimeAlive: false,
+ bootstrapConfirmed: false,
+ hardFailure: false,
+ diagnostics: [],
+ ...overrides,
+ };
+}
+
+function makeLaunchResult(
+ member: TeamRuntimeMemberLaunchEvidence = makeEvidence(),
+ overrides: Partial = {}
+): TeamRuntimeLaunchResult {
+ return {
+ runId: 'run-1',
+ teamName: 'demo',
+ launchPhase: 'active',
+ teamLaunchState: 'partial_pending',
+ members: { [member.memberName]: member },
+ warnings: [],
+ diagnostics: [],
+ ...overrides,
+ };
+}
+
+describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => {
+ it('formats timing diagnostics and appends diagnostics without duplicates', () => {
+ expect(formatOpenCodeLaneTimingMs(12.6)).toBe('13ms');
+ expect(formatOpenCodeLaneTimingMs(-4.4)).toBe('0ms');
+ expect(formatOpenCodeLaneTimingMs(Number.NaN)).toBe('n/a');
+ expect(appendDiagnosticOnce(['existing'], 'new')).toEqual(['existing', 'new']);
+ expect(appendDiagnosticOnce(['existing'], 'existing')).toEqual(['existing']);
+ expect(appendDiagnosticOnce(['existing'], null)).toEqual(['existing']);
+ expect(
+ buildOpenCodeSecondaryLaneTimingDiagnostic({
+ member: { name: 'Builder' },
+ queuedAtMs: 10,
+ launchStartedAtMs: 40,
+ launchFinishedAtMs: 145,
+ })
+ ).toBe(
+ 'OpenCode secondary lane timing: member=Builder queueWaitMs=30ms launchMs=105ms totalMs=135ms'
+ );
+ expect(
+ buildOpenCodeSecondaryLaneTimingDiagnostic({
+ member: { name: 'Builder' },
+ queuedAtMs: 10,
+ launchStartedAtMs: 40,
+ })
+ ).toBeNull();
+ });
+
+ it('recognizes OpenCode runtime handles without accepting empty or failed sessions', () => {
+ expect(hasOpenCodeRuntimeHandle({ runtimePid: 12 })).toBe(true);
+ expect(hasOpenCodeRuntimeHandle({ runtimeSessionId: ' session-1 ' })).toBe(true);
+ expect(hasOpenCodeRuntimeHandle({ sessionId: 'session-2' })).toBe(true);
+ expect(hasOpenCodeRuntimeHandle({ runtimePid: 0, sessionId: ' ' })).toBe(false);
+ expect(hasOpenCodeRuntimeLivenessMarker({ livenessKind: 'runtime_process_candidate' })).toBe(
+ true
+ );
+ expect(hasOpenCodeRuntimeLivenessMarker({ livenessKind: 'registered_only' })).toBe(false);
+ });
+
+ it('collects launch diagnostics and detects definitive pre-launch failures', () => {
+ const result = makeLaunchResult(
+ makeEvidence({
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ hardFailureReason: 'binary missing',
+ diagnostics: ['member diagnostic'],
+ }),
+ { diagnostics: ['result diagnostic'] }
+ );
+ expect(collectRuntimeLaunchFailureDiagnostics(result, 'Builder')).toEqual([
+ 'member diagnostic',
+ 'binary missing',
+ 'result diagnostic',
+ ]);
+ expect(collectOpenCodeSecondaryLaneFailureDiagnostics(result, 'Builder', ['prefix'])).toEqual([
+ 'prefix',
+ 'member diagnostic',
+ 'binary missing',
+ 'result diagnostic',
+ ]);
+ expect(
+ collectOpenCodeSecondaryLaneFailureDiagnostics(makeLaunchResult(), 'Missing', [])
+ ).toEqual(['OpenCode bridge reported member launch failure']);
+ expect(isDefinitiveOpenCodePreLaunchFailure(result, 'Builder')).toBe(true);
+ expect(
+ isDefinitiveOpenCodePreLaunchFailure(
+ makeLaunchResult(
+ makeEvidence({
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ diagnostics: ['outcome must be reconciled before retry'],
+ })
+ ),
+ 'Builder'
+ )
+ ).toBe(false);
+ expect(
+ isDefinitiveOpenCodePreLaunchFailure(
+ makeLaunchResult(
+ makeEvidence({
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ sessionId: 'runtime-session',
+ })
+ ),
+ 'Builder'
+ )
+ ).toBe(false);
+ });
+
+ it('normalizes recoverable bootstrap-pending launch results', () => {
+ const result = makeLaunchResult(
+ makeEvidence({
+ sessionId: 'runtime-session',
+ diagnostics: ['member_briefing not connected'],
+ }),
+ { diagnostics: ['result diagnostic'] }
+ );
+ expect(isMaterializedOpenCodeSessionId('runtime-session')).toBe(true);
+ expect(isMaterializedOpenCodeSessionId('failed:runtime-session')).toBe(false);
+ expect(hasMaterializedOpenCodeRuntimeForBootstrap(result.members.Builder)).toBe(true);
+ expect(isRecoverableOpenCodeBootstrapPendingLaunchResult(result, 'Builder')).toBe(true);
+
+ const normalized = normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(result, 'Builder', [
+ 'extra diagnostic',
+ ]);
+ expect(normalized.members.Builder.launchState).toBe('runtime_pending_bootstrap');
+ expect(normalized.members.Builder.runtimeAlive).toBe(true);
+ expect(normalized.members.Builder.hardFailure).toBe(false);
+ expect(normalized.members.Builder.diagnostics).toEqual([
+ 'member_briefing not connected',
+ OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC,
+ OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC,
+ 'extra diagnostic',
+ ]);
+ expect(normalized.diagnostics).toContain('result diagnostic');
+ expect(normalized.diagnostics).toContain(OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC);
+ expect(normalized.teamLaunchState).toBe('partial_pending');
+ expect(normalizeRecoverableOpenCodeBootstrapPendingLaunchResult(result, 'Missing', [])).toBe(
+ result
+ );
+ });
+
+ it('summarizes launch result members and transforms bootstrap evidence', () => {
+ expect(
+ summarizeRuntimeLaunchResultMembers({
+ Builder: makeEvidence({ launchState: 'confirmed_alive' }),
+ Reviewer: makeEvidence({ memberName: 'Reviewer', launchState: 'confirmed_alive' }),
+ })
+ ).toBe('clean_success');
+ expect(
+ summarizeRuntimeLaunchResultMembers({
+ Builder: makeEvidence({ launchState: 'confirmed_alive' }),
+ Reviewer: makeEvidence({
+ memberName: 'Reviewer',
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ }),
+ })
+ ).toBe('partial_failure');
+ expect(summarizeRuntimeLaunchResultMembers({})).toBe('partial_pending');
+
+ expect(
+ buildOpenCodeUncommittedBootstrapDiagnostic({
+ manifestEntryCount: 2,
+ manifestUpdatedAt: '2026-01-01T00:00:00.000Z',
+ fileNames: ['lane-a.json', 'lane-b.json'],
+ })
+ ).toEqual([
+ 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
+ 'OpenCode lane manifest entries: 2',
+ 'OpenCode lane manifest updated at: 2026-01-01T00:00:00.000Z',
+ 'OpenCode lane files: lane-a.json, lane-b.json',
+ ]);
+
+ const downgraded = downgradeUncommittedOpenCodeBootstrapEvidence(
+ makeEvidence({
+ sessionId: 'runtime-session',
+ livenessKind: 'confirmed_bootstrap',
+ diagnostics: ['existing'],
+ }),
+ ['new diagnostic']
+ );
+ expect(downgraded.launchState).toBe('runtime_pending_bootstrap');
+ expect(downgraded.livenessKind).toBe('runtime_process_candidate');
+ expect(downgraded.diagnostics).toEqual(['existing', 'new diagnostic']);
+
+ const promoted = promoteCommittedOpenCodeAppManagedBootstrapEvidence(
+ makeEvidence({ diagnostics: ['existing'] })
+ );
+ expect(promoted.launchState).toBe('confirmed_alive');
+ expect(promoted.bootstrapConfirmed).toBe(true);
+ expect(promoted.livenessKind).toBe('confirmed_bootstrap');
+ expect(promoted.diagnostics).toEqual([
+ 'existing',
+ 'OpenCode app-managed bootstrap evidence committed and read back.',
+ ]);
+ });
+
+ it('builds unexpected mixed secondary lane failure results', () => {
+ const result = createUnexpectedMixedSecondaryLaneFailureResult({
+ runId: 'run-1',
+ teamName: 'demo',
+ memberName: 'Builder',
+ message: 'launch failed',
+ });
+ expect(result.launchPhase).toBe('finished');
+ expect(result.teamLaunchState).toBe('partial_failure');
+ expect(result.members.Builder).toMatchObject({
+ providerId: 'opencode',
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ hardFailureReason: 'launch failed',
+ diagnostics: ['launch failed'],
+ });
+ expect(result.diagnostics).toEqual(['launch failed']);
+ });
+
+ it('recognizes runtime entry handles from pid, runtime session, or liveness', () => {
+ expect(hasOpenCodeRuntimeEntryHandle({ pid: 7 })).toBe(true);
+ expect(hasOpenCodeRuntimeEntryHandle({ runtimePid: 8 })).toBe(true);
+ expect(hasOpenCodeRuntimeEntryHandle({ runtimeSessionId: 'runtime-session' })).toBe(true);
+ expect(hasOpenCodeRuntimeEntryHandle({ livenessKind: 'permission_blocked' })).toBe(true);
+ expect(hasOpenCodeRuntimeEntryHandle({ pid: 0, runtimeSessionId: ' ' })).toBe(false);
+ });
+
+ it('keeps recoverable bootstrap diagnostics separate from real failures', () => {
+ expect(hasRecoverableOpenCodeBootstrapDiagnostic(['member_briefing not connected'])).toBe(true);
+ expect(hasRecoverableOpenCodeBootstrapDiagnostic(['runtime_bootstrap_checkin pending'])).toBe(
+ true
+ );
+ expect(hasRecoverableOpenCodeBootstrapDiagnostic(['provider unavailable: quota exceeded'])).toBe(
+ false
+ );
+ expect(hasRecoverableOpenCodeBootstrapDiagnostic([])).toBe(false);
+ });
+
+ it('classifies recoverable persisted OpenCode runtime candidates', () => {
+ expect(
+ isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ runtimeSessionId: 'rt-1' }))
+ ).toBe(true);
+ expect(
+ isRecoverablePersistedOpenCodeRuntimeCandidate(
+ makePersisted({ pendingPermissionRequestIds: ['perm-1'] })
+ )
+ ).toBe(true);
+ expect(
+ isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ agentToolAccepted: false }))
+ ).toBe(false);
+ expect(
+ isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ skippedForLaunch: true }))
+ ).toBe(false);
+ expect(
+ isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ laneKind: 'primary' }))
+ ).toBe(false);
+ });
+
+ it('selects the earliest accepted-at timestamp from first spawn and diagnostics', () => {
+ expect(normalizeIsoTimestamp('2026-01-01T00:00:00Z')).toBe('2026-01-01T00:00:00.000Z');
+ expect(normalizeIsoTimestamp('not a date')).toBeNull();
+ expect(
+ resolveOpenCodeBootstrapAcceptedAt(
+ makePersisted({
+ firstSpawnAcceptedAt: '2026-01-01T00:10:00.000Z',
+ diagnostics: ['member_session_recorded at 2026-01-01T00:05:00.000Z'],
+ })
+ )
+ ).toBe('2026-01-01T00:05:00.000Z');
+ expect(
+ resolveOpenCodeBootstrapAcceptedAt(
+ makePersisted({ firstSpawnAcceptedAt: 'not a date', diagnostics: ['noise'] })
+ )
+ ).toBeUndefined();
+ });
+
+ it('builds legacy and app-managed bootstrap stall diagnostics', () => {
+ expect(isExplicitLegacyOpenCodeBootstrap({ bootstrapMode: 'model_tool_checkin' })).toBe(true);
+ expect(isExplicitLegacyOpenCodeBootstrap({ bootstrapMode: 'app_managed_context' })).toBe(false);
+ expect(
+ selectOpenCodeSecondaryBootstrapStallDiagnostic([
+ 'OpenCode secondary lane timing: 100ms',
+ 'member_briefing delivery pending',
+ ])
+ ).toBe('member_briefing delivery pending; runtime_bootstrap_checkin did not complete after 5 min.');
+ expect(getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted(makePersisted())).toBe(
+ OPENCODE_APP_MANAGED_BOOTSTRAP_STALLED_DIAGNOSTIC
+ );
+ expect(
+ getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted(
+ makePersisted({
+ bootstrapMode: 'model_tool_checkin',
+ diagnostics: ['member_briefing delivery pending'],
+ })
+ )
+ ).toBe('member_briefing delivery pending; runtime_bootstrap_checkin did not complete after 5 min.');
+ expect(
+ getOpenCodeSecondaryBootstrapStallDiagnosticFromPersisted(
+ makePersisted({
+ bootstrapMode: 'model_tool_checkin',
+ runtimeDiagnostic: 'runtime_bootstrap_checkin timed out',
+ })
+ )
+ ).toBe('runtime_bootstrap_checkin timed out');
+ });
+
+ it('marks persisted bootstrap as stalled only after threshold with recoverable evidence', () => {
+ expect(
+ shouldMarkPersistedOpenCodeBootstrapStalled(
+ makePersisted({ runtimeSessionId: 'runtime-session' }),
+ stalledAtMs
+ )
+ ).toBe(true);
+ expect(
+ shouldMarkPersistedOpenCodeBootstrapStalled(
+ makePersisted({ diagnostics: ['member_briefing not connected'] }),
+ stalledAtMs
+ )
+ ).toBe(true);
+ expect(
+ shouldMarkPersistedOpenCodeBootstrapStalled(
+ makePersisted({ runtimeSessionId: 'runtime-session' }),
+ Date.parse(acceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - 1
+ )
+ ).toBe(false);
+ expect(
+ shouldMarkPersistedOpenCodeBootstrapStalled(
+ makePersisted({ runtimeSessionId: 'runtime-session', hardFailureReason: 'model not found' }),
+ stalledAtMs
+ )
+ ).toBe(false);
+ expect(
+ shouldMarkPersistedOpenCodeBootstrapStalled(
+ makePersisted({
+ runtimeSessionId: 'runtime-session',
+ pendingPermissionRequestIds: ['perm-1'],
+ }),
+ stalledAtMs
+ )
+ ).toBe(false);
+ });
+
+ it('recognizes recoverable terminal persisted candidates and runtime evidence', () => {
+ expect(
+ isRecoverablePersistedOpenCodeTerminalRuntimeCandidate(
+ makePersisted({
+ launchState: 'failed_to_start',
+ hardFailure: true,
+ runtimeSessionId: 'runtime-session',
+ })
+ )
+ ).toBe(true);
+ expect(
+ isRecoverablePersistedOpenCodeTerminalRuntimeCandidate(
+ makePersisted({ launchState: 'failed_to_start', hardFailure: true })
+ )
+ ).toBe(false);
+
+ const evidence: Partial = {
+ agentToolAccepted: true,
+ livenessKind: 'runtime_process',
+ };
+ expect(isRecoverableOpenCodeRuntimeEvidence(evidence as TeamRuntimeMemberLaunchEvidence)).toBe(
+ true
+ );
+ expect(isRecoverableOpenCodeRuntimeEvidence({ runtimeAlive: true } as TeamRuntimeMemberLaunchEvidence)).toBe(
+ true
+ );
+ expect(isRecoverableOpenCodeRuntimeEvidence(undefined)).toBe(false);
+ });
+
+ it('checks bootstrap evidence recency against the current member spawn boundary', () => {
+ const current = {
+ firstSpawnAcceptedAt: '2026-01-01T00:01:00.000Z',
+ lastEvaluatedAt: '2026-01-01T00:01:10.000Z',
+ };
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ current,
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:01:01.000Z',
+ lastHeartbeatAt: '2026-01-01T00:01:02.000Z',
+ lastEvaluatedAt: '2026-01-01T00:01:03.000Z',
+ },
+ 'acceptance'
+ )
+ ).toBe(true);
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ current,
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:30.000Z',
+ lastHeartbeatAt: '2026-01-01T00:00:40.000Z',
+ lastEvaluatedAt: '2026-01-01T00:00:50.000Z',
+ },
+ 'confirmation'
+ )
+ ).toBe(false);
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ { firstSpawnAcceptedAt: undefined, lastEvaluatedAt: undefined },
+ {
+ firstSpawnAcceptedAt: 'not-a-date',
+ lastHeartbeatAt: undefined,
+ lastRuntimeAliveAt: '2026-01-01T00:00:40.000Z',
+ lastEvaluatedAt: '2026-01-01T00:00:30.000Z',
+ },
+ 'confirmation'
+ )
+ ).toBe(true);
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts b/test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts
new file mode 100644
index 00000000..7e4397c7
--- /dev/null
+++ b/test/main/services/team/TeamProvisioningRuntimeDiagnostics.test.ts
@@ -0,0 +1,213 @@
+import {
+ buildRuntimeLaunchWarning,
+ getAnthropicFastModeDefault,
+ getConfiguredRuntimeBackend,
+ getPromptSizeSummary,
+ getTeamProviderLabel,
+ logRuntimeLaunchSnapshot,
+} from '@main/services/team/provisioning/TeamProvisioningRuntimeDiagnostics';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { GeminiRuntimeAuthState } from '@main/services/runtime/geminiRuntimeAuth';
+import type { ProviderModelLaunchIdentity, TeamProviderId } from '@shared/types';
+
+vi.mock('@main/services/infrastructure/ConfigManager', () => ({
+ ConfigManager: {
+ getInstance: vi.fn().mockReturnValue({
+ getConfig: vi.fn().mockReturnValue({
+ providerConnections: {
+ anthropic: {
+ fastModeDefault: true,
+ },
+ },
+ runtime: {
+ providerBackends: {
+ codex: 'codex-native',
+ gemini: 'cli-sdk',
+ },
+ },
+ }),
+ }),
+ },
+}));
+
+describe('TeamProvisioningRuntimeDiagnostics', () => {
+ it('keeps prompt size accounting stable for empty and multiline prompts', () => {
+ expect(getPromptSizeSummary('')).toEqual({ chars: 0, lines: 0 });
+ expect(getPromptSizeSummary('alpha\r\nbeta\ngamma')).toEqual({
+ chars: 'alpha\r\nbeta\ngamma'.length,
+ lines: 3,
+ });
+ });
+
+ it('labels supported team providers without leaking raw ids into diagnostics', () => {
+ const labels = new Map([
+ ['anthropic', 'Anthropic'],
+ ['codex', 'Codex'],
+ ['gemini', 'Gemini'],
+ ['opencode', 'OpenCode'],
+ ]);
+
+ for (const [providerId, label] of labels) {
+ expect(getTeamProviderLabel(providerId)).toBe(label);
+ }
+ });
+
+ it('reads configured runtime defaults through a narrow diagnostics adapter', () => {
+ expect(getAnthropicFastModeDefault()).toBe(true);
+ expect(getConfiguredRuntimeBackend('anthropic')).toBeNull();
+ expect(getConfiguredRuntimeBackend('opencode')).toBeNull();
+ expect(getConfiguredRuntimeBackend('codex')).toBe('codex-native');
+ expect(getConfiguredRuntimeBackend('gemini')).toBe('cli-sdk');
+ });
+
+ it('builds Codex launch warnings with explicit backend, prompt and env evidence', () => {
+ const warning = buildRuntimeLaunchWarning(
+ {
+ providerId: 'codex',
+ providerBackendId: 'codex-native',
+ model: 'gpt-5.4',
+ effort: 'high',
+ fastMode: 'on',
+ },
+ {
+ CLAUDE_CODE_USE_OPENAI: '1',
+ CLAUDE_CODE_ENTRY_PROVIDER: 'codex',
+ CLAUDE_CODE_CODEX_BACKEND: 'codex-native',
+ CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: '1',
+ },
+ {
+ promptSize: { chars: 12345, lines: 7 },
+ expectedMembersCount: 3,
+ }
+ );
+
+ expect(warning).toContain('Launch runtime: Codex');
+ expect(warning).toContain('gpt-5.4');
+ expect(warning).toContain('high');
+ expect(warning).toContain('fast on');
+ expect(warning).toContain('backend codex-native');
+ expect(warning).toContain('prompt 12,345 chars/7 lines');
+ expect(warning).toContain('members 3');
+ expect(warning).toContain(
+ 'env USE_OPENAI, ENTRY_PROVIDER=codex, CODEX_BACKEND=codex-native, FORCE_PROCESS_TEAMMATES'
+ );
+ });
+
+ it('includes Gemini auth diagnostics only for Gemini launch warnings', () => {
+ const geminiRuntimeAuth: GeminiRuntimeAuthState = {
+ authenticated: true,
+ authMethod: 'adc_authorized_user',
+ resolvedBackend: 'cli-sdk',
+ projectId: 'agent-teams-dev',
+ statusMessage: null,
+ };
+
+ expect(
+ buildRuntimeLaunchWarning(
+ {
+ providerId: 'gemini',
+ providerBackendId: 'cli-sdk',
+ model: 'gemini-2.5-pro',
+ effort: undefined,
+ fastMode: undefined,
+ },
+ {
+ CLAUDE_CODE_USE_GEMINI: '1',
+ CLAUDE_CODE_GEMINI_BACKEND: 'cli-sdk',
+ },
+ { geminiRuntimeAuth }
+ )
+ ).toContain('auth adc_authorized_user/cli-sdk');
+
+ expect(
+ buildRuntimeLaunchWarning(
+ {
+ providerId: 'anthropic',
+ providerBackendId: undefined,
+ model: undefined,
+ effort: undefined,
+ fastMode: undefined,
+ },
+ {},
+ { geminiRuntimeAuth }
+ )
+ ).not.toContain('auth adc_authorized_user/cli-sdk');
+ });
+
+ it('logs a structured launch snapshot with nullable env fields and launch identity', () => {
+ const messages: string[] = [];
+ const launchIdentity: ProviderModelLaunchIdentity = {
+ providerId: 'codex',
+ providerBackendId: 'codex-native',
+ selectedModel: 'gpt-5.4',
+ selectedModelKind: 'explicit',
+ resolvedLaunchModel: 'gpt-5.4',
+ catalogId: null,
+ catalogSource: 'runtime',
+ catalogFetchedAt: null,
+ selectedEffort: 'medium',
+ resolvedEffort: 'medium',
+ selectedFastMode: 'on',
+ resolvedFastMode: true,
+ fastResolutionReason: 'explicit',
+ };
+
+ logRuntimeLaunchSnapshot(
+ { info: (message) => messages.push(message) },
+ 'atlas',
+ '/usr/local/bin/claude',
+ ['--model', 'gpt-5.4'],
+ {
+ providerId: 'codex',
+ providerBackendId: 'codex-native',
+ model: 'gpt-5.4',
+ effort: 'medium',
+ fastMode: 'on',
+ },
+ { CLAUDE_CODE_USE_OPENAI: '1' },
+ {
+ promptSize: { chars: 10, lines: 2 },
+ expectedMembersCount: 2,
+ launchIdentity,
+ }
+ );
+
+ expect(messages).toHaveLength(1);
+ const prefix = '[atlas] Launch runtime snapshot ';
+ expect(messages[0]?.startsWith(prefix)).toBe(true);
+ const snapshot = JSON.parse(messages[0]!.slice(prefix.length)) as {
+ providerId: string;
+ providerBackendId: string | null;
+ model: string | null;
+ effort: string | null;
+ fastMode: string | null;
+ configuredBackend: string | null;
+ promptSize: { chars: number; lines: number } | null;
+ expectedMembersCount: number | null;
+ launchIdentity: ProviderModelLaunchIdentity | null;
+ geminiRuntimeAuth: unknown;
+ env: Record;
+ args: string[];
+ claudePath: string;
+ };
+
+ expect(snapshot).toMatchObject({
+ providerId: 'codex',
+ providerBackendId: 'codex-native',
+ model: 'gpt-5.4',
+ effort: 'medium',
+ fastMode: 'on',
+ configuredBackend: 'codex-native',
+ promptSize: { chars: 10, lines: 2 },
+ expectedMembersCount: 2,
+ launchIdentity,
+ geminiRuntimeAuth: null,
+ args: ['--model', 'gpt-5.4'],
+ claudePath: '/usr/local/bin/claude',
+ });
+ expect(snapshot.env.CLAUDE_CODE_USE_OPENAI).toBe('1');
+ expect(snapshot.env.CLAUDE_CODE_USE_GEMINI).toBeNull();
+ expect(snapshot.env.CLAUDE_CONFIG_DIR).toBeNull();
+ });
+});
diff --git a/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts b/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts
index 295de18d..9e601d96 100644
--- a/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts
+++ b/test/main/services/team/TeamProvisioningServiceAuditWarnings.test.ts
@@ -1,10 +1,9 @@
-import { describe, expect, it } from 'vitest';
-
import {
getOpenCodeMixedProviderProvisioningError,
shouldWarnOnMissingRegisteredMember,
shouldWarnOnUnreadableMemberAuditConfig,
} from '@main/services/team/TeamProvisioningService';
+import { describe, expect, it } from 'vitest';
describe('TeamProvisioningService audit warning policy', () => {
it('suppresses unreadable config warnings during the short post-accept grace window', () => {
From f6b2bc4cec05eb03348ddc61b4c3033d7ef40a90 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 12:21:58 +0300
Subject: [PATCH 02/16] fix(team): reject failed opencode session handles
---
.../TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts | 9 ++++-----
...TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts | 4 ++++
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
index 30ce8949..13e9d308 100644
--- a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
+++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
@@ -185,7 +185,7 @@ export function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean {
return false;
}
const trimmed = sessionId.trim();
- return trimmed.length > 0 && !trimmed.startsWith('failed:');
+ return trimmed.length > 0 && !trimmed.toLowerCase().startsWith('failed:');
}
export function hasMaterializedOpenCodeRuntimeForBootstrap(
@@ -372,8 +372,8 @@ export function hasOpenCodeRuntimeHandle(
const runtimeSessionId = (value as { runtimeSessionId?: unknown }).runtimeSessionId;
const runtimeEvidenceSessionId = (value as { sessionId?: unknown }).sessionId;
const sessionId =
- (typeof runtimeSessionId === 'string' && runtimeSessionId.trim().length > 0) ||
- (typeof runtimeEvidenceSessionId === 'string' && runtimeEvidenceSessionId.trim().length > 0);
+ isMaterializedOpenCodeSessionId(runtimeSessionId) ||
+ isMaterializedOpenCodeSessionId(runtimeEvidenceSessionId);
return runtimePid || sessionId;
}
@@ -401,8 +401,7 @@ export function hasOpenCodeRuntimeEntryHandle(
typeof value.runtimePid === 'number' &&
Number.isFinite(value.runtimePid) &&
value.runtimePid > 0;
- const runtimeSessionId =
- typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0;
+ const runtimeSessionId = isMaterializedOpenCodeSessionId(value.runtimeSessionId);
return pid || runtimePid || runtimeSessionId || hasOpenCodeRuntimeLivenessMarker(value);
}
diff --git a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
index a226818b..90f37cc1 100644
--- a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
+++ b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
@@ -128,6 +128,9 @@ describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => {
expect(hasOpenCodeRuntimeHandle({ runtimeSessionId: ' session-1 ' })).toBe(true);
expect(hasOpenCodeRuntimeHandle({ sessionId: 'session-2' })).toBe(true);
expect(hasOpenCodeRuntimeHandle({ runtimePid: 0, sessionId: ' ' })).toBe(false);
+ expect(hasOpenCodeRuntimeHandle({ sessionId: 'failed:session-2' })).toBe(false);
+ expect(hasOpenCodeRuntimeHandle({ runtimeSessionId: 'FAILED:session-3' })).toBe(false);
+ expect(hasOpenCodeRuntimeEntryHandle({ runtimeSessionId: 'failed:entry-session' })).toBe(false);
expect(hasOpenCodeRuntimeLivenessMarker({ livenessKind: 'runtime_process_candidate' })).toBe(
true
);
@@ -195,6 +198,7 @@ describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => {
);
expect(isMaterializedOpenCodeSessionId('runtime-session')).toBe(true);
expect(isMaterializedOpenCodeSessionId('failed:runtime-session')).toBe(false);
+ expect(isMaterializedOpenCodeSessionId('FAILED:runtime-session')).toBe(false);
expect(hasMaterializedOpenCodeRuntimeForBootstrap(result.members.Builder)).toBe(true);
expect(isRecoverableOpenCodeBootstrapPendingLaunchResult(result, 'Builder')).toBe(true);
From bc8d47aaa2e517d64e9588ff2151687fc239a334 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:36:34 +0300
Subject: [PATCH 03/16] perf(team): lazy load create team suggestions
---
.../team/dialogs/CreateTeamDialog.tsx | 22 +++++++---
.../team/dialogs/OptionalSettingsSection.tsx | 10 ++++-
.../team/members/MemberDraftRow.tsx | 13 +++++-
.../team/members/MembersEditorSection.tsx | 5 +++
.../team/members/TeamRosterEditorSection.tsx | 3 ++
src/renderer/hooks/useFileSuggestions.ts | 15 ++-----
src/renderer/hooks/useTaskSuggestions.ts | 43 ++++++++++++++-----
src/renderer/hooks/useTeamSuggestions.ts | 28 ++++++++++--
8 files changed, 106 insertions(+), 33 deletions(-)
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 450e224f..05dcfe38 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -46,7 +46,6 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useCreateTeamDraft } from '@renderer/hooks/useCreateTeamDraft';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
-import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
import { useTheme } from '@renderer/hooks/useTheme';
@@ -473,6 +472,7 @@ export const CreateTeamDialog = ({
>({});
const [providerSettingsProviderId, setProviderSettingsProviderId] =
useState(null);
+ const [workflowMentionSuggestionsEnabled, setWorkflowMentionSuggestionsEnabled] = useState(false);
const prepareRequestSeqRef = useRef(0);
const prepareIdleHandlesRef = useRef(new Set());
const prepareUnmountGenerationRef = useRef(0);
@@ -561,6 +561,9 @@ export const CreateTeamDialog = ({
setSelectedFastModeRaw(value);
setStoredCreateTeamFastMode(value);
}, []);
+ const enableWorkflowMentionSuggestions = useCallback((): void => {
+ setWorkflowMentionSuggestionsEnabled(true);
+ }, []);
const setWorktreeEnabled = (value: boolean): void => {
setWorktreeEnabledRaw(value);
@@ -1235,6 +1238,7 @@ export const CreateTeamDialog = ({
useEffect(() => {
if (!open) {
+ setWorkflowMentionSuggestionsEnabled(false);
return;
}
@@ -1430,10 +1434,12 @@ export const CreateTeamDialog = ({
setSelectedProjectPath('');
}, [open, cwdMode, projects, selectedProjectPath, setSelectedProjectPath]);
- useFileListCacheWarmer(effectiveCwd || null);
-
- const { suggestions: taskSuggestions } = useTaskSuggestions(null);
- const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null);
+ const { suggestions: taskSuggestions } = useTaskSuggestions(null, {
+ enabled: workflowMentionSuggestionsEnabled,
+ });
+ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(null, {
+ enabled: workflowMentionSuggestionsEnabled,
+ });
const description = descriptionDraft.value;
const prompt = promptDraft.value;
@@ -2206,6 +2212,7 @@ export const CreateTeamDialog = ({
projectPath={effectiveCwd || null}
taskSuggestions={taskSuggestions}
teamSuggestions={teamMentionSuggestions}
+ onWorkflowSuggestionsNeeded={enableWorkflowMentionSuggestions}
defaultProviderId={selectedProviderId}
inheritedProviderId={selectedProviderId}
inheritedModel={selectedModel}
@@ -2297,6 +2304,11 @@ export const CreateTeamDialog = ({
title="Optional launch settings"
description="Prompt, safety, and CLI overrides live here when you need them."
summary={launchOptionalSummary}
+ onOpenChange={(isOpen) => {
+ if (isOpen) {
+ enableWorkflowMentionSuggestions();
+ }
+ }}
>
{selectedProviderId === 'anthropic' ? (
diff --git a/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx b/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
index 7e0355e6..953c4f07 100644
--- a/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
+++ b/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
@@ -10,6 +10,7 @@ interface OptionalSettingsSectionProps {
summary?: string[];
defaultOpen?: boolean;
className?: string;
+ onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
@@ -61,6 +62,7 @@ export const OptionalSettingsSection = ({
summary = [],
defaultOpen = false,
className,
+ onOpenChange,
children,
}: OptionalSettingsSectionProps): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(defaultOpen);
@@ -106,6 +108,12 @@ export const OptionalSettingsSection = ({
? 'color-mix(in srgb, var(--color-text-muted) 64%, var(--color-text) 36%)'
: 'color-mix(in srgb, var(--color-text-muted) 54%, white 46%)';
+ const handleToggleOpen = (): void => {
+ const nextOpen = !isOpen;
+ setIsOpen(nextOpen);
+ onOpenChange?.(nextOpen);
+ };
+
return (
setIsOpen((prev) => !prev)}
+ onClick={handleToggleOpen}
aria-expanded={isOpen}
>
void;
lockProviderModel?: boolean;
lockRole?: boolean;
lockedRoleLabel?: string;
@@ -144,6 +145,7 @@ export const MemberDraftRow = ({
mentionSuggestions = [],
taskSuggestions,
teamSuggestions,
+ onWorkflowSuggestionsNeeded,
lockProviderModel = false,
lockRole = false,
lockedRoleLabel,
@@ -428,6 +430,15 @@ export const MemberDraftRow = ({
effectiveModel?.trim() ?? '',
effectiveEffort
);
+ const toggleWorkflowExpanded = useCallback(() => {
+ setWorkflowExpanded((prev) => {
+ const next = !prev;
+ if (next) {
+ onWorkflowSuggestionsNeeded?.();
+ }
+ return next;
+ });
+ }, [onWorkflowSuggestionsNeeded]);
return (
setWorkflowExpanded((prev) => !prev)}
+ onClick={toggleWorkflowExpanded}
>
{!workflowExpanded && workflowDraft.value.trim() ? (
diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx
index 6c1ad20e..b9aef166 100644
--- a/src/renderer/components/team/members/MembersEditorSection.tsx
+++ b/src/renderer/components/team/members/MembersEditorSection.tsx
@@ -111,6 +111,8 @@ export interface MembersEditorSectionProps {
taskSuggestions?: MentionSuggestion[];
/** Team suggestions for @@team mentions in workflow */
teamSuggestions?: MentionSuggestion[];
+ /** Called before workflow mention suggestions are needed. */
+ onWorkflowSuggestionsNeeded?: () => void;
/** Extra content rendered right below the "Members" label row */
headerExtra?: React.ReactNode;
/** When true, hides member rows and action buttons (label + headerExtra still visible) */
@@ -166,6 +168,7 @@ export const MembersEditorSection = ({
projectPath,
taskSuggestions,
teamSuggestions,
+ onWorkflowSuggestionsNeeded,
headerExtra,
hideContent = false,
existingMembers,
@@ -552,6 +555,7 @@ export const MembersEditorSection = ({
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
+ onWorkflowSuggestionsNeeded={onWorkflowSuggestionsNeeded}
lockProviderModel={lockProviderModel}
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
identityLockReason={identityLockReason}
@@ -604,6 +608,7 @@ export const MembersEditorSection = ({
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
+ onWorkflowSuggestionsNeeded={onWorkflowSuggestionsNeeded}
lockProviderModel
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved
diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx
index bb2dcf4e..3a7f1a8e 100644
--- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx
+++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx
@@ -20,6 +20,7 @@ interface TeamRosterEditorSectionProps {
projectPath?: string | null;
taskSuggestions?: MentionSuggestion[];
teamSuggestions?: MentionSuggestion[];
+ onWorkflowSuggestionsNeeded?: () => void;
hideMembersContent?: boolean;
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
defaultProviderId?: TeamProviderId;
@@ -76,6 +77,7 @@ const TeamRosterEditorSectionImpl = ({
projectPath,
taskSuggestions,
teamSuggestions,
+ onWorkflowSuggestionsNeeded,
hideMembersContent = false,
existingMembers,
defaultProviderId = 'anthropic',
@@ -153,6 +155,7 @@ const TeamRosterEditorSectionImpl = ({
projectPath={projectPath}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
+ onWorkflowSuggestionsNeeded={onWorkflowSuggestionsNeeded}
hideContent={hideMembersContent}
existingMembers={existingMembers}
defaultProviderId={defaultProviderId}
diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts
index f5a8449c..54fec72d 100644
--- a/src/renderer/hooks/useFileSuggestions.ts
+++ b/src/renderer/hooks/useFileSuggestions.ts
@@ -6,7 +6,7 @@
* Folders are derived from file paths (no extra IPC call needed).
*/
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getQuickOpenCache,
@@ -190,16 +190,6 @@ export function useFileSuggestions(
return onQuickOpenCacheInvalidated(() => setFetchTrigger((n) => n + 1));
}, []);
- // Lazy refetch: when dropdown opens and cache is stale, trigger a reload
- const prevEnabledRef = useRef(enabled);
- useEffect(() => {
- if (enabled && !prevEnabledRef.current && projectPath && !getQuickOpenCache(projectPath)) {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional trigger on state transition
- setFetchTrigger((n) => n + 1);
- }
- prevEnabledRef.current = enabled;
- }, [enabled, projectPath]);
-
// Load files from API when cache is empty.
// Uses project:listFiles (not editor:listFiles) — works without editor being open.
const fetchFiles = useCallback(
@@ -231,13 +221,14 @@ export function useFileSuggestions(
// - effect (projectPath change)
useEffect(() => {
if (!projectPath) return;
+ if (!enabled) return;
const cached = getQuickOpenCache(projectPath);
if (cached) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- setLoading before async fetch is intentional
return fetchFiles(projectPath);
- }, [projectPath, fetchTrigger, fetchFiles]);
+ }, [projectPath, enabled, fetchTrigger, fetchFiles]);
// Derive folders from file list (memoized)
const allFolders = useMemo(
diff --git a/src/renderer/hooks/useTaskSuggestions.ts b/src/renderer/hooks/useTaskSuggestions.ts
index 892942a1..e5f6f48c 100644
--- a/src/renderer/hooks/useTaskSuggestions.ts
+++ b/src/renderer/hooks/useTaskSuggestions.ts
@@ -10,12 +10,22 @@ import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import { useShallow } from 'zustand/react/shallow';
import type { MentionSuggestion } from '@renderer/types/mention';
-import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
+import type { GlobalTask, TeamSummary, TeamTaskWithKanban } from '@shared/types';
+
+const EMPTY_GLOBAL_TASKS: GlobalTask[] = [];
+const EMPTY_TEAM_TASKS: TeamTaskWithKanban[] = [];
+const EMPTY_TEAM_MEMBERS: NonNullable
= [];
+const EMPTY_TEAM_BY_NAME: Record = {};
+const EMPTY_TASK_SUGGESTIONS: MentionSuggestion[] = [];
export interface UseTaskSuggestionsResult {
suggestions: MentionSuggestion[];
}
+interface UseTaskSuggestionsOptions {
+ enabled?: boolean;
+}
+
interface TaskWithTeamContext {
task: TeamTaskWithKanban | GlobalTask;
teamName: string;
@@ -60,19 +70,29 @@ function isVisibleTask(task: TeamTaskWithKanban | GlobalTask): boolean {
return task.status !== 'deleted' && !task.deletedAt;
}
-export function useTaskSuggestions(currentTeamName: string | null): UseTaskSuggestionsResult {
+export function useTaskSuggestions(
+ currentTeamName: string | null,
+ options: UseTaskSuggestionsOptions = {}
+): UseTaskSuggestionsResult {
+ const enabled = options.enabled ?? true;
const { globalTasks, currentTeamData, currentTeamMembers, teamByName } = useStore(
useShallow((s) => ({
- globalTasks: s.globalTasks,
- currentTeamData: currentTeamName ? selectTeamDataForName(s, currentTeamName) : null,
- currentTeamMembers: currentTeamName
- ? selectResolvedMembersForTeamName(s, currentTeamName)
- : [],
- teamByName: s.teamByName,
+ globalTasks: enabled ? s.globalTasks : EMPTY_GLOBAL_TASKS,
+ currentTeamData:
+ enabled && currentTeamName ? selectTeamDataForName(s, currentTeamName) : null,
+ currentTeamMembers:
+ enabled && currentTeamName
+ ? selectResolvedMembersForTeamName(s, currentTeamName)
+ : EMPTY_TEAM_MEMBERS,
+ teamByName: enabled ? s.teamByName : EMPTY_TEAM_BY_NAME,
}))
);
const suggestions = useMemo(() => {
+ if (!enabled) {
+ return EMPTY_TASK_SUGGESTIONS;
+ }
+
const tasks: TaskWithTeamContext[] = [];
const seenTaskIds = new Set();
@@ -80,7 +100,10 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
const currentTeamSummary = teamByName[currentTeamName];
const currentTeamDisplayName = currentTeamSummary?.displayName || currentTeamName;
const currentTeamTasks =
- currentTeamData?.tasks ?? globalTasks.filter((task) => task.teamName === currentTeamName);
+ currentTeamData?.tasks ??
+ (currentTeamName
+ ? globalTasks.filter((task) => task.teamName === currentTeamName)
+ : EMPTY_TEAM_TASKS);
const currentTeamMemberColors =
currentTeamMembers.length > 0 ? currentTeamMembers : (currentTeamSummary?.members ?? []);
@@ -125,7 +148,7 @@ export function useTaskSuggestions(currentTeamName: string | null): UseTaskSugge
});
return tasks.map(buildTaskSuggestion);
- }, [currentTeamData, currentTeamMembers, currentTeamName, globalTasks, teamByName]);
+ }, [currentTeamData, currentTeamMembers, currentTeamName, enabled, globalTasks, teamByName]);
return { suggestions };
}
diff --git a/src/renderer/hooks/useTeamSuggestions.ts b/src/renderer/hooks/useTeamSuggestions.ts
index 9b376167..a71e8711 100644
--- a/src/renderer/hooks/useTeamSuggestions.ts
+++ b/src/renderer/hooks/useTeamSuggestions.ts
@@ -15,19 +15,31 @@ import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import type { MentionSuggestion } from '@renderer/types/mention';
+import type { TeamSummary } from '@shared/types';
export interface UseTeamSuggestionsResult {
suggestions: MentionSuggestion[];
loading: boolean;
}
+interface UseTeamSuggestionsOptions {
+ enabled?: boolean;
+}
+
+const EMPTY_TEAMS: TeamSummary[] = [];
+const EMPTY_TEAM_SUGGESTIONS: MentionSuggestion[] = [];
+
/**
* Returns team MentionSuggestion[] sorted by online status (online first).
*
* @param currentTeamName - The current team name to exclude from suggestions
*/
-export function useTeamSuggestions(currentTeamName: string | null): UseTeamSuggestionsResult {
- const teams = useStore(useShallow((s) => s.teams));
+export function useTeamSuggestions(
+ currentTeamName: string | null,
+ options: UseTeamSuggestionsOptions = {}
+): UseTeamSuggestionsResult {
+ const enabled = options.enabled ?? true;
+ const teams = useStore(useShallow((s) => (enabled ? s.teams : EMPTY_TEAMS)));
const [aliveTeams, setAliveTeams] = useState>(new Set());
const [loading, setLoading] = useState(false);
@@ -45,11 +57,19 @@ export function useTeamSuggestions(currentTeamName: string | null): UseTeamSugge
// Fetch on mount and when teams list changes
useEffect(() => {
+ if (!enabled) {
+ setLoading(false);
+ return;
+ }
void fetchAlive();
- }, [fetchAlive, teams]);
+ }, [enabled, fetchAlive, teams]);
// Build suggestion list sorted: online first, then offline
const suggestions = useMemo(() => {
+ if (!enabled) {
+ return EMPTY_TEAM_SUGGESTIONS;
+ }
+
const nonDeleted = teams.filter((t) => !t.deletedAt && t.teamName !== currentTeamName);
const result: MentionSuggestion[] = nonDeleted.map((t) => {
@@ -72,7 +92,7 @@ export function useTeamSuggestions(currentTeamName: string | null): UseTeamSugge
});
return result;
- }, [teams, currentTeamName, aliveTeams]);
+ }, [enabled, teams, currentTeamName, aliveTeams]);
return { suggestions, loading };
}
From 6855d63ec6757cdb64f2b0259803cdff7dd8c52b Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:33:51 +0300
Subject: [PATCH 04/16] feat(i18n): add localization foundation
Refs https://github.com/777genius/agent-teams-ai/issues/139
---
i18next.config.ts | 29 +
package.json | 7 +
pnpm-lock.yaml | 930 ++-
scripts/i18n/validate.ts | 145 +
.../renderer/ui/GraphActivityHud.tsx | 8 +-
.../renderer/ui/GraphBlockingEdgePopover.tsx | 14 +-
.../renderer/ui/GraphMemberLogPreviewHud.tsx | 10 +-
.../renderer/ui/GraphNodePopover.tsx | 61 +-
.../renderer/ui/GraphProvisioningHud.tsx | 10 +-
.../localization/contracts/appLocale.ts | 19 +
src/features/localization/contracts/index.ts | 11 +
.../localization/contracts/namespaces.ts | 13 +
.../core/application/resolveRuntimeLocale.ts | 15 +
.../validateTranslationCatalogs.ts | 7 +
.../localization/core/domain/catalogPolicy.ts | 203 +
.../localization/core/domain/localePolicy.ts | 43 +
src/features/localization/index.ts | 11 +
.../adapters/browserSystemLocaleAdapter.ts | 3 +
.../composition/createI18nextInstance.ts | 35 +
.../composition/localizationResources.ts | 34 +
.../renderer/hooks/useAppTranslation.ts | 17 +
.../renderer/hooks/useLocaleFormatters.ts | 54 +
.../localization/renderer/i18next.d.ts | 10 +
src/features/localization/renderer/index.ts | 4 +
.../renderer/locales/en/common.json | 900 +++
.../renderer/locales/en/dashboard.json | 197 +
.../renderer/locales/en/errors.json | 3 +
.../renderer/locales/en/extensions.json | 684 +++
.../renderer/locales/en/report.json | 217 +
.../renderer/locales/en/settings.json | 983 +++
.../renderer/locales/en/team.json | 2415 ++++++++
.../renderer/locales/ru/common.json | 900 +++
.../renderer/locales/ru/dashboard.json | 197 +
.../renderer/locales/ru/errors.json | 3 +
.../renderer/locales/ru/extensions.json | 684 +++
.../renderer/locales/ru/report.json | 217 +
.../renderer/locales/ru/settings.json | 983 +++
.../renderer/locales/ru/team.json | 2415 ++++++++
.../localization/renderer/resources.d.ts | 5402 +++++++++++++++++
.../renderer/ui/AppLanguageSelect.tsx | 64 +
.../renderer/ui/LocalizationProvider.tsx | 40 +
.../adapters/MemberLogStreamSection.tsx | 14 +-
.../renderer/ui/ExecutionLogStreamView.tsx | 4 +-
.../ui/MemberRuntimeProcessLogsPanel.tsx | 13 +-
.../renderer/ui/MemberWorkSyncDetails.tsx | 31 +-
.../renderer/ui/MemberWorkSyncStatusPanel.tsx | 11 +-
.../renderer/ui/RecentProjectCard.tsx | 23 +-
.../renderer/ui/RecentProjectsSection.tsx | 25 +-
.../renderer/ui/RunningTeamsSection.tsx | 4 +-
.../ui/RuntimeProviderManagementPanelView.tsx | 215 +-
.../renderer/ui/TmuxInstallerBannerView.tsx | 41 +-
src/main/ipc/configValidation.ts | 8 +
.../services/infrastructure/ConfigManager.ts | 4 +
src/renderer/App.tsx | 20 +-
src/renderer/components/chat/AIChatGroup.tsx | 4 +-
src/renderer/components/chat/ChatHistory.tsx | 12 +-
.../components/chat/ChatHistoryEmptyState.tsx | 11 +-
.../components/chat/CompactBoundary.tsx | 19 +-
src/renderer/components/chat/ContextBadge.tsx | 63 +-
.../components/chat/DisplayItemList.tsx | 13 +-
.../components/chat/LastOutputDisplay.tsx | 8 +-
.../DirectoryTree/DirectoryTreeNode.tsx | 6 +-
.../components/ClaudeMdFilesSection.tsx | 5 +-
.../components/ClaudeMdSection.tsx | 6 +-
.../components/CollapsibleSection.tsx | 5 +-
.../components/FlatInjectionList.tsx | 4 +-
.../components/MentionedFilesSection.tsx | 5 +-
.../components/RankedInjectionList.tsx | 4 +-
.../components/SessionContextHeader.tsx | 44 +-
.../components/SessionContextHelpTooltip.tsx | 22 +-
.../components/TaskCoordinationSection.tsx | 5 +-
.../components/ThinkingTextSection.tsx | 5 +-
.../components/ToolOutputsSection.tsx | 5 +-
.../components/UserMessagesSection.tsx | 5 +-
.../chat/SessionContextPanel/index.tsx | 8 +-
.../items/ClaudeMdItem.tsx | 4 +-
.../items/MentionedFileItem.tsx | 12 +-
.../items/TaskCoordinationItem.tsx | 12 +-
.../items/ThinkingTextItem.tsx | 14 +-
.../items/ToolBreakdownItem.tsx | 5 +-
.../items/ToolOutputItem.tsx | 12 +-
.../items/UserMessageItem.tsx | 10 +-
.../components/chat/SystemChatGroup.tsx | 4 +-
.../components/chat/UserChatGroup.tsx | 18 +-
.../components/chat/items/ExecutionTrace.tsx | 18 +-
.../components/chat/items/LinkedToolItem.tsx | 14 +-
.../components/chat/items/MetricsPill.tsx | 8 +-
.../components/chat/items/SubagentItem.tsx | 39 +-
.../chat/items/TeammateMessageItem.tsx | 12 +-
.../items/linkedTool/DefaultToolViewer.tsx | 16 +-
.../chat/items/linkedTool/EditToolViewer.tsx | 6 +-
.../chat/items/linkedTool/ReadToolViewer.tsx | 8 +-
.../chat/items/linkedTool/SkillToolViewer.tsx | 8 +-
.../items/linkedTool/ToolErrorDisplay.tsx | 5 +-
.../chat/items/linkedTool/WriteToolViewer.tsx | 10 +-
.../chat/items/linkedTool/renderHelpers.tsx | 33 +-
src/renderer/components/chat/session-panel.ts | 1 +
.../chat/viewers/CodeBlockViewer.tsx | 6 +-
.../components/chat/viewers/DiffViewer.tsx | 8 +-
.../chat/viewers/MarkdownViewer.tsx | 45 +-
.../chat/viewers/MermaidDiagram.tsx | 4 +-
.../common/CliInstallWarningBanner.tsx | 4 +-
.../components/common/ConfirmDialog.tsx | 4 +-
.../common/ContextSwitchOverlay.tsx | 10 +-
src/renderer/components/common/CopyButton.tsx | 6 +-
.../components/common/ErrorBoundary.tsx | 57 +-
.../components/common/ExportDropdown.tsx | 6 +-
.../components/common/OngoingIndicator.tsx | 5 +-
.../common/ProviderActivityStatusStrip.tsx | 15 +-
.../components/common/RepositoryDropdown.tsx | 10 +-
.../components/common/TokenUsageDisplay.tsx | 66 +-
.../components/common/UpdateBanner.tsx | 8 +-
.../components/common/UpdateDialog.tsx | 18 +-
.../components/common/WorkspaceIndicator.tsx | 4 +-
.../components/dashboard/CliStatusBanner.tsx | 639 +-
.../dashboard/DashboardUpdateBanner.tsx | 6 +-
.../components/dashboard/DashboardView.tsx | 13 +-
.../components/dashboard/WebPreviewBanner.tsx | 11 +-
.../dashboard/WindowsAdministratorBanner.tsx | 9 +-
.../extensions/ExtensionStoreView.tsx | 170 +-
.../extensions/apikeys/ApiKeyCard.tsx | 4 +-
.../extensions/apikeys/ApiKeyFormDialog.tsx | 76 +-
.../extensions/apikeys/ApiKeysPanel.tsx | 24 +-
.../extensions/common/InstallButton.tsx | 14 +-
.../extensions/mcp/CustomMcpServerDialog.tsx | 60 +-
.../extensions/mcp/McpServerCard.tsx | 22 +-
.../extensions/mcp/McpServerDetailDialog.tsx | 69 +-
.../extensions/mcp/McpServersPanel.tsx | 77 +-
.../extensions/plugins/PluginCard.tsx | 4 +-
.../extensions/plugins/PluginDetailDialog.tsx | 58 +-
.../extensions/plugins/PluginsPanel.tsx | 82 +-
.../extensions/skills/SkillDetailDialog.tsx | 94 +-
.../extensions/skills/SkillEditorDialog.tsx | 168 +-
.../extensions/skills/SkillImportDialog.tsx | 88 +-
.../extensions/skills/SkillReviewDialog.tsx | 45 +-
.../extensions/skills/SkillsPanel.tsx | 152 +-
.../components/layout/CustomTitleBar.tsx | 16 +-
src/renderer/components/layout/MoreMenu.tsx | 26 +-
.../components/layout/PaneContent.tsx | 23 +-
src/renderer/components/layout/PaneView.tsx | 4 +-
.../components/layout/SessionTabContent.tsx | 12 +-
src/renderer/components/layout/Sidebar.tsx | 12 +-
.../components/layout/SortableTab.tsx | 10 +-
src/renderer/components/layout/TabBar.tsx | 8 +-
.../components/layout/TabBarActions.tsx | 26 +-
src/renderer/components/layout/TabBarRow.tsx | 6 +-
.../components/layout/TabContextMenu.tsx | 29 +-
.../components/layout/TeamTabSectionNav.tsx | 20 +-
.../notifications/NotificationRow.tsx | 13 +-
.../notifications/NotificationsView.tsx | 48 +-
.../components/report/SessionReportTab.tsx | 6 +-
.../report/sections/CostSection.tsx | 79 +-
.../report/sections/ErrorSection.tsx | 25 +-
.../report/sections/FrictionSection.tsx | 22 +-
.../components/report/sections/GitSection.tsx | 17 +-
.../report/sections/InsightsSection.tsx | 34 +-
.../report/sections/KeyTakeawaysSection.tsx | 5 +-
.../report/sections/OverviewSection.tsx | 24 +-
.../report/sections/QualitySection.tsx | 56 +-
.../report/sections/SubagentSection.tsx | 22 +-
.../report/sections/TimelineSection.tsx | 22 +-
.../report/sections/TokenSection.tsx | 30 +-
.../report/sections/ToolSection.tsx | 20 +-
.../runtime/CodexLoginLinkCopyButton.tsx | 19 +-
.../runtime/ProviderModelBadges.tsx | 29 +-
.../ProviderRuntimeBackendSelector.tsx | 85 +-
.../runtime/ProviderRuntimeSettingsDialog.tsx | 641 +-
.../runtime/providerConnectionUi.ts | 521 +-
.../components/schedules/SchedulesView.tsx | 72 +-
.../components/search/CommandPalette.tsx | 64 +-
src/renderer/components/search/SearchBar.tsx | 22 +-
.../components/AddTriggerForm.tsx | 14 +-
.../components/ColorPaletteSelector.tsx | 8 +-
.../components/DynamicConfigSection.tsx | 44 +-
.../components/GeneralInfoSection.tsx | 14 +-
.../components/IgnorePatternsSection.tsx | 13 +-
.../components/ModeSelector.tsx | 8 +-
.../components/RepositoryScopeSection.tsx | 13 +-
.../components/TriggerCardHeader.tsx | 25 +-
.../components/TriggerConfiguration.tsx | 61 +-
.../components/TriggerPreview.tsx | 22 +-
.../hooks/useTriggerForm.ts | 19 +-
.../NotificationTriggerSettings/index.tsx | 16 +-
.../NotificationTriggerSettings/types.ts | 1 +
.../utils/constants.ts | 70 +-
.../components/settings/SettingsTabs.tsx | 37 +-
.../components/settings/SettingsView.tsx | 12 +-
.../settings/hooks/useSettingsConfig.ts | 2 +
.../settings/hooks/useSettingsHandlers.ts | 10 +
.../settings/sections/AdvancedSection.tsx | 40 +-
.../settings/sections/CliStatusSection.tsx | 107 +-
.../settings/sections/ConfigEditorDialog.tsx | 34 +-
.../settings/sections/ConnectionSection.tsx | 73 +-
.../settings/sections/GeneralSection.tsx | 192 +-
.../sections/NotificationsSection.tsx | 195 +-
.../settings/sections/WorkspaceSection.tsx | 68 +-
.../sidebar/DateGroupedSessions.tsx | 78 +-
.../components/sidebar/GlobalTaskList.tsx | 72 +-
.../sidebar/SessionFiltersPopover.tsx | 10 +-
.../components/sidebar/SessionItem.tsx | 18 +-
.../components/sidebar/SidebarTaskItem.tsx | 52 +-
.../components/sidebar/TaskContextMenu.tsx | 17 +-
.../components/sidebar/TaskFiltersPopover.tsx | 46 +-
.../components/sidebar/taskFiltersState.ts | 16 +-
.../team/ClaudeLogsFilterPopover.tsx | 24 +-
.../components/team/ClaudeLogsPanel.tsx | 34 +-
.../components/team/ClaudeLogsSection.tsx | 10 +-
.../team/LiveRuntimeStatusBridge.tsx | 4 +-
.../team/LiveRuntimeStatusSection.tsx | 39 +-
.../components/team/ProcessesSection.tsx | 22 +-
.../team/ProvisioningProgressBlock.tsx | 45 +-
src/renderer/components/team/RoleSelect.tsx | 65 +-
src/renderer/components/team/TaskTooltip.tsx | 6 +-
.../components/team/TeamChangesSection.tsx | 76 +-
.../components/team/TeamDetailView.tsx | 571 +-
.../components/team/TeamEmptyState.tsx | 14 +-
.../components/team/TeamListFilterPopover.tsx | 16 +-
src/renderer/components/team/TeamListView.tsx | 136 +-
.../components/team/TeamSessionsSection.tsx | 32 +-
.../components/team/TeamTaskStatusSummary.tsx | 13 +-
.../team/ToolApprovalDiffPreview.tsx | 12 +-
.../components/team/ToolApprovalSheet.tsx | 16 +-
.../team/activity/ActiveTasksBlock.tsx | 4 +-
.../components/team/activity/ActivityItem.tsx | 128 +-
.../team/activity/ActivityTimeline.tsx | 66 +-
.../team/activity/LeadThoughtsGroup.tsx | 18 +-
.../team/activity/MessageExpandDialog.tsx | 11 +-
.../team/activity/PendingRepliesBlock.tsx | 20 +-
.../team/activity/ReplyQuoteBlock.tsx | 6 +-
.../team/activity/ThoughtBodyContent.tsx | 6 +-
.../team/attachments/AttachmentDisplay.tsx | 4 +-
.../team/attachments/DropZoneOverlay.tsx | 5 +-
.../attachments/SourceMessageAttachments.tsx | 5 +-
.../components/team/context-metric-alias.ts | 2 +
.../team/dialogs/AddMemberDialog.tsx | 10 +-
.../team/dialogs/AdvancedCliSection.tsx | 30 +-
.../dialogs/AnthropicExtraUsageWarning.tsx | 34 +-
.../dialogs/AnthropicFastModeSelector.tsx | 25 +-
.../team/dialogs/CodexFastModeSelector.tsx | 18 +-
.../team/dialogs/CodexReconnectPrompt.tsx | 7 +-
.../team/dialogs/CreateTaskDialog.tsx | 75 +-
.../team/dialogs/CreateTeamDialog.tsx | 176 +-
.../team/dialogs/EditTeamDialog.tsx | 147 +-
.../team/dialogs/EffortLevelSelector.tsx | 7 +-
.../team/dialogs/GlobalTaskDetailDialog.tsx | 4 +-
.../team/dialogs/LaunchTeamDialog.tsx | 213 +-
.../team/dialogs/LimitContextCheckbox.tsx | 66 +-
.../team/dialogs/MembersJsonEditor.tsx | 4 +-
.../dialogs/OpenCodeContextConfigHint.tsx | 26 +-
.../team/dialogs/OptionalSettingsSection.tsx | 4 +-
.../team/dialogs/ProjectPathSelector.tsx | 63 +-
.../ProvisioningProviderStatusList.tsx | 258 +-
.../components/team/dialogs/ReviewDialog.tsx | 14 +-
.../team/dialogs/SendMessageDialog.tsx | 45 +-
.../team/dialogs/SkipPermissionsCheckbox.tsx | 96 +-
.../team/dialogs/StatusHistoryTimeline.tsx | 79 +-
.../team/dialogs/TaskAttachments.tsx | 10 +-
.../team/dialogs/TaskCommentAwaitingReply.tsx | 12 +-
.../team/dialogs/TaskCommentInput.tsx | 25 +-
.../team/dialogs/TaskCommentsSection.tsx | 52 +-
.../team/dialogs/TaskDetailDialog.tsx | 123 +-
.../team/dialogs/TeamModelSelector.tsx | 348 +-
.../TeammateRuntimeCompatibilityNotice.tsx | 5 +-
.../dialogs/ToolApprovalSettingsPanel.tsx | 64 +-
.../dialogs/WorktreeGitReadinessBanner.tsx | 19 +-
.../team/editor/EditorBinaryPlaceholder.tsx | 6 +-
.../team/editor/EditorEmptyState.tsx | 27 +-
.../team/editor/EditorErrorBoundary.tsx | 31 +-
.../team/editor/EditorErrorState.tsx | 6 +-
.../components/team/editor/EditorFileTree.tsx | 27 +-
.../team/editor/EditorImagePreview.tsx | 8 +-
.../team/editor/EditorSearchPanel.tsx | 28 +-
.../team/editor/EditorShortcutsHelp.tsx | 157 +-
.../team/editor/EditorStatusBar.tsx | 22 +-
.../components/team/editor/EditorTabBar.tsx | 4 +-
.../components/team/editor/EditorToolbar.tsx | 22 +-
.../components/team/editor/GoToLineDialog.tsx | 12 +-
.../components/team/editor/NewFileDialog.tsx | 28 +-
.../team/editor/ProjectEditorOverlay.tsx | 85 +-
.../team/editor/QuickOpenDialog.tsx | 12 +-
.../team/editor/SearchInFilesPanel.tsx | 32 +-
.../components/team/kanban/KanbanBoard.tsx | 39 +-
.../team/kanban/KanbanFilterPopover.tsx | 34 +-
.../team/kanban/KanbanGridLayout.tsx | 8 +-
.../team/kanban/KanbanSearchInput.tsx | 17 +-
.../team/kanban/KanbanSortPopover.tsx | 44 +-
.../components/team/kanban/KanbanTaskCard.tsx | 49 +-
.../components/team/kanban/TrashDialog.tsx | 21 +-
.../components/team/lead-load-guards.ts | 1 +
.../team/members/CurrentTaskIndicator.tsx | 4 +-
.../components/team/members/LeadModelRow.tsx | 27 +-
.../components/team/members/MemberCard.tsx | 27 +-
.../team/members/MemberDetailDialog.tsx | 26 +-
.../team/members/MemberDetailHeader.tsx | 4 +-
.../team/members/MemberDraftRow.tsx | 115 +-
.../team/members/MemberExecutionLog.tsx | 20 +-
.../team/members/MemberHoverCard.tsx | 4 +-
.../components/team/members/MemberList.tsx | 18 +-
.../components/team/members/MemberLogsTab.tsx | 33 +-
.../team/members/MemberMessagesTab.tsx | 28 +-
.../team/members/MemberStatsTab.tsx | 53 +-
.../team/members/MemberTasksTab.tsx | 4 +-
.../team/members/MembersEditorSection.tsx | 20 +-
.../members/SubagentRecentMessagesPreview.tsx | 12 +-
.../team/messages/ActionModeSelector.tsx | 4 +-
.../team/messages/MessageComposer.tsx | 92 +-
.../team/messages/MessagesFilterPopover.tsx | 24 +-
.../team/messages/MessagesPanel.tsx | 109 +-
.../team/messages/OpenCodeDeliveryWarning.tsx | 74 +-
.../components/team/messages/StatusBlock.tsx | 4 +-
.../components/team/provisioningSteps.ts | 8 +-
.../team/review/ChangeReviewDialog.tsx | 23 +-
.../team/review/ChangesLoadingAnimation.tsx | 34 +-
.../team/review/CodeMirrorDiffView.tsx | 22 +-
.../team/review/ConfidenceBadge.tsx | 21 +-
.../components/team/review/ConflictDialog.tsx | 18 +-
.../team/review/ContinuousScrollView.tsx | 4 +-
.../team/review/DiffErrorBoundary.tsx | 45 +-
.../team/review/FileEditTimeline.tsx | 5 +-
.../team/review/FileSectionDiff.tsx | 6 +-
.../team/review/FileSectionHeader.tsx | 108 +-
.../team/review/FileSectionPlaceholder.tsx | 52 +-
.../team/review/FullDiffLoadingBanner.tsx | 30 +-
.../team/review/KeyboardShortcutsHelp.tsx | 37 +-
.../components/team/review/ReviewFileTree.tsx | 35 +-
.../components/team/review/ReviewToolbar.tsx | 40 +-
.../team/review/ScopeWarningBanner.tsx | 100 +-
.../team/review/ViewedProgressBar.tsx | 8 +-
.../team/schedule/CronScheduleInput.tsx | 51 +-
.../team/schedule/ScheduleEmptyState.tsx | 26 +-
.../team/schedule/ScheduleRunLogDialog.tsx | 20 +-
.../team/schedule/ScheduleSection.tsx | 27 +-
.../team/schedule/ScheduleStatusBadge.tsx | 79 +-
.../team/session-injection-types.ts | 1 +
.../team/taskLogs/ExactTaskLogCard.tsx | 6 +-
.../team/taskLogs/ExactTaskLogsSection.tsx | 21 +-
.../taskLogs/ExecutionSessionsSection.tsx | 10 +-
.../team/taskLogs/TaskActivitySection.tsx | 20 +-
.../team/taskLogs/TaskLogStreamSection.tsx | 4 +-
.../components/team/tasks/TaskList.tsx | 35 +-
.../team/useTeamProvisioningPresentation.ts | 5 +-
.../components/terminal/TerminalModal.tsx | 35 +-
.../components/ui/ChipInteractionLayer.tsx | 13 +-
.../components/ui/ExpandableContent.tsx | 6 +-
src/renderer/components/ui/MemberSelect.tsx | 12 +-
.../components/ui/MentionSuggestionList.tsx | 18 +-
src/renderer/components/ui/dialog.tsx | 53 +-
.../components/ui/tiptap/TiptapBubbleMenu.tsx | 10 +-
src/renderer/hooks/useOptionalTabId.ts | 1 +
.../utils/teamProvisioningPresentation.ts | 551 +-
src/shared/types/notifications.ts | 2 +
.../localization/core/catalogPolicy.test.ts | 51 +
.../localization/core/localePolicy.test.ts | 30 +
test/main/ipc/configValidation.test.ts | 16 +
test/renderer/store/extensionsSlice.test.ts | 5 +-
355 files changed, 26205 insertions(+), 4964 deletions(-)
create mode 100644 i18next.config.ts
create mode 100644 scripts/i18n/validate.ts
create mode 100644 src/features/localization/contracts/appLocale.ts
create mode 100644 src/features/localization/contracts/index.ts
create mode 100644 src/features/localization/contracts/namespaces.ts
create mode 100644 src/features/localization/core/application/resolveRuntimeLocale.ts
create mode 100644 src/features/localization/core/application/validateTranslationCatalogs.ts
create mode 100644 src/features/localization/core/domain/catalogPolicy.ts
create mode 100644 src/features/localization/core/domain/localePolicy.ts
create mode 100644 src/features/localization/index.ts
create mode 100644 src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts
create mode 100644 src/features/localization/renderer/composition/createI18nextInstance.ts
create mode 100644 src/features/localization/renderer/composition/localizationResources.ts
create mode 100644 src/features/localization/renderer/hooks/useAppTranslation.ts
create mode 100644 src/features/localization/renderer/hooks/useLocaleFormatters.ts
create mode 100644 src/features/localization/renderer/i18next.d.ts
create mode 100644 src/features/localization/renderer/index.ts
create mode 100644 src/features/localization/renderer/locales/en/common.json
create mode 100644 src/features/localization/renderer/locales/en/dashboard.json
create mode 100644 src/features/localization/renderer/locales/en/errors.json
create mode 100644 src/features/localization/renderer/locales/en/extensions.json
create mode 100644 src/features/localization/renderer/locales/en/report.json
create mode 100644 src/features/localization/renderer/locales/en/settings.json
create mode 100644 src/features/localization/renderer/locales/en/team.json
create mode 100644 src/features/localization/renderer/locales/ru/common.json
create mode 100644 src/features/localization/renderer/locales/ru/dashboard.json
create mode 100644 src/features/localization/renderer/locales/ru/errors.json
create mode 100644 src/features/localization/renderer/locales/ru/extensions.json
create mode 100644 src/features/localization/renderer/locales/ru/report.json
create mode 100644 src/features/localization/renderer/locales/ru/settings.json
create mode 100644 src/features/localization/renderer/locales/ru/team.json
create mode 100644 src/features/localization/renderer/resources.d.ts
create mode 100644 src/features/localization/renderer/ui/AppLanguageSelect.tsx
create mode 100644 src/features/localization/renderer/ui/LocalizationProvider.tsx
create mode 100644 src/renderer/components/chat/session-panel.ts
create mode 100644 src/renderer/components/team/context-metric-alias.ts
create mode 100644 src/renderer/components/team/lead-load-guards.ts
create mode 100644 src/renderer/components/team/session-injection-types.ts
create mode 100644 src/renderer/hooks/useOptionalTabId.ts
create mode 100644 test/features/localization/core/catalogPolicy.test.ts
create mode 100644 test/features/localization/core/localePolicy.test.ts
diff --git a/i18next.config.ts b/i18next.config.ts
new file mode 100644
index 00000000..a8c56bbb
--- /dev/null
+++ b/i18next.config.ts
@@ -0,0 +1,29 @@
+import { defineConfig } from 'i18next-cli';
+
+import {
+ DEFAULT_TRANSLATION_NAMESPACE,
+ FALLBACK_APP_LOCALE,
+ RESOLVED_APP_LOCALES,
+} from './src/features/localization/contracts';
+
+export default defineConfig({
+ locales: [...RESOLVED_APP_LOCALES],
+ extract: {
+ defaultNS: DEFAULT_TRANSLATION_NAMESPACE,
+ input: ['src/**/*.{ts,tsx}'],
+ ignore: ['src/**/*.test.{ts,tsx}', 'src/**/__tests__/**'],
+ output: 'src/features/localization/renderer/locales/{{language}}/{{namespace}}.json',
+ primaryLanguage: FALLBACK_APP_LOCALE,
+ sort: true,
+ useTranslationNames: ['useTranslation', { name: 'useAppTranslation', nsArg: 0 }],
+ },
+ lint: {
+ ignore: ['src/**/*.test.{ts,tsx}', 'src/**/__tests__/**'],
+ },
+ types: {
+ basePath: `src/features/localization/renderer/locales/${FALLBACK_APP_LOCALE}`,
+ input: [`src/features/localization/renderer/locales/${FALLBACK_APP_LOCALE}/*.json`],
+ output: 'src/features/localization/renderer/i18next.d.ts',
+ resourcesFile: 'src/features/localization/renderer/resources.d.ts',
+ },
+});
diff --git a/package.json b/package.json
index ac084a39..f10e6bf4 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,10 @@
"test:semantic": "tsx test/test-semantic-steps.ts",
"test:noise": "tsx test/test-noise-filtering.ts",
"test:task-filtering": "tsx test/test-task-filtering.ts",
+ "i18n:extract": "i18next-cli extract --with-types",
+ "i18n:status": "i18next-cli status",
+ "i18n:validate": "tsx scripts/i18n/validate.ts",
+ "i18n:types": "i18next-cli types --quiet",
"test": "vitest run",
"test:ci": "vitest run --maxWorkers 1 --minWorkers 1",
"test:task-change-ledger": "vitest run test/main/services/team/TaskChangeLedgerReader.test.ts test/main/services/team/taskChangeLedgerFixtures.integration.test.ts test/main/services/team/ReviewApplierService.test.ts test/main/services/team/FileContentResolver.test.ts test/main/services/team/ChangeExtractorService.test.ts test/renderer/store/changeReviewSlice.test.ts test/renderer/utils/reviewKey.test.ts test/main/services/team/TeamLogSourceTracker.test.ts test/main/services/team/stallMonitor/TeamTaskLogFreshnessReader.test.ts",
@@ -164,6 +168,7 @@
"fast-json-stringify": "^6.4.0",
"fastify": "^5.8.5",
"highlight.js": "^11.11.1",
+ "i18next": "26.2.0",
"idb-keyval": "^6.2.2",
"isbinaryfile": "^6.0.0",
"json-schema-ref-resolver": "^3.0.0",
@@ -178,6 +183,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-grid-layout": "^2.2.2",
+ "react-i18next": "17.0.8",
"react-markdown": "^10.1.0",
"react-modal-sheet": "5.6.0",
"react-resizable": "^3.1.3",
@@ -232,6 +238,7 @@
"globals": "^17.2.0",
"happy-dom": "^20.9.0",
"husky": "^9.1.7",
+ "i18next-cli": "1.58.0",
"knip": "^5.82.1",
"lint-staged": "^16.2.7",
"postcss": "^8.5.10",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ba38e32b..2793d188 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -275,6 +275,9 @@ importers:
highlight.js:
specifier: ^11.11.1
version: 11.11.1
+ i18next:
+ specifier: 26.2.0
+ version: 26.2.0(typescript@5.9.3)
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
@@ -317,6 +320,9 @@ importers:
react-grid-layout:
specifier: ^2.2.2
version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react-i18next:
+ specifier: 17.0.8
+ version: 17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.14)(react@19.2.4)
@@ -377,7 +383,7 @@ importers:
version: 4.0.4
'@eslint-community/eslint-plugin-eslint-comments':
specifier: ^4.6.0
- version: 4.6.0(eslint@9.39.4(jiti@2.7.0))
+ version: 4.6.0(eslint@9.39.4(jiti@1.21.7))
'@eslint/js':
specifier: ^9.39.2
version: 9.39.2
@@ -410,10 +416,10 @@ importers:
version: 1.15.5
'@vitejs/plugin-react':
specifier: ^4.3.1
- version: 4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ version: 4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/coverage-v8':
specifier: ^3.1.4
- version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
autoprefixer:
specifier: ^10.4.17
version: 10.4.23(postcss@8.5.10)
@@ -425,43 +431,43 @@ importers:
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
electron-vite:
specifier: ^5.0.0
- version: 5.0.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ version: 5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
eslint:
specifier: ^9.39.4
- version: 9.39.4(jiti@2.7.0)
+ version: 9.39.4(jiti@1.21.7)
eslint-config-prettier:
specifier: ^10.1.8
- version: 10.1.8(eslint@9.39.4(jiti@2.7.0))
+ version: 10.1.8(eslint@9.39.4(jiti@1.21.7))
eslint-import-resolver-typescript:
specifier: ^4.4.4
- version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
+ version: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-boundaries:
specifier: ^5.3.1
- version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-import:
specifier: ^2.32.0
- version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-jsx-a11y:
specifier: ^6.10.2
- version: 6.10.2(eslint@9.39.4(jiti@2.7.0))
+ version: 6.10.2(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react:
specifier: ^7.37.5
- version: 7.37.5(eslint@9.39.4(jiti@2.7.0))
+ version: 7.37.5(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-hooks:
specifier: ^7.0.1
- version: 7.0.1(eslint@9.39.4(jiti@2.7.0))
+ version: 7.0.1(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-react-refresh:
specifier: ^0.4.26
- version: 0.4.26(eslint@9.39.4(jiti@2.7.0))
+ version: 0.4.26(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-security:
specifier: ^3.0.1
version: 3.0.1
eslint-plugin-simple-import-sort:
specifier: ^12.1.1
- version: 12.1.1(eslint@9.39.4(jiti@2.7.0))
+ version: 12.1.1(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-sonarjs:
specifier: ^3.0.6
- version: 3.0.6(eslint@9.39.4(jiti@2.7.0))
+ version: 3.0.6(eslint@9.39.4(jiti@1.21.7))
eslint-plugin-tailwindcss:
specifier: ^3.18.2
version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.9.0))
@@ -474,6 +480,9 @@ importers:
husky:
specifier: ^9.1.7
version: 9.1.7
+ i18next-cli:
+ specifier: 1.58.0
+ version: 1.58.0(@types/node@25.0.7)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3)
knip:
specifier: ^5.82.1
version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3)
@@ -500,13 +509,13 @@ importers:
version: 5.9.3
typescript-eslint:
specifier: ^8.54.0
- version: 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ version: 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
vite:
specifier: ^6.4.2
- version: 6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ version: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vitest:
specifier: ^3.1.4
- version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
agent-teams-controller: {}
@@ -612,7 +621,7 @@ importers:
version: 22.19.15
tsup:
specifier: ^8.5.1
- version: 8.5.1(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
+ version: 8.5.1(@swc/core@1.15.33)(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -809,6 +818,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -978,6 +991,12 @@ packages:
'@colordx/core@5.4.3':
resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==}
+ '@croct/json5-parser@0.2.2':
+ resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==}
+
+ '@croct/json@2.1.0':
+ resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==}
+
'@develar/schema-utils@2.6.5':
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'}
@@ -1877,6 +1896,140 @@ packages:
peerDependencies:
vue: '>=3'
+ '@inquirer/ansi@2.0.5':
+ resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+
+ '@inquirer/checkbox@5.1.5':
+ resolution: {integrity: sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/confirm@6.0.13':
+ resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/core@11.1.10':
+ resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/editor@5.1.2':
+ resolution: {integrity: sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/expand@5.0.14':
+ resolution: {integrity: sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/external-editor@3.0.0':
+ resolution: {integrity: sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/figures@2.0.5':
+ resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+
+ '@inquirer/input@5.0.13':
+ resolution: {integrity: sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/number@4.0.13':
+ resolution: {integrity: sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/password@5.0.13':
+ resolution: {integrity: sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/prompts@8.4.3':
+ resolution: {integrity: sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/rawlist@5.2.9':
+ resolution: {integrity: sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/search@4.1.9':
+ resolution: {integrity: sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/select@5.1.5':
+ resolution: {integrity: sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
+ '@inquirer/type@4.0.5':
+ resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
'@intlify/bundle-utils@10.0.1':
resolution: {integrity: sha512-WkaXfSevtpgtUR4t8K2M6lbR7g03mtOxFeh+vXp5KExvPqS12ppaRj1QxzwRuRI5VUto54A22BjKoBMLyHILWQ==}
engines: {node: '>= 18'}
@@ -4102,6 +4255,99 @@ packages:
peerDependencies:
eslint: ^9.0.0 || ^10.0.0
+ '@swc/core-darwin-arm64@1.15.33':
+ resolution: {integrity: sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@swc/core-darwin-x64@1.15.33':
+ resolution: {integrity: sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@swc/core-linux-arm-gnueabihf@1.15.33':
+ resolution: {integrity: sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==}
+ engines: {node: '>=10'}
+ cpu: [arm]
+ os: [linux]
+
+ '@swc/core-linux-arm64-gnu@1.15.33':
+ resolution: {integrity: sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-arm64-musl@1.15.33':
+ resolution: {integrity: sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-linux-ppc64-gnu@1.15.33':
+ resolution: {integrity: sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==}
+ engines: {node: '>=10'}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-s390x-gnu@1.15.33':
+ resolution: {integrity: sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==}
+ engines: {node: '>=10'}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-gnu@1.15.33':
+ resolution: {integrity: sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@swc/core-linux-x64-musl@1.15.33':
+ resolution: {integrity: sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@swc/core-win32-arm64-msvc@1.15.33':
+ resolution: {integrity: sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==}
+ engines: {node: '>=10'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@swc/core-win32-ia32-msvc@1.15.33':
+ resolution: {integrity: sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==}
+ engines: {node: '>=10'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@swc/core-win32-x64-msvc@1.15.33':
+ resolution: {integrity: sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==}
+ engines: {node: '>=10'}
+ cpu: [x64]
+ os: [win32]
+
+ '@swc/core@1.15.33':
+ resolution: {integrity: sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ '@swc/helpers': '>=0.5.17'
+ peerDependenciesMeta:
+ '@swc/helpers':
+ optional: true
+
+ '@swc/counter@0.1.3':
+ resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
+
+ '@swc/types@0.1.26':
+ resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
+
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@@ -5556,6 +5802,10 @@ packages:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
+ chalk@5.6.2:
+ resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
+ engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
+
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
@@ -5571,6 +5821,9 @@ packages:
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
+ chardet@2.1.1:
+ resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
+
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
@@ -5622,6 +5875,10 @@ packages:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
+ cli-spinners@3.4.0:
+ resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==}
+ engines: {node: '>=18.20'}
+
cli-truncate@2.1.0:
resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==}
engines: {node: '>=8'}
@@ -5630,6 +5887,10 @@ packages:
resolution: {integrity: sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==}
engines: {node: '>=20'}
+ cli-width@4.1.0:
+ resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
+ engines: {node: '>= 12'}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -7285,6 +7546,9 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+ html-parse-stringify@3.0.1:
+ resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -7349,6 +7613,23 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ i18next-cli@1.58.0:
+ resolution: {integrity: sha512-S5Nm6fE/HZzbuC5g8i2JQGq2CCVw7uSpDomY+PpqIglslEuQpje1rQ5r97ZDBdJebUZ4E5v7wf9FRLfGXDGjtQ==}
+ engines: {node: '>=22'}
+ hasBin: true
+
+ i18next-resources-for-ts@2.1.0:
+ resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==}
+ hasBin: true
+
+ i18next@26.2.0:
+ resolution: {integrity: sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==}
+ peerDependencies:
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
iconv-corefoundation@1.1.7:
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
engines: {node: ^8.11.2 || >=10}
@@ -7414,6 +7695,15 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+ inquirer@13.4.3:
+ resolution: {integrity: sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==}
+ engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -7545,6 +7835,10 @@ packages:
resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==}
engines: {node: '>=18'}
+ is-interactive@2.0.0:
+ resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
+ engines: {node: '>=12'}
+
is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'}
@@ -7774,6 +8068,9 @@ packages:
resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ jsonc-parser@3.3.1:
+ resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@@ -7944,6 +8241,10 @@ packages:
lodash@4.18.1:
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+ log-symbols@7.0.1:
+ resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
+ engines: {node: '>=18'}
+
log-update@6.1.0:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
@@ -8348,6 +8649,10 @@ packages:
multimath@2.0.0:
resolution: {integrity: sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==}
+ mute-stream@3.0.0:
+ resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
+ engines: {node: ^20.17.0 || >=22.9.0}
+
mux-embed@5.18.1:
resolution: {integrity: sha512-ePsHjiEKY+FgrSBiMmaF+LOtTQSSBWv/1zqpREQFN96JE93xlsArT/MEi30yKOE06MgjOlL70YI750molu3y7g==}
@@ -8599,6 +8904,10 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
+ ora@9.4.0:
+ resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==}
+ engines: {node: '>=20'}
+
orderedmap@2.1.1:
resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
@@ -9324,6 +9633,22 @@ packages:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
+ react-i18next@17.0.8:
+ resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
+ peerDependencies:
+ i18next: '>= 26.2.0'
+ react: '>= 16.8.0'
+ react-dom: '*'
+ react-native: '*'
+ typescript: ^5 || ^6
+ peerDependenciesMeta:
+ react-dom:
+ optional: true
+ react-native:
+ optional: true
+ typescript:
+ optional: true
+
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -9393,6 +9718,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ react@19.2.6:
+ resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==}
+ engines: {node: '>=0.10.0'}
+
read-binary-file-arch@1.0.6:
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
hasBin: true
@@ -9609,12 +9938,19 @@ packages:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
+ run-async@4.0.6:
+ resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==}
+ engines: {node: '>=0.12.0'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
+ rxjs@7.8.2:
+ resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
+
safe-array-concat@1.1.3:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
@@ -9905,6 +10241,10 @@ packages:
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+ stdin-discarder@0.3.2:
+ resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==}
+ engines: {node: '>=18'}
+
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -10890,6 +11230,10 @@ packages:
jsdom:
optional: true
+ void-elements@3.1.0:
+ resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
+ engines: {node: '>=0.10.0'}
+
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
@@ -11445,6 +11789,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/runtime@7.29.2': {}
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -11494,10 +11840,10 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
- '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))':
+ '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))':
dependencies:
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
handlebars: 4.7.9
is-core-module: 2.16.1
micromatch: 4.0.8
@@ -11800,6 +12146,12 @@ snapshots:
'@colordx/core@5.4.3': {}
+ '@croct/json5-parser@0.2.2':
+ dependencies:
+ '@croct/json': 2.1.0
+
+ '@croct/json@2.1.0': {}
+
'@develar/schema-utils@2.6.5':
dependencies:
ajv: 6.14.0
@@ -12297,12 +12649,17 @@ snapshots:
'@esbuild/win32-x64@0.28.0':
optional: true
- '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.4(jiti@2.7.0))':
+ '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.4(jiti@1.21.7))':
dependencies:
escape-string-regexp: 4.0.0
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
ignore: 7.0.5
+ '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))':
+ dependencies:
+ eslint: 9.39.4(jiti@1.21.7)
+ eslint-visitor-keys: 3.4.3
+
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))':
dependencies:
eslint: 9.39.4(jiti@2.7.0)
@@ -12492,6 +12849,125 @@ snapshots:
'@iconify/types': 2.0.0
vue: 3.5.30(typescript@5.9.3)
+ '@inquirer/ansi@2.0.5': {}
+
+ '@inquirer/checkbox@5.1.5(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/confirm@6.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/core@11.1.10(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ cli-width: 4.1.0
+ fast-wrap-ansi: 0.2.2
+ mute-stream: 3.0.0
+ signal-exit: 4.1.0
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/editor@5.1.2(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/external-editor': 3.0.0(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/expand@5.0.14(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/external-editor@3.0.0(@types/node@25.0.7)':
+ dependencies:
+ chardet: 2.1.1
+ iconv-lite: 0.7.2
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/figures@2.0.5': {}
+
+ '@inquirer/input@5.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/number@4.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/password@5.0.13(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/prompts@8.4.3(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/checkbox': 5.1.5(@types/node@25.0.7)
+ '@inquirer/confirm': 6.0.13(@types/node@25.0.7)
+ '@inquirer/editor': 5.1.2(@types/node@25.0.7)
+ '@inquirer/expand': 5.0.14(@types/node@25.0.7)
+ '@inquirer/input': 5.0.13(@types/node@25.0.7)
+ '@inquirer/number': 4.0.13(@types/node@25.0.7)
+ '@inquirer/password': 5.0.13(@types/node@25.0.7)
+ '@inquirer/rawlist': 5.2.9(@types/node@25.0.7)
+ '@inquirer/search': 4.1.9(@types/node@25.0.7)
+ '@inquirer/select': 5.1.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/rawlist@5.2.9(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/search@4.1.9(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/select@5.1.5(@types/node@25.0.7)':
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/figures': 2.0.5
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ optionalDependencies:
+ '@types/node': 25.0.7
+
+ '@inquirer/type@4.0.5(@types/node@25.0.7)':
+ optionalDependencies:
+ '@types/node': 25.0.7
+
'@intlify/bundle-utils@10.0.1(vue-i18n@10.0.8(vue@3.5.30(typescript@5.9.3)))':
dependencies:
'@intlify/message-compiler': 11.3.0
@@ -14879,6 +15355,66 @@ snapshots:
estraverse: 5.3.0
picomatch: 4.0.4
+ '@swc/core-darwin-arm64@1.15.33':
+ optional: true
+
+ '@swc/core-darwin-x64@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm-gnueabihf@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-arm64-musl@1.15.33':
+ optional: true
+
+ '@swc/core-linux-ppc64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-s390x-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-x64-gnu@1.15.33':
+ optional: true
+
+ '@swc/core-linux-x64-musl@1.15.33':
+ optional: true
+
+ '@swc/core-win32-arm64-msvc@1.15.33':
+ optional: true
+
+ '@swc/core-win32-ia32-msvc@1.15.33':
+ optional: true
+
+ '@swc/core-win32-x64-msvc@1.15.33':
+ optional: true
+
+ '@swc/core@1.15.33':
+ dependencies:
+ '@swc/counter': 0.1.3
+ '@swc/types': 0.1.26
+ optionalDependencies:
+ '@swc/core-darwin-arm64': 1.15.33
+ '@swc/core-darwin-x64': 1.15.33
+ '@swc/core-linux-arm-gnueabihf': 1.15.33
+ '@swc/core-linux-arm64-gnu': 1.15.33
+ '@swc/core-linux-arm64-musl': 1.15.33
+ '@swc/core-linux-ppc64-gnu': 1.15.33
+ '@swc/core-linux-s390x-gnu': 1.15.33
+ '@swc/core-linux-x64-gnu': 1.15.33
+ '@swc/core-linux-x64-musl': 1.15.33
+ '@swc/core-win32-arm64-msvc': 1.15.33
+ '@swc/core-win32-ia32-msvc': 1.15.33
+ '@swc/core-win32-x64-msvc': 1.15.33
+
+ '@swc/counter@0.1.3': {}
+
+ '@swc/types@0.1.26':
+ dependencies:
+ '@swc/counter': 0.1.3
+
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
@@ -15385,15 +15921,15 @@ snapshots:
'@types/node': 25.0.7
optional: true
- '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.54.0
- '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -15417,14 +15953,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -15477,13 +16013,13 @@ snapshots:
dependencies:
typescript: 5.9.3
- '@typescript-eslint/type-utils@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/type-utils@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -15535,17 +16071,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
+ '@typescript-eslint/utils@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
'@typescript-eslint/scope-manager': 8.54.0
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
+ '@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@typescript-eslint/scope-manager': 8.57.1
+ '@typescript-eslint/types': 8.57.1
+ '@typescript-eslint/typescript-estree': 8.57.1(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
'@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
@@ -15658,7 +16206,7 @@ snapshots:
- rollup
- supports-color
- '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitejs/plugin-react@4.7.0(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@babel/core': 7.28.6
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6)
@@ -15666,7 +16214,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
- vite: 6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
@@ -15694,7 +16242,7 @@ snapshots:
vite: 7.3.3(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
vue: 3.5.34(typescript@5.9.3)
- '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -15709,7 +16257,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
- vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
@@ -15729,13 +16277,13 @@ snapshots:
optionalDependencies:
vite: 7.3.2(@types/node@22.19.15)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
- '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
+ '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -16566,7 +17114,7 @@ snapshots:
dotenv: 17.3.1
exsolve: 1.0.8
giget: 2.0.0
- jiti: 2.6.1
+ jiti: 2.7.0
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 2.1.0
@@ -16661,6 +17209,8 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ chalk@5.6.2: {}
+
change-case@5.4.4: {}
character-entities-html4@2.1.0: {}
@@ -16671,6 +17221,8 @@ snapshots:
character-reference-invalid@2.0.1: {}
+ chardet@2.1.1: {}
+
check-error@2.1.3: {}
chokidar@3.6.0:
@@ -16721,6 +17273,8 @@ snapshots:
dependencies:
restore-cursor: 5.1.0
+ cli-spinners@3.4.0: {}
+
cli-truncate@2.1.0:
dependencies:
slice-ansi: 3.0.0
@@ -16732,6 +17286,8 @@ snapshots:
slice-ansi: 7.1.2
string-width: 8.2.0
+ cli-width@4.1.0: {}
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -17422,7 +17978,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- electron-vite@5.0.0(vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
+ electron-vite@5.0.0(@swc/core@1.15.33)(vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0)
@@ -17430,7 +17986,9 @@ snapshots:
esbuild: 0.25.12
magic-string: 0.30.21
picocolors: 1.1.1
- vite: 6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ optionalDependencies:
+ '@swc/core': 1.15.33
transitivePeerDependencies:
- supports-color
@@ -17741,9 +18299,9 @@ snapshots:
'@eslint/compat': 2.0.3(eslint@9.39.4(jiti@2.7.0))
eslint: 9.39.4(jiti@2.7.0)
- eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)):
+ eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-flat-config-utils@3.0.2:
dependencies:
@@ -17765,10 +18323,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-import-resolver-typescript@4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
debug: 4.4.3
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
get-tsconfig: 4.13.0
is-bun-module: 2.0.0
@@ -17776,8 +18334,8 @@ snapshots:
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
- eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
+ eslint-plugin-import-x: 4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@@ -17785,24 +18343,24 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.7.0)
- eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
debug: 3.2.7
optionalDependencies:
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0))
+ eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)))(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
- eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
chalk: 4.1.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
micromatch: 4.0.8
transitivePeerDependencies:
- '@typescript-eslint/parser'
@@ -17814,6 +18372,26 @@ snapshots:
dependencies:
eslint: 9.39.4(jiti@2.7.0)
+ eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@1.21.7)):
+ dependencies:
+ '@package-json/types': 0.0.12
+ '@typescript-eslint/types': 8.57.1
+ comment-parser: 1.4.5
+ debug: 4.4.3
+ eslint: 9.39.4(jiti@1.21.7)
+ eslint-import-context: 0.1.9(unrs-resolver@1.11.1)
+ is-glob: 4.0.3
+ minimatch: 9.0.7
+ semver: 7.7.4
+ stable-hash-x: 0.2.0
+ unrs-resolver: 1.11.1
+ optionalDependencies:
+ '@typescript-eslint/utils': 8.57.1(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint-import-resolver-node: 0.3.9
+ transitivePeerDependencies:
+ - supports-color
+ optional: true
+
eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4(jiti@2.7.0)):
dependencies:
'@package-json/types': 0.0.12
@@ -17833,7 +18411,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -17842,9 +18420,9 @@ snapshots:
array.prototype.flatmap: 1.3.3
debug: 3.2.7
doctrine: 2.1.0
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@2.7.0))
+ eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.4(jiti@1.21.7))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -17856,7 +18434,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -17882,7 +18460,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)):
dependencies:
aria-query: 5.3.2
array-includes: 3.1.9
@@ -17892,7 +18470,7 @@ snapshots:
axobject-query: 4.1.0
damerau-levenshtein: 1.0.8
emoji-regex: 9.2.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
hasown: 2.0.2
jsx-ast-utils: 3.3.5
language-tags: 1.0.9
@@ -17901,22 +18479,22 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
- eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@babel/core': 7.28.6
'@babel/parser': 7.28.6
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
hermes-parser: 0.25.1
zod: 4.3.6
zod-validation-error: 4.0.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-react-refresh@0.4.26(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
- eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)):
dependencies:
array-includes: 3.1.9
array.prototype.findlast: 1.2.5
@@ -17924,7 +18502,7 @@ snapshots:
array.prototype.tosorted: 1.1.4
doctrine: 2.1.0
es-iterator-helpers: 1.2.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
estraverse: 5.3.0
hasown: 2.0.2
jsx-ast-utils: 3.3.5
@@ -17953,16 +18531,16 @@ snapshots:
dependencies:
safe-regex: 2.1.1
- eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.4(jiti@1.21.7)):
dependencies:
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
- eslint-plugin-sonarjs@3.0.6(eslint@9.39.4(jiti@2.7.0)):
+ eslint-plugin-sonarjs@3.0.6(eslint@9.39.4(jiti@1.21.7)):
dependencies:
'@eslint-community/regexpp': 4.12.2
builtin-modules: 3.3.0
bytes: 3.1.2
- eslint: 9.39.4(jiti@2.7.0)
+ eslint: 9.39.4(jiti@1.21.7)
functional-red-black-tree: 1.0.1
jsx-ast-utils-x: 0.1.0
lodash.merge: 4.6.2
@@ -18033,6 +18611,47 @@ snapshots:
eslint-visitor-keys@5.0.1: {}
+ eslint@9.39.4(jiti@1.21.7):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.21.2
+ '@eslint/config-helpers': 0.4.2
+ '@eslint/core': 0.17.0
+ '@eslint/eslintrc': 3.3.5
+ '@eslint/js': 9.39.4
+ '@eslint/plugin-kit': 0.4.1
+ '@humanfs/node': 0.16.7
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.14.0
+ chalk: 4.1.2
+ cross-spawn: 7.0.6
+ debug: 4.4.3
+ escape-string-regexp: 4.0.0
+ eslint-scope: 8.4.0
+ eslint-visitor-keys: 4.2.1
+ espree: 10.4.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 8.0.0
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ lodash.merge: 4.6.2
+ minimatch: 3.1.4
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 1.21.7
+ transitivePeerDependencies:
+ - supports-color
+
eslint@9.39.4(jiti@2.7.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0))
@@ -18890,6 +19509,10 @@ snapshots:
html-escaper@2.0.2: {}
+ html-parse-stringify@3.0.1:
+ dependencies:
+ void-elements: 3.1.0
+
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
@@ -18959,6 +19582,45 @@ snapshots:
husky@9.1.7: {}
+ i18next-cli@1.58.0(@types/node@25.0.7)(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(typescript@5.9.3):
+ dependencies:
+ '@croct/json5-parser': 0.2.2
+ '@swc/core': 1.15.33
+ chokidar: 5.0.0
+ commander: 14.0.3
+ execa: 9.6.1
+ glob: 13.0.6
+ i18next-resources-for-ts: 2.1.0
+ inquirer: 13.4.3(@types/node@25.0.7)
+ jiti: 2.7.0
+ jsonc-parser: 3.3.1
+ magic-string: 0.30.21
+ minimatch: 10.2.3
+ ora: 9.4.0
+ react: 19.2.6
+ react-i18next: 17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.6)(typescript@5.9.3)
+ yaml: 2.9.0
+ transitivePeerDependencies:
+ - '@swc/helpers'
+ - '@types/node'
+ - i18next
+ - react-dom
+ - react-native
+ - typescript
+
+ i18next-resources-for-ts@2.1.0:
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@swc/core': 1.15.33
+ chokidar: 5.0.0
+ yaml: 2.9.0
+ transitivePeerDependencies:
+ - '@swc/helpers'
+
+ i18next@26.2.0(typescript@5.9.3):
+ optionalDependencies:
+ typescript: 5.9.3
+
iconv-corefoundation@1.1.7:
dependencies:
cli-truncate: 2.1.0
@@ -19020,6 +19682,18 @@ snapshots:
inline-style-parser@0.2.7: {}
+ inquirer@13.4.3(@types/node@25.0.7):
+ dependencies:
+ '@inquirer/ansi': 2.0.5
+ '@inquirer/core': 11.1.10(@types/node@25.0.7)
+ '@inquirer/prompts': 8.4.3(@types/node@25.0.7)
+ '@inquirer/type': 4.0.5(@types/node@25.0.7)
+ mute-stream: 3.0.0
+ run-async: 4.0.6
+ rxjs: 7.8.2
+ optionalDependencies:
+ '@types/node': 25.0.7
+
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -19154,6 +19828,8 @@ snapshots:
global-directory: 4.0.1
is-path-inside: 4.0.0
+ is-interactive@2.0.0: {}
+
is-map@2.0.3: {}
is-module@1.0.0: {}
@@ -19350,6 +20026,8 @@ snapshots:
espree: 9.6.1
semver: 7.7.4
+ jsonc-parser@3.3.1: {}
+
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
@@ -19563,6 +20241,11 @@ snapshots:
lodash@4.18.1: {}
+ log-symbols@7.0.1:
+ dependencies:
+ is-unicode-supported: 2.1.0
+ yoctocolors: 2.1.2
+
log-update@6.1.0:
dependencies:
ansi-escapes: 7.3.0
@@ -20194,6 +20877,8 @@ snapshots:
glur: 1.1.2
object-assign: 4.1.1
+ mute-stream@3.0.0: {}
+
mux-embed@5.18.1: {}
mz@2.7.0:
@@ -20659,6 +21344,17 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
+ ora@9.4.0:
+ dependencies:
+ chalk: 5.6.2
+ cli-cursor: 5.0.0
+ cli-spinners: 3.4.0
+ is-interactive: 2.0.0
+ is-unicode-supported: 2.1.0
+ log-symbols: 7.0.1
+ stdin-discarder: 0.3.2
+ string-width: 8.2.0
+
orderedmap@2.1.1: {}
own-keys@1.0.1:
@@ -21441,6 +22137,28 @@ snapshots:
react-resizable: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
resize-observer-polyfill: 1.5.1
+ react-i18next@17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 26.2.0(typescript@5.9.3)
+ react: 19.2.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ typescript: 5.9.3
+
+ react-i18next@17.0.8(i18next@26.2.0(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.6)(typescript@5.9.3):
+ dependencies:
+ '@babel/runtime': 7.29.2
+ html-parse-stringify: 3.0.1
+ i18next: 26.2.0(typescript@5.9.3)
+ react: 19.2.6
+ use-sync-external-store: 1.6.0(react@19.2.6)
+ optionalDependencies:
+ react-dom: 19.2.4(react@19.2.4)
+ typescript: 5.9.3
+
react-is@16.13.1: {}
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4):
@@ -21513,6 +22231,8 @@ snapshots:
react@19.2.4: {}
+ react@19.2.6: {}
+
read-binary-file-arch@1.0.6:
dependencies:
debug: 4.4.3
@@ -21812,12 +22532,18 @@ snapshots:
run-applescript@7.1.0: {}
+ run-async@4.0.6: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
rw@1.3.3: {}
+ rxjs@7.8.2:
+ dependencies:
+ tslib: 2.8.1
+
safe-array-concat@1.1.3:
dependencies:
call-bind: 1.0.8
@@ -22120,6 +22846,8 @@ snapshots:
std-env@4.1.0: {}
+ stdin-discarder@0.3.2: {}
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -22525,7 +23253,7 @@ snapshots:
tsscmp@1.0.6: {}
- tsup@8.5.1(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
+ tsup@8.5.1(@swc/core@1.15.33)(jiti@2.7.0)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.2)
cac: 6.7.14
@@ -22545,6 +23273,7 @@ snapshots:
tinyglobby: 0.2.15
tree-kill: 1.2.2
optionalDependencies:
+ '@swc/core': 1.15.33
postcss: 8.5.10
typescript: 5.9.3
transitivePeerDependencies:
@@ -22614,13 +23343,13 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript-eslint@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3):
+ typescript-eslint@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
- '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
- eslint: 9.39.4(jiti@2.7.0)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)
+ eslint: 9.39.4(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -22948,6 +23677,10 @@ snapshots:
dependencies:
react: 19.2.4
+ use-sync-external-store@1.6.0(react@19.2.6):
+ dependencies:
+ react: 19.2.6
+
utf8-byte-length@1.0.5: {}
util-deprecate@1.0.2: {}
@@ -23009,13 +23742,13 @@ snapshots:
- tsx
- yaml
- vite-node@3.2.4(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ vite-node@3.2.4(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -23105,7 +23838,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- vite@6.4.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ vite@6.4.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4)
@@ -23116,7 +23849,7 @@ snapshots:
optionalDependencies:
'@types/node': 25.0.7
fsevents: 2.3.3
- jiti: 2.7.0
+ jiti: 1.21.7
sass: 1.98.0
terser: 5.46.0
tsx: 4.21.0
@@ -23139,6 +23872,23 @@ snapshots:
tsx: 4.21.0
yaml: 2.9.0
+ vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ dependencies:
+ esbuild: 0.27.4
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
+ postcss: 8.5.10
+ rollup: 4.59.0
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 25.0.7
+ fsevents: 2.3.3
+ jiti: 1.21.7
+ sass: 1.98.0
+ terser: 5.46.0
+ tsx: 4.21.0
+ yaml: 2.9.0
+
vite@7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
esbuild: 0.27.4
@@ -23288,11 +24038,11 @@ snapshots:
- tsx
- yaml
- vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
+ vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@20.9.0)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
+ '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -23310,8 +24060,8 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
- vite: 7.3.2(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
- vite-node: 3.2.4(@types/node@25.0.7)(jiti@2.7.0)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite: 7.3.2(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
+ vite-node: 3.2.4(@types/node@25.0.7)(jiti@1.21.7)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
@@ -23331,6 +24081,8 @@ snapshots:
- tsx
- yaml
+ void-elements@3.1.0: {}
+
vscode-uri@3.1.0: {}
vue-bundle-renderer@2.2.0:
diff --git a/scripts/i18n/validate.ts b/scripts/i18n/validate.ts
new file mode 100644
index 00000000..98abbbbd
--- /dev/null
+++ b/scripts/i18n/validate.ts
@@ -0,0 +1,145 @@
+import { readdir, readFile } from 'node:fs/promises';
+import path from 'node:path';
+import process from 'node:process';
+
+import {
+ FALLBACK_APP_LOCALE,
+ RESOLVED_APP_LOCALES,
+ TRANSLATION_NAMESPACES,
+} from '../../src/features/localization/contracts';
+import { validateTranslationCatalogs } from '../../src/features/localization/core/application/validateTranslationCatalogs';
+
+import type {
+ CatalogValidationIssue,
+ TranslationCatalogByNamespace,
+ TranslationCatalogsByLocale,
+ TranslationCatalogNode,
+} from '../../src/features/localization/core/application/validateTranslationCatalogs';
+
+const repoRoot = process.cwd();
+const localesRoot = path.join(repoRoot, 'src/features/localization/renderer/locales');
+
+const issues: CatalogValidationIssue[] = [];
+const catalogs = await readCatalogs(localesRoot, issues);
+
+validateConfiguredLocales(catalogs, issues);
+validateConfiguredNamespaces(catalogs, issues);
+issues.push(...validateTranslationCatalogs(catalogs, FALLBACK_APP_LOCALE));
+
+if (issues.length > 0) {
+ for (const issue of issues) {
+ console.error(`${issue.locale}/${issue.namespace}: ${issue.message}`);
+ }
+ process.exit(1);
+}
+
+console.log(
+ `i18n catalogs valid (${RESOLVED_APP_LOCALES.length} locale set, ${TRANSLATION_NAMESPACES.length} namespaces)`
+);
+
+async function readCatalogs(
+ root: string,
+ issuesOutput: CatalogValidationIssue[]
+): Promise {
+ const localeEntries = await readdir(root, { withFileTypes: true });
+ const result: TranslationCatalogsByLocale = {};
+
+ for (const localeEntry of localeEntries) {
+ if (!localeEntry.isDirectory()) continue;
+
+ const locale = localeEntry.name;
+ const localeDir = path.join(root, locale);
+ const namespaceEntries = await readdir(localeDir, { withFileTypes: true });
+ const localeCatalog: TranslationCatalogByNamespace = {};
+
+ for (const namespaceEntry of namespaceEntries) {
+ if (!namespaceEntry.isFile() || !namespaceEntry.name.endsWith('.json')) continue;
+
+ const namespace = namespaceEntry.name.slice(0, -'.json'.length);
+ const filePath = path.join(localeDir, namespaceEntry.name);
+ const parsed = JSON.parse(await readFile(filePath, 'utf8')) as unknown;
+
+ if (!isTranslationCatalogNode(parsed)) {
+ issuesOutput.push({
+ type: 'shape-mismatch',
+ locale,
+ namespace,
+ message: `Catalog "${locale}/${namespace}.json" must contain a JSON object of nested strings`,
+ });
+ continue;
+ }
+
+ localeCatalog[namespace] = parsed;
+ }
+
+ result[locale] = localeCatalog;
+ }
+
+ return result;
+}
+
+function validateConfiguredLocales(
+ catalogs: TranslationCatalogsByLocale,
+ issuesOutput: CatalogValidationIssue[]
+): void {
+ for (const locale of RESOLVED_APP_LOCALES) {
+ if (!catalogs[locale]) {
+ issuesOutput.push({
+ type: 'missing-namespace',
+ locale,
+ namespace: '*',
+ message: `Configured locale "${locale}" has no catalog directory`,
+ });
+ }
+ }
+
+ for (const locale of Object.keys(catalogs)) {
+ if (!RESOLVED_APP_LOCALES.includes(locale as (typeof RESOLVED_APP_LOCALES)[number])) {
+ issuesOutput.push({
+ type: 'extra-key',
+ locale,
+ namespace: '*',
+ message: `Catalog directory "${locale}" is not listed in RESOLVED_APP_LOCALES`,
+ });
+ }
+ }
+}
+
+function validateConfiguredNamespaces(
+ catalogs: TranslationCatalogsByLocale,
+ issuesOutput: CatalogValidationIssue[]
+): void {
+ for (const [locale, catalog] of Object.entries(catalogs)) {
+ for (const namespace of TRANSLATION_NAMESPACES) {
+ if (!catalog[namespace]) {
+ issuesOutput.push({
+ type: 'missing-namespace',
+ locale,
+ namespace,
+ message: `Configured namespace "${namespace}" is missing for locale "${locale}"`,
+ });
+ }
+ }
+
+ for (const namespace of Object.keys(catalog)) {
+ if (!TRANSLATION_NAMESPACES.includes(namespace as (typeof TRANSLATION_NAMESPACES)[number])) {
+ issuesOutput.push({
+ type: 'extra-key',
+ locale,
+ namespace,
+ message: `Catalog namespace "${namespace}" is not listed in TRANSLATION_NAMESPACES`,
+ });
+ }
+ }
+ }
+}
+
+function isTranslationCatalogNode(value: unknown): value is TranslationCatalogNode {
+ if (typeof value === 'string') return true;
+ if (!isPlainObject(value)) return false;
+ return Object.values(value).every(isTranslationCatalogNode);
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
index b81c165e..a6315b25 100644
--- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { ACTIVITY_LANE } from '@claude-teams/agent-graph';
+import { useAppTranslation } from '@features/localization/renderer';
import { buildMessageContext } from '@renderer/components/team/activity/activityMessageContext';
import { MessageExpandDialog } from '@renderer/components/team/activity/MessageExpandDialog';
import { useStableTeamMentionMeta } from '@renderer/hooks/useStableTeamMentionMeta';
@@ -77,6 +78,7 @@ export const GraphActivityHud = ({
onOpenTaskDetail,
onOpenMemberProfile,
}: GraphActivityHudProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const worldLayerRef = useRef(null);
const shellRefs = useRef(new Map());
const connectorRefs = useRef(new Map());
@@ -552,12 +554,12 @@ export const GraphActivityHud = ({
>
- Activity
+ {t('agentGraph.activityHud.activity')}
{lane.entries.length === 0 && lane.overflowCount === 0 ? (
- No recent activity
+ {t('agentGraph.activityHud.noRecentActivity')}
) : null}
{lane.entries.map(renderLaneEntry)}
@@ -568,7 +570,7 @@ export const GraphActivityHud = ({
className={`${INTERACTIVE_ACTIVITY_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
onClick={() => handleOpenOwnerActivity(lane.node)}
>
- +{lane.overflowCount} more
+ {t('agentGraph.activityHud.more', { count: lane.overflowCount })}
) : null}
diff --git a/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx b/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx
index 5b4d5c82..27db9063 100644
--- a/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphBlockingEdgePopover.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -63,6 +64,7 @@ export const GraphBlockingEdgePopover = ({
onSelectNode,
onOpenTaskDetail,
}: GraphBlockingEdgePopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { teamData } = useGraphActivityContext(teamName);
const tasksById = useMemo(
() => new Map((teamData?.tasks ?? []).map((task) => [task.id, task] as const)),
@@ -102,7 +104,7 @@ export const GraphBlockingEdgePopover = ({
- Blocking Dependency
+ {t('agentGraph.blockingEdge.title')}
{relationCount > 1 && (
{sourceLabel}
{sourceHiddenTasks.length > 0 && (
)}
-
blocks
+
+ {t('agentGraph.blockingEdge.blocks')}
+
{targetLabel}
{targetHiddenTasks.length > 0 && (
)}
- Close
+ {t('agentGraph.blockingEdge.close')}
diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx
index 16c4e79a..51e32def 100644
--- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
AlertCircle,
Brain,
@@ -279,6 +280,7 @@ export const GraphMemberLogPreviewHud = ({
enabled = true,
onOpenMemberProfile,
}: GraphMemberLogPreviewHudProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const worldLayerRef = useRef(null);
const shellRefs = useRef(new Map());
const visibleKeyRef = useRef('');
@@ -607,7 +609,7 @@ export const GraphMemberLogPreviewHud = ({
- Logs
+ {t('agentGraph.logPreview.logs')}
{items.length > 0 ? (
@@ -617,10 +619,10 @@ export const GraphMemberLogPreviewHud = ({
type="button"
className={`${INTERACTIVE_LOG_CONTROL_CLASS} flex min-h-0 flex-1 rounded-md text-left text-[11px] text-slate-400/60`}
aria-busy="true"
- aria-label="Loading logs"
+ aria-label={t('agentGraph.logPreview.loading')}
onClick={() => openLogs(memberName)}
>
- Loading logs
+ {t('agentGraph.logPreview.loading')}
{renderLoadingSkeleton()}
) : (
@@ -638,7 +640,7 @@ export const GraphMemberLogPreviewHud = ({
className={`${INTERACTIVE_LOG_CONTROL_CLASS} h-8 min-h-8 w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]`}
onClick={() => openLogs(memberName)}
>
- +{preview.overflowCount} more
+ {t('agentGraph.logPreview.more', { count: preview.overflowCount })}
) : null}
diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
index 2c2b3a4d..6ea2258d 100644
--- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx
@@ -6,6 +6,7 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -115,6 +116,7 @@ export const GraphNodePopover = ({
onViewChanges,
onDeleteTask,
}: GraphNodePopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
if (node.kind === 'member' || node.kind === 'lead') {
return (
{'\u{2194}'}
{extTeamName}
- External team
+
+ {t('agentGraph.popover.externalTeam')}
+
);
}
@@ -185,11 +189,15 @@ export const GraphNodePopover = ({
{node.processRegisteredBy && (
- Started by: {node.processRegisteredBy}
+ {t('agentGraph.popover.process.startedBy')}{' '}
+ {node.processRegisteredBy}
)}
{node.processRegisteredAt && (
-
At: {new Date(node.processRegisteredAt).toLocaleTimeString()}
+
+ {t('agentGraph.popover.process.at')}{' '}
+ {new Date(node.processRegisteredAt).toLocaleTimeString()}
+
)}
{node.exceptionLabel && (
- Open URL
+ {t('agentGraph.popover.process.openUrl')}
)}
@@ -229,6 +237,7 @@ const OverflowPopoverContent = ({
onClose: () => void;
onOpenTaskDetail?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { teamData } = useGraphActivityContext(teamName);
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
const hiddenTasks = (node.overflowTaskIds ?? [])
@@ -238,14 +247,18 @@ const OverflowPopoverContent = ({
return (
-
Hidden tasks
+
+ {t('agentGraph.popover.overflow.hiddenTasks')}
+
{node.overflowCount ?? hiddenTasks.length}
{hiddenTasks.length === 0 ? (
-
No hidden tasks available.
+
+ {t('agentGraph.popover.overflow.empty')}
+
) : (
hiddenTasks.map((task) => {
const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]);
@@ -303,6 +316,7 @@ const MemberPopoverContent = ({
onCreateTask?: (owner: string) => void;
onOpenTask?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const memberName =
node.domainRef.kind === 'member' || node.domainRef.kind === 'lead'
? node.domainRef.memberName
@@ -342,6 +356,7 @@ const MemberPopoverContent = ({
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
+ t,
})
: null;
const launchPresentation = member
@@ -372,11 +387,11 @@ const MemberPopoverContent = ({
const fallbackSpawnStatusLabel =
node.spawnStatus && node.spawnStatus !== 'online'
? node.spawnStatus === 'waiting'
- ? 'waiting to start'
+ ? t('agentGraph.popover.member.spawn.waitingToStart')
: node.spawnStatus === 'spawning'
- ? 'starting'
+ ? t('agentGraph.popover.member.spawn.starting')
: node.spawnStatus === 'error'
- ? 'failed'
+ ? t('agentGraph.popover.member.spawn.failed')
: node.spawnStatus
: null;
const statusLabel =
@@ -385,13 +400,13 @@ const MemberPopoverContent = ({
launchPresentation?.presenceLabel ??
fallbackSpawnStatusLabel ??
(node.state === 'active'
- ? 'active'
+ ? t('agentGraph.popover.member.state.active')
: node.state === 'idle'
- ? 'idle'
+ ? t('agentGraph.popover.member.state.idle')
: node.state === 'terminated'
- ? 'offline'
+ ? t('agentGraph.popover.member.state.offline')
: node.state === 'tool_calling'
- ? 'running tool'
+ ? t('agentGraph.popover.member.state.runningTool')
: node.state);
const statusDotClass =
launchPresentation?.dotClass ??
@@ -464,7 +479,7 @@ const MemberPopoverContent = ({
variant="outline"
className="border-blue-500/30 px-1.5 py-0 text-[10px] text-blue-400"
>
- Lead
+ {t('agentGraph.popover.member.lead')}
)}
{(launchPresentation?.spawnBadgeLabel ?? fallbackSpawnStatusLabel) &&
@@ -499,7 +514,9 @@ const MemberPopoverContent = ({
className="size-3 shrink-0 animate-spin"
style={{ color: node.color ?? '#66ccff' }}
/>
-
working on
+
+ {t('agentGraph.popover.member.workingOn')}
+
{node.activeTool.state === 'running'
- ? 'Running tool'
+ ? t('agentGraph.popover.member.activeTool.running')
: node.activeTool.state === 'error'
- ? 'Tool failed'
- : 'Tool finished'}
+ ? t('agentGraph.popover.member.activeTool.failed')
+ : t('agentGraph.popover.member.activeTool.finished')}
@@ -555,7 +572,7 @@ const MemberPopoverContent = ({
{node.recentTools && node.recentTools.length > 0 && (
- Recent tools
+ {t('agentGraph.popover.member.recentTools')}
{node.recentTools.slice(0, 5).map((tool) => {
@@ -594,7 +611,7 @@ const MemberPopoverContent = ({
onClose();
}}
>
-
Message
+
{t('agentGraph.popover.member.actions.message')}
- Profile
+ {t('agentGraph.popover.member.actions.profile')}
- Task
+ {t('agentGraph.popover.member.actions.task')}
diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
index fd3bad8b..0700f332 100644
--- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
+++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { DISPLAY_STEPS } from '@renderer/components/team/provisioningSteps';
import { StepProgressBar } from '@renderer/components/team/StepProgressBar';
import { TeamProvisioningPanel } from '@renderer/components/team/TeamProvisioningPanel';
@@ -15,7 +16,6 @@ import {
import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation';
import type { CSSProperties } from 'react';
-const MINI_STEPS = DISPLAY_STEPS.map((step) => ({ key: step.key, label: step.label }));
const HUD_STEPPER_STYLE: CSSProperties = {
['--stepper-done' as string]: '#22c55e',
['--stepper-done-glow' as string]: 'rgba(34, 197, 94, 0.24)',
@@ -46,6 +46,8 @@ export const GraphProvisioningHud = ({
teamName,
enabled = true,
}: GraphProvisioningHudProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+ const miniSteps = DISPLAY_STEPS.map((step) => ({ key: step.key, label: t(step.labelKey) }));
const { presentation, runInstanceKey } = useTeamProvisioningPresentation(teamName);
const lastActiveStepRef = useRef(-1);
const [detailsOpen, setDetailsOpen] = useState(false);
@@ -88,7 +90,7 @@ export const GraphProvisioningHud = ({
>
- Launch details
+ {t('agentGraph.provisioning.launchDetails')}
- Detailed team launch progress, live output and CLI logs.
+ {t('agentGraph.provisioning.launchDetailsDescription')}
diff --git a/src/features/localization/contracts/appLocale.ts b/src/features/localization/contracts/appLocale.ts
new file mode 100644
index 00000000..aa3d490b
--- /dev/null
+++ b/src/features/localization/contracts/appLocale.ts
@@ -0,0 +1,19 @@
+export const APP_LOCALE_PREFERENCES = ['system', 'en', 'ru'] as const;
+
+export const RESOLVED_APP_LOCALES = ['en', 'ru'] as const;
+
+export type AppLocalePreference = (typeof APP_LOCALE_PREFERENCES)[number];
+
+export type ResolvedAppLocale = (typeof RESOLVED_APP_LOCALES)[number];
+
+export const DEFAULT_APP_LOCALE_PREFERENCE: AppLocalePreference = 'system';
+
+export const FALLBACK_APP_LOCALE: ResolvedAppLocale = 'en';
+
+export function isAppLocalePreference(value: unknown): value is AppLocalePreference {
+ return typeof value === 'string' && APP_LOCALE_PREFERENCES.includes(value as AppLocalePreference);
+}
+
+export function isResolvedAppLocale(value: unknown): value is ResolvedAppLocale {
+ return typeof value === 'string' && RESOLVED_APP_LOCALES.includes(value as ResolvedAppLocale);
+}
diff --git a/src/features/localization/contracts/index.ts b/src/features/localization/contracts/index.ts
new file mode 100644
index 00000000..3d29bb19
--- /dev/null
+++ b/src/features/localization/contracts/index.ts
@@ -0,0 +1,11 @@
+export type { AppLocalePreference, ResolvedAppLocale } from './appLocale';
+export {
+ APP_LOCALE_PREFERENCES,
+ DEFAULT_APP_LOCALE_PREFERENCE,
+ FALLBACK_APP_LOCALE,
+ isAppLocalePreference,
+ isResolvedAppLocale,
+ RESOLVED_APP_LOCALES,
+} from './appLocale';
+export type { TranslationNamespace } from './namespaces';
+export { DEFAULT_TRANSLATION_NAMESPACE, TRANSLATION_NAMESPACES } from './namespaces';
diff --git a/src/features/localization/contracts/namespaces.ts b/src/features/localization/contracts/namespaces.ts
new file mode 100644
index 00000000..f7b75a92
--- /dev/null
+++ b/src/features/localization/contracts/namespaces.ts
@@ -0,0 +1,13 @@
+export const TRANSLATION_NAMESPACES = [
+ 'common',
+ 'settings',
+ 'errors',
+ 'report',
+ 'dashboard',
+ 'extensions',
+ 'team',
+] as const;
+
+export type TranslationNamespace = (typeof TRANSLATION_NAMESPACES)[number];
+
+export const DEFAULT_TRANSLATION_NAMESPACE: TranslationNamespace = 'common';
diff --git a/src/features/localization/core/application/resolveRuntimeLocale.ts b/src/features/localization/core/application/resolveRuntimeLocale.ts
new file mode 100644
index 00000000..e8c8301d
--- /dev/null
+++ b/src/features/localization/core/application/resolveRuntimeLocale.ts
@@ -0,0 +1,15 @@
+import { resolveAppLocale } from '../domain/localePolicy';
+
+import type { AppLocalePreference, ResolvedAppLocale } from '../../contracts';
+
+export interface ResolveRuntimeLocaleInput {
+ readonly preference: AppLocalePreference;
+ readonly systemLocale: string | null;
+}
+
+export function resolveRuntimeLocale(input: ResolveRuntimeLocaleInput): ResolvedAppLocale {
+ return resolveAppLocale({
+ preference: input.preference,
+ systemLocale: input.systemLocale,
+ });
+}
diff --git a/src/features/localization/core/application/validateTranslationCatalogs.ts b/src/features/localization/core/application/validateTranslationCatalogs.ts
new file mode 100644
index 00000000..c8b6340c
--- /dev/null
+++ b/src/features/localization/core/application/validateTranslationCatalogs.ts
@@ -0,0 +1,7 @@
+export type {
+ CatalogValidationIssue,
+ TranslationCatalogByNamespace,
+ TranslationCatalogNode,
+ TranslationCatalogsByLocale,
+} from '../domain/catalogPolicy';
+export { validateCatalogCompleteness as validateTranslationCatalogs } from '../domain/catalogPolicy';
diff --git a/src/features/localization/core/domain/catalogPolicy.ts b/src/features/localization/core/domain/catalogPolicy.ts
new file mode 100644
index 00000000..9b36102e
--- /dev/null
+++ b/src/features/localization/core/domain/catalogPolicy.ts
@@ -0,0 +1,203 @@
+export type TranslationCatalogNode = string | { readonly [key: string]: TranslationCatalogNode };
+
+export interface CatalogValidationIssue {
+ readonly type:
+ | 'missing-namespace'
+ | 'missing-key'
+ | 'extra-key'
+ | 'shape-mismatch'
+ | 'empty-message'
+ | 'interpolation-mismatch';
+ readonly locale: string;
+ readonly namespace: string;
+ readonly key?: string;
+ readonly message: string;
+}
+
+export type TranslationCatalogByNamespace = Record;
+
+export type TranslationCatalogsByLocale = Record;
+
+export function validateCatalogCompleteness(
+ catalogsByLocale: TranslationCatalogsByLocale,
+ sourceLocale: string
+): CatalogValidationIssue[] {
+ const sourceCatalog = catalogsByLocale[sourceLocale];
+ if (!sourceCatalog) {
+ return [
+ {
+ type: 'missing-namespace',
+ locale: sourceLocale,
+ namespace: '*',
+ message: `Source locale "${sourceLocale}" is missing`,
+ },
+ ];
+ }
+
+ const issues: CatalogValidationIssue[] = [];
+ for (const [locale, localeCatalog] of Object.entries(catalogsByLocale)) {
+ compareLocaleCatalog(issues, locale, localeCatalog, sourceCatalog);
+ }
+ return issues;
+}
+
+function compareLocaleCatalog(
+ issues: CatalogValidationIssue[],
+ locale: string,
+ localeCatalog: TranslationCatalogByNamespace,
+ sourceCatalog: TranslationCatalogByNamespace
+): void {
+ for (const [namespace, sourceNamespaceCatalog] of Object.entries(sourceCatalog)) {
+ const targetNamespaceCatalog = localeCatalog[namespace];
+ if (!targetNamespaceCatalog) {
+ issues.push({
+ type: 'missing-namespace',
+ locale,
+ namespace,
+ message: `Locale "${locale}" is missing namespace "${namespace}"`,
+ });
+ continue;
+ }
+
+ compareCatalogNode(issues, {
+ locale,
+ namespace,
+ keyPath: [],
+ sourceNode: sourceNamespaceCatalog,
+ targetNode: targetNamespaceCatalog,
+ });
+ }
+
+ for (const namespace of Object.keys(localeCatalog)) {
+ if (!(namespace in sourceCatalog)) {
+ issues.push({
+ type: 'extra-key',
+ locale,
+ namespace,
+ message: `Locale "${locale}" has extra namespace "${namespace}"`,
+ });
+ }
+ }
+}
+
+interface CompareCatalogNodeInput {
+ readonly locale: string;
+ readonly namespace: string;
+ readonly keyPath: readonly string[];
+ readonly sourceNode: TranslationCatalogNode;
+ readonly targetNode: TranslationCatalogNode;
+}
+
+function compareCatalogNode(
+ issues: CatalogValidationIssue[],
+ input: CompareCatalogNodeInput
+): void {
+ const key = input.keyPath.join('.');
+
+ if (typeof input.sourceNode === 'string') {
+ validateStringNode(issues, input, key);
+ return;
+ }
+
+ if (typeof input.targetNode === 'string') {
+ issues.push({
+ type: 'shape-mismatch',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Expected object at "${input.namespace}:${key}"`,
+ });
+ return;
+ }
+
+ for (const [childKey, sourceChildNode] of Object.entries(input.sourceNode)) {
+ if (!(childKey in input.targetNode)) {
+ const missingKey = [...input.keyPath, childKey].join('.');
+ issues.push({
+ type: 'missing-key',
+ locale: input.locale,
+ namespace: input.namespace,
+ key: missingKey,
+ message: `Missing key "${input.namespace}:${missingKey}" for locale "${input.locale}"`,
+ });
+ continue;
+ }
+
+ compareCatalogNode(issues, {
+ locale: input.locale,
+ namespace: input.namespace,
+ keyPath: [...input.keyPath, childKey],
+ sourceNode: sourceChildNode,
+ targetNode: input.targetNode[childKey],
+ });
+ }
+
+ for (const childKey of Object.keys(input.targetNode)) {
+ if (!(childKey in input.sourceNode)) {
+ const extraKey = [...input.keyPath, childKey].join('.');
+ issues.push({
+ type: 'extra-key',
+ locale: input.locale,
+ namespace: input.namespace,
+ key: extraKey,
+ message: `Extra key "${input.namespace}:${extraKey}" for locale "${input.locale}"`,
+ });
+ }
+ }
+}
+
+function validateStringNode(
+ issues: CatalogValidationIssue[],
+ input: CompareCatalogNodeInput,
+ key: string
+): void {
+ const sourceMessage = input.sourceNode;
+ if (typeof sourceMessage !== 'string') {
+ return;
+ }
+
+ if (typeof input.targetNode !== 'string') {
+ issues.push({
+ type: 'shape-mismatch',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Expected string at "${input.namespace}:${key}"`,
+ });
+ return;
+ }
+
+ if (input.targetNode.trim().length === 0) {
+ issues.push({
+ type: 'empty-message',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Empty message at "${input.namespace}:${key}" for locale "${input.locale}"`,
+ });
+ }
+
+ const sourceVariables = extractInterpolationVariables(sourceMessage);
+ const targetVariables = extractInterpolationVariables(input.targetNode);
+ if (!hasSameItems(sourceVariables, targetVariables)) {
+ issues.push({
+ type: 'interpolation-mismatch',
+ locale: input.locale,
+ namespace: input.namespace,
+ key,
+ message: `Interpolation variables differ at "${input.namespace}:${key}" for locale "${input.locale}"`,
+ });
+ }
+}
+
+export function extractInterpolationVariables(message: string): readonly string[] {
+ const variables = new Set();
+ for (const match of message.matchAll(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g)) {
+ variables.add(match[1]);
+ }
+ return [...variables].sort();
+}
+
+function hasSameItems(left: readonly string[], right: readonly string[]): boolean {
+ return left.length === right.length && left.every((item, index) => item === right[index]);
+}
diff --git a/src/features/localization/core/domain/localePolicy.ts b/src/features/localization/core/domain/localePolicy.ts
new file mode 100644
index 00000000..28151706
--- /dev/null
+++ b/src/features/localization/core/domain/localePolicy.ts
@@ -0,0 +1,43 @@
+import {
+ FALLBACK_APP_LOCALE,
+ isAppLocalePreference,
+ isResolvedAppLocale,
+ RESOLVED_APP_LOCALES,
+} from '../../contracts';
+
+import type { AppLocalePreference, ResolvedAppLocale } from '../../contracts';
+
+export interface LocaleResolutionInput {
+ readonly preference: unknown;
+ readonly systemLocale?: string | null;
+ readonly supportedLocales?: readonly ResolvedAppLocale[];
+ readonly fallbackLocale?: ResolvedAppLocale;
+}
+
+export function normalizeAppLocalePreference(value: unknown): AppLocalePreference {
+ return isAppLocalePreference(value) ? value : 'system';
+}
+
+export function extractPrimaryLocaleSubtag(locale: string | null | undefined): string | null {
+ const trimmed = locale?.trim();
+ if (!trimmed) return null;
+
+ const normalized = trimmed.replace('_', '-').toLowerCase();
+ const primary = normalized.split('-')[0]?.trim();
+ return primary || null;
+}
+
+export function resolveAppLocale(input: LocaleResolutionInput): ResolvedAppLocale {
+ const supportedLocales = input.supportedLocales ?? RESOLVED_APP_LOCALES;
+ const fallbackLocale = input.fallbackLocale ?? FALLBACK_APP_LOCALE;
+ const preference = normalizeAppLocalePreference(input.preference);
+
+ if (preference !== 'system') {
+ return supportedLocales.includes(preference) ? preference : fallbackLocale;
+ }
+
+ const primarySystemLocale = extractPrimaryLocaleSubtag(input.systemLocale);
+ return isResolvedAppLocale(primarySystemLocale) && supportedLocales.includes(primarySystemLocale)
+ ? primarySystemLocale
+ : fallbackLocale;
+}
diff --git a/src/features/localization/index.ts b/src/features/localization/index.ts
new file mode 100644
index 00000000..555be93b
--- /dev/null
+++ b/src/features/localization/index.ts
@@ -0,0 +1,11 @@
+export type { AppLocalePreference, ResolvedAppLocale, TranslationNamespace } from './contracts';
+export {
+ APP_LOCALE_PREFERENCES,
+ DEFAULT_APP_LOCALE_PREFERENCE,
+ FALLBACK_APP_LOCALE,
+ isAppLocalePreference,
+ isResolvedAppLocale,
+ RESOLVED_APP_LOCALES,
+ TRANSLATION_NAMESPACES,
+} from './contracts';
+export { normalizeAppLocalePreference, resolveAppLocale } from './core/domain/localePolicy';
diff --git a/src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts b/src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts
new file mode 100644
index 00000000..5fa9acff
--- /dev/null
+++ b/src/features/localization/renderer/adapters/browserSystemLocaleAdapter.ts
@@ -0,0 +1,3 @@
+export function getBrowserSystemLocale(): string | null {
+ return globalThis.navigator?.language ?? null;
+}
diff --git a/src/features/localization/renderer/composition/createI18nextInstance.ts b/src/features/localization/renderer/composition/createI18nextInstance.ts
new file mode 100644
index 00000000..8a0339eb
--- /dev/null
+++ b/src/features/localization/renderer/composition/createI18nextInstance.ts
@@ -0,0 +1,35 @@
+import { initReactI18next } from 'react-i18next';
+
+import i18next from 'i18next';
+
+import {
+ DEFAULT_TRANSLATION_NAMESPACE,
+ FALLBACK_APP_LOCALE,
+ RESOLVED_APP_LOCALES,
+ TRANSLATION_NAMESPACES,
+} from '../../contracts';
+
+import { localizationResources } from './localizationResources';
+
+export function createI18nextInstance(initialLocale = FALLBACK_APP_LOCALE): typeof i18next {
+ const instance = i18next.createInstance();
+
+ void instance.use(initReactI18next).init({
+ debug: false,
+ defaultNS: DEFAULT_TRANSLATION_NAMESPACE,
+ fallbackLng: FALLBACK_APP_LOCALE,
+ initAsync: false,
+ interpolation: {
+ escapeValue: false,
+ },
+ lng: initialLocale,
+ ns: [...TRANSLATION_NAMESPACES],
+ resources: localizationResources,
+ returnEmptyString: false,
+ supportedLngs: [...RESOLVED_APP_LOCALES],
+ });
+
+ return instance;
+}
+
+export const appI18n = createI18nextInstance();
diff --git a/src/features/localization/renderer/composition/localizationResources.ts b/src/features/localization/renderer/composition/localizationResources.ts
new file mode 100644
index 00000000..c7ef2394
--- /dev/null
+++ b/src/features/localization/renderer/composition/localizationResources.ts
@@ -0,0 +1,34 @@
+import { RESOLVED_APP_LOCALES, TRANSLATION_NAMESPACES } from '../../contracts';
+
+import type { ResolvedAppLocale, TranslationNamespace } from '../../contracts';
+
+type TranslationResource = Record;
+type TranslationResources = Record<
+ ResolvedAppLocale,
+ Record
+>;
+
+const catalogModules = import.meta.glob('../locales/*/*.json', {
+ eager: true,
+ import: 'default',
+});
+
+export const localizationResources = buildLocalizationResources();
+
+function buildLocalizationResources(): TranslationResources {
+ const resources = {} as TranslationResources;
+
+ for (const locale of RESOLVED_APP_LOCALES) {
+ resources[locale] = {} as Record;
+
+ for (const namespace of TRANSLATION_NAMESPACES) {
+ const resource = catalogModules[`../locales/${locale}/${namespace}.json`];
+ if (!resource) {
+ throw new Error(`Missing i18n catalog: ${locale}/${namespace}.json`);
+ }
+ resources[locale][namespace] = resource;
+ }
+ }
+
+ return resources;
+}
diff --git a/src/features/localization/renderer/hooks/useAppTranslation.ts b/src/features/localization/renderer/hooks/useAppTranslation.ts
new file mode 100644
index 00000000..d3c98ef1
--- /dev/null
+++ b/src/features/localization/renderer/hooks/useAppTranslation.ts
@@ -0,0 +1,17 @@
+import { useTranslation } from 'react-i18next';
+
+import type { TranslationNamespace } from '../../contracts';
+import type { TFunction } from 'i18next';
+
+export interface AppTranslationApi {
+ readonly t: TFunction;
+ readonly resolvedLanguage: string | undefined;
+}
+
+export function useAppTranslation(namespace: TranslationNamespace): AppTranslationApi {
+ const { i18n, t } = useTranslation(namespace);
+ return {
+ t,
+ resolvedLanguage: i18n.resolvedLanguage,
+ };
+}
diff --git a/src/features/localization/renderer/hooks/useLocaleFormatters.ts b/src/features/localization/renderer/hooks/useLocaleFormatters.ts
new file mode 100644
index 00000000..d6a0fcb5
--- /dev/null
+++ b/src/features/localization/renderer/hooks/useLocaleFormatters.ts
@@ -0,0 +1,54 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { FALLBACK_APP_LOCALE } from '../../contracts';
+
+export interface LocaleFormatters {
+ readonly date: (value: Date | string | number, options?: Intl.DateTimeFormatOptions) => string;
+ readonly time: (value: Date | string | number, options?: Intl.DateTimeFormatOptions) => string;
+ readonly dateTime: (
+ value: Date | string | number,
+ options?: Intl.DateTimeFormatOptions
+ ) => string;
+ readonly number: (value: number, options?: Intl.NumberFormatOptions) => string;
+ readonly currency: (
+ value: number,
+ currency: string,
+ options?: Intl.NumberFormatOptions
+ ) => string;
+}
+
+export function useLocaleFormatters(): LocaleFormatters {
+ const { i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage || i18n.language || FALLBACK_APP_LOCALE;
+
+ return useMemo(
+ () => ({
+ date: (value, options) =>
+ new Intl.DateTimeFormat(locale, options ?? { dateStyle: 'medium' }).format(
+ normalizeDate(value)
+ ),
+ time: (value, options) =>
+ new Intl.DateTimeFormat(locale, options ?? { hour: '2-digit', minute: '2-digit' }).format(
+ normalizeDate(value)
+ ),
+ dateTime: (value, options) =>
+ new Intl.DateTimeFormat(
+ locale,
+ options ?? { dateStyle: 'medium', timeStyle: 'short' }
+ ).format(normalizeDate(value)),
+ number: (value, options) => new Intl.NumberFormat(locale, options).format(value),
+ currency: (value, currency, options) =>
+ new Intl.NumberFormat(locale, {
+ currency,
+ style: 'currency',
+ ...options,
+ }).format(value),
+ }),
+ [locale]
+ );
+}
+
+function normalizeDate(value: Date | string | number): Date {
+ return value instanceof Date ? value : new Date(value);
+}
diff --git a/src/features/localization/renderer/i18next.d.ts b/src/features/localization/renderer/i18next.d.ts
new file mode 100644
index 00000000..b1127ae9
--- /dev/null
+++ b/src/features/localization/renderer/i18next.d.ts
@@ -0,0 +1,10 @@
+// This file is automatically generated by i18next-cli, because it was not existing. You can edit it based on your needs: https://www.i18next.com/overview/typescript#custom-type-options
+import type Resources from './resources';
+
+declare module 'i18next' {
+ interface CustomTypeOptions {
+ enableSelector: false;
+ defaultNS: 'common';
+ resources: Resources;
+ }
+}
diff --git a/src/features/localization/renderer/index.ts b/src/features/localization/renderer/index.ts
new file mode 100644
index 00000000..c2ea035b
--- /dev/null
+++ b/src/features/localization/renderer/index.ts
@@ -0,0 +1,4 @@
+export { useAppTranslation } from './hooks/useAppTranslation';
+export { useLocaleFormatters } from './hooks/useLocaleFormatters';
+export { AppLanguageSelect } from './ui/AppLanguageSelect';
+export { LocalizationProvider } from './ui/LocalizationProvider';
diff --git a/src/features/localization/renderer/locales/en/common.json b/src/features/localization/renderer/locales/en/common.json
new file mode 100644
index 00000000..0b020a1c
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/common.json
@@ -0,0 +1,900 @@
+{
+ "actions": {
+ "cancel": "Cancel",
+ "close": "Close",
+ "copied": "Copied",
+ "copyUrl": "Copy URL",
+ "open": "Open",
+ "reveal": "Reveal",
+ "retry": "Retry",
+ "save": "Save",
+ "showLess": "Show less",
+ "showMore": "Show more",
+ "refresh": "Refresh",
+ "reset": "Reset",
+ "copyToClipboard": "Copy to clipboard",
+ "moreActions": "More actions",
+ "closeDialog": "Close dialog",
+ "goToDashboard": "Go to Dashboard",
+ "or": "or",
+ "hide": "Hide",
+ "resetSelection": "Reset selection"
+ },
+ "code": {
+ "line": "line {{line}}",
+ "lines": "lines {{from}}-{{to}}",
+ "moreLines": "({{count}} more lines...)",
+ "moreLines_few": "({{count}} more lines...)",
+ "moreLines_many": "({{count}} more lines...)",
+ "moreLines_one": "({{count}} more line...)",
+ "moreLines_other": "({{count}} more lines...)",
+ "code": "Code",
+ "preview": "Preview",
+ "markdownPreview": "Markdown Preview",
+ "linesParenthesized": "(lines {{from}}-{{to}})",
+ "mermaidSyntaxError": "Mermaid syntax error"
+ },
+ "contextBadge": {
+ "badge": "Context",
+ "breakdown": {
+ "text": "Text",
+ "thinking": "Thinking"
+ },
+ "detailsAria": "Context injection details",
+ "sectionSummary": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sections": {
+ "claudeMdFiles": "CLAUDE.md Files",
+ "mentionedFiles": "Mentioned Files",
+ "taskCoordination": "Task Coordination",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Tool Outputs",
+ "userMessages": "User Messages"
+ },
+ "title": "New Context Injected In This Turn",
+ "tokenCount": "~{{tokens}} tokens",
+ "totalNewTokens": "Total new tokens",
+ "turn": "Turn {{turn}}",
+ "sectionSummary_few": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sectionSummary_many": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sectionSummary_one": "{{title}} ({{count}}) ~{{tokens}} tokens",
+ "sectionSummary_other": "{{title}} ({{count}}) ~{{tokens}} tokens"
+ },
+ "locales": {
+ "emptyMessage": "No language found.",
+ "names": {
+ "en": "English",
+ "ru": "Russian",
+ "system": "System"
+ },
+ "searchPlaceholder": "Search language...",
+ "selectPlaceholder": "Select app language...",
+ "systemWithResolved": "System - {{locale}}"
+ },
+ "members": {
+ "emptyMessage": "No members found.",
+ "searchPlaceholder": "Search members...",
+ "unassigned": "Unassigned",
+ "teammateFallback": "teammate"
+ },
+ "providerRuntime": {
+ "codex": {
+ "install": {
+ "checking": "Checking",
+ "downloading": "Downloading",
+ "installCli": "Install Codex CLI",
+ "installing": "Installing",
+ "retryInstall": "Retry install"
+ }
+ }
+ },
+ "search": {
+ "noMatchingSuggestions": "No matching suggestions",
+ "searching": "Searching...",
+ "searchingFiles": "Searching files...",
+ "findInConversation": "Find in conversation...",
+ "resultCount": "{{current}} of {{total}}",
+ "resultCountCapped": "{{current}} of {{total}}+",
+ "noResults": "No results",
+ "previousResultShortcut": "Previous result (Shift+Enter)",
+ "nextResultShortcut": "Next result (Enter)",
+ "closeShortcut": "Close (Esc)",
+ "nothingFound": "Nothing found",
+ "placeholder": "Search..."
+ },
+ "schedules": {
+ "actions": {
+ "addSchedule": "Add Schedule",
+ "clearFilters": "Clear filters",
+ "createSchedule": "Create Schedule",
+ "delete": "Delete",
+ "edit": "Edit",
+ "pause": "Pause",
+ "resume": "Resume",
+ "runNow": "Run now"
+ },
+ "empty": {
+ "description": "Create a schedule on any team to automate Claude task execution with cron expressions. Schedules from all teams will appear here.",
+ "noMatches": "No schedules match the current filters",
+ "title": "No scheduled tasks"
+ },
+ "filters": {
+ "allTeams": "All teams"
+ },
+ "item": {
+ "loadingRunHistory": "Loading run history...",
+ "nextRun": "Next: {{value}}",
+ "noRunsYet": "No runs yet"
+ },
+ "loading": "Loading schedules...",
+ "searchPlaceholder": "Search schedules...",
+ "status": {
+ "active": "Active",
+ "all": "All",
+ "disabled": "Disabled",
+ "paused": "Paused"
+ },
+ "title": "Schedules"
+ },
+ "sessions": {
+ "actions": {
+ "hide": "Hide",
+ "pin": "Pin",
+ "unhide": "Unhide"
+ },
+ "empty": {
+ "noMatchingSessions": "No matching sessions",
+ "noMatchingSessionsDescription": "This project has no matching sessions yet.",
+ "noMatchingSessionsFiltered": "Try another query or reset the provider filter.",
+ "noSessions": "No sessions found",
+ "noSessionsDescription": "This project has no sessions yet",
+ "selectProject": "Select a project to view sessions"
+ },
+ "errors": {
+ "loading": "Error loading sessions"
+ },
+ "loadedMatchingMore": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadingMore": "Loading more sessions...",
+ "pinned": "Pinned",
+ "scrollToLoadMore": "Scroll to load more",
+ "search": {
+ "clear": "Clear session search",
+ "placeholder": "Search sessions..."
+ },
+ "selection": {
+ "cancel": "Cancel selection",
+ "exitMode": "Exit selection mode",
+ "hideSelected": "Hide selected sessions",
+ "pinSelected": "Pin selected sessions",
+ "selectSessions": "Select sessions",
+ "selected": "{{count}} selected",
+ "unhideSelected": "Unhide selected sessions",
+ "selected_few": "{{count}} selected",
+ "selected_many": "{{count}} selected",
+ "selected_one": "{{count}} selected",
+ "selected_other": "{{count}} selected"
+ },
+ "sort": {
+ "byContext": "By Context",
+ "byContextTooltip": "Sort by context consumption",
+ "byRecentTooltip": "Sort by recent",
+ "contextLoadedOnly": "Context sorting only ranks loaded sessions."
+ },
+ "title": "Sessions",
+ "visibility": {
+ "hideHidden": "Hide hidden sessions",
+ "showHidden": "Show hidden sessions"
+ },
+ "worktree": {
+ "switch": "Switch Worktree"
+ },
+ "loadedMatchingMore_few": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadedMatchingMore_many": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadedMatchingMore_one": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "loadedMatchingMore_other": "{{count}} matching sessions loaded so far - scroll down to load more.",
+ "failedToLoad": "Failed to load session",
+ "loading": "Loading session...",
+ "filter": {
+ "title": "Filter sessions"
+ },
+ "count": "{{count}} sessions",
+ "count_one": "{{count}} session",
+ "count_other": "{{count}} sessions",
+ "count_few": "{{count}} sessions",
+ "count_many": "{{count}} sessions",
+ "inProgress": "Session is in progress..."
+ },
+ "states": {
+ "loading": "Loading...",
+ "offline": "Offline",
+ "online": "Online",
+ "unknown": "Unknown",
+ "error": "Error"
+ },
+ "markdown": {
+ "imageFallback": "[Image: {{label}}]",
+ "largeContentNotice": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentTitle": "Large content is shown as raw to prevent UI freeze",
+ "raw": "Raw",
+ "rawPreview": "Raw preview",
+ "renderMarkdown": "Render markdown",
+ "showAll": "Show all",
+ "showMore": "Show more",
+ "showRaw": "Show raw",
+ "showingChars": "Showing {{shown}} / {{total}} chars",
+ "largeContentNotice_few": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentNotice_many": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentNotice_one": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.",
+ "largeContentNotice_other": "Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive."
+ },
+ "terminal": {
+ "checkOutputForDetails": "Check terminal output above for details",
+ "closingInSeconds": "Closing in {{count}}s...",
+ "closingInSeconds_few": "Closing in {{count}}s...",
+ "closingInSeconds_many": "Closing in {{count}}s...",
+ "closingInSeconds_one": "Closing in {{count}}s...",
+ "closingInSeconds_other": "Closing in {{count}}s...",
+ "completedSuccessfully": "Completed successfully",
+ "exitCode": "(exit code {{code}})",
+ "processFailed": "Process failed",
+ "title": "Terminal"
+ },
+ "tokens": {
+ "accumulatedWithoutDuplication": "Accumulated across entire session without duplication",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "costUsd": "Cost (USD)",
+ "inputTokens": "Input Tokens",
+ "model": "Model",
+ "outputTokens": "Output Tokens",
+ "phase": "Phase {{phase}}/{{total}}",
+ "promptInputShare": "{{percent}}% of prompt input",
+ "taskCoordination": "Task Coordination",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Tool Outputs",
+ "total": "Total",
+ "userMessages": "User Messages",
+ "visibleContext": "Visible Context",
+ "includesClaudeMd": "incl. CLAUDE.md ×{{count}}",
+ "claudeMd": "CLAUDE.md",
+ "mentionedFiles": "@files",
+ "percentValue": "({{percent}}%)",
+ "approxTokens": "~{{tokens}} tokens",
+ "approxTokensParenthesized": "(~{{tokens}})"
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Copy team",
+ "createTeam": "Create Team",
+ "deleteForever": "Delete forever",
+ "deletePermanently": "Delete permanently",
+ "deleteTeam": "Delete team",
+ "launching": "Launching...",
+ "launchTeam": "Launch team",
+ "relaunchTeam": "Relaunch team",
+ "restore": "Restore",
+ "restoreTeam": "Restore team",
+ "retry": "Retry",
+ "stopTeam": "Stop team",
+ "stopping": "Stopping..."
+ },
+ "status": {
+ "active": "Active",
+ "deleted": "Deleted",
+ "launching": "Launching...",
+ "offline": "Offline",
+ "partialFailure": "Launch failed partway",
+ "partialPending": "Bootstrap pending",
+ "partialSkipped": "Launch skipped member",
+ "running": "Running"
+ },
+ "partial": {
+ "pending": "Last launch is still reconciling.",
+ "skipped": "Last launch has skipped teammates.",
+ "skippedWithCount": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "stopped": "Last launch stopped before all teammates joined.",
+ "stoppedWithCount": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_few": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_many": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_one": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_other": "Last launch stopped before {{count}}/{{expected}} teammates joined."
+ },
+ "noDescription": "No description",
+ "solo": "Solo",
+ "membersCount": "Members: {{count}}",
+ "membersCount_few": "Members: {{count}}",
+ "membersCount_many": "Members: {{count}}",
+ "membersCount_one": "Member: {{count}}",
+ "membersCount_other": "Members: {{count}}",
+ "all": "All",
+ "moreCount": "+{{count}} more",
+ "moreCount_one": "+{{count}} more",
+ "moreCount_other": "+{{count}} more",
+ "moreCount_few": "+{{count}} more",
+ "moreCount_many": "+{{count}} more"
+ },
+ "runtimeProvider": {
+ "defaults": {
+ "scopeDescriptionAllProjects": "Default for every project that does not have its own OpenCode override.",
+ "scopeDescriptionProject": "Override only the selected project. Running teams are not changed.",
+ "setAllProjectsDefault": "Set all-projects default",
+ "setProjectDefault": "Set project default",
+ "validationContext": "Validation context",
+ "projectOverrideContext": "Project override context",
+ "selectProjectHint": "Select a project before testing local models or saving defaults.",
+ "allProjectsHint": "Tests use {{project}}. Default applies unless a project has an override.",
+ "projectHint": "Saving overrides only {{project}}."
+ }
+ },
+ "sessionContext": {
+ "header": {
+ "title": "Context",
+ "closePanel": "Close panel",
+ "phase": "Phase:",
+ "current": "Current",
+ "view": "View:",
+ "category": "Category",
+ "bySize": "By Size"
+ },
+ "metrics": {
+ "unavailable": "Unavailable",
+ "contextUsed": "Context Used",
+ "promptInput": "Prompt Input",
+ "visibleContext": "Visible Context",
+ "ofContext": "of context",
+ "ofPrompt": "of prompt",
+ "codexTelemetryUnavailable": "Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt Input and Context Used stay unavailable instead of showing a fake zero.",
+ "sessionCost": "Session Cost:",
+ "parentPlus": "parent +",
+ "subagents": "subagents",
+ "details": "details"
+ },
+ "help": {
+ "contextUsed": {
+ "title": "Context Used",
+ "description": "Prompt input plus output tokens currently occupying the model's context window."
+ },
+ "promptInput": {
+ "title": "Prompt Input",
+ "description": "Tokens sent to the model before generation. For Claude this includes `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`."
+ },
+ "visibleContext": {
+ "title": "Visible Context",
+ "description": "The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user messages, and similar injections that you can optimize directly."
+ },
+ "availability": {
+ "title": "Availability",
+ "description": "If a provider runtime does not expose prompt-side usage yet, the panel shows metrics as unavailable instead of pretending they are zero."
+ }
+ },
+ "items": {
+ "turn": "@Turn {{turn}}",
+ "tokensApprox": "~{{tokens}} tokens",
+ "toolsCount": "{{count}} tools",
+ "toolsCount_one": "{{count}} tool",
+ "toolsCount_other": "{{count}} tools",
+ "toolsCount_few": "{{count}} tools",
+ "toolsCount_many": "{{count}} tools",
+ "itemsCount": "{{count}} items",
+ "itemsCount_one": "{{count}} item",
+ "itemsCount_other": "{{count}} items",
+ "itemsCount_few": "{{count}} items",
+ "itemsCount_many": "{{count}} items",
+ "missing": "missing",
+ "thinking": "Thinking",
+ "text": "Text"
+ },
+ "empty": "No context injections detected in this session",
+ "view": {
+ "grouped": "Grouped",
+ "flat": "Flat"
+ },
+ "claudeMdFiles": "CLAUDE.md Files",
+ "mentionedFiles": "Mentioned Files"
+ },
+ "chat": {
+ "subagent": {
+ "fallbackName": "Subagent",
+ "shutdownConfirmed": "Shutdown confirmed",
+ "summary": {
+ "tools": "{{count}} tools",
+ "tools_one": "{{count}} tool",
+ "tools_other": "{{count}} tools",
+ "tools_few": "{{count}} tools",
+ "tools_many": "{{count}} tools"
+ },
+ "meta": {
+ "type": "Type",
+ "duration": "Duration",
+ "model": "Model",
+ "id": "ID"
+ },
+ "metrics": {
+ "contextWindow": "Context Window",
+ "contextUsage": "Context Usage",
+ "mainContext": "Main Context",
+ "totalOutput": "Total Output",
+ "turns": "({{count}} turns)",
+ "turns_one": "({{count}} turn)",
+ "turns_other": "({{count}} turns)",
+ "subagentContext": "Subagent Context",
+ "phase": "Phase {{phase}}",
+ "turns_few": "({{count}} turns)",
+ "turns_many": "({{count}} turns)"
+ },
+ "trace": {
+ "title": "Execution Trace"
+ }
+ },
+ "user": {
+ "you": "You",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "backgroundTask": "Background task",
+ "exitCode": "exit {{code}}",
+ "imagesAttached": "{{count}} images attached",
+ "imagesAttached_one": "{{count}} image attached",
+ "imagesAttached_few": "{{count}} images attached",
+ "imagesAttached_many": "{{count}} images attached",
+ "imagesAttached_other": "{{count}} images attached"
+ },
+ "compact": {
+ "toggle": "Toggle compacted content",
+ "contextCompacted": "Context compacted",
+ "freedTokens": "({{tokens}} freed)",
+ "phase": "Phase {{phase}}",
+ "conversationCompacted": "Conversation Compacted",
+ "summary": "Previous messages were summarized to save context. The full conversation history is preserved in the session file.",
+ "compacted": "Compacted"
+ },
+ "executionTrace": {
+ "empty": "No execution items",
+ "nested": "Nested: {{name}}",
+ "input": "Input"
+ },
+ "items": {
+ "empty": "No items to display"
+ },
+ "tools": {
+ "teammateSpawned": "Teammate spawned",
+ "shutdownRequested": "Shutdown requested ->",
+ "noResultReceived": "No result received",
+ "duration": "Duration: {{duration}}",
+ "result": "Result",
+ "write": {
+ "createdFile": "Created file",
+ "wroteToFile": "Wrote to file"
+ },
+ "skill": {
+ "instructions": "Skill Instructions",
+ "unknown": "Unknown Skill"
+ }
+ },
+ "lastOutput": {
+ "requestInterrupted": "Request interrupted by user",
+ "planReadyForApproval": "Plan Ready for Approval"
+ },
+ "empty": {
+ "icon": "💬",
+ "title": "No conversation history",
+ "description": "This session does not contain any messages yet."
+ },
+ "context": {
+ "remainingPercent": "({{percent}}% left)",
+ "count": "Context ({{count}})",
+ "count_one": "Context ({{count}})",
+ "count_other": "Context ({{count}})",
+ "count_few": "Context ({{count}})",
+ "count_many": "Context ({{count}})"
+ },
+ "scrollToBottom": "Scroll to bottom",
+ "bottom": "Bottom",
+ "teammateMessage": {
+ "message": "Message",
+ "resent": "Resent",
+ "fallback": "Teammate message"
+ },
+ "system": {
+ "label": "System"
+ }
+ },
+ "tmuxInstaller": {
+ "summaryTitle": "tmux is not installed",
+ "detectedOs": "Detected OS: {{os}}",
+ "runtimePath": "Runtime path: {{path}}",
+ "phase": "Phase: {{phase}}",
+ "actions": {
+ "cancel": "Cancel",
+ "manualGuide": "Manual guide",
+ "hideSetupSteps": "Hide setup steps",
+ "showSetupSteps": "Show setup steps ({{count}})",
+ "showSetupSteps_one": "Show setup step ({{count}})",
+ "showSetupSteps_other": "Show setup steps ({{count}})",
+ "recheck": "Re-check",
+ "showSetupSteps_few": "Show setup steps ({{count}})",
+ "showSetupSteps_many": "Show setup steps ({{count}})"
+ },
+ "installerProgress": "Installer progress",
+ "input": {
+ "placeholder": "Send input to the installer",
+ "send": "Send input",
+ "passwordNotice": "Password input is sent directly to the installer terminal and is not added to the log output."
+ },
+ "details": {
+ "show": "Show details",
+ "hide": "Hide details"
+ }
+ },
+ "commandPalette": {
+ "noRecentActivity": "No recent activity",
+ "sessionsCount": "{{count}} sessions",
+ "sessionsCount_one": "{{count}} session",
+ "sessionsCount_other": "{{count}} sessions",
+ "mode": {
+ "searchProjects": "Search projects",
+ "searchAcrossProjects": "Search across all projects",
+ "searchInProject": "Search in project"
+ },
+ "currentProject": "Current project",
+ "global": "Global",
+ "placeholders": {
+ "projects": "Search projects...",
+ "conversations": "Search conversations..."
+ },
+ "empty": {
+ "noProjectsForQuery": "No projects found for \"{{query}}\"",
+ "noProjects": "No projects found",
+ "minChars": "Type at least 2 characters to search",
+ "noFastResults": "No fast results in recent sessions for \"{{query}}\"",
+ "noResults": "No results found for \"{{query}}\""
+ },
+ "footer": {
+ "projectsCount": "{{count}} projects",
+ "projectsCount_one": "{{count}} project",
+ "projectsCount_other": "{{count}} projects",
+ "results": "{{count}} {{speed}}results",
+ "results_one": "{{count}} {{speed}}result",
+ "results_other": "{{count}} {{speed}}results",
+ "resultsAcrossProjects": "{{count}} {{speed}}results across all projects",
+ "resultsAcrossProjects_one": "{{count}} {{speed}}result across all projects",
+ "resultsAcrossProjects_other": "{{count}} {{speed}}results across all projects",
+ "fastPrefix": "fast ",
+ "typeToSearch": "Type to search",
+ "navigate": "navigate",
+ "select": "select",
+ "open": "open",
+ "global": "global",
+ "close": "close",
+ "results_few": "{{count}} {{speed}}results",
+ "results_many": "{{count}} {{speed}}results",
+ "resultsAcrossProjects_few": "{{count}} {{speed}}results across all projects",
+ "resultsAcrossProjects_many": "{{count}} {{speed}}results across all projects",
+ "projectsCount_few": "{{count}} projects",
+ "projectsCount_many": "{{count}} projects",
+ "upDownKey": "↑↓",
+ "escapeKey": "esc"
+ },
+ "sessionsCount_few": "{{count}} sessions",
+ "sessionsCount_many": "{{count}} sessions"
+ },
+ "tasksPanel": {
+ "title": "Tasks",
+ "searchPlaceholder": "Search tasks...",
+ "pinned": "Pinned",
+ "groupByLabel": "Group by:",
+ "groupByAria": "Group by",
+ "groupModes": {
+ "none": "None",
+ "project": "Project",
+ "time": "Time"
+ },
+ "showArchived": "Show archived",
+ "hideArchived": "Hide archived",
+ "empty": {
+ "noMatchingTasks": "No matching tasks",
+ "noTasks": "No tasks found"
+ },
+ "teamLabel": "Team: {{team}}",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "deleteConfirm": {
+ "title": "Delete task",
+ "message": "Move task #{{taskId}} to trash?",
+ "confirmLabel": "Delete",
+ "cancelLabel": "Cancel"
+ },
+ "deleteFailed": {
+ "title": "Failed to delete task",
+ "fallbackMessage": "An unexpected error occurred",
+ "confirmLabel": "OK"
+ },
+ "sort": {
+ "byTime": "By time",
+ "byUnread": "By unread",
+ "byProject": "By project",
+ "byTeam": "By team"
+ }
+ },
+ "toolViewer": {
+ "input": "Input",
+ "replaceAll": "(replace all)",
+ "noInputRecorded": "No input recorded for this tool call.",
+ "agent": {
+ "action": "action",
+ "teammate": "teammate",
+ "team": "team",
+ "runtime": "runtime",
+ "type": "type",
+ "startupInstructionsHidden": "Startup instructions are hidden in the UI."
+ }
+ },
+ "taskContextMenu": {
+ "unpin": "Unpin",
+ "pin": "Pin",
+ "rename": "Rename",
+ "markUnread": "Mark as unread",
+ "unarchive": "Unarchive",
+ "archive": "Archive",
+ "deleteTask": "Delete task"
+ },
+ "updateDialog": {
+ "closeDialog": "Close dialog",
+ "updateAvailable": "Update available",
+ "updateReady": "Update Ready",
+ "noReleaseNotes": "No release notes available.",
+ "viewOnGitHub": "View on GitHub",
+ "later": "Later",
+ "restartNow": "Restart now",
+ "download": "Download"
+ },
+ "errorBoundary": {
+ "title": "Something went wrong",
+ "description": "An unexpected error occurred in the application. You can try reloading the page or resetting the error state.",
+ "componentStack": "Component Stack",
+ "tryAgain": "Try Again",
+ "copied": "Copied",
+ "copyErrorDetails": "Copy Error Details",
+ "reportBugOnGitHub": "Report Bug on GitHub",
+ "reloadApp": "Reload App",
+ "diagnosticsNotice": "GitHub bug reports and copied diagnostics include the error message, stack traces, app version, active tab, selected team, task context, and environment details."
+ },
+ "runtimeBackendSelector": {
+ "label": "Runtime backend",
+ "resolved": "Resolved: {{backend}}",
+ "current": "Current",
+ "recommended": "Recommended",
+ "unavailable": "Unavailable",
+ "cannotSelectYet": "This backend cannot be selected yet.",
+ "auto": "Auto",
+ "autoCurrently": "Auto (currently: {{backend}})",
+ "audience": {
+ "internal": "Internal"
+ },
+ "states": {
+ "locked": "Locked",
+ "disabled": "Disabled",
+ "authRequired": "Auth required",
+ "runtimeMissing": "Runtime missing",
+ "degraded": "Degraded",
+ "unavailable": "Unavailable"
+ }
+ },
+ "providerModelBadges": {
+ "checking": "Checking",
+ "unavailable": "Unavailable",
+ "checkFailed": "Check failed",
+ "free": "Free",
+ "freeTooltip": "Reported by OpenCode metadata. Availability and limits may change."
+ },
+ "taskFilters": {
+ "status": "Status",
+ "clearAll": "Clear all",
+ "selectAll": "Select all",
+ "team": "Team",
+ "allTeams": "All teams",
+ "searchTeams": "Search teams...",
+ "noTeamsFound": "No teams found",
+ "project": "Project",
+ "allProjects": "All Projects",
+ "searchProjects": "Search projects...",
+ "noProjects": "No projects",
+ "comments": "Comments",
+ "apply": "Apply",
+ "read": {
+ "all": "All",
+ "unread": "Unread",
+ "read": "Read"
+ },
+ "statusOptions": {
+ "todo": "TODO",
+ "inProgress": "IN PROGRESS",
+ "needsFix": "NEEDS FIXES",
+ "done": "DONE",
+ "review": "REVIEW",
+ "approved": "APPROVED"
+ }
+ },
+ "sessionItem": {
+ "totalContext": "Total Context: {{tokens}} tokens",
+ "context": "Context: {{tokens}}",
+ "phase": "Phase {{phase}}:",
+ "compactedTo": "(compacted to {{tokens}})"
+ },
+ "notifications": {
+ "row": {
+ "team": "team",
+ "subagent": "subagent",
+ "markAsRead": "Mark as read",
+ "delete": "Delete",
+ "viewInSession": "View in session"
+ },
+ "title": "Notifications",
+ "loading": "Loading notifications...",
+ "actions": {
+ "markFilteredAsRead": "Mark filtered as read",
+ "markAllAsRead": "Mark all as read",
+ "markFilteredRead": "Mark filtered read",
+ "markAllRead": "Mark all read",
+ "clearFilteredNotifications": "Clear filtered notifications",
+ "clearAllNotifications": "Clear all notifications",
+ "clickToConfirm": "Click to confirm",
+ "clearFiltered": "Clear filtered",
+ "clearAll": "Clear all"
+ },
+ "counts": {
+ "unreadInFilter": "{{count}} unread in filter",
+ "unreadInFilter_one": "{{count}} unread in filter",
+ "unreadInFilter_few": "{{count}} unread in filter",
+ "unreadInFilter_many": "{{count}} unread in filter",
+ "unreadInFilter_other": "{{count}} unread in filter",
+ "inFilter": "{{count}} in filter",
+ "inFilter_one": "{{count}} in filter",
+ "inFilter_few": "{{count}} in filter",
+ "inFilter_many": "{{count}} in filter",
+ "inFilter_other": "{{count}} in filter",
+ "unread": "{{count}} unread",
+ "unread_one": "{{count}} unread",
+ "unread_few": "{{count}} unread",
+ "unread_many": "{{count}} unread",
+ "unread_other": "{{count}} unread",
+ "total": "{{count}} total",
+ "total_one": "{{count}} total",
+ "total_few": "{{count}} total",
+ "total_many": "{{count}} total",
+ "total_other": "{{count}} total"
+ },
+ "filters": {
+ "other": "Other"
+ },
+ "empty": {
+ "noMatching": "No matching notifications",
+ "noNotifications": "No notifications",
+ "tryDifferentFilter": "Try a different filter",
+ "allCaughtUp": "You're all caught up!"
+ }
+ },
+ "updates": {
+ "restartToUpdate": "Restart to update",
+ "updateApp": "Update app",
+ "downloadedRestartTooltip": "Update downloaded, restart to apply",
+ "newVersionAvailable": "New version available",
+ "updatingApp": "Updating app",
+ "updateReady": "Update ready",
+ "restartNow": "Restart now"
+ },
+ "layout": {
+ "github": "GitHub",
+ "discord": "Discord",
+ "expandSidebar": "Expand sidebar",
+ "collapseSidebarShortcut": "Collapse sidebar ({{shortcut}})",
+ "sidebarView": "Sidebar view",
+ "resizeSidebar": "Resize sidebar",
+ "closeTab": "Close tab",
+ "openedFromSearch": "Opened from search",
+ "pinnedSession": "Pinned session",
+ "jumpToSection": "Jump to section",
+ "newTab": "New tab",
+ "newTabDashboard": "New tab (Dashboard)",
+ "refreshSession": "Refresh session",
+ "refreshSessionWithShortcut": "Refresh Session ({{shortcut}})",
+ "loadingTab": "Loading tab",
+ "menu": {
+ "teams": "Teams",
+ "settings": "Settings",
+ "extensions": "Extensions",
+ "search": "Search",
+ "schedules": "Schedules",
+ "docs": "Docs",
+ "exportMarkdown": "Export as Markdown",
+ "exportJson": "Export as JSON",
+ "exportPlainText": "Export as Plain Text",
+ "analyzeSession": "Analyze Session"
+ },
+ "tabMenu": {
+ "closeTabs": "Close {{count}} Tabs",
+ "closeTabs_one": "Close {{count}} Tab",
+ "closeTabs_few": "Close {{count}} Tabs",
+ "closeTabs_many": "Close {{count}} Tabs",
+ "closeTabs_other": "Close {{count}} Tabs",
+ "closeTab": "Close Tab",
+ "closeOtherTabs": "Close Other Tabs",
+ "splitRight": "Split Right",
+ "splitLeft": "Split Left",
+ "pinToSidebar": "Pin to Sidebar",
+ "unpinFromSidebar": "Unpin from Sidebar",
+ "hideFromSidebar": "Hide from Sidebar",
+ "unhideFromSidebar": "Unhide from Sidebar",
+ "closeAllTabs": "Close All Tabs"
+ },
+ "sections": {
+ "team": "Team",
+ "sessions": "Sessions",
+ "kanban": "Kanban",
+ "claudeLogs": "Claude Logs",
+ "messages": "Messages"
+ }
+ },
+ "editorFormatting": {
+ "bold": "Bold",
+ "italic": "Italic",
+ "strike": "Strike",
+ "code": "Code"
+ },
+ "diff": {
+ "changed": "Changed",
+ "noChangesDetected": "No changes detected"
+ },
+ "codexLogin": {
+ "copyLoginLinkAndCode": "Copy ChatGPT login link and code",
+ "copyLoginLink": "Copy ChatGPT login link",
+ "copyFailed": "Copy failed",
+ "copyLinkAndCode": "Copy link + code",
+ "copyLink": "Copy link",
+ "enterCodeOnLoginPage": "Enter this code on the ChatGPT login page"
+ },
+ "window": {
+ "minimize": "Minimize",
+ "maximize": "Maximize",
+ "restore": "Restore"
+ },
+ "context": {
+ "local": "Local",
+ "switchingTo": "Switching to {{workspace}}",
+ "loadingWorkspace": "Loading workspace",
+ "switchWorkspace": "Switch Workspace"
+ },
+ "repositories": {
+ "noneAvailable": "No repositories available",
+ "remove": "Remove repository"
+ },
+ "export": {
+ "session": "Export session",
+ "sessionTitle": "Export Session"
+ },
+ "brand": {
+ "claude": "Claude"
+ },
+ "sessionReport": {
+ "noSessionData": "No session data available",
+ "title": "Session Report"
+ },
+ "sessionFilters": {
+ "project": {
+ "selectProject": "Select Project"
+ }
+ },
+ "tasks": {
+ "date": {
+ "updatedPrefix": "upd",
+ "updatedYesterday": "upd yesterday",
+ "yesterday": "Yesterday"
+ },
+ "reviewState": {
+ "needsFix": "Needs Fixes"
+ },
+ "unassigned": "unassigned"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/dashboard.json b/src/features/localization/renderer/locales/en/dashboard.json
new file mode 100644
index 00000000..7fe4d83e
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/dashboard.json
@@ -0,0 +1,197 @@
+{
+ "cliStatus": {
+ "actions": {
+ "alreadyLoggedIn": "Already logged in?",
+ "becomeSponsor": "Become a sponsor",
+ "cancel": "Cancel",
+ "checkNow": "Check now",
+ "checkUpdates": "Check for Updates",
+ "checking": "Checking...",
+ "connect": "Connect",
+ "extensions": "Extensions",
+ "login": "Login",
+ "manage": "Manage",
+ "manageProviders": "Manage Providers",
+ "plan": "Plan",
+ "recheck": "Re-check",
+ "recheckProvider": "Re-check {{provider}}",
+ "retry": "Retry",
+ "updateTo": "Update to v{{version}}",
+ "useCode": "Use code"
+ },
+ "atlas": {
+ "alt": "Atlas Cloud",
+ "description": "Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.",
+ "openCodeProvider": "OpenCode provider",
+ "plan": "Atlas Cloud coding plan",
+ "sponsor": "Sponsor"
+ },
+ "errors": {
+ "checkStatusFailed": "Failed to check CLI status",
+ "installationFailed": "Installation failed",
+ "refreshFailed": "Failed to check for updates. Check your network connection and try again.",
+ "runtimeUpdatedRefreshFailed": "Runtime updated, but failed to refresh provider status."
+ },
+ "hints": {
+ "backgroundStatus": "{{runtime}} status will be checked in the background.",
+ "codexApiKeyFallback": "{{hint}} API key fallback is available if you switch auth mode.",
+ "codexAutoApiKey": "{{hint}} Auto will keep using the API key until ChatGPT is connected.",
+ "codexFinishLogin": "Finish ChatGPT login in the browser. Enter the shown code if prompted.",
+ "codexNoActiveLogin": "Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.",
+ "codexNoActiveManagedSession": "Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.",
+ "codexReconnectNeeded": "Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.",
+ "firstCheckSlow": "First check may take up to 30 seconds",
+ "loginRequiredForTeams": "Browsing sessions and projects works without login. Login is only needed to run agent teams.",
+ "troubleshootTitle": "If you're sure you're logged in, try these steps:"
+ },
+ "installer": {
+ "checkingLatest": "Checking latest version...",
+ "downloading": "Downloading {{runtime}}...",
+ "installing": "Installing {{runtime}}...",
+ "success": "Successfully installed {{runtime}} v{{version}}",
+ "verifying": "Verifying checksum..."
+ },
+ "labels": {
+ "apiKeyRequired": "API key required",
+ "comingSoon": "Coming soon",
+ "collapseProviderDetails": "Collapse provider details",
+ "expandProviderDetails": "Expand provider details",
+ "generateLink": "Generate link",
+ "loadingRateLimits": "Rate limits loading",
+ "loggedOut": "Provider logged out",
+ "loginAuthFailed": "Authentication failed",
+ "loginAuthUpdated": "Authentication updated",
+ "loginComplete": "Login complete",
+ "loginFailed": "Login failed",
+ "loginTitle": "Login",
+ "logoutFailed": "Logout failed",
+ "logoutTitle": "Logout",
+ "notLoggedIn": "Not logged in",
+ "openLogin": "Open login",
+ "providerActionRequired": "Provider action required",
+ "resets": "resets {{time}}",
+ "runtimeLoginTitle": "{{runtime}} Login"
+ },
+ "loading": {
+ "aiProviders": "Checking AI Providers...",
+ "claudeCli": "Checking Claude CLI..."
+ },
+ "provider": {
+ "authenticated": "Authenticated",
+ "backend": "Backend: {{backend}}",
+ "checkingAuthentication": "Checking authentication...",
+ "checkingProviders": "Checking providers...",
+ "configuredLocalCount": "{{count}} configured local",
+ "configuredLocalCount_few": "{{count}} configured local",
+ "configuredLocalCount_many": "{{count}} configured local",
+ "configuredLocalCount_one": "{{count}} configured local",
+ "configuredLocalCount_other": "{{count}} configured local",
+ "configuredLocalTitle": "Local OpenCode routes imported from your OpenCode config.",
+ "connectedCount": "Providers: {{connected}}/{{denominator}} connected",
+ "freeModels": "Free models",
+ "freeModelsTitle": "OpenCode includes free model options such as Big Pickle when available in your setup. OpenRouter through OpenCode can also expose free models, but not every OpenCode/OpenRouter model is free. Availability and limits may change.",
+ "loadingModels": "Loading models...",
+ "modelsUnavailable": "Models unavailable for this runtime build",
+ "runtime": "Runtime: {{runtime}}",
+ "verifiedCount": "{{count}} verified",
+ "verifiedCount_few": "{{count}} verified",
+ "verifiedCount_many": "{{count}} verified",
+ "verifiedCount_one": "{{count}} verified",
+ "verifiedCount_other": "{{count}} verified",
+ "verifiedTitle": "OpenCode routes with a successful execution proof."
+ },
+ "runtime": {
+ "configuredHealthCheckFailed": "The configured {{runtime}} failed its startup health check.",
+ "configuredNotFound": "The configured {{runtime}} was not found.",
+ "foundButFailed": "{{runtime}} was found but failed to start",
+ "healthCheckFailedDescription": "The app found the configured {{runtime}}, but its startup health check failed. Repair or reinstall it, then retry.",
+ "install": "Install {{runtime}}",
+ "installRequiredDescription": "{{runtime}} is required for team provisioning and session management. Install it to get started.",
+ "isRequired": "{{runtime}} is required",
+ "reinstall": "Reinstall {{runtime}}"
+ },
+ "runtimeInstall": {
+ "checking": "Checking",
+ "codexTitle": "Install Codex CLI into app data",
+ "downloading": "Downloading",
+ "downloadingPercent": "Downloading {{percent}}%",
+ "install": "Install",
+ "installing": "Installing",
+ "openCodeTitle": "Install OpenCode runtime into app data",
+ "retryInstall": "Retry install"
+ },
+ "troubleshoot": {
+ "again": "again",
+ "authStatusCommand": "your configured CLI auth status command",
+ "checkLoggedIn": "- check if it shows \"Logged in\"",
+ "click": "Click",
+ "loginCommand": "the runtime login command",
+ "logoutCommand": "the runtime logout command",
+ "openTerminal": "Open your terminal and run:",
+ "reloginPrefix": "If it says logged in but the app doesn't see it, try:",
+ "sameRuntime": "Make sure the CLI in your terminal is the same runtime the app uses",
+ "statusCacheHint": "- sometimes the status is cached for a few seconds",
+ "then": "then"
+ },
+ "warnings": {
+ "multipleApiKeysMissing": "One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.",
+ "multipleApiKeysNeedAttention": "One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.",
+ "notAuthenticated": "{{runtime}} is installed but you are not authenticated. Login is required for team provisioning and AI features.",
+ "singleApiKeyMissing": "{{provider}} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.",
+ "singleApiKeyNeedsAttention": "{{provider}} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode."
+ }
+ },
+ "recentProjects": {
+ "selectFolderTitle": "Select a project folder",
+ "selectFolder": "Select Folder",
+ "failedToLoad": "Failed to load projects",
+ "retry": "Retry",
+ "noProjects": "No projects found",
+ "noMatches": "No matches for \"{{query}}\"",
+ "noRecentProjects": "No recent projects found",
+ "emptyDescription": "Recent Claude and Codex activity will appear here.",
+ "loadMore": "Load more",
+ "card": {
+ "deleted": "Deleted",
+ "projectFolderMissing": "Project folder no longer exists",
+ "taskCounts": {
+ "active": "{{count}} active",
+ "active_one": "{{count}} active",
+ "active_other": "{{count}} active",
+ "active_few": "{{count}} active",
+ "active_many": "{{count}} active",
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "done": "{{count}} done",
+ "done_one": "{{count}} done",
+ "done_other": "{{count}} done",
+ "done_few": "{{count}} done",
+ "done_many": "{{count}} done"
+ }
+ },
+ "title": "Recent Projects",
+ "searchResults": "Search Results",
+ "searchPlaceholder": "Search projects..."
+ },
+ "actions": {
+ "selectTeam": "Select Team",
+ "or": "or",
+ "clearSearch": "Clear search"
+ },
+ "windowsAdmin": {
+ "title": "Windows Administrator mode recommended",
+ "description": "OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app with Run as administrator before launching OpenCode teams."
+ },
+ "webPreview": {
+ "title": "Open the desktop app for full functionality",
+ "description": "The browser version is still in development. Project actions, integrations, and live status updates may be limited here. Use the desktop app to access all features reliably."
+ },
+ "updateBanner": {
+ "newVersionAvailable": "New version available",
+ "restartNow": "Restart now",
+ "viewDetails": "View details"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/errors.json b/src/features/localization/renderer/locales/en/errors.json
new file mode 100644
index 00000000..027abdd1
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/errors.json
@@ -0,0 +1,3 @@
+{
+ "fallback": "Something went wrong."
+}
diff --git a/src/features/localization/renderer/locales/en/extensions.json b/src/features/localization/renderer/locales/en/extensions.json
new file mode 100644
index 00000000..acfcea20
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/extensions.json
@@ -0,0 +1,684 @@
+{
+ "store": {
+ "actions": {
+ "addCustom": "Add Custom",
+ "openDashboard": "Open Dashboard",
+ "refreshCatalog": "Refresh catalog"
+ },
+ "capabilities": {
+ "mcp": "MCP: {{status}}",
+ "plugins": "Plugins: {{status}}",
+ "skills": "Skills: {{status}}"
+ },
+ "desktopOnly": "Available in the desktop app only.",
+ "provider": {
+ "checkingStatus": "Checking provider status...",
+ "connected": "Connected",
+ "loading": "Loading...",
+ "needsSetup": "Needs setup",
+ "readyToConfigure": "Ready to configure",
+ "unsupported": "Unsupported"
+ },
+ "runtime": {
+ "checkingAvailabilityDescription": "Extensions need the configured runtime to manage plugins, MCP servers, skills, and provider connections.",
+ "checkingAvailabilityTitle": "Checking extensions runtime availability",
+ "failedToStartDescription": "Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.",
+ "failedToStartTitle": "The configured runtime was found but failed to start",
+ "multimodelCapabilitiesDescription": "Provider support can differ by section. Plugins are shown only where the runtime explicitly declares support.",
+ "multimodelCapabilitiesTitle": "Multimodel runtime capabilities",
+ "needsSignInDescription": "{{runtime}} was found{{version}}, but plugin installs are disabled until you sign in from the Dashboard.",
+ "needsSignInTitle": "{{runtime}} needs sign-in",
+ "notAvailableDescription": "Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.",
+ "notAvailableTitle": "The configured runtime is not available",
+ "readyDescription": "Plugins can be installed from this page{{versionSuffix}}.",
+ "readyTitle": "{{runtime}} is ready",
+ "requiredForMutations": "The configured runtime is required to install or uninstall extensions. Install or repair it from the Dashboard."
+ },
+ "sessionsRestartWarning": "Running sessions won't pick up extension changes until restarted.",
+ "tabs": {
+ "apiKeys": {
+ "description": "Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.",
+ "label": "API Keys"
+ },
+ "mcpServers": {
+ "description": "Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.",
+ "label": "MCP Servers"
+ },
+ "plugins": {
+ "description": "Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.",
+ "label": "Plugins"
+ },
+ "skills": {
+ "description": "Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.",
+ "label": "Skills"
+ }
+ },
+ "title": "Extensions"
+ },
+ "pluginsPanel": {
+ "activeFilters": "{{count}} active",
+ "browseByFit": "Browse by fit",
+ "capabilities": "Capabilities",
+ "categories": "Categories",
+ "clearAllFilters": "Clear all filters",
+ "clearFilters": "Clear filters",
+ "counts": {
+ "capabilities": "{{count}} capabilities",
+ "categories": "{{count}} categories",
+ "plugins": "{{count}} plugins",
+ "capabilities_few": "{{count}} capabilities",
+ "capabilities_many": "{{count}} capabilities",
+ "capabilities_one": "{{count}} capabilities",
+ "capabilities_other": "{{count}} capabilities",
+ "categories_few": "{{count}} categories",
+ "categories_many": "{{count}} categories",
+ "categories_one": "{{count}} categories",
+ "categories_other": "{{count}} categories",
+ "plugins_few": "{{count}} plugins",
+ "plugins_many": "{{count}} plugins",
+ "plugins_one": "{{count}} plugins",
+ "plugins_other": "{{count}} plugins"
+ },
+ "empty": {
+ "description": "Check back later for new plugins",
+ "filteredDescription": "Try adjusting your search or filter criteria",
+ "filteredTitle": "No plugins match your filters",
+ "title": "No plugins available"
+ },
+ "filterDescription": "Narrow the catalog by category, capability, or installed state.",
+ "installedOnly": "Installed only",
+ "providerSupportNotice": "Plugin support is currently guaranteed for Anthropic (Claude) sessions only. We're working to support plugins across all agents.",
+ "resultsUpdateInstantly": "Results update instantly as you refine filters.",
+ "searchPlaceholder": "Search plugins...",
+ "selectedCount": "{{count}} selected",
+ "showing": "Showing {{shown}} of {{total}} plugins",
+ "sort": {
+ "category": "Category",
+ "nameAsc": "Name A-Z",
+ "nameDesc": "Name Z-A",
+ "popular": "Popular"
+ },
+ "activeFilters_few": "{{count}} active",
+ "activeFilters_many": "{{count}} active",
+ "activeFilters_one": "{{count}} active",
+ "activeFilters_other": "{{count}} active",
+ "selectedCount_few": "{{count}} selected",
+ "selectedCount_many": "{{count}} selected",
+ "selectedCount_one": "{{count}} selected",
+ "selectedCount_other": "{{count}} selected"
+ },
+ "customMcp": {
+ "actions": {
+ "add": "Add",
+ "cancel": "Cancel",
+ "install": "Install",
+ "installing": "Installing..."
+ },
+ "description": "Add a server manually without the catalog.",
+ "errors": {
+ "installFailed": "Install failed",
+ "invalidServerName": "Invalid server name. Use alphanumeric characters, dashes, underscores, dots.",
+ "npmPackageRequired": "npm package name is required",
+ "serverNameRequired": "Server name is required",
+ "serverUrlRequired": "Server URL is required"
+ },
+ "fields": {
+ "environmentVariables": "Environment Variables",
+ "headers": "Headers",
+ "npmPackage": "npm Package",
+ "scope": "Scope",
+ "serverName": "Server Name",
+ "serverUrl": "Server URL",
+ "transport": "Transport",
+ "transportType": "Transport Type",
+ "versionOptional": "Version (optional)"
+ },
+ "title": "Add Custom MCP Server",
+ "transport": {
+ "httpSse": "HTTP / SSE",
+ "stdio": "Stdio (npm)"
+ },
+ "placeholders": {
+ "headerName": "Header-Name",
+ "envVarName": "ENV_VAR_NAME",
+ "serverName": "my-server",
+ "latest": "latest",
+ "value": "value",
+ "serverUrl": "https://api.example.com/mcp"
+ }
+ },
+ "mcpDetail": {
+ "auth": {
+ "remoteMayNeedHeaders": "Remote MCP servers may still require custom headers or API keys even when the registry does not describe them. If connection fails after install, check the provider docs.",
+ "required": "This server requires authentication"
+ },
+ "diagnostics": {
+ "launchTarget": "Launch Target"
+ },
+ "form": {
+ "autoFilled": "Auto-filled",
+ "environmentVariables": "Environment Variables",
+ "headers": "Headers",
+ "scope": "Scope",
+ "serverName": "Server Name"
+ },
+ "install": {
+ "httpTransport": "HTTP: {{transport}}",
+ "manualSetupDescription": "This server requires manual setup. Check the repository for installation instructions.",
+ "manualSetupRequired": "Manual setup required",
+ "npmPackage": "npm: {{package}}",
+ "manage": "Manage Installation",
+ "install": "Install Server"
+ },
+ "links": {
+ "glama": "Glama",
+ "repository": "Repository",
+ "website": "Website"
+ },
+ "metadata": {
+ "author": "Author",
+ "githubStars": "GitHub Stars",
+ "hosting": "Hosting",
+ "installType": "Install Type",
+ "license": "License",
+ "published": "Published",
+ "source": "Source",
+ "updated": "Updated",
+ "version": "Version"
+ },
+ "scope": {
+ "local": "Local",
+ "project": "Project"
+ },
+ "tools": {
+ "title": "Tools ({{count}})",
+ "title_few": "Tools ({{count}})",
+ "title_many": "Tools ({{count}})",
+ "title_one": "Tools ({{count}})",
+ "title_other": "Tools ({{count}})"
+ },
+ "placeholders": {
+ "serverName": "my-server"
+ }
+ },
+ "skillEditor": {
+ "actions": {
+ "cancel": "Cancel",
+ "createSkill": "Create Skill",
+ "preparing": "Preparing...",
+ "reviewAndCreate": "Review And Create",
+ "reviewAndSave": "Review And Save",
+ "saveSkill": "Save Skill"
+ },
+ "advanced": {
+ "customDescription": "This skill uses a custom markdown format, so edit it directly here.",
+ "customTitle": "2. SKILL.md editor",
+ "description": "Most people can skip this. Open it only if you want direct control over the raw markdown file.",
+ "hide": "Hide Advanced Editor",
+ "resetFromStructuredFields": "Reset From Structured Fields",
+ "show": "Show Advanced Editor",
+ "title": "4. Advanced SKILL.md editor"
+ },
+ "basics": {
+ "description": "Give this skill a clear name, choose who can use it, and decide where it should live.",
+ "title": "1. Basics"
+ },
+ "description": {
+ "create": "Describe the workflow in plain language, review the files that will be created, then save it.",
+ "edit": "Update this skill, review the resulting file changes, then save it."
+ },
+ "extraFiles": {
+ "addedFiles": "Added files:",
+ "assets": "Assets",
+ "assetsDescription": "Add screenshots or bundled media only if they help explain the workflow.",
+ "description": "Add supporting docs, scripts, or assets only if this skill really needs them.",
+ "lockedForEdits": "Root and folder are locked for edits",
+ "optionalDescription": "Add starter files that will be included in the review and written together with `SKILL.md`.",
+ "optionalTitle": "Optional files",
+ "references": "References",
+ "referencesDescription": "Add supporting docs, links, or examples the runtime can look at.",
+ "scripts": "Scripts",
+ "scriptsDescription": "Add helper commands or setup notes. Review carefully before sharing this skill.",
+ "title": "3. Extra files"
+ },
+ "fields": {
+ "compatibility": "Compatibility",
+ "description": "Description",
+ "folderName": "Folder name",
+ "folderNameHint": "We suggest this automatically from the skill name so review works right away.",
+ "invocation": "How it should be used",
+ "license": "License",
+ "name": "Skill name",
+ "notes": "Extra notes or guardrails",
+ "root": "Where to store it",
+ "scope": "Who can use it",
+ "steps": "Main steps to follow",
+ "whenToUse": "When to reach for this"
+ },
+ "instructions": {
+ "description": "These sections generate the skill file for you, so you do not need to edit markdown unless you want to.",
+ "locked": "Structured fields are locked because you switched to manual `SKILL.md` editing below.",
+ "title": "2. Instructions"
+ },
+ "invocation": {
+ "auto": "Can be used automatically",
+ "manualOnly": "Only when you ask for it"
+ },
+ "placeholders": {
+ "description": "What this skill helps with",
+ "name": "Write concise skill name",
+ "notes": "Example: Call out missing tests, regressions, and risky assumptions.",
+ "steps": "1. Inspect the relevant files.\n2. Explain the main risk first.\n3. Suggest the safest fix.",
+ "whenToUse": "Example: Use this when the task is a code review or bug triage request.",
+ "license": "MIT",
+ "compatibility": "claude-code, cursor"
+ },
+ "review": {
+ "creating": "Creating a skill",
+ "hint": "Review the file changes first, then confirm save in the next step.",
+ "saving": "Saving this skill"
+ },
+ "root": {
+ "codexOnly": " - Codex only",
+ "shared": " - Shared"
+ },
+ "scope": {
+ "project": "Project: {{project}}",
+ "projectUnavailable": "Project unavailable",
+ "user": "User"
+ },
+ "title": {
+ "create": "Create skill",
+ "edit": "Edit skill"
+ }
+ },
+ "skillDetail": {
+ "actions": {
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "deleteSkill": "Delete Skill",
+ "deleting": "Deleting...",
+ "editSkill": "Edit Skill",
+ "openFolder": "Open Folder",
+ "openSkillFile": "Open SKILL.md",
+ "retry": "Retry"
+ },
+ "badges": {
+ "assets": "Assets",
+ "autoUse": "Auto use",
+ "hasScripts": "Has scripts",
+ "manualUse": "Manual use",
+ "references": "References",
+ "storedIn": "Stored in {{root}}"
+ },
+ "deleteDialog": {
+ "description": "Delete this skill and move it to Trash?",
+ "descriptionWithName": "Delete \"{{name}}\" and move it to Trash? You can restore it later from Trash if needed.",
+ "title": "Delete skill?"
+ },
+ "descriptionFallback": "Inspect discovered skill metadata and raw instructions.",
+ "errors": {
+ "deleteFailed": "Failed to delete skill",
+ "loadFailed": "Unable to load this skill."
+ },
+ "files": {
+ "advancedDetails": "Advanced file details",
+ "assets": "Assets",
+ "references": "References",
+ "scripts": "Scripts",
+ "storedAt": "Stored at"
+ },
+ "includes": {
+ "assets": "assets",
+ "instructionsOnly": "Just the skill instructions",
+ "references": "references",
+ "scripts": "scripts"
+ },
+ "invocation": {
+ "auto": "Runs automatically when it matches the task.",
+ "manualOnly": "Only runs when you explicitly ask for it."
+ },
+ "issues": {
+ "bundledScripts": "This skill includes bundled scripts",
+ "reviewCarefully": "Review this skill carefully before using it"
+ },
+ "loading": "Loading skill details...",
+ "scope": {
+ "personal": "Your personal skills",
+ "projectOnly": "This project only"
+ },
+ "summary": {
+ "howUsed": "How it is used",
+ "included": "What comes with it",
+ "whoCanUse": "Who can use it"
+ },
+ "titleFallback": "Skill details"
+ },
+ "skillsPanel": {
+ "actions": {
+ "createSkill": "Create Skill",
+ "import": "Import"
+ },
+ "badges": {
+ "assets": "Assets",
+ "hasScripts": "Has scripts",
+ "needsAttention": "Needs attention",
+ "references": "References",
+ "storedIn": "Stored in {{root}}"
+ },
+ "configuredRuntime": "the configured runtime",
+ "counts": {
+ "codexOnly": "{{count}} Codex only",
+ "personal": "{{count}} personal",
+ "project": "{{count}} project",
+ "shared": "{{count}} shared",
+ "total": "{{count}} total",
+ "codexOnly_few": "{{count}} Codex only",
+ "codexOnly_many": "{{count}} Codex only",
+ "codexOnly_one": "{{count}} Codex only",
+ "codexOnly_other": "{{count}} Codex only",
+ "personal_few": "{{count}} personal",
+ "personal_many": "{{count}} personal",
+ "personal_one": "{{count}} personal",
+ "personal_other": "{{count}} personal",
+ "project_few": "{{count}} project",
+ "project_many": "{{count}} project",
+ "project_one": "{{count}} project",
+ "project_other": "{{count}} project",
+ "shared_few": "{{count}} shared",
+ "shared_many": "{{count}} shared",
+ "shared_one": "{{count}} shared",
+ "shared_other": "{{count}} shared",
+ "total_few": "{{count}} total",
+ "total_many": "{{count}} total",
+ "total_one": "{{count}} total",
+ "total_other": "{{count}} total"
+ },
+ "empty": {
+ "noMatches": "No skills match your search",
+ "noMatchesDescription": "Try a different search term or switch filters.",
+ "noSkills": "No skills yet",
+ "noSkillsDescription": "Create your first skill to teach a repeatable workflow, or import one you already use."
+ },
+ "filters": {
+ "all": "All skills",
+ "codexOnly": "Codex only",
+ "hasScripts": "Has scripts",
+ "needsAttention": "Needs attention",
+ "personal": "Personal",
+ "project": "Project",
+ "shared": "Shared"
+ },
+ "hero": {
+ "codexAvailable": "Use `.codex` when a skill should stay Codex-only.",
+ "codexUnavailable": "Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.",
+ "description": "Skills are reusable instructions that help the runtime handle the same kind of task more consistently.",
+ "guidance": "Use personal skills for habits you want everywhere. Use project skills for workflows that only make sense inside one codebase.",
+ "personalContext": "You are seeing only your personal skills right now.",
+ "projectContext": "You are seeing skills for {{project}} plus your personal skills.",
+ "title": "Teach repeatable work"
+ },
+ "invocation": {
+ "auto": "Runs automatically when it fits",
+ "manualOnly": "Only runs when you explicitly ask for it"
+ },
+ "loading": {
+ "loading": "Loading skills...",
+ "refreshing": "Refreshing skills..."
+ },
+ "runtimeAudience": "Shared skills in `.claude`, `.cursor`, and `.agents` are available to {{audience}}. Skills stored in `.codex` stay Codex-only when Codex support is available.",
+ "scope": {
+ "project": "This project",
+ "user": "Personal"
+ },
+ "searchPlaceholder": "Search by skill name or what it helps with...",
+ "sections": {
+ "personal": {
+ "description": "Habits and instructions you want available everywhere.",
+ "title": "Personal skills"
+ },
+ "project": {
+ "description": "Workflows that only make sense for this codebase.",
+ "title": "Project skills"
+ }
+ },
+ "sort": {
+ "label": "Sort skills",
+ "name": "Name",
+ "recent": "Recent"
+ },
+ "status": {
+ "hasScripts": "Includes scripts, so review it carefully",
+ "needsAttention": "Needs attention before you rely on it",
+ "ready": "Ready to use"
+ },
+ "success": {
+ "created": "Skill created successfully.",
+ "imported": "Skill imported successfully.",
+ "saved": "Skill saved successfully."
+ }
+ },
+ "pluginDetail": {
+ "unknown": "Unknown",
+ "metadata": {
+ "author": "Author",
+ "category": "Category",
+ "source": "Source",
+ "version": "Version",
+ "capabilities": "Capabilities",
+ "installs": "Installs"
+ },
+ "scope": {
+ "label": "Scope:",
+ "options": {
+ "user": "User (global)",
+ "project": "Project (shared)",
+ "local": "Local (gitignored)"
+ }
+ },
+ "links": {
+ "homepage": "Homepage",
+ "contact": "Contact"
+ },
+ "readme": {
+ "loading": "Loading README...",
+ "empty": "No README available."
+ }
+ },
+ "skillImport": {
+ "title": "Import skill",
+ "description": "Pick an existing skill folder, review what will be copied, then import it into one of your supported skill locations.",
+ "steps": {
+ "chooseFolder": {
+ "title": "1. Choose a skill folder",
+ "description": "This should be a folder that already contains a `SKILL.md`, `Skill.md`, or `skill.md` file."
+ },
+ "location": {
+ "title": "2. Decide where it belongs",
+ "description": "Personal skills work everywhere. Project skills only show up for one codebase."
+ }
+ },
+ "fields": {
+ "sourceFolder": "Source folder",
+ "destinationFolderName": "Destination folder name",
+ "audience": "Who can use it",
+ "storage": "Where to store it"
+ },
+ "placeholders": {
+ "defaultFolderName": "Defaults to source folder name"
+ },
+ "actions": {
+ "browse": "Browse",
+ "cancel": "Cancel",
+ "preparing": "Preparing...",
+ "reviewAndImport": "Review And Import",
+ "importSkill": "Import Skill",
+ "backToImport": "Back To Import"
+ },
+ "scope": {
+ "user": "User",
+ "project": "Project: {{project}}",
+ "projectUnavailable": "Project unavailable"
+ },
+ "rootSuffix": {
+ "codexOnly": " - Codex only",
+ "shared": " - Shared"
+ },
+ "reviewHint": "Review the copied files first, then confirm the import in the next step.",
+ "reviewLabel": "Importing this skill",
+ "errors": {
+ "missingSkillFile": "This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.",
+ "symbolicLinks": "This folder contains symbolic links. Import the real files instead of links.",
+ "tooManyFiles": "This skill folder is too large to import at once. Remove extra files and try again.",
+ "tooLarge": "This skill folder is too large to import safely. Trim large assets and try again.",
+ "invalidFolderName": "Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.",
+ "mustBeDirectory": "Choose a folder to import, not a single file.",
+ "reviewFailed": "Failed to review import changes",
+ "importFailed": "Failed to import skill"
+ }
+ },
+ "mcpPanel": {
+ "sort": {
+ "nameAsc": "Name A→Z",
+ "nameDesc": "Name Z→A",
+ "toolsDesc": "Most tools"
+ },
+ "health": {
+ "title": "MCP Health Status",
+ "checkingViaRuntime": "Checking installed MCP servers via {{runtime}} ...",
+ "lastChecked": "Last checked {{time}}",
+ "description": "Run diagnostics from this page to verify installed MCP connectivity.",
+ "checking": "Checking...",
+ "checkStatus": "Check Status"
+ },
+ "diagnostics": {
+ "title": "Runtime MCP Diagnostics",
+ "serversCount": "{{count}} servers",
+ "serversCount_one": "{{count}} server",
+ "serversCount_other": "{{count}} servers",
+ "waiting": "Waiting for diagnostics results...",
+ "disableReasons": {
+ "checkingRuntimeStatus": "Checking runtime status...",
+ "checkingRuntimeAvailability": "Checking runtime availability...",
+ "runtimeFailedToStart": "The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.",
+ "runtimeRequired": "The configured runtime is required. Install or repair it from the Dashboard."
+ },
+ "serversCount_few": "{{count}} servers",
+ "serversCount_many": "{{count}} servers"
+ },
+ "searchPlaceholder": "Search MCP servers...",
+ "runtime": {
+ "notAvailable": "{{runtime}} not available",
+ "notInstalled": "{{runtime}} not installed",
+ "requiredDescription": "MCP health checks require {{runtime}}. Go to the Dashboard to install or repair it."
+ },
+ "empty": {
+ "searchTitle": "No servers found",
+ "title": "No MCP servers available",
+ "searchDescription": "Try a different search term",
+ "description": "Check back later for new servers"
+ },
+ "loadMore": "Load more"
+ },
+ "apiKeys": {
+ "description": "Securely store API keys for auto-filling when installing MCP servers.",
+ "storage": {
+ "osKeychain": "Keys are encrypted via {{backend}} and stored with restricted file permissions (owner-only).",
+ "localEncryption": "OS keychain unavailable - keys are encrypted locally with AES-256. For stronger protection, install a keyring service (gnome-keyring, kwallet)."
+ },
+ "actions": {
+ "add": "Add API Key",
+ "addFirst": "Add your first key",
+ "edit": "Edit"
+ },
+ "empty": {
+ "title": "No API keys saved",
+ "description": "Add keys to auto-fill environment variables when installing MCP servers."
+ },
+ "form": {
+ "addTitle": "Add API Key",
+ "editTitle": "Edit API Key",
+ "addDescription": "Store an API key for auto-filling in MCP server installations.",
+ "editDescription": "Update the key details. You must re-enter the value.",
+ "keychainUnavailable": "OS keychain unavailable - keys encrypted with AES-256 locally. Install gnome-keyring for OS-level protection.",
+ "name": "Name",
+ "namePlaceholder": "e.g. OpenAI Production",
+ "environmentVariableName": "Environment Variable Name",
+ "envVarPlaceholder": "e.g. OPENAI_API_KEY",
+ "value": "Value",
+ "reenterValue": "Re-enter key value",
+ "valuePlaceholder": "sk-...",
+ "scope": "Scope",
+ "userScopeLabel": "User (global)",
+ "projectScopeLabel": "Project: {{project}}",
+ "projectUnavailable": "Project unavailable",
+ "boundTo": "Bound to {{path}}",
+ "cancel": "Cancel",
+ "saving": "Saving...",
+ "update": "Update",
+ "save": "Save",
+ "errors": {
+ "invalidEnvVarFormat": "Use letters, digits, underscores. Must start with a letter or underscore.",
+ "nameRequired": "Name is required",
+ "envVarRequired": "Environment variable name is required",
+ "invalidEnvVar": "Invalid environment variable name",
+ "valueRequired": "Key value is required",
+ "projectScopeRequiresProject": "Project-scoped API keys require an active project",
+ "saveFailed": "Failed to save"
+ }
+ }
+ },
+ "skillReview": {
+ "title": "Review skill changes",
+ "description": "{{reviewLabel}} previews the filesystem changes first. Nothing is written until you confirm below.",
+ "noPreview": "No preview available.",
+ "confirmPromptPrefix": "Review the diff below, then use",
+ "confirmPromptSuffix": "to apply these changes.",
+ "noChanges": "No file changes detected yet.",
+ "binaryBadge": "binary",
+ "binaryPreviewHidden": "Binary file preview is not shown. The file will be copied as-is.",
+ "summary": {
+ "fileChanges": "{{count}} file changes",
+ "fileChanges_one": "{{count}} file change",
+ "fileChanges_other": "{{count}} file changes",
+ "new": "{{count}} new",
+ "updated": "{{count}} updated",
+ "removed": "{{count}} removed",
+ "binary": "{{count}} binary",
+ "fileChanges_few": "{{count}} file changes",
+ "fileChanges_many": "{{count}} file changes"
+ }
+ },
+ "mcpCard": {
+ "toolsCount": "{{count}} tools",
+ "toolsCount_one": "{{count}} tool",
+ "toolsCount_other": "{{count}} tools",
+ "envCount": "{{count}} envs",
+ "envCount_one": "{{count}} env",
+ "envCount_other": "{{count}} envs",
+ "auth": "Auth",
+ "byAuthor": "by {{author}}",
+ "hosting": {
+ "remote": "Remote",
+ "local": "Local",
+ "both": "Both"
+ },
+ "toolsCount_few": "{{count}} tools",
+ "toolsCount_many": "{{count}} tools",
+ "envCount_few": "{{count}} envs",
+ "envCount_many": "{{count}} envs",
+ "repository": "Repository",
+ "website": "Website"
+ },
+ "installButton": {
+ "installing": "Installing...",
+ "removing": "Removing...",
+ "done": "Done",
+ "retry": "Retry",
+ "uninstall": "Uninstall",
+ "install": "Install"
+ },
+ "pluginCard": {
+ "official": "Official"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/report.json b/src/features/localization/renderer/locales/en/report.json
new file mode 100644
index 00000000..6a68920e
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/report.json
@@ -0,0 +1,217 @@
+{
+ "cost": {
+ "breakdownTitle": "Cost Breakdown (per 1M tokens)",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "cost": "Cost",
+ "input": "Input",
+ "noCommits": "no commits",
+ "noLinesChanged": "no lines changed",
+ "output": "Output",
+ "parent": "Parent: {{cost}}",
+ "parentCost": "Parent Cost",
+ "perCommit": "Per Commit",
+ "perCommitFormula": "total cost ÷ {{count}} commit",
+ "perCommitFormula_few": "total cost ÷ {{count}} commits",
+ "perCommitFormula_many": "total cost ÷ {{count}} commits",
+ "perCommitFormula_one": "total cost ÷ {{count}} commit",
+ "perCommitFormula_other": "total cost ÷ {{count}} commits",
+ "perLineChanged": "Per Line Changed",
+ "perLineFormula": "total cost ÷ {{count}} line",
+ "perLineFormula_few": "total cost ÷ {{count}} lines",
+ "perLineFormula_many": "total cost ÷ {{count}} lines",
+ "perLineFormula_one": "total cost ÷ {{count}} line",
+ "perLineFormula_other": "total cost ÷ {{count}} lines",
+ "subagent": "Subagent: {{cost}}",
+ "subagentCost": "Subagent Cost",
+ "title": "Cost Analysis",
+ "total": "Total"
+ },
+ "insights": {
+ "agent": "agent",
+ "agent_few": "agents",
+ "agent_many": "agents",
+ "agent_one": "agent",
+ "agent_other": "agents",
+ "agentTree": "Agent Tree ({{count}} {{unit}})",
+ "background": "(background)",
+ "bashCommands": "Bash Commands",
+ "outOfScopeFindings": "Out-of-Scope Findings ({{count}})",
+ "questionsAsked": "Questions Asked ({{count}})",
+ "repeated": "Repeated",
+ "skillsInvoked": "Skills Invoked ({{count}})",
+ "taskDispatches": "Task Dispatches ({{count}})",
+ "tasksCreated": "Tasks Created ({{count}})",
+ "teamMode": "Team Mode",
+ "teams": "Teams: {{teams}}",
+ "title": "Session Insights",
+ "total": "Total",
+ "unique": "Unique",
+ "skillsInvoked_few": "Skills Invoked ({{count}})",
+ "skillsInvoked_many": "Skills Invoked ({{count}})",
+ "skillsInvoked_one": "Skills Invoked ({{count}})",
+ "skillsInvoked_other": "Skills Invoked ({{count}})",
+ "taskDispatches_few": "Task Dispatches ({{count}})",
+ "taskDispatches_many": "Task Dispatches ({{count}})",
+ "taskDispatches_one": "Task Dispatches ({{count}})",
+ "taskDispatches_other": "Task Dispatches ({{count}})",
+ "tasksCreated_few": "Tasks Created ({{count}})",
+ "tasksCreated_many": "Tasks Created ({{count}})",
+ "tasksCreated_one": "Tasks Created ({{count}})",
+ "tasksCreated_other": "Tasks Created ({{count}})",
+ "questionsAsked_few": "Questions Asked ({{count}})",
+ "questionsAsked_many": "Questions Asked ({{count}})",
+ "questionsAsked_one": "Questions Asked ({{count}})",
+ "questionsAsked_other": "Questions Asked ({{count}})",
+ "agentTree_few": "Agent Tree ({{count}} {{unit}})",
+ "agentTree_many": "Agent Tree ({{count}} {{unit}})",
+ "agentTree_one": "Agent Tree ({{count}} {{unit}})",
+ "agentTree_other": "Agent Tree ({{count}} {{unit}})",
+ "outOfScopeFindings_few": "Out-of-Scope Findings ({{count}})",
+ "outOfScopeFindings_many": "Out-of-Scope Findings ({{count}})",
+ "outOfScopeFindings_one": "Out-of-Scope Findings ({{count}})",
+ "outOfScopeFindings_other": "Out-of-Scope Findings ({{count}})",
+ "keyTakeaways": "Key Takeaways"
+ },
+ "quality": {
+ "chars": "chars",
+ "corrections": "Corrections",
+ "failed": "failed",
+ "fileReadRedundancy": "File Read Redundancy",
+ "firstMessage": "First Message",
+ "firstRun": "First Run",
+ "frictionRate": "Friction Rate",
+ "lastRun": "Last Run",
+ "messagesBeforeWork": "Messages Before Work",
+ "passed": "passed",
+ "promptQuality": "Prompt Quality",
+ "readsPerUniqueFile": "Reads/Unique File",
+ "snapshot": "snapshot",
+ "snapshot_few": "snapshots",
+ "snapshot_many": "snapshots",
+ "snapshot_one": "snapshot",
+ "snapshot_other": "snapshots",
+ "startupOverhead": "Startup Overhead",
+ "testProgression": "Test Progression",
+ "title": "Quality Signals",
+ "tokensBeforeWork": "Tokens Before Work",
+ "totalReads": "Total Reads",
+ "uniqueFiles": "Unique Files",
+ "userMessages": "User Messages",
+ "percentOfTotal": "% of Total"
+ },
+ "tokens": {
+ "apiCalls": "API Calls",
+ "cacheCreate": "Cache Create",
+ "cacheEfficiency": "Cache Efficiency",
+ "cacheRead": "Cache Read",
+ "cacheReadPct": "Cache Read %",
+ "coldStart": "Cold Start",
+ "cost": "Cost",
+ "input": "Input",
+ "model": "Model",
+ "no": "No",
+ "output": "Output",
+ "readWriteRatio": "R/W Ratio",
+ "title": "Token Usage",
+ "total": "Total",
+ "yes": "Yes"
+ },
+ "subagents": {
+ "title": "Subagents",
+ "metrics": {
+ "count": "Count",
+ "totalTokens": "Total Tokens",
+ "totalDuration": "Total Duration",
+ "totalCost": "Total Cost"
+ },
+ "table": {
+ "description": "Description",
+ "type": "Type",
+ "tokens": "Tokens",
+ "duration": "Duration",
+ "cost": "Cost"
+ }
+ },
+ "overview": {
+ "title": "Overview",
+ "yes": "Yes",
+ "no": "No",
+ "metrics": {
+ "duration": "Duration",
+ "messages": "Messages",
+ "contextUsage": "Context Usage",
+ "compactions": "Compactions",
+ "branch": "Branch",
+ "subagents": "Subagents",
+ "project": "Project",
+ "sessionId": "Session ID"
+ }
+ },
+ "timeline": {
+ "title": "Timeline & Activity",
+ "idleAnalysis": "Idle Analysis",
+ "metrics": {
+ "idleGaps": "Idle Gaps",
+ "totalIdle": "Total Idle",
+ "activeTime": "Active Time",
+ "idlePercent": "Idle %"
+ },
+ "modelSwitches": "Model Switches ({{count}})",
+ "modelSwitches_one": "Model Switches ({{count}})",
+ "modelSwitches_other": "Model Switches ({{count}})",
+ "messageNumber": "msg #{{number}}",
+ "keyEvents": "Key Events",
+ "modelSwitches_few": "Model Switches ({{count}})",
+ "modelSwitches_many": "Model Switches ({{count}})"
+ },
+ "tools": {
+ "title": "Tool Usage",
+ "summary": "{{formattedCount}} total calls across {{toolCount}} tools",
+ "columns": {
+ "tool": "Tool",
+ "calls": "Calls",
+ "errors": "Errors",
+ "successPercent": "Success %",
+ "health": "Health"
+ }
+ },
+ "git": {
+ "title": "Git Activity",
+ "commits": "Commits",
+ "pushes": "Pushes",
+ "linesAdded": "Lines Added",
+ "linesRemoved": "Lines Removed",
+ "branchesCreated": "Branches Created"
+ },
+ "friction": {
+ "title": "Friction Signals",
+ "rate": "Friction Rate: {{rate}}%",
+ "correctionsCount": "{{count}} corrections",
+ "correctionsCount_one": "{{count}} correction",
+ "corrections": "Corrections",
+ "thrashingSignals": "Thrashing Signals",
+ "repeatedBashCommands": "Repeated Bash Commands",
+ "reworkedFiles": "Reworked Files (3+ edits)",
+ "correctionsCount_few": "{{count}} corrections",
+ "correctionsCount_many": "{{count}} corrections",
+ "correctionsCount_other": "{{count}} corrections"
+ },
+ "errors": {
+ "title": "Errors",
+ "permissionDenied": "Permission Denied",
+ "messageIndex": "msg #{{index}}",
+ "input": "Input",
+ "error": "Error",
+ "count": "{{count}} errors",
+ "count_one": "{{count}} error",
+ "permissionDenialCount": "{{count}} permission denials",
+ "permissionDenialCount_one": "{{count}} permission denial",
+ "count_few": "{{count}} errors",
+ "count_many": "{{count}} errors",
+ "count_other": "{{count}} errors",
+ "permissionDenialCount_few": "{{count}} permission denials",
+ "permissionDenialCount_many": "{{count}} permission denials",
+ "permissionDenialCount_other": "{{count}} permission denials"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/settings.json b/src/features/localization/renderer/locales/en/settings.json
new file mode 100644
index 00000000..e9b1876b
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/settings.json
@@ -0,0 +1,983 @@
+{
+ "tabs": {
+ "advanced": {
+ "description": "Power-user options: export/import config, reset defaults, and raw configuration editing.",
+ "label": "Advanced"
+ },
+ "general": {
+ "description": "Core app preferences like theme, language, display density, and startup behavior.",
+ "label": "General"
+ },
+ "infoAriaLabel": "What is {{label}}?",
+ "notifications": {
+ "description": "Control when and how you get notified about agent activity, task completions, and errors.",
+ "label": "Notifications"
+ }
+ },
+ "view": {
+ "description": "Manage your app preferences",
+ "loading": "Loading settings...",
+ "title": "Settings"
+ },
+ "runtimeProvider": {
+ "actions": {
+ "cancel": "Cancel",
+ "test": "Test"
+ },
+ "defaults": {
+ "allProjects": "All projects",
+ "allProjectsHint": "Tests use {{project}}. Default applies unless a project has an override.",
+ "loadingContexts": "Loading contexts...",
+ "projectHint": "Saving overrides only {{project}}.",
+ "projectOverrideContext": "Project override context",
+ "scopeDescriptionAllProjects": "Default for every project that does not have its own OpenCode override.",
+ "scopeDescriptionProject": "Override only the selected project. Running teams are not changed.",
+ "selectProjectContext": "Select project context",
+ "selectProjectHint": "Select a project before testing local models or saving defaults.",
+ "selectValidationContext": "Select validation context",
+ "setAllProjectsDefault": "Set all-projects default",
+ "setProjectDefault": "Set project default",
+ "thisProject": "This project",
+ "title": "OpenCode defaults",
+ "validationContext": "Validation context"
+ },
+ "diagnostics": {
+ "copied": "Diagnostics copied",
+ "copiedShort": "Copied",
+ "copy": "Copy diagnostics",
+ "hints": "Hints",
+ "likelyCause": "Likely cause:"
+ },
+ "models": {
+ "alreadyDefault": "This is already the selected OpenCode default.",
+ "empty": "No models found.",
+ "emptyFree": "No free models found.",
+ "emptyRecommended": "No recommended models found.",
+ "emptyRecommendedFree": "No recommended free models found.",
+ "freeOnly": "Free only",
+ "launchableDescription": "Routes you can test or use in the team picker: local config, free built-in models, and current default.",
+ "launchableTitle": "Launchable OpenCode models",
+ "loadingRoutes": "Loading OpenCode model routes...",
+ "noRoutesMatch": "No OpenCode model routes match \"{{query}}\".",
+ "noneReported": "No launchable OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.",
+ "recommendedOnly": "Recommended only",
+ "searchPlaceholder": "Search models",
+ "selectProjectBeforeTesting": "Select a project context before testing models.",
+ "selectProjectBeforeTestingDefaults": "Select a project context before testing or saving OpenCode defaults.",
+ "useInTeamPicker": "Use in team picker"
+ },
+ "providers": {
+ "catalog": "OpenCode provider catalog",
+ "countFallback": "OpenCode providers",
+ "description": "{{count}}. Connected and recommended providers are shown first.",
+ "loadMore": "Load more providers",
+ "loading": "Loading OpenCode providers",
+ "noMatches": "No providers match that search.",
+ "noneReported": "No OpenCode providers reported by the managed runtime.",
+ "recommended": "Recommended",
+ "refreshCatalog": "Refresh catalog",
+ "searchPlaceholder": "Search providers",
+ "description_few": "{{count}}. Connected and recommended providers are shown first.",
+ "description_many": "{{count}}. Connected and recommended providers are shown first.",
+ "description_one": "{{count}}. Connected and recommended providers are shown first.",
+ "description_other": "{{count}}. Connected and recommended providers are shown first."
+ },
+ "setup": {
+ "loading": "Loading provider setup..."
+ },
+ "summary": {
+ "defaultModel": "OpenCode default: {{model}}",
+ "loading": "Loading managed OpenCode runtime, connected providers, and model defaults...",
+ "source": "Source: {{source}}",
+ "title": "OpenCode runtime"
+ },
+ "tabs": {
+ "models": "Models",
+ "providers": "Providers"
+ },
+ "modelRoutes": {
+ "searchPlaceholder": "Search model routes"
+ },
+ "badges": {
+ "usedInTeamPicker": "Used in team picker",
+ "free": "free",
+ "local": "local",
+ "configured": "configured",
+ "connected": "connected",
+ "verified": "verified",
+ "needsTest": "needs test",
+ "failed": "failed",
+ "unknown": "unknown",
+ "default": "default"
+ },
+ "compatibleEndpoint": {
+ "baseUrlPlaceholder": "http://localhost:1234"
+ }
+ },
+ "general": {
+ "agentLanguage": {
+ "description": "Language for agent communication",
+ "descriptionWithDetected": "Language for agent communication (detected: {{detected}})",
+ "emptyMessage": "No language found.",
+ "label": "Language",
+ "searchPlaceholder": "Search language...",
+ "selectPlaceholder": "Select language...",
+ "title": "Agent Language"
+ },
+ "appLanguage": {
+ "description": "Language for the application interface.",
+ "label": "Language",
+ "title": "App Language"
+ },
+ "appearance": {
+ "autoExpandAIGroups": {
+ "description": "Automatically expand each response turn when opening a transcript or receiving a new message",
+ "label": "Expand AI responses by default"
+ },
+ "nativeTitleBar": {
+ "description": "Use the default system window frame instead of the custom title bar",
+ "label": "Use native title bar",
+ "restartConfirm": {
+ "confirmLabel": "Restart",
+ "message": "The app needs to restart to apply the title bar change. Restart now?",
+ "title": "Restart required"
+ }
+ },
+ "theme": {
+ "description": "Choose your preferred color theme",
+ "label": "Theme",
+ "options": {
+ "dark": "Dark",
+ "light": "Light",
+ "system": "System"
+ }
+ },
+ "title": "Appearance"
+ },
+ "browserAccess": {
+ "serverMode": {
+ "description": "Start an HTTP server to access the UI from a browser or embed in iframes",
+ "label": "Enable server mode"
+ },
+ "title": "Browser Access"
+ },
+ "localClaudeRoot": {
+ "actions": {
+ "selectFolder": "Select Folder",
+ "selectFolderManually": "Select Folder Manually",
+ "useAutoDetect": "Use Auto-Detect",
+ "useFolder": "Use Folder",
+ "usePath": "Use Path",
+ "useThisPath": "Use This Path",
+ "useWsl": "Using Linux/WSL?"
+ },
+ "confirm": {
+ "noProjectsDir": {
+ "message": "This folder does not contain a \"projects\" directory. Continue anyway?",
+ "title": "No projects directory found"
+ },
+ "notClaudeDir": {
+ "message": "This folder is named \"{{folderName}}\", not \".claude\". Continue anyway?",
+ "title": "Selected folder is not .claude"
+ },
+ "noWslPaths": {
+ "message": "Could not find WSL distros with Claude data automatically. Select folder manually?",
+ "title": "No WSL Claude paths found"
+ },
+ "wslNoProjectsDir": {
+ "message": "\"{{path}}\" does not contain a \"projects\" directory. Continue anyway?",
+ "title": "WSL path missing projects directory"
+ }
+ },
+ "current": {
+ "autoDetected": "Auto-detected: {{path}}",
+ "autoDetectedPath": "Using auto-detected path",
+ "customPath": "Using custom path",
+ "label": "Current Local Root"
+ },
+ "description": "Choose which local folder is treated as your Claude data root",
+ "errors": {
+ "detectWslFailed": "Failed to detect WSL Claude root paths",
+ "loadFailed": "Failed to load local Claude root settings",
+ "updateFailed": "Failed to update Claude root"
+ },
+ "title": "Local Claude Root",
+ "wslModal": {
+ "closeAriaLabel": "Close WSL path modal",
+ "description": "Detected WSL distributions and Claude root candidates",
+ "noProjectsDir": "No projects directory detected",
+ "title": "Select WSL Claude Root"
+ }
+ },
+ "privacy": {
+ "telemetry": {
+ "description": "Help improve the app by sending anonymous crash and performance data",
+ "label": "Send crash reports"
+ },
+ "title": "Privacy"
+ },
+ "server": {
+ "runningOn": "Running on",
+ "standaloneModeDescription": "Running in standalone mode. The HTTP server is always active. System notifications are not available - notification triggers are logged in-app only.",
+ "title": "Server"
+ },
+ "startup": {
+ "launchAtLogin": {
+ "description": "Automatically start the app when you log in",
+ "label": "Launch at login"
+ },
+ "showDockIcon": {
+ "description": "Display the app icon in the dock (macOS)",
+ "label": "Show dock icon"
+ },
+ "title": "Startup"
+ }
+ },
+ "notifications": {
+ "dev": {
+ "descriptionPrefix": "Notifications may not work in development mode. macOS identifies the app as \"Electron\" (bundle ID",
+ "descriptionSuffix": ") instead of the production app name. Check System Settings > Notifications > Electron to verify permissions.",
+ "title": "Dev Mode"
+ },
+ "ignoredRepositories": {
+ "description": "Notifications from these repositories will be ignored",
+ "empty": "No repositories ignored",
+ "selectPlaceholder": "Select repository to ignore...",
+ "title": "Ignored Repositories"
+ },
+ "settings": {
+ "enabled": {
+ "description": "Show system notifications for errors and events",
+ "label": "Enable System Notifications"
+ },
+ "sound": {
+ "description": "Play a sound when notifications appear",
+ "label": "Play sound"
+ },
+ "subagentErrors": {
+ "description": "Detect and notify about errors in subagent sessions",
+ "label": "Include subagent errors"
+ },
+ "title": "Notification Settings"
+ },
+ "snooze": {
+ "clear": "Clear Snooze",
+ "description": "Temporarily pause notifications",
+ "descriptionWithTime": "Snoozed until {{time}}",
+ "label": "Snooze notifications",
+ "options": {
+ "15": "15 minutes",
+ "30": "30 minutes",
+ "60": "1 hour",
+ "120": "2 hours",
+ "240": "4 hours",
+ "-1": "Until tomorrow"
+ },
+ "selectDuration": "Select duration..."
+ },
+ "taskCompletion": {
+ "description": "Get native OS notifications when Claude finishes tasks - sounds, banners, and Dock/taskbar badges. Works on macOS, Linux, and Windows.",
+ "installPlugin": "Install claude-notifications-go plugin",
+ "title": "Task Completion Notifications"
+ },
+ "team": {
+ "allTasksCompleted": {
+ "description": "Notify when every task in a team reaches completed status",
+ "label": "All tasks completed"
+ },
+ "autoResumeOnRateLimit": {
+ "description": "When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets",
+ "label": "Auto-resume after rate limit"
+ },
+ "clarifications": {
+ "description": "Show native OS notifications when a task needs your input",
+ "label": "Task clarification notifications"
+ },
+ "crossTeamMessage": {
+ "description": "Notify when a message arrives from another team",
+ "label": "Cross-team message notifications"
+ },
+ "leadInbox": {
+ "description": "Notify when teammates send messages to the team lead",
+ "label": "Lead inbox notifications"
+ },
+ "statusChange": {
+ "description": "Show native OS notifications when a task's status changes",
+ "label": "Task status change notifications",
+ "onlySolo": {
+ "description": "Notify only when the team has no teammates",
+ "label": "Only in Solo mode"
+ },
+ "statuses": {
+ "description": "Which target statuses trigger a notification",
+ "label": "Notify on these statuses",
+ "options": {
+ "approved": "Approved",
+ "completed": "Completed",
+ "deleted": "Deleted",
+ "in_progress": "Started",
+ "needsFix": "Needs Fixes",
+ "pending": "Pending",
+ "review": "Review"
+ }
+ }
+ },
+ "taskComments": {
+ "description": "Show native OS notifications when agents comment on tasks",
+ "label": "Task comment notifications"
+ },
+ "taskCreated": {
+ "description": "Show native OS notifications when a new task is created",
+ "label": "Task created notifications"
+ },
+ "teamLaunched": {
+ "description": "Notify when a team finishes launching and is ready",
+ "label": "Team launched notifications"
+ },
+ "title": "Team Notifications",
+ "toolApproval": {
+ "description": "Notify when a tool needs your approval (Allow/Deny) while the app is not focused",
+ "label": "Tool approval notifications"
+ },
+ "userInbox": {
+ "description": "Notify when teammates send messages to you",
+ "label": "User inbox notifications"
+ }
+ },
+ "test": {
+ "action": "Send Test",
+ "description": "Send a test notification to verify delivery",
+ "failedToSend": "Failed to send test notification",
+ "label": "Test notification",
+ "sending": "Sending...",
+ "sent": "Sent!",
+ "unknownError": "Unknown error"
+ }
+ },
+ "advanced": {
+ "about": {
+ "appIconAlt": "App icon",
+ "description": "Assemble AI agent teams that work autonomously in parallel, communicate across teams, and manage tasks on a kanban board - with built-in code review, live process monitoring, and full tool visibility.",
+ "standalone": "Standalone",
+ "title": "About",
+ "version": "Version {{version}}"
+ },
+ "configuration": {
+ "editConfig": "Edit Config",
+ "exportConfig": "Export Config",
+ "importConfig": "Import Config",
+ "openInEditor": "Open in Editor",
+ "resetToDefaults": "Reset to Defaults",
+ "title": "Configuration"
+ },
+ "updates": {
+ "available": "v{{version}} available",
+ "check": "Check for Updates",
+ "checking": "Checking...",
+ "ready": "Update ready",
+ "unknownVersion": "unknown",
+ "upToDate": "Up to date"
+ },
+ "appName": "Agent Teams AI"
+ },
+ "configEditor": {
+ "errors": {
+ "loadFailed": "Failed to load config",
+ "saveFailed": "Failed to save config"
+ },
+ "footer": {
+ "autoSave": "Changes auto-save after editing",
+ "toClose": "to close",
+ "escapeKey": "Esc"
+ },
+ "loading": "Loading config...",
+ "status": {
+ "invalidJson": "Invalid JSON",
+ "saveFailed": "Save failed",
+ "saved": "Saved",
+ "saving": "Saving..."
+ },
+ "title": "Edit Configuration"
+ },
+ "notificationTriggers": {
+ "add": {
+ "cancel": "Cancel",
+ "submit": "Add Trigger",
+ "title": "Add Custom Trigger"
+ },
+ "builtin": {
+ "description": "Default triggers that come with the application. You can enable or disable them and customize their patterns.",
+ "title": "Built-in Triggers"
+ },
+ "card": {
+ "builtinBadge": "Builtin",
+ "collapseAriaLabel": "Collapse",
+ "deleteAriaLabel": "Delete trigger",
+ "editNameAriaLabel": "Edit name",
+ "expandAriaLabel": "Expand"
+ },
+ "color": {
+ "customHexTitle": "Custom hex color",
+ "invalidHex": "Invalid hex"
+ },
+ "configuration": {
+ "alertIfGreaterThan": "Alert if >",
+ "emptyPatternHint": "Leave empty to match all content. Uses JavaScript regex syntax.",
+ "errorStatusDescription": "Triggers when a tool execution reports an error (is_error: true).",
+ "tokensUnit": "tokens",
+ "matchPatternPlaceholder": "e.g., error|failed|exception"
+ },
+ "custom": {
+ "description": "Create your own triggers to get notified for specific patterns or tool outputs.",
+ "empty": "No custom triggers configured yet.",
+ "title": "Custom Triggers"
+ },
+ "errors": {
+ "invalidRegexPattern": "Invalid regex pattern"
+ },
+ "fields": {
+ "contentType": "Content Type",
+ "matchField": "Match Field",
+ "matchPattern": "Match Pattern (Regex)",
+ "scopeToolName": "Scope / Tool Name",
+ "scopeToolNameOptional": "Scope / Tool Name (optional)",
+ "threshold": "Threshold",
+ "tokenType": "Token Type",
+ "triggerNamePlaceholder": "e.g., Build Failure Alert",
+ "triggerNameRequired": "Trigger Name *"
+ },
+ "ignorePatterns": {
+ "hint": "Press Enter to add. Notification is skipped if any pattern matches.",
+ "placeholder": "Add ignore regex...",
+ "removeAriaLabel": "Remove ignore pattern",
+ "summary": "Advanced: Exclusion Rules",
+ "title": "Ignore Patterns (skip if matches)"
+ },
+ "options": {
+ "contentTypes": {
+ "text": "Text Output",
+ "thinking": "Thinking",
+ "tool_result": "Tool Result",
+ "tool_use": "Tool Use"
+ },
+ "matchFields": {
+ "args": "Arguments",
+ "command": "Command",
+ "content": "Content",
+ "description": "Description",
+ "file_path": "File Path",
+ "fullInput": "Full Input (JSON)",
+ "glob": "Glob Filter",
+ "new_string": "New String",
+ "old_string": "Old String",
+ "path": "Path",
+ "pattern": "Pattern",
+ "prompt": "Prompt",
+ "query": "Query",
+ "skill": "Skill Name",
+ "subagent_type": "Subagent Type",
+ "text": "Text Content",
+ "thinking": "Thinking Content",
+ "url": "URL"
+ },
+ "modes": {
+ "content_match": "Content Pattern",
+ "error_status": "Execution Error",
+ "token_threshold": "High Token Usage"
+ },
+ "tokenTypes": {
+ "input": "Input Tokens",
+ "output": "Output Tokens",
+ "total": "Total Tokens"
+ },
+ "toolNames": {
+ "anyTool": "Any Tool"
+ }
+ },
+ "preview": {
+ "defaultTestTriggerName": "Test Trigger",
+ "detectedSuffix": "errors would have been detected",
+ "more": "...and {{count}} more",
+ "more_few": "...and {{count}} more",
+ "more_many": "...and {{count}} more",
+ "more_one": "...and {{count}} more",
+ "more_other": "...and {{count}} more",
+ "testTrigger": "Test Trigger",
+ "testing": "Testing...",
+ "title": "Preview",
+ "truncatedWarning": "Search stopped early (timeout or count limit). Actual matches may be higher.",
+ "viewSession": "View Session"
+ },
+ "repositoryScope": {
+ "empty": "No repositories selected - trigger applies to all repositories",
+ "hint": "When repositories are selected, this trigger only fires for errors in those repositories.",
+ "placeholder": "Select repository to add...",
+ "summary": "Advanced: Repository Scope",
+ "title": "Limit to Repositories (applies only to selected repositories)"
+ },
+ "sections": {
+ "configuration": "Configuration",
+ "dotColor": "Dot Color",
+ "generalInfo": "General Info",
+ "triggerCondition": "Trigger Condition"
+ }
+ },
+ "workspaceProfiles": {
+ "actions": {
+ "addProfile": "Add Profile",
+ "cancel": "Cancel",
+ "deleteProfile": "Delete profile",
+ "editProfile": "Edit profile",
+ "save": "Save"
+ },
+ "authMethods": {
+ "agent": "SSH Agent",
+ "auto": "Auto (from SSH Config)",
+ "password": "Password",
+ "privateKey": "Private Key"
+ },
+ "deleteConfirm": {
+ "confirmLabel": "Delete",
+ "message": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
+ "title": "Delete Profile"
+ },
+ "description": "Save SSH connection profiles for quick reconnection",
+ "empty": {
+ "description": "Add an SSH profile to connect quickly",
+ "title": "No saved profiles"
+ },
+ "form": {
+ "authentication": "Authentication",
+ "host": "Host",
+ "name": "Name",
+ "passwordPrompt": "You will be prompted for the password when connecting.",
+ "port": "Port",
+ "privateKeyPath": "Private Key Path",
+ "username": "Username",
+ "namePlaceholder": "My Server",
+ "hostPlaceholder": "hostname or IP",
+ "usernamePlaceholder": "user"
+ },
+ "loading": "Loading profiles...",
+ "title": "Workspace Profiles"
+ },
+ "connection": {
+ "actions": {
+ "connect": "Connect",
+ "connecting": "Connecting...",
+ "disconnect": "Disconnect",
+ "testConnection": "Test Connection",
+ "testing": "Testing..."
+ },
+ "currentMode": {
+ "description": "Data source for session files",
+ "label": "Current Mode",
+ "local": "Local ({{path}})"
+ },
+ "description": "Connect to a remote machine to view Claude Code sessions running there",
+ "form": {
+ "authentication": "Authentication",
+ "host": "Host",
+ "password": "Password",
+ "port": "Port",
+ "privateKeyPath": "Private Key Path",
+ "username": "Username",
+ "hostPlaceholder": "hostname or SSH config alias",
+ "usernamePlaceholder": "user"
+ },
+ "savedProfiles": {
+ "title": "Saved Profiles"
+ },
+ "ssh": {
+ "title": "SSH Connection"
+ },
+ "status": {
+ "connectedTo": "Connected to {{host}}",
+ "remoteSessions": "Viewing remote sessions via SSH"
+ },
+ "test": {
+ "failed": "Connection failed: {{error}}",
+ "success": "Connection successful",
+ "unknownError": "Unknown error"
+ },
+ "title": "Remote Connection"
+ },
+ "providerRuntime": {
+ "actions": {
+ "cancel": "Cancel",
+ "cancelLogin": "Cancel login",
+ "connectChatGpt": "Connect ChatGPT",
+ "delete": "Delete",
+ "disable": "Disable",
+ "disconnectAccount": "Disconnect account",
+ "generateLink": "Generate link",
+ "openLogin": "Open login",
+ "reconnectAnthropic": "Reconnect Anthropic",
+ "refresh": "Refresh",
+ "replaceKey": "Replace key",
+ "saveEndpoint": "Save endpoint",
+ "saveKey": "Save key",
+ "saving": "Saving...",
+ "setApiKey": "Set API key",
+ "updateKey": "Update key",
+ "useCode": "Use code"
+ },
+ "apiKey": {
+ "loadingStoredCredentials": "Loading stored credentials...",
+ "projectScope": "Project",
+ "scope": "Scope",
+ "storedIn": "Stored in {{backend}}",
+ "userScope": "User",
+ "storedInApp": "Stored in app",
+ "providers": {
+ "anthropic": {
+ "name": "Anthropic API Key",
+ "title": "API key",
+ "description": "Use a direct Anthropic API key for API-billed access. Your Anthropic subscription session stays available when you switch back.",
+ "placeholder": "sk-ant-..."
+ },
+ "codex": {
+ "name": "Codex API Key",
+ "title": "API key",
+ "description": "Use an OpenAI API key as a secondary Codex auth path. If you switch Codex to API key mode, the app will mirror OPENAI_API_KEY into CODEX_API_KEY for native launches.",
+ "placeholder": "sk-proj-..."
+ },
+ "gemini": {
+ "name": "Gemini API Key",
+ "title": "API access",
+ "description": "Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK and ADC do not require it.",
+ "placeholder": "AIza..."
+ }
+ }
+ },
+ "codex": {
+ "account": {
+ "appServer": "App-server: {{state}}",
+ "connected": "Connected",
+ "description": "Manage the local Codex app-server account session that powers subscription-backed native launches.",
+ "loginInProgress": "Login in progress",
+ "plan": "Plan: {{plan}}",
+ "reconnectRequired": "Reconnect required",
+ "title": "ChatGPT account",
+ "hints": {
+ "autoUsesApiKeyUntilChatgpt": "{{message}} Auto will keep using the detected API key until ChatGPT is connected.",
+ "detectedApiKeyNeedsApiMode": "{{message}} The detected API key is only used after you switch Codex to API key mode.",
+ "localArtifactsNoSession": "Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.",
+ "noActiveAccount": "Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.",
+ "reconnectBeforeUsage": "Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.",
+ "usageLimitsAfterReport": "Usage limits appear here after Codex reports them for the connected ChatGPT account."
+ }
+ },
+ "install": {
+ "checking": "Checking",
+ "downloading": "Downloading",
+ "installCli": "Install Codex CLI",
+ "installing": "Installing",
+ "retryInstall": "Retry install",
+ "title": "Install Codex CLI into app data"
+ },
+ "rateLimits": {
+ "credits": "Credits",
+ "creditsDescription": "Credits are shown separately from window-based subscription usage and may be unavailable for plan-backed ChatGPT sessions.",
+ "noSecondaryWindow": "Codex did not return a secondary window for this account snapshot.",
+ "notReported": "Not reported",
+ "primaryReset": "Primary reset",
+ "primaryUsed": "Primary used",
+ "primaryWindow": "Primary window",
+ "remainingLeft": "{{value}} left",
+ "remainingUnknown": "Remaining unknown",
+ "secondaryReset": "Secondary reset",
+ "secondaryUsed": "Secondary used",
+ "secondaryWindow": "Secondary window",
+ "usedQuotaNote": "These percentages show used quota, not remaining quota.",
+ "weeklyReset": "Weekly reset",
+ "weeklyUsed": "Weekly used",
+ "weeklyUsedOneWeek": "Weekly used (1w)",
+ "weeklyWindow": "Weekly window",
+ "secondaryFallback": "secondary",
+ "secondaryWindowNote": " Weekly limits are shown separately in the {{window}} window.",
+ "usageExplanationGeneric": "Shows used quota, not remaining quota.",
+ "usageExplanationWindowOnly": "Shows used quota in the current {{window}} window, not remaining quota.",
+ "usageExplanationWithRemaining": "{{used}} used - about {{remaining}} left in the current {{window}} window."
+ }
+ },
+ "compatibleEndpoint": {
+ "authToken": "Auth token",
+ "authTokenMissing": "Auth token is not configured.",
+ "baseUrl": "Base URL",
+ "description": "Use an Anthropic-compatible local runtime endpoint.",
+ "keepSavedToken": "Leave blank to keep saved token",
+ "title": "Local / compatible endpoint",
+ "tokenStatus": "Token {{status}}",
+ "validation": {
+ "baseUrlRequired": "Base URL is required",
+ "firstPartyAnthropic": "Use Auto, Subscription, or API key for first-party Anthropic",
+ "httpRequired": "Base URL must use http:// or https://",
+ "invalidUrl": "Invalid URL",
+ "noCredentials": "Base URL must not include credentials"
+ },
+ "status": {
+ "endpointDisabledTokenKept": "Endpoint disabled. Saved token was kept.",
+ "endpointSaved": "Endpoint saved",
+ "endpointSavedTokenMissing": "Endpoint saved. Auth token is not configured."
+ }
+ },
+ "connection": {
+ "authenticationMethod": "Authentication method",
+ "descriptions": {
+ "anthropic": "Choose how app-launched Anthropic sessions authenticate.",
+ "codex": "Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.",
+ "gemini": "Configure optional API access. CLI SDK and ADC are still discovered automatically.",
+ "opencode": "OpenCode authentication and provider inventory are managed by the OpenCode runtime."
+ },
+ "method": "Connection method",
+ "mode": "Mode: {{mode}}",
+ "selected": "Selected",
+ "switching": "Switching...",
+ "title": "Connection"
+ },
+ "connectionCards": {
+ "apiKey": {
+ "title": "API key"
+ },
+ "anthropic": {
+ "apiKeyDescription": "Use ANTHROPIC_API_KEY and Anthropic API billing.",
+ "autoDescription": "Use Anthropic runtime defaults and the best local credential available.",
+ "hint": "Auto keeps Anthropic on its default local credential resolution.",
+ "subscriptionDescription": "Use your local Anthropic sign-in session and subscription access.",
+ "subscriptionTitle": "Anthropic subscription"
+ },
+ "auto": {
+ "title": "Auto"
+ },
+ "codex": {
+ "apiKeyDescription": "Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.",
+ "autoDescription": "Prefer your ChatGPT account and subscription. Use API key mode only if needed.",
+ "chatgptDescription": "Use your connected ChatGPT account and Codex subscription.",
+ "chatgptTitle": "ChatGPT account",
+ "hint": "Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials."
+ }
+ },
+ "description": "Manage how each provider connects and, when supported, which backend the multimodel runtime should use.",
+ "fastMode": {
+ "defaultOff": "Default Off",
+ "description": "Apply Claude Code Fast mode by default for new Anthropic team launches when the resolved model and runtime allow it.",
+ "disabledHint": "New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.",
+ "enabledHint": "New Anthropic launches will request Fast mode by default when the resolved model supports it.",
+ "notExposed": "This Anthropic runtime does not expose Fast mode.",
+ "preferFast": "Prefer Fast",
+ "title": "Fast mode default",
+ "unavailableForRuntime": "Fast mode is currently unavailable for this Anthropic runtime."
+ },
+ "alerts": {
+ "anthropicApiKeyMissing": "API key mode is selected, but no Anthropic API credential is available yet.",
+ "anthropicStoredKeyAvailable": "A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.",
+ "anthropicSubscriptionMissing": "Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.",
+ "authTokenMissing": "Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.",
+ "chatgptLoginPending": "Waiting for ChatGPT account login to finish...",
+ "chatgptLoginStarting": "Starting ChatGPT login...",
+ "codexApiKeyMissing": "API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.",
+ "codexLocalArtifactsNoSession": "Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.",
+ "codexNeedsReconnect": "Codex has a locally selected ChatGPT account, but the current session needs reconnect.",
+ "codexNoChatgptAccount": "Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.",
+ "codexNoCredential": "No ChatGPT account or API key is available yet.",
+ "geminiApiUnavailable": "Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use valid Google ADC credentials.",
+ "withApiKeyFallback": "{{message}} Switch to API key mode to use the detected API key."
+ },
+ "authModeDescriptions": {
+ "anthropic": {
+ "apiKey": "Force app-launched Anthropic sessions to use an API key credential.",
+ "auto": "Use the runtime default behavior. Saved API keys in this app are only used after you switch to API key mode.",
+ "oauth": "Force app-launched Anthropic sessions to use the local Anthropic subscription session."
+ },
+ "codex": {
+ "apiKey": "Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.",
+ "auto": "Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.",
+ "chatgpt": "Force native Codex launches to use your connected ChatGPT account and subscription."
+ }
+ },
+ "progress": {
+ "applyingConnectionChanges": "Applying connection changes...",
+ "refreshingProviderStatus": "Refreshing provider status...",
+ "savingCompatibleEndpoint": "Saving compatible endpoint...",
+ "switchingAnthropicSubscription": "Switching to Anthropic subscription...",
+ "switchingApiKey": "Switching to API key...",
+ "switchingApiKeyMode": "Switching to API key mode...",
+ "switchingAuto": "Switching to Auto...",
+ "switchingChatgpt": "Switching to ChatGPT account mode..."
+ },
+ "provider": "Provider",
+ "runtime": {
+ "descriptions": {
+ "anthropic": "Anthropic currently has no separate runtime backend selector.",
+ "codex": "Codex now runs only through the native runtime path.",
+ "gemini": "Choose which Gemini runtime backend multimodel should use.",
+ "opencode": "OpenCode uses its own managed runtime host. Desktop currently exposes status only."
+ },
+ "title": "Runtime",
+ "updating": "Updating runtime..."
+ },
+ "runtimeSummary": "Runtime: {{runtime}}",
+ "status": {
+ "configured": "configured",
+ "enabled": "Enabled",
+ "notConfigured": "Not configured",
+ "notSet": "not set",
+ "off": "Off",
+ "unknown": "Unknown"
+ },
+ "title": "Provider Settings",
+ "usage": {
+ "apiKey": "Using API key",
+ "apiKeyRequired": "API key required",
+ "compatibleEndpoint": "Using compatible endpoint",
+ "notConnected": "Not connected",
+ "usingMethod": "Using {{method}}"
+ },
+ "errors": {
+ "apiKeyDeletedRefreshFailed": "API key deleted, but failed to refresh provider status.",
+ "apiKeySavedRefreshFailed": "API key saved, but failed to refresh provider status.",
+ "connectionUpdatedRefreshFailed": "Connection updated, but failed to refresh provider status.",
+ "deleteApiKey": "Failed to delete API key",
+ "disableEndpoint": "Failed to disable endpoint",
+ "endpointDisabledRefreshFailed": "Endpoint disabled, but failed to refresh provider status.",
+ "endpointSavedRefreshFailed": "Endpoint saved, but failed to refresh provider status.",
+ "refreshCodexAccount": "Failed to refresh Codex account",
+ "saveApiKey": "Failed to save API key",
+ "saveEndpoint": "Failed to save endpoint",
+ "updateAnthropicFastMode": "Failed to update Anthropic Fast mode",
+ "updateConnection": "Failed to update connection",
+ "updateRuntimeBackend": "Failed to update runtime backend",
+ "apiKeyRequired": "API key is required"
+ },
+ "connectionUi": {
+ "authMode": {
+ "auto": "Auto",
+ "oauth": "Subscription / OAuth",
+ "chatgpt": "ChatGPT account",
+ "apiKey": "API key",
+ "anthropicSubscription": "Anthropic subscription"
+ },
+ "authMethod": {
+ "apiKey": "API key",
+ "apiKeyHelper": "API key helper",
+ "oauth": "OAuth",
+ "claudeSubscription": "Claude subscription",
+ "geminiCli": "Gemini CLI",
+ "googleAccount": "Google account",
+ "serviceAccount": "service account"
+ },
+ "runtime": {
+ "codexNative": "Codex native",
+ "currentRuntime": "Current runtime",
+ "selectedRuntime": "Selected runtime",
+ "summary": "{{prefix}}: {{runtime}}"
+ },
+ "status": {
+ "checking": "Checking...",
+ "checked": "Checked",
+ "providerActivity": "Provider Activity",
+ "notConnected": "Not connected",
+ "startingChatGptLogin": "Starting ChatGPT login...",
+ "waitingForChatGptLogin": "Waiting for ChatGPT account login...",
+ "chatGptVerificationDegraded": "ChatGPT account detected - account verification is currently degraded.",
+ "chatGptAccountReady": "ChatGPT account ready",
+ "apiKeyReady": "API key ready",
+ "codexLocalAccountNeedsReconnect": "Codex has a locally selected ChatGPT account, but the current session needs reconnect.",
+ "codexNoActiveManagedSession": "Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.",
+ "codexNoActiveChatGptLogin": "Codex CLI reports no active ChatGPT login",
+ "connectChatGptForSubscription": "Connect a ChatGPT account to use your Codex subscription.",
+ "codexNativeReady": "Codex native ready",
+ "codexNativeUnavailable": "Codex native unavailable",
+ "unavailableInCurrentRuntime": "Unavailable in current runtime",
+ "connectedViaApiKey": "Connected via API key",
+ "apiKeyConfiguredNotVerified": "API key configured, but not verified yet",
+ "apiKeyModeMissingCredential": "API key mode selected, but no API key is configured",
+ "connectedVia": "Connected via {{method}}",
+ "unableToVerify": "Unable to verify"
+ },
+ "mode": {
+ "selectedAuth": "Selected auth: {{authMode}}",
+ "preferredAuth": "Preferred auth: {{authMode}}"
+ },
+ "credential": {
+ "apiKeyConfigured": "API key is configured",
+ "savedApiKeyAvailable": "Saved API key available in Manage",
+ "apiKeyAlsoConfigured": "API key also configured in Manage",
+ "apiKeyConfiguredInManage": "API key is configured in Manage",
+ "apiKeyFallbackInManage": "API key also available in Manage as fallback",
+ "availableAsFallback": "{{summary}} - available as fallback",
+ "savedApiKeyAvailableIfSwitch": "Saved API key available in Manage if you switch to API key mode",
+ "availableIfSwitch": "{{summary}} - available if you switch to API key mode",
+ "autoWillUseUntilChatGpt": "{{summary}} - Auto will use this until ChatGPT is connected"
+ },
+ "actions": {
+ "connect": "Connect",
+ "connectAnthropic": "Connect Anthropic",
+ "connectChatGpt": "Connect ChatGPT",
+ "disconnect": "Disconnect",
+ "openLogin": "Open Login"
+ },
+ "disconnect": {
+ "anthropicTitle": "Disconnect Anthropic subscription?",
+ "anthropic": "This removes the local Anthropic subscription session from the Claude CLI runtime.",
+ "anthropicWithApiKey": "This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.",
+ "geminiTitle": "Disconnect Gemini CLI?",
+ "gemini": "This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed."
+ }
+ }
+ },
+ "cliRuntime": {
+ "actions": {
+ "checkForUpdates": "Check for Updates",
+ "checking": "Checking...",
+ "extensions": "Extensions",
+ "installRuntime": "Install {{runtime}}",
+ "manage": "Manage",
+ "recheck": "Re-check",
+ "reinstallRuntime": "Reinstall {{runtime}}",
+ "retry": "Retry",
+ "update": "Update"
+ },
+ "installer": {
+ "checkingLatest": "Checking latest version...",
+ "downloading": "Downloading...",
+ "failed": "Installation failed",
+ "installed": "Installed v{{version}}",
+ "installing": "Installing...",
+ "latest": "latest",
+ "verifying": "Verifying checksum..."
+ },
+ "labels": {
+ "multimodel": "Multimodel"
+ },
+ "loading": {
+ "aiProviders": "Checking AI Providers...",
+ "claudeCli": "Checking Claude CLI..."
+ },
+ "provider": {
+ "backend": "Backend: {{backend}}",
+ "loadingModels": "Loading models...",
+ "modelsUnavailable": "Models unavailable for this runtime build",
+ "runtime": "Runtime: {{runtime}}"
+ },
+ "providerTerminal": {
+ "authFailed": "Authentication failed",
+ "authUpdated": "Authentication updated",
+ "loggedOut": "Provider logged out",
+ "login": "Login",
+ "logout": "Logout",
+ "logoutFailed": "Logout failed"
+ },
+ "status": {
+ "configuredNotFound": "The configured {{runtime}} was not found.",
+ "foundButFailed": "{{runtime}} was found but failed to start",
+ "healthCheckFailed": "The configured {{runtime}} failed its startup health check.",
+ "notInstalled": "{{runtime}} not installed"
+ },
+ "title": "CLI Runtime"
+ },
+ "cliStatus": {
+ "versionUpgrade": "v{{current}} -> v{{latest}}"
+ }
+}
diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json
new file mode 100644
index 00000000..4e31e250
--- /dev/null
+++ b/src/features/localization/renderer/locales/en/team.json
@@ -0,0 +1,2415 @@
+{
+ "activity": {
+ "actions": {
+ "createTaskFromMessage": "Create task from message",
+ "expandMessage": "Expand message",
+ "replyToMessage": "Reply to message",
+ "restartTeam": "Restart team"
+ },
+ "authError": {
+ "description": "Authentication failed. Restarting the team will refresh the session and may resolve this issue. If the problem persists, check your API credentials or try again later."
+ },
+ "automation": {
+ "reviewPickup": "Asked teammate to pick up review",
+ "stallNudge": "Asked teammate to continue stalled task",
+ "workSyncBody": "Asked teammate to sync current work"
+ },
+ "badges": {
+ "automation": "automation",
+ "bootstrap": "bootstrap",
+ "command": "command",
+ "comment": "Comment",
+ "live": "live",
+ "note": "note",
+ "rateLimited": "Rate Limited",
+ "restart": "restart",
+ "result": "result",
+ "session": "session",
+ "stallNudge": "stall nudge",
+ "start": "start",
+ "workSync": "work sync"
+ },
+ "bootstrap": {
+ "acknowledged": "Bootstrap acknowledged",
+ "restarting": "Restarting teammate",
+ "starting": "Starting teammate"
+ },
+ "rawJson": "Raw JSON",
+ "unread": "Unread",
+ "thoughts": {
+ "count": "{{count}} thoughts",
+ "count_one": "{{count}} thought",
+ "expand": "Expand thoughts",
+ "showMore": "Show more",
+ "showLess": "Show less",
+ "count_few": "{{count}} thoughts",
+ "count_many": "{{count}} thoughts",
+ "count_other": "{{count}} thoughts",
+ "toolSummary": "🔧 {{summary}}",
+ "titleForMember": "{{name}} - thoughts"
+ },
+ "timeline": {
+ "loadingMessages": "Loading messages...",
+ "noMessages": "No messages",
+ "emptyHint": "Send a message to a member to see activity.",
+ "newSession": "New session",
+ "olderCount": "+{{count}} older",
+ "showMore": "Show {{count}} more",
+ "showAll": "Show all",
+ "olderCount_one": "+{{count}} older",
+ "olderCount_few": "+{{count}} older",
+ "olderCount_many": "+{{count}} older",
+ "olderCount_other": "+{{count}} older"
+ },
+ "pendingReplies": {
+ "title": "Awaiting replies",
+ "openMember": "Open member",
+ "messageSentAwaitingReply": "Message sent, awaiting reply",
+ "awaitingReply": "awaiting reply",
+ "externalTeam": "external team",
+ "crossTeamAwaitingReply": "Cross-team message sent, awaiting reply",
+ "user": "user",
+ "awaitingApproval": "awaiting approval"
+ },
+ "reply": {
+ "replyingTo": "Replying to",
+ "action": "Reply"
+ },
+ "activeTasks": {
+ "inProgress": "In progress"
+ },
+ "expandDialog": {
+ "description": "Expanded message view"
+ }
+ },
+ "create": {
+ "actions": {
+ "create": "Create",
+ "creating": "Creating...",
+ "openExisting": "Open Existing Team",
+ "skipPreflightAndCreate": "Skip preflight and create"
+ },
+ "conflict": {
+ "description": "Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.",
+ "title": "Another team \"{{team}}\" is already running for this working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "copy": "Create a new team based on an existing one.",
+ "create": "Set up your team and choose how it starts."
+ },
+ "errors": {
+ "nameExists": "Team name already exists",
+ "nameLaunching": "A team with this name is currently launching",
+ "createConfigFailed": "Failed to create team config",
+ "loadProjectsFailed": "Failed to load projects"
+ },
+ "fields": {
+ "color": "Color (optional)",
+ "description": "Description (optional)",
+ "prompt": "Prompt for team lead (optional)",
+ "teamName": "Team name"
+ },
+ "launchAfterCreate": {
+ "description": "Start the team immediately via local Claude CLI.",
+ "label": "Run command after create"
+ },
+ "localOnly": "Available only in local Electron mode.",
+ "onDisk": "On disk:",
+ "placeholders": {
+ "description": "Brief description of the team purpose",
+ "prompt": "Instructions for the team lead during provisioning..."
+ },
+ "saved": "Saved",
+ "solo": {
+ "description": "Only the team lead (main process) will be started - no teammates will be spawned. Works like a regular agent session in your chosen runtime (Claude Code, Codex, OpenCode, Gemini) but with access to the task board for planning. Saves tokens by avoiding teammate coordination overhead. You can add members later from the team settings.",
+ "label": "Solo team"
+ },
+ "title": {
+ "copy": "Copy Team",
+ "create": "Create Team"
+ },
+ "optional": {
+ "launchSettingsTitle": "Optional launch settings",
+ "launchSettingsDescription": "Prompt, safety, and CLI overrides live here when you need them.",
+ "teamDetailsTitle": "Optional team details",
+ "teamDetailsDescription": "Keep the default flow compact and only open this when you want extra context or a custom color."
+ },
+ "prepare": {
+ "unsupportedPreload": "Current preload version does not support team:prepareProvisioning. Restart the dev app.",
+ "selectWorkingDirectory": "Select a working directory to validate the launch environment.",
+ "someProvidersNeedAttention": "Some selected providers need attention.",
+ "readyWithNotes": "All selected providers are ready, with notes.",
+ "ready": "All selected providers are ready.",
+ "failed": "Failed to prepare selected providers",
+ "checkingProviders": "Checking selected providers...",
+ "preparingEnvironment": "Preparing environment...",
+ "selectedProvidersReadyWithNotes": "Selected providers ready (with notes)",
+ "selectedProvidersReady": "Selected providers ready"
+ },
+ "validation": {
+ "nameMustContainLetterOrDigit": "Name must contain at least one letter or digit",
+ "nameTooLong": "Name is too long (max 128 chars)",
+ "selectWorkingDirectory": "Select working directory (cwd)",
+ "memberNameRequired": "Member name cannot be empty",
+ "memberNameInvalid": "Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars",
+ "memberNamesUnique": "Member names must be unique",
+ "openCodeLeadModelRequired": "OpenCode lead requires a selected model.",
+ "openCodeTeammateRequired": "OpenCode lead requires at least one OpenCode teammate.",
+ "teamLaunching": "Team is currently launching",
+ "teamNameExists": "Team name already exists",
+ "checkFormFields": "Check form fields"
+ }
+ },
+ "editTeam": {
+ "actions": {
+ "cancel": "Cancel",
+ "save": "Save"
+ },
+ "addMemberLockReason": "Use the dedicated Add member dialog to add new teammates while the team is live.",
+ "description": "Change team name, description and color",
+ "errors": {
+ "changesSavedRefreshFailed": "Team changes were saved, but failed to refresh the latest view: {{message}}",
+ "liveRenameBlocked": "Existing teammates cannot be renamed while the team is live. renamed: {{names}}",
+ "memberNameEmpty": "Member name cannot be empty",
+ "memberNameInvalid": "Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars",
+ "memberNameNumericSuffix": "Member name \"{{name}}\" is not allowed (reserved for Claude CLI auto-suffix). Use \"{{base}}\" instead.",
+ "memberNameReserved": "Member name \"{{name}}\" is reserved",
+ "memberNamesUnique": "Member names must be unique before saving",
+ "newLiveTeammates": "Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.",
+ "provisioning": "Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.",
+ "restartFailedMany": "Team saved, but failed to restart these teammates: {{failures}}",
+ "restartFailedOne": "Team saved, but failed to restart this teammate: {{failures}}",
+ "saveFailed": "Failed to save",
+ "settingsChanged": "Team settings changed while this dialog was open. Reopen it and review the latest state before saving.",
+ "settingsSavedMembersAndRefreshFailed": "Team settings were saved, but member changes failed: {{message}}. Refresh also failed: {{refreshError}}",
+ "settingsSavedMembersFailed": "Team settings were saved, but member changes failed: {{message}}",
+ "settingsSavedRefreshFailed": "Team settings were saved, but failed to refresh the latest view: {{message}}",
+ "teamNameEmpty": "Team name cannot be empty",
+ "unsupportedMixedPrimaryMutation": "Live edits to primary-owned teammates in mixed OpenCode teams are not supported yet. Stop the team, edit the roster, then relaunch. Affected: {{names}}"
+ },
+ "fields": {
+ "colorOptional": "Color (optional)",
+ "description": "Description",
+ "name": "Name"
+ },
+ "memberRestartWarning": "Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.",
+ "notices": {
+ "liveRenameBlocked": "Live save is blocked because existing teammates were renamed. Revert those identity changes or stop the team first.",
+ "newLiveTeammates": "New teammates cannot be added from Edit Team while the team is live. Use the Add member dialog instead.",
+ "provisioning": "Team provisioning is still in progress. Editing is temporarily locked until launch finishes.",
+ "restartMany": "Saving will restart or relaunch these teammates to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.",
+ "restartOne": "Saving will restart or relaunch this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.",
+ "unsupportedMixedPrimaryMutation": "Live edits/removals for primary-owned teammates in mixed OpenCode teams require stopping and relaunching the team: {{names}}."
+ },
+ "placeholders": {
+ "description": "Team description (optional)",
+ "teamName": "Team name"
+ },
+ "teamLead": {
+ "changeRuntime": "Change lead runtime",
+ "changeRuntimeDescription": "Open Relaunch Team to change the lead provider, model, or effort.",
+ "modelLockReason": "Team lead runtime is managed from Relaunch Team.",
+ "readOnlyHint": "Team lead name and role stay read-only here. Open the runtime panel on the lead row to change provider, model, or effort.",
+ "role": "Team Lead"
+ },
+ "title": "Edit Team"
+ },
+ "memberDraft": {
+ "actions": {
+ "remove": "Remove member",
+ "removeAria": "Remove {{name}}",
+ "restore": "Restore member",
+ "restoreAria": "Restore {{name}}"
+ },
+ "anthropicContext": {
+ "defaultSetting": "default context setting",
+ "description": "Anthropic context is team-wide for this launch: {{mode}}. Use the lead runtime panel's Limit context checkbox to change it.",
+ "limitEnabled": "200K limit enabled"
+ },
+ "mcp": {
+ "buttonInherit": "MCP inherit",
+ "buttonScopes": "MCP scopes",
+ "chooseScopes": "Choose scopes",
+ "inheritLead": "Inherit lead",
+ "lockedInfo": "Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.",
+ "mode": "MCP mode",
+ "scopes": {
+ "local": "local",
+ "project": "project",
+ "user": "user"
+ },
+ "serverNames": "Server names",
+ "settingInfo": "Agent Teams MCP launches this teammate with only the Agent Teams server. Scope and allowlist modes apply only to this teammate launch.",
+ "strictAllowlist": "Strict allowlist",
+ "tooltip": "{{label}}: Control this member's MCP inheritance policy",
+ "agentTeamsMcp": "Agent Teams MCP"
+ },
+ "model": {
+ "ariaLabel": "{{provider}} provider, {{model}}",
+ "currentLeadRuntime": "Current lead runtime",
+ "default": "Default",
+ "inheritedTooltip": "Provider, model, and effort are inherited from the lead while sync is enabled.",
+ "leadSuffix": "{{label}} (lead)",
+ "liveDisabled": "Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.",
+ "lockedActionFallback": "Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.",
+ "restartWholeTeam": "Saving those runtime changes restarts the whole team."
+ },
+ "nameAria": "Member {{index}} name",
+ "nameFallback": "member {{index}}",
+ "noRole": "No role",
+ "removed": "Removed",
+ "workflow": {
+ "addTooltip": "Add teammate workflow",
+ "editTooltip": "Edit teammate workflow",
+ "label": "Workflow (optional)",
+ "placeholder": "How this agent should behave, interact with others...",
+ "saved": "Saved"
+ },
+ "worktree": {
+ "description": "Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.",
+ "label": "Worktree"
+ },
+ "addMembers": {
+ "title": "Add Members",
+ "description": "Add new members to {{teamName}}"
+ },
+ "placeholders": {
+ "name": "member-name",
+ "mcpServers": "github, sentry"
+ }
+ },
+ "detail": {
+ "actions": {
+ "add": "Add",
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "editCode": "Edit code",
+ "launch": "Launch",
+ "remove": "Remove",
+ "stop": "Stop",
+ "task": "Task",
+ "visualize": "Visualize"
+ },
+ "deleteTeam": {
+ "description": "Delete team \"{{team}}\"? This action is irreversible. All team data and tasks will be deleted.",
+ "title": "Delete team"
+ },
+ "draft": {
+ "descriptionPrefix": "This is a draft team -",
+ "descriptionSuffix": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_few": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_many": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_one": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "descriptionSuffix_other": "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.",
+ "member": "members",
+ "member_few": "members",
+ "member_many": "members",
+ "member_one": "member",
+ "member_other": "members",
+ "title": "Team not launched yet"
+ },
+ "invalidTab": "Invalid team tab",
+ "kanbanSafeData": "Failed to fully load kanban. Displaying safe data.",
+ "loadFailed": "Failed to load team",
+ "loading": "Loading team",
+ "loadingSidebar": "Loading team sidebar",
+ "offline": {
+ "offline": "Team is offline",
+ "partialFailed": "Last launch failed partway",
+ "partialMissing": "Last launch failed partway - {{missing}}/{{expected}} teammates did not join",
+ "reconciling": "Last launch is still reconciling"
+ },
+ "previous": "Previous: {{paths}}",
+ "removeMember": {
+ "description": "Remove \"{{member}}\" from the team? Tasks and messages will be preserved, but this name cannot be reused.",
+ "title": "Remove member"
+ },
+ "sections": {
+ "team": "Team"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Active",
+ "launching": "Launching...",
+ "running": "Running"
+ },
+ "telemetry": {
+ "cpu": "CPU",
+ "memory": "Memory"
+ },
+ "tooltips": {
+ "deleteTeam": "Delete team",
+ "editTeam": "Edit team",
+ "editUnavailableProvisioning": "Edit team is unavailable while provisioning is still in progress",
+ "openBuiltInEditor": "Open project in built-in editor",
+ "openTeamGraph": "Open team graph",
+ "stopTeam": "Stop team"
+ },
+ "waitingForProvisioning": "Team data will appear once provisioning completes",
+ "context": {
+ "title": "Context"
+ }
+ },
+ "review": {
+ "fileHeader": {
+ "actions": {
+ "accept": "Accept",
+ "discard": "Discard",
+ "discardTooltip": "Discard all edits for this file",
+ "keepMyDraft": "Keep my draft",
+ "reject": "Reject",
+ "reloadFromDisk": "Reload from disk",
+ "restore": "Restore",
+ "restoreTooltip": "Create/restore this file on disk from the preview",
+ "saveFile": "Save File",
+ "saveFileTooltip": "Save file to disk"
+ },
+ "badges": {
+ "deleted": "DELETED",
+ "manualReview": "MANUAL REVIEW",
+ "new": "NEW",
+ "worktree": "WORKTREE"
+ },
+ "contentSource": {
+ "disk-current": "Current Disk",
+ "file-history": "File History",
+ "git-fallback": "Git Fallback",
+ "ledger-exact": "Task Ledger",
+ "ledger-snapshot": "Ledger Snapshot",
+ "snippet-reconstruction": "Reconstructed",
+ "unavailable": "Content unavailable"
+ },
+ "contentUnavailable": {
+ "badge": "Content unavailable",
+ "description": "The ledger recorded metadata for this change, but full text content is not available. This usually means binary, large, or hash-only content.",
+ "safety": "Automatic accept/reject is disabled for this file to avoid unsafe disk writes.",
+ "title": "Text content is unavailable"
+ },
+ "disabled": {
+ "acceptRejectContentUnavailable": "Accept/Reject is disabled because full text content is unavailable.",
+ "acceptRejectMissingOnDisk": "Accept/Reject is disabled while the file is missing on disk.",
+ "rejectBaselineUnavailable": "Reject is disabled because the original baseline is unavailable.",
+ "rejectContentUnavailable": "Reject is disabled because full text content is unavailable.",
+ "rejectManualLedgerReview": "Reject is disabled because this ledger change has binary, large, or unavailable content."
+ },
+ "externalChange": {
+ "changedOnDisk": "Changed on disk",
+ "deletedOnDisk": "Deleted on disk",
+ "recreatedOnDisk": "Recreated on disk"
+ },
+ "missingOnDisk": {
+ "badge": "Missing on disk",
+ "description": "We can still show a preview from agent logs, but your filesystem is out of sync.",
+ "restorePrefix": "Use",
+ "restoreSuffix": "to write the preview content back to disk.",
+ "restoreUnavailable": "Full file content is not available to restore automatically.",
+ "title": "File is missing on disk"
+ },
+ "pathChange": {
+ "from": "From {{path}}",
+ "to": "To {{path}}"
+ },
+ "worktree": {
+ "isolated": "Isolated worktree"
+ }
+ },
+ "toolbar": {
+ "stats": {
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "accepted": "{{count}} accepted",
+ "accepted_one": "{{count}} accepted",
+ "accepted_other": "{{count}} accepted",
+ "rejected": "{{count}} rejected",
+ "rejected_one": "{{count}} rejected",
+ "rejected_other": "{{count}} rejected",
+ "acrossFiles": "across {{count}} files",
+ "acrossFiles_one": "across {{count}} file",
+ "acrossFiles_other": "across {{count}} files",
+ "edited": "{{count}} edited",
+ "edited_one": "{{count}} edited",
+ "edited_other": "{{count}} edited",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "accepted_few": "{{count}} accepted",
+ "accepted_many": "{{count}} accepted",
+ "rejected_few": "{{count}} rejected",
+ "rejected_many": "{{count}} rejected",
+ "acrossFiles_few": "across {{count}} files",
+ "acrossFiles_many": "across {{count}} files",
+ "edited_few": "{{count}} edited",
+ "edited_many": "{{count}} edited"
+ },
+ "actions": {
+ "auto": "Auto",
+ "undo": "Undo",
+ "acceptAll": "Accept All",
+ "rejectAll": "Reject All",
+ "applying": "Applying...",
+ "applyRejections": "Apply Rejections"
+ },
+ "tooltips": {
+ "autoOn": "Auto-mark files as viewed when scrolled to end (ON)",
+ "autoOff": "Auto-mark files as viewed when scrolled to end (OFF)",
+ "undo": "Undo last review operation (Ctrl+Z)",
+ "acceptAll": "Accept all changes across all files",
+ "rejectAll": "Reject all safely rejectable changes across all files",
+ "rejectAllDisabled": "No pending files have a safe original baseline to reject.",
+ "applyRejections": "Apply rejected hunks to disk; accepted changes are kept as-is"
+ }
+ },
+ "diffError": {
+ "title": "Failed to render diff view",
+ "unexpected": "An unexpected error occurred while rendering the diff.",
+ "actions": {
+ "retry": "Retry"
+ },
+ "raw": {
+ "show": "Show raw diff data",
+ "file": "File: {{file}}",
+ "original": "--- Original",
+ "modified": "+++ Modified",
+ "charsTotal": "... ({{count}} chars total)",
+ "charsTotal_one": "... ({{count}} char total)",
+ "charsTotal_other": "... ({{count}} chars total)",
+ "charsTotal_few": "... ({{count}} chars total)",
+ "charsTotal_many": "... ({{count}} chars total)"
+ }
+ },
+ "fileTree": {
+ "viewed": "Viewed",
+ "badges": {
+ "new": "new",
+ "deleted": "deleted"
+ },
+ "collapseFolder": "Collapse {{name}}",
+ "expandFolder": "Expand {{name}}",
+ "empty": {
+ "noChangedFiles": "No changed files",
+ "noMatchingFiles": "No matching files"
+ },
+ "searchPlaceholder": "Search files…",
+ "filters": {
+ "unresolved": "Unresolved",
+ "rejected": "Rejected",
+ "new": "New",
+ "clear": "Clear"
+ }
+ },
+ "diffControls": {
+ "previousChunk": "Previous chunk",
+ "nextChunk": "Next chunk",
+ "rejectChange": "Reject change (⌘N)",
+ "acceptChange": "Accept change (⌘Y)",
+ "undo": "Undo",
+ "keep": "Keep",
+ "rejectShortcut": "⌘N",
+ "acceptShortcut": "⌘Y"
+ },
+ "conflict": {
+ "title": "Conflict Detected",
+ "description": "This file has been modified since the agent's changes",
+ "cancel": "Cancel",
+ "saveResolution": "Save Resolution",
+ "editManually": "Edit Manually",
+ "useOriginal": "Use Original",
+ "keepCurrent": "Keep Current"
+ },
+ "fullDiffLoading": {
+ "titleOne": "Preparing Full Diff",
+ "titleMany": "Preparing {{count}} Full Diffs",
+ "subtitleForFile": "Finalizing the exact editor diff for {{file}}.",
+ "subtitleCurrentFile": "Finalizing the exact editor diff for the current file.",
+ "subtitleMany": "Resolving exact before/after baselines for the files currently loading.",
+ "previewsReady": "{{count}} previews ready",
+ "previewsReady_one": "{{count}} preview ready",
+ "editorViewLoading": "Editor view loading",
+ "filesInProgress": "{{count}} files in progress",
+ "filesInProgress_one": "{{count}} file in progress",
+ "filesReady": "{{ready}}/{{total}} files ready",
+ "progressDescription": "{{ready}} ready, {{loading}} still loading. Preview diffs stay visible below while the remaining baselines are resolved.",
+ "singleDescription": "Preview diffs stay visible below while the exact baseline is resolved.",
+ "previewsReady_few": "{{count}} previews ready",
+ "previewsReady_many": "{{count}} previews ready",
+ "previewsReady_other": "{{count}} previews ready",
+ "filesInProgress_few": "{{count}} files in progress",
+ "filesInProgress_many": "{{count}} files in progress",
+ "filesInProgress_other": "{{count}} files in progress"
+ },
+ "fileMissingPrefix": "File is missing on disk. This diff may be only a preview from agent logs. Use",
+ "restore": "Restore",
+ "fileMissingSuffix": "to create the file on disk.",
+ "filePlaceholder": {
+ "loading": "Loading",
+ "description": "Preparing a full editor diff for this file."
+ },
+ "loading": {
+ "diff": "DIFF",
+ "ledgerObjectsProcessed": "{{count}} ledger objects processed",
+ "ledgerObjectsProcessed_one": "{{count}} ledger object processed",
+ "ledgerObjectsProcessed_other": "{{count}} ledger objects processed",
+ "ledgerObjectsProcessed_few": "{{count}} ledger objects processed",
+ "ledgerObjectsProcessed_many": "{{count}} ledger objects processed",
+ "phases": {
+ "readingLedger": "Reading task ledger...",
+ "resolvingFiles": "Resolving file states...",
+ "checkingWorktree": "Checking worktree context...",
+ "preparingDiffs": "Preparing review diffs..."
+ }
+ },
+ "progress": {
+ "viewed": "{{viewed}}/{{total}} viewed"
+ },
+ "scope": {
+ "readMore": "Read more",
+ "tiers": {
+ "exact": {
+ "title": "Task scope determined precisely",
+ "detail": "Both start and completion markers found in the session log. The diff includes only changes made during this specific task - other tasks that modified the same files are excluded."
+ },
+ "endEstimated": {
+ "title": "End boundary estimated",
+ "detail": "Only the start marker was found - the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included."
+ },
+ "startEstimated": {
+ "title": "Start boundary estimated",
+ "detail": "Only the completion marker was found - the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included."
+ },
+ "allSession": {
+ "title": "Showing all session changes",
+ "detail": "No task markers found in the session log. Cannot isolate this task - all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows."
+ }
+ },
+ "ledger": {
+ "exact": {
+ "title": "Changes captured by task ledger",
+ "detail": "The orchestrator captured these file changes while the agent was working on this task.",
+ "badge": "Ledger exact"
+ },
+ "limited": {
+ "title": "Changes captured with limited reviewability",
+ "detail": "The orchestrator captured these file changes for this task, but at least one change was captured from a snapshot or metadata-only source. Review exact text diffs where available; binary or unavailable content may require manual review.",
+ "mixedBadge": "Mixed reviewability",
+ "needsReviewBadge": "Needs review"
+ }
+ },
+ "workInterval": {
+ "title": "Scoped by persisted work interval",
+ "detail": "The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.",
+ "badge": "Interval scoped"
+ },
+ "confidence": {
+ "high": "High confidence",
+ "medium": "Medium confidence",
+ "low": "Low confidence",
+ "bestEffort": "Best effort"
+ }
+ },
+ "shortcuts": {
+ "title": "Keyboard Shortcuts",
+ "actions": {
+ "nextChange": "Next change",
+ "previousChange": "Previous change",
+ "nextFile": "Next file",
+ "previousFile": "Previous file",
+ "acceptChange": "Accept change",
+ "rejectChange": "Reject change",
+ "saveFile": "Save file",
+ "undo": "Undo",
+ "redo": "Redo",
+ "toggleShortcuts": "Toggle shortcuts",
+ "closeDialog": "Close dialog"
+ }
+ },
+ "timeline": {
+ "empty": "No edit events",
+ "titleWithCount": "Edit Timeline ({{count}})"
+ },
+ "continuousScroll": {
+ "empty": "No reviewable file changes"
+ },
+ "empty": {
+ "noSafeDiff": "No safe diff available",
+ "noFileChangesRecorded": "No file changes recorded",
+ "noSafeDiffDescription": "The task ledger did not expose a safe file diff for this task.",
+ "noSafeDiffDiagnosticsDescription": "The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.",
+ "noFileEventsYet": "The task ledger has no file events for this task yet.",
+ "noFileEvents": "The task ledger has no file events for this task."
+ }
+ },
+ "messages": {
+ "actions": {
+ "bottomSheetActions": "Message bottom sheet actions",
+ "collapseAll": "Collapse all messages",
+ "collapseSheet": "Collapse sheet",
+ "expandAll": "Expand all messages",
+ "expandSheet": "Expand sheet",
+ "floatComposer": "Float composer",
+ "floatMessagesComposer": "Float messages composer",
+ "hideSearch": "Hide search",
+ "loadOlder": "Load older messages",
+ "markAllRead": "Mark all as read",
+ "messageActions": "Message actions",
+ "moveMessagesToBottomSheet": "Move messages to bottom sheet",
+ "moveMessagesToSidebar": "Move messages to sidebar",
+ "moveToBottomSheet": "Move to bottom sheet",
+ "moveToInline": "Move to inline",
+ "moveToSidebar": "Move to sidebar",
+ "panelActions": "Message panel actions",
+ "searchMessages": "Search messages"
+ },
+ "delivery": {
+ "copied": "Copied",
+ "copyDebugDetails": "Copy debug details",
+ "details": "Details",
+ "fields": {
+ "acceptanceUnknown": "acceptanceUnknown",
+ "delivered": "delivered",
+ "diagnostics": "diagnostics",
+ "ledgerStatus": "ledgerStatus",
+ "messageId": "messageId",
+ "providerId": "providerId",
+ "queuedBehindMessageId": "queuedBehindMessageId",
+ "reason": "reason",
+ "responsePending": "responsePending",
+ "responseState": "responseState",
+ "statusMessageId": "statusMessageId",
+ "userVisibleMessage": "userVisibleMessage",
+ "userVisibleNextReviewAt": "userVisibleNextReviewAt",
+ "userVisibleReasonCode": "userVisibleReasonCode",
+ "userVisibleState": "userVisibleState",
+ "visibleReplyCorrelation": "visibleReplyCorrelation",
+ "visibleReplyMessageId": "visibleReplyMessageId"
+ }
+ },
+ "panelMode": "Message panel mode",
+ "title": "Messages",
+ "unread": {
+ "new": "{{count}} new",
+ "unread": "{{count}} unread",
+ "new_few": "{{count}} new",
+ "new_many": "{{count}} new",
+ "new_one": "{{count}} new",
+ "new_other": "{{count}} new",
+ "unread_few": "{{count}} unread",
+ "unread_many": "{{count}} unread",
+ "unread_one": "{{count}} unread",
+ "unread_other": "{{count}} unread"
+ },
+ "filter": {
+ "ariaLabel": "Filter messages",
+ "tooltip": "Filter messages",
+ "from": "From",
+ "to": "To",
+ "noData": "No data",
+ "showStatusUpdates": "Show status updates (idle/shutdown)",
+ "actions": {
+ "reset": "Reset",
+ "save": "Save"
+ }
+ },
+ "status": {
+ "title": "Status"
+ },
+ "actionMode": {
+ "label": "Action mode"
+ },
+ "search": {
+ "placeholder": "Search..."
+ }
+ },
+ "modelSelector": {
+ "badges": {
+ "configured": "Configured",
+ "connected": "Connected",
+ "failed": "Failed",
+ "free": "Free",
+ "local": "Local",
+ "needsTest": "Needs test",
+ "verified": "Verified",
+ "unavailable": "Unavailable",
+ "issue": "Issue"
+ },
+ "customModelId": "Custom model id",
+ "label": "Model (optional)",
+ "multimodelRequired": "Codex and Gemini require Multimodel mode.",
+ "openCode": {
+ "allSources": "All OpenCode sources",
+ "filterSource": "Filter {{source}}",
+ "filterSources": "Filter OpenCode sources",
+ "freeOnly": "Free only",
+ "freeTooltip": "OpenCode marks this model as free.",
+ "loadingModels": "Loading OpenCode models...",
+ "noSourcesFound": "No sources found.",
+ "recommendedOnly": "Recommended only",
+ "searchSources": "Search sources",
+ "sourcesCount": "{{count}} OpenCode sources",
+ "sourcesCount_few": "{{count}} OpenCode sources",
+ "sourcesCount_many": "{{count}} OpenCode sources",
+ "sourcesCount_one": "{{count}} OpenCode sources",
+ "sourcesCount_other": "{{count}} OpenCode sources"
+ },
+ "reason": "Reason: {{reason}}",
+ "runtimeModelsSyncing": "Explicit models load from the current runtime. Default remains available while the list is syncing.",
+ "fastMode": {
+ "codexLabel": "Fast mode (2x credits)",
+ "optionalLabel": "Fast mode (optional)",
+ "defaultOff": "Default (Off)",
+ "fast": "Fast",
+ "off": "Off",
+ "defaultFast": "Default (Fast)",
+ "defaultResolvesTo": "Default currently resolves to {{mode}}.",
+ "runtimeBackedHint": "Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it."
+ },
+ "anthropicExtraUsage": {
+ "pricingDocs": "Read Anthropic pricing docs"
+ },
+ "searchModels": "Search models",
+ "defaultModel": "Default",
+ "empty": {
+ "noSearchMatches": "No models match this search.",
+ "recommendedFreeOpenCode": "No recommended free OpenCode models are available in the current runtime list.",
+ "freeOpenCode": "No free OpenCode models are available in the current runtime list.",
+ "recommendedOpenCode": "No recommended OpenCode models are available in the current runtime list.",
+ "noModels": "No models are available in the current runtime list."
+ },
+ "openCodeStatus": {
+ "notReadyTitle": "OpenCode is not ready for team launch",
+ "freeModelsAvailableTitle": "OpenCode free models are available",
+ "providerNotConnectedTitle": "OpenCode provider is not connected",
+ "readyTitle": "OpenCode is ready",
+ "readyMessage": "OpenCode passed provider readiness. Select it to use OpenCode models for this team.",
+ "useOpenCode": "Use OpenCode",
+ "badges": {
+ "check": "Check",
+ "install": "Install",
+ "free": "Free",
+ "setup": "Setup"
+ },
+ "summary": {
+ "checking": "OpenCode status: checking runtime",
+ "status": "OpenCode status: {{parts}}"
+ },
+ "summaryParts": {
+ "teamLaunchBlocked": "team launch blocked",
+ "providerOptional": "provider connection optional",
+ "providerModelsNeedSetup": "provider-backed models need setup",
+ "teamLaunchReady": "team launch ready",
+ "runtimeDetected": "runtime detected",
+ "runtimeMissing": "runtime missing",
+ "freeWithoutAuth": "free models available without auth",
+ "providerConnected": "provider connected",
+ "providerNotConnected": "provider not connected"
+ },
+ "messages": {
+ "checking": "The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.",
+ "unsupported": "OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.",
+ "freeAvailable": "OpenCode is detected. You can use free OpenCode models such as Big Pickle without connecting a provider. Connect a provider only when you want provider-backed models.",
+ "noFreeListed": "OpenCode is detected, but no free OpenCode model is listed yet. Refresh provider status, or connect a provider in OpenCode for provider-backed models.",
+ "launchBlocked": "OpenCode is installed and authenticated, but Agent Teams launch readiness is blocked.",
+ "ready": "OpenCode is ready for team launch."
+ },
+ "loadingRuntime": "OpenCode runtime status is still loading."
+ },
+ "advisory": {
+ "pingNotConfirmed": "Ping not confirmed",
+ "note": "Note"
+ },
+ "placeholders": {
+ "customModelId": "openai/gpt-oss-20b"
+ },
+ "routeGroups": {
+ "openCodeConfig": "OpenCode config",
+ "builtinFree": "Free built-in",
+ "connectedProviders": "Connected providers",
+ "otherCatalog": "Other OpenCode catalog"
+ },
+ "pricing": {
+ "free": "Free",
+ "inputShort": "in {{rate}}",
+ "outputShort": "out {{rate}}",
+ "perMillionSummary": "{{summary}} / 1M",
+ "inputTitle": "Input: {{rate}} per 1M tokens",
+ "outputTitle": "Output: {{rate}} per 1M tokens",
+ "cacheReadTitle": "Cache read: {{rate}} per 1M tokens",
+ "cacheWriteTitle": "Cache write: {{rate}} per 1M tokens"
+ },
+ "defaultTooltip": {
+ "anthropicCompatibleWithResolved": "Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to {{model}}.",
+ "anthropicCompatible": "Uses the Anthropic-compatible endpoint default model.",
+ "anthropic": "Uses the Claude team default model.\nResolves to {{longContextModel}} with 1M context, or {{limitedContextModel}} with 200K context when Limit context is enabled.",
+ "openCodeWithResolved": "Uses the OpenCode default model.\nCurrently resolves to {{model}}.",
+ "openCode": "Uses the OpenCode runtime default model.",
+ "runtime": "Uses the runtime default for the selected provider."
+ },
+ "multimodelOff": "Multimodel off",
+ "unavailableInRuntime": "Unavailable in current runtime"
+ },
+ "taskDetail": {
+ "actions": {
+ "cancel": "Cancel",
+ "delete": "Delete",
+ "markResolved": "Mark resolved",
+ "save": "Save"
+ },
+ "attachments": {
+ "commentAttachment": "Comment attachment",
+ "fromComments": "From comments",
+ "preview": "Preview {{filename}}"
+ },
+ "changes": {
+ "badges": {
+ "attention": "attention",
+ "noSafeDiff": "no safe diff"
+ },
+ "empty": {
+ "noFileChangesRecorded": "No file changes recorded",
+ "noFileChangesRecordedYet": "No file changes recorded yet",
+ "noReviewableChangesRecovered": "No reviewable file changes recovered",
+ "noSafeDiffAvailable": "No safe diff available"
+ },
+ "loadFailed": "Failed to load task changes summary",
+ "loading": "Loading changes...",
+ "fileCount": "{{count}} files",
+ "fileRowsHidden": "{{count}} file rows hidden",
+ "moreDiagnostics": "{{count}} more diagnostics",
+ "moreFiles": "{{count}} more files",
+ "openInEditor": "Open in editor",
+ "openTask": "Open task {{subject}}",
+ "refresh": "Refresh changes",
+ "refreshFailed": "Refresh failed: {{error}}",
+ "refreshing": "Refreshing",
+ "refreshingChanges": "Refreshing changes...",
+ "refreshTeamChanges": "Refresh team changes",
+ "refreshShort": "Refresh",
+ "reviewDiff": "Review diff",
+ "reviewTaskDiff": "Review task diff",
+ "scannedCandidateTasks": "Scanned {{requested}} of {{eligible}} candidate tasks",
+ "tasksDeferred": "{{count}} tasks deferred this pass",
+ "title": "Changes",
+ "fileCount_few": "{{count}} files",
+ "fileCount_many": "{{count}} files",
+ "fileCount_one": "{{count}} files",
+ "fileCount_other": "{{count}} files",
+ "fileRowsHidden_few": "{{count}} file rows hidden",
+ "fileRowsHidden_many": "{{count}} file rows hidden",
+ "fileRowsHidden_one": "{{count}} file rows hidden",
+ "fileRowsHidden_other": "{{count}} file rows hidden",
+ "moreDiagnostics_few": "{{count}} more diagnostics",
+ "moreDiagnostics_many": "{{count}} more diagnostics",
+ "moreDiagnostics_one": "{{count}} more diagnostics",
+ "moreDiagnostics_other": "{{count}} more diagnostics",
+ "moreFiles_few": "{{count}} more files",
+ "moreFiles_many": "{{count}} more files",
+ "moreFiles_one": "{{count}} more files",
+ "moreFiles_other": "{{count}} more files",
+ "tasksDeferred_few": "{{count}} tasks deferred this pass",
+ "tasksDeferred_many": "{{count}} tasks deferred this pass",
+ "tasksDeferred_one": "{{count}} tasks deferred this pass",
+ "tasksDeferred_other": "{{count}} tasks deferred this pass"
+ },
+ "clarification": {
+ "awaitingLead": "Awaiting clarification from team lead",
+ "awaitingUser": "Awaiting clarification from you"
+ },
+ "description": {
+ "add": "Click to add description...",
+ "edit": "Edit description",
+ "placeholder": "Task description (supports markdown)"
+ },
+ "loading": {
+ "fetchingTeamData": "Fetching team data",
+ "title": "Loading task..."
+ },
+ "logs": {
+ "newArriving": "New task logs arriving"
+ },
+ "notFound": "Task not found",
+ "related": {
+ "blockedBy": "Blocked by",
+ "blocks": "Blocks",
+ "linkedFrom": "Linked from",
+ "links": "Links",
+ "title": "Related tasks"
+ },
+ "review": {
+ "reviewer": "Reviewer: {{reviewer}}"
+ },
+ "sections": {
+ "attachments": "Attachments",
+ "changes": "Changes",
+ "comments": "Comments",
+ "description": "Description",
+ "taskLogs": "Task Logs",
+ "workflowHistory": "Workflow History"
+ },
+ "unassigned": "Unassigned",
+ "workflow": {
+ "implementationTimeTitle": "Implementation time from persisted work intervals",
+ "inProgressTime": "In progress time {{duration}}"
+ },
+ "comments": {
+ "renderLimit": "Showing the most recent {{formattedCount}} comments to keep the UI responsive.",
+ "badges": {
+ "approved": "Approved",
+ "reviewRequested": "Review requested"
+ },
+ "unknownTime": "unknown time",
+ "actions": {
+ "reply": "Reply",
+ "replyToComment": "Reply to comment",
+ "showMore": "Show more comments ({{visible}}/{{total}})",
+ "cancelReply": "Cancel reply",
+ "comment": "Comment"
+ },
+ "attachments": {
+ "previewAlt": "Attachment preview",
+ "downloadFailed": "Download failed"
+ },
+ "replyingTo": "Replying to",
+ "input": {
+ "placeholder": "Add a comment... (Enter to send)",
+ "charsLeft": "{{count}} chars left",
+ "charsLeft_one": "{{count}} char left",
+ "charsLeft_other": "{{count}} chars left",
+ "charsLeft_few": "{{count}} chars left",
+ "charsLeft_many": "{{count}} chars left"
+ }
+ },
+ "workflowTimeline": {
+ "empty": "No workflow history recorded",
+ "currentImplementationInterval": "Current implementation interval",
+ "implementationIntervalEnded": "Implementation interval ended at this transition",
+ "runningPrefix": "running ",
+ "createdAs": "Created as",
+ "by": "by",
+ "reassigned": "Reassigned",
+ "assignedTo": "Assigned to",
+ "unassignedFrom": "Unassigned from",
+ "ownerChanged": "Owner changed",
+ "reviewRequested": "Review requested",
+ "reviewStarted": "Review started",
+ "changesRequested": "Changes requested",
+ "approved": "Approved",
+ "unknownEvent": "Unknown event"
+ },
+ "reviewStates": {
+ "approved": "Approved",
+ "needsFix": "Needs fix",
+ "inReview": "In review"
+ }
+ },
+ "tasks": {
+ "createTask": {
+ "assignee": "Assignee",
+ "assigneeOptional": "Assignee (optional)",
+ "blockedByOptional": "Blocked by tasks (optional)",
+ "blockedBySummary": "Task will be blocked by: {{tasks}}",
+ "cancel": "Cancel",
+ "create": "Create",
+ "creating": "Creating...",
+ "description": "The task will be created in the team's tasks/ directory and appear on the Kanban board.",
+ "descriptionOptional": "Description (optional)",
+ "detailsPlaceholder": "Task details (supports markdown)",
+ "hideOptionalFields": "Hide optional fields",
+ "offlineNotice": {
+ "after": "- launch the team to start execution.",
+ "before": "Team is offline. The task will be added to"
+ },
+ "promptOptional": "Prompt for assignee (optional)",
+ "promptPlaceholder": "Custom instructions for the team member...",
+ "relatedOptional": "Related tasks (optional)",
+ "relatedSummary": "Related: {{tasks}}",
+ "saved": "Saved",
+ "searchTasks": "Search tasks...",
+ "selectMember": "Select a member",
+ "selectMemberOptional": "Select member...",
+ "showOptionalFields": "Show optional fields",
+ "startImmediately": "Start immediately",
+ "startOfflineHint": "Team is offline. Launch the team first to start tasks immediately.",
+ "subject": "Subject",
+ "subjectPlaceholder": "What needs to be done?",
+ "title": "Create Task",
+ "todo": "TODO"
+ },
+ "list": {
+ "columns": {
+ "blockedBy": "Blocked By",
+ "blocks": "Blocks",
+ "id": "ID",
+ "owner": "Owner",
+ "status": "Status",
+ "subject": "Subject"
+ },
+ "empty": "No tasks in this team",
+ "filters": {
+ "allOwners": "All owners",
+ "allStatuses": "All statuses",
+ "ownerAria": "Filter tasks by owner",
+ "statusAria": "Filter tasks by status"
+ },
+ "showing": "Showing {{shown}} of {{total}}"
+ },
+ "status": {
+ "completed": "completed",
+ "deleted": "deleted",
+ "inProgress": "in_progress",
+ "pending": "pending"
+ },
+ "statusSummary": {
+ "progressAria": "Tasks {{completed}}/{{total}} completed",
+ "inProgress": "{{count}} in_progress",
+ "inProgress_one": "{{count}} in_progress",
+ "inProgress_other": "{{count}} in_progress",
+ "inProgress_few": "{{count}} in_progress",
+ "inProgress_many": "{{count}} in_progress",
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "completed": "{{count}} completed",
+ "completed_one": "{{count}} completed",
+ "completed_other": "{{count}} completed",
+ "completed_few": "{{count}} completed",
+ "completed_many": "{{count}} completed"
+ },
+ "unassigned": "Unassigned",
+ "teamPrefix": "Team:",
+ "openTask": "Open task",
+ "deleteConfirm": {
+ "title": "Delete task",
+ "message": "Move task #{{taskId}} to trash?",
+ "confirmLabel": "Delete",
+ "cancelLabel": "Cancel"
+ }
+ },
+ "editor": {
+ "actions": {
+ "cancel": "Cancel",
+ "closeEditor": "Close editor",
+ "closeTab": "Close tab",
+ "closeTooltip": "Close editor (Esc)",
+ "discard": "Discard",
+ "discardAndClose": "Discard & Close",
+ "keep": "Keep",
+ "keepMine": "Keep mine",
+ "keyboardShortcuts": "Keyboard shortcuts",
+ "overwrite": "Overwrite",
+ "refreshAria": "Refresh (F5)",
+ "refreshTooltip": "Refresh git status (F5)",
+ "reload": "Reload",
+ "retry": "Retry",
+ "save": "Save",
+ "saveAllAndClose": "Save All & Close"
+ },
+ "ariaLabel": "Project Editor",
+ "dialogs": {
+ "conflictDescription": "The file has been modified externally since you opened it. Overwrite with your changes?",
+ "conflictTitle": "Save Conflict",
+ "unsavedDescription": "You have unsaved changes. What would you like to do?",
+ "unsavedFileDescription": "This file has unsaved changes. What would you like to do?",
+ "unsavedTitle": "Unsaved Changes"
+ },
+ "newFile": {
+ "validation": {
+ "nameRequired": "Name cannot be empty",
+ "invalidName": "Invalid name",
+ "invalidCharacters": "Name contains invalid characters",
+ "nameTooLong": "Name is too long"
+ },
+ "placeholders": {
+ "fileName": "File name...",
+ "folderName": "Folder name..."
+ },
+ "aria": {
+ "newFileName": "New file name",
+ "newFolderName": "New folder name"
+ }
+ },
+ "draftRecovered": "Recovered unsaved changes from a previous session.",
+ "externalChange": {
+ "changed": "File changed on disk.",
+ "deleted": "File no longer exists on disk."
+ },
+ "saveFailed": "Save failed: {{error}}",
+ "sidebar": {
+ "explorer": "Explorer",
+ "hide": "Hide sidebar",
+ "hideWithShortcut": "Hide sidebar ({{shortcut}})",
+ "show": "Show sidebar",
+ "showWithShortcut": "Show sidebar ({{shortcut}})"
+ },
+ "searchInFiles": {
+ "title": "Search in Files",
+ "closeSearch": "Close search",
+ "closeSearchShortcut": "Close search (Esc)",
+ "searchPlaceholder": "Search...",
+ "matchCase": "Match Case",
+ "matchCaseToggle": "Aa",
+ "noResults": "No results found",
+ "resultsSummary": "{{count}} matches in {{fileCount}} files",
+ "resultsSummary_one": "{{count}} match in {{fileCount}} files",
+ "truncated": "(truncated)",
+ "resultsSummary_few": "{{count}} matches in {{fileCount}} files",
+ "resultsSummary_many": "{{count}} matches in {{fileCount}} files",
+ "resultsSummary_other": "{{count}} matches in {{fileCount}} files"
+ },
+ "fileTree": {
+ "failedToLoadFiles": "Failed to load files: {{error}}",
+ "loading": "Loading files...",
+ "empty": "No files found",
+ "dropForProjectRoot": "Drop here for project root",
+ "moveToTrash": "Move to Trash",
+ "moveToTrashConfirm": "Move \"{{name}}\" to Trash?",
+ "cancel": "Cancel"
+ },
+ "goToLine": {
+ "title": "Go to Line",
+ "position": "(current: {{current}}, total: {{total}})",
+ "placeholder": "Line number, +offset, -offset, or %",
+ "go": "Go"
+ },
+ "searchPanel": {
+ "previousMatch": "Previous Match",
+ "nextMatch": "Next Match",
+ "close": "Close",
+ "replacePlaceholder": "Replace",
+ "replace": "Replace",
+ "replaceNext": "Replace Next",
+ "all": "All",
+ "replaceAll": "Replace All"
+ },
+ "statusBar": {
+ "position": "Ln {{line}}, Col {{col}}",
+ "enableWatcher": "Enable file watcher",
+ "disableWatcher": "Disable file watcher",
+ "watch": "watch",
+ "watching": "watching",
+ "watchExternalChanges": "Watch for external changes",
+ "disableExternalWatcher": "Disable external change watcher",
+ "encodingUtf8": "UTF-8",
+ "spaces": "Spaces: {{count}}"
+ },
+ "imagePreview": {
+ "loading": "Loading preview...",
+ "openFullSize": "Open full-size preview",
+ "openSystemViewer": "Open in System Viewer"
+ },
+ "quickOpen": {
+ "title": "Quick Open",
+ "searchPlaceholder": "Search files by name...",
+ "loading": "Loading files...",
+ "empty": "No files found"
+ },
+ "errorBoundary": {
+ "crashed": "Editor crashed",
+ "unknownError": "Unknown error"
+ },
+ "binaryPlaceholder": {
+ "file": "Binary file ({{size}})"
+ },
+ "unsavedChanges": "Unsaved changes",
+ "empty": {
+ "selectFile": "Select a file from the tree to edit"
+ },
+ "search": {
+ "toggleReplace": "Toggle Replace",
+ "placeholder": "Search"
+ },
+ "shortcuts": {
+ "title": "Keyboard Shortcuts",
+ "groups": {
+ "fileOperations": "File Operations",
+ "search": "Search",
+ "navigation": "Navigation",
+ "editing": "Editing",
+ "markdown": "Markdown",
+ "general": "General"
+ },
+ "actions": {
+ "quickOpen": "Quick Open",
+ "save": "Save",
+ "saveAll": "Save All",
+ "closeTab": "Close Tab",
+ "findInFile": "Find in File",
+ "searchInFiles": "Search in Files",
+ "goToLine": "Go to Line",
+ "nextTab": "Next Tab",
+ "previousTab": "Previous Tab",
+ "cycleTabs": "Cycle Tabs",
+ "toggleSidebar": "Toggle Sidebar",
+ "undo": "Undo",
+ "redo": "Redo",
+ "selectNextMatch": "Select Next Match",
+ "toggleComment": "Toggle Comment",
+ "splitPreview": "Split Preview",
+ "fullPreview": "Full Preview",
+ "closeEditor": "Close Editor"
+ }
+ },
+ "toolbar": {
+ "enableWordWrap": "Enable word wrap",
+ "disableWordWrap": "Disable word wrap",
+ "closeSplitPreview": "Close split preview",
+ "closePreview": "Close preview"
+ }
+ },
+ "launch": {
+ "actions": {
+ "createSchedule": "Create Schedule",
+ "creating": "Creating...",
+ "goToDashboard": "Go to Dashboard",
+ "launchTeam": "Launch team",
+ "launching": "Launching...",
+ "relaunchTeam": "Relaunch team",
+ "relaunching": "Relaunching...",
+ "saveChanges": "Save Changes",
+ "saving": "Saving..."
+ },
+ "billing": {
+ "prefix": "Starting June 15, 2026, Anthropic bills",
+ "readArticle": "Read Anthropic article",
+ "suffix": "and Agent SDK usage from the monthly Agent SDK credit, separate from interactive Claude Code limits. The credit resets each billing cycle and unused credit does not roll over."
+ },
+ "conflict": {
+ "description": "Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.",
+ "title": "Another team \"{{team}}\" is already running for this working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "createSchedule": "Schedule automatic Claude task execution",
+ "createScheduleForTeam": "Schedule automatic runs for team \"{{team}}\"",
+ "editSchedule": "Editing schedule for team \"{{team}}\"",
+ "launchPrefix": "Start team",
+ "launchSuffix": "via local Claude CLI.",
+ "relaunchPrefix": "Stop the current run for",
+ "relaunchSuffix": "and start it again via local Claude CLI."
+ },
+ "prepare": {
+ "action": {
+ "launch": "launch",
+ "relaunch": "relaunch"
+ },
+ "blocked": "Runtime environment is not available - {{action}} is blocked",
+ "checkingProviders": "Checking selected providers...",
+ "failed": "Failed to prepare selected providers",
+ "preflight": "Pre-flight check to catch errors before {{action}}",
+ "preparingEnvironment": "Preparing environment...",
+ "ready": "All selected providers are ready.",
+ "readyWithNotes": "All selected providers are ready, with notes.",
+ "unsupportedPreload": "Current preload version does not support team:prepareProvisioning. Restart the dev app.",
+ "selectWorkingDirectory": "Select a working directory to validate the launch environment.",
+ "someProvidersNeedAttention": "Some selected providers need attention."
+ },
+ "prompt": {
+ "label": "Prompt",
+ "oneShotPrefix": "This prompt will be passed to",
+ "oneShotSuffix": "for one-shot execution",
+ "saved": "Saved",
+ "schedulePlaceholder": "Instructions for Claude to execute on schedule...",
+ "teamLeadOptional": "Prompt for team lead (optional)",
+ "teamLeadPlaceholder": "Instructions for team lead..."
+ },
+ "providerChanged": "Provider changed from {{from}} to {{to}}. The previous lead session will not be resumed, and the lead will start with fresh context so the new runtime is applied correctly.",
+ "relaunchFreshSession": "Team relaunch starts a fresh lead session. Durable team state, task board, and member configuration are rehydrated into the launch prompt.",
+ "relaunchWarning": {
+ "description": "Saving these settings will stop the current team process, persist the updated roster, and launch the team again with the new runtime.",
+ "title": "Relaunch will restart the current team run"
+ },
+ "schedule": {
+ "labelOptional": "Label (optional)",
+ "labelPlaceholder": "e.g., Daily code review, Nightly tests...",
+ "maxBudgetUsd": "Max budget (USD)",
+ "maxTurns": "Max turns",
+ "noLimit": "No limit",
+ "noMatches": "No teams match your search.",
+ "noTeams": "No teams available. Create a team first.",
+ "searchTeams": "Search teams...",
+ "selectTeam": "Select a team...",
+ "team": "Team",
+ "title": "Schedule"
+ },
+ "title": {
+ "createSchedule": "Create Schedule",
+ "editSchedule": "Edit Schedule",
+ "launch": "Launch Team",
+ "relaunch": "Relaunch Team"
+ },
+ "errors": {
+ "loadProjectsFailed": "Failed to load projects",
+ "saveScheduleFailed": "Failed to save schedule",
+ "relaunchFailed": "Failed to relaunch team",
+ "launchFailed": "Failed to launch team"
+ },
+ "validation": {
+ "openCodeLeadModelRequired": "OpenCode lead requires a selected model.",
+ "openCodeTeammateRequired": "OpenCode lead requires at least one OpenCode teammate.",
+ "selectWorkingDirectory": "Select working directory (cwd)",
+ "fixMemberNames": "Fix member names before launch",
+ "memberNamesUnique": "Member names must be unique before launch"
+ },
+ "optionalSettings": {
+ "relaunchTitle": "Relaunch settings",
+ "title": "Optional launch settings",
+ "relaunchDescription": "Review the roster and lead runtime before restarting the team.",
+ "description": "Keep the launch flow focused on the project path and only expand this when you want extra control."
+ }
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Copy team",
+ "createTeam": "Create Team",
+ "deleteForever": "Delete forever",
+ "deletePermanently": "Delete permanently",
+ "deleteTeam": "Delete team",
+ "launching": "Launching...",
+ "launchTeam": "Launch team",
+ "relaunchTeam": "Relaunch team",
+ "restore": "Restore",
+ "restoreTeam": "Restore team",
+ "retry": "Retry",
+ "stopTeam": "Stop team",
+ "stopping": "Stopping..."
+ },
+ "electronOnly": {
+ "description": "In browser mode, access to local `~/.claude/teams` directories is not available.",
+ "title": "Teams is only available in Electron mode"
+ },
+ "empty": {
+ "description": "Create a team here to get started. It will show up in the list automatically.",
+ "localOnly": "Team creation is only available in local Electron mode.",
+ "title": "No teams found"
+ },
+ "filter": {
+ "clearAll": "Clear all",
+ "label": "Filter teams",
+ "projectPriority": "Project priority",
+ "status": "Status"
+ },
+ "loadFailed": "Failed to load teams",
+ "loading": "Loading teams...",
+ "localOnly": "Only available in local Electron mode.",
+ "membersCount": "Members: {{count}}",
+ "membersCount_few": "Members: {{count}}",
+ "membersCount_many": "Members: {{count}}",
+ "membersCount_one": "Member: {{count}}",
+ "membersCount_other": "Members: {{count}}",
+ "noDescription": "No description",
+ "noMatches": "No teams matching current filters",
+ "partial": {
+ "pending": "Last launch is still reconciling.",
+ "skipped": "Last launch has skipped teammates.",
+ "skippedWithCount": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Last launch skipped {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Last launch skipped {{count}}/{{expected}} teammates.",
+ "stopped": "Last launch stopped before all teammates joined.",
+ "stoppedWithCount": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_few": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_many": "Last launch stopped before {{count}}/{{expected}} teammates joined.",
+ "stoppedWithCount_one": "Last launch stopped before {{count}}/{{expected}} teammate joined.",
+ "stoppedWithCount_other": "Last launch stopped before {{count}}/{{expected}} teammates joined."
+ },
+ "searchPlaceholder": "Search teams...",
+ "sections": {
+ "otherTeams": "Other teams",
+ "projectTeams": "Teams for {{project}}",
+ "selectedProject": "selected project"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Active",
+ "deleted": "Deleted",
+ "launching": "Launching...",
+ "offline": "Offline",
+ "partialFailure": "Launch failed partway",
+ "partialPending": "Bootstrap pending",
+ "partialSkipped": "Launch skipped member",
+ "running": "Running"
+ },
+ "title": "Select Team",
+ "trash": "Trash ({{count}})",
+ "trash_few": "Trash ({{count}})",
+ "trash_many": "Trash ({{count}})",
+ "trash_one": "Trash ({{count}})",
+ "trash_other": "Trash ({{count}})",
+ "deleteDraft": {
+ "title": "Delete draft",
+ "message": "Delete draft team \"{{teamName}}\"? This cannot be undone.",
+ "confirmLabel": "Delete",
+ "cancelLabel": "Cancel"
+ },
+ "moveToTrash": {
+ "title": "Move to trash",
+ "message": "Move team \"{{teamName}}\" to trash? You can restore it later.",
+ "confirmLabel": "Move to trash",
+ "cancelLabel": "Cancel"
+ },
+ "deleteForever": {
+ "title": "Delete permanently",
+ "message": "Delete team \"{{teamName}}\" permanently? All data will be lost.",
+ "confirmLabel": "Delete forever",
+ "cancelLabel": "Cancel"
+ }
+ },
+ "messageComposer": {
+ "crossTeam": {
+ "hint": "Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message."
+ },
+ "attachments": {
+ "attachFiles": "Attach files (paste or drag & drop)",
+ "unavailable": "Attachments are unavailable",
+ "disabledHint": "File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.",
+ "restrictions": {
+ "crossTeam": "File attachments are not supported for cross-team messages",
+ "teamOffline": "Team must be online to attach files",
+ "unsupportedRecipient": "Files can be sent to the team lead or OpenCode teammates",
+ "openCodeOffline": "Team must be online to attach files for OpenCode teammates",
+ "sending": "Wait for current message to finish sending before adding files",
+ "maximumReached": "Maximum attachments reached",
+ "leadOnly": "Files can only be sent to the team lead"
+ }
+ },
+ "slash": {
+ "restrictions": {
+ "attachments": "Slash commands require a live team lead and cannot be sent with attachments",
+ "crossTeam": "Slash commands can only be run on the current team lead",
+ "notLead": "Slash commands can only be sent to the team lead",
+ "leadOffline": "Slash commands require the team lead to be online"
+ }
+ },
+ "status": {
+ "reusedCrossTeamRequest": "Reused recent cross-team request",
+ "teamOffline": "Team offline"
+ },
+ "input": {
+ "charsLeft": "{{count}} chars left",
+ "charsLeft_one": "{{count}} char left",
+ "charsLeft_other": "{{count}} chars left",
+ "teamLaunchingPlaceholder": "Team is launching... message will be queued for inbox delivery.",
+ "crossTeamPlaceholder": "Cross-team message to {{team}}...",
+ "teamFallback": "team",
+ "placeholder": "Write a message... (Enter to send, Shift+Enter for new line)",
+ "slashTip": "Tip: You can use \"/\" to run any Claude commands.",
+ "charsLeft_few": "{{count}} chars left",
+ "charsLeft_many": "{{count}} chars left"
+ },
+ "teamSelector": {
+ "thisTeam": "This team",
+ "current": "current",
+ "online": "online",
+ "offline": "offline",
+ "onlineTitle": "Online",
+ "offlineTitle": "Offline"
+ },
+ "recipient": {
+ "select": "Select...",
+ "searchPlaceholder": "Search...",
+ "noResults": "No results"
+ },
+ "actions": {
+ "voiceToText": "Voice to text",
+ "send": "Send",
+ "sendingUnavailableLaunching": "Sending unavailable while team is launching"
+ }
+ },
+ "claudeLogs": {
+ "filter": {
+ "ariaLabel": "Filter Claude logs",
+ "tooltip": "Filter logs",
+ "sections": {
+ "stream": "Stream",
+ "content": "Content"
+ },
+ "kinds": {
+ "output": "Output",
+ "thinking": "Thinking",
+ "tool": "Tool calls"
+ },
+ "actions": {
+ "reset": "Reset",
+ "save": "Save"
+ },
+ "streams": {
+ "stdout": "stdout",
+ "stderr": "stderr"
+ }
+ },
+ "rawLineCount": "{{formattedCount}} raw lines",
+ "rawLineCount_one": "{{formattedCount}} raw line",
+ "rawLinesCaptured": "{{count}} captured",
+ "emptyRawLogs": "{{count}}; none are assistant/tool output yet.",
+ "noLogsYet": "No logs yet.",
+ "teamNotRunning": "Team is not running.",
+ "searchPlaceholder": "Search logs...",
+ "clearSearch": "Clear search",
+ "newCount": "+{{count}} new",
+ "loading": "Loading...",
+ "showMore": "Show more",
+ "noLogsCaptured": "No logs captured.",
+ "noMatchingLogs": "No matching logs.",
+ "rawLineCount_few": "{{formattedCount}} raw lines",
+ "rawLineCount_many": "{{formattedCount}} raw lines",
+ "rawLineCount_other": "{{formattedCount}} raw lines",
+ "openFullscreen": "Open fullscreen logs",
+ "fullscreen": "Fullscreen",
+ "viewingFullscreen": "Viewing in fullscreen mode",
+ "logsTitle": "Logs"
+ },
+ "agentGraph": {
+ "popover": {
+ "externalTeam": "External team",
+ "process": {
+ "startedBy": "Started by:",
+ "at": "At:",
+ "openUrl": "Open URL"
+ },
+ "overflow": {
+ "hiddenTasks": "Hidden tasks",
+ "empty": "No hidden tasks available."
+ },
+ "member": {
+ "lead": "Lead",
+ "workingOn": "working on",
+ "recentTools": "Recent tools",
+ "spawn": {
+ "waitingToStart": "waiting to start",
+ "starting": "starting",
+ "failed": "failed"
+ },
+ "state": {
+ "active": "active",
+ "idle": "idle",
+ "offline": "offline",
+ "runningTool": "running tool"
+ },
+ "activeTool": {
+ "running": "Running tool",
+ "failed": "Tool failed",
+ "finished": "Tool finished"
+ },
+ "actions": {
+ "message": "Message",
+ "profile": "Profile",
+ "task": "Task"
+ }
+ }
+ },
+ "logPreview": {
+ "logs": "Logs",
+ "loading": "Loading logs",
+ "more": "+{{count}} more",
+ "more_one": "+{{count}} more",
+ "more_other": "+{{count}} more",
+ "more_few": "+{{count}} more",
+ "more_many": "+{{count}} more"
+ },
+ "blockingEdge": {
+ "title": "Blocking Dependency",
+ "blocks": "blocks",
+ "close": "Close",
+ "blockingHiddenTasks": "Blocking hidden tasks",
+ "blockedHiddenTasks": "Blocked hidden tasks"
+ },
+ "activityHud": {
+ "activity": "Activity",
+ "noRecentActivity": "No recent activity",
+ "more": "+{{count}} more",
+ "more_one": "+{{count}} more",
+ "more_other": "+{{count}} more",
+ "more_few": "+{{count}} more",
+ "more_many": "+{{count}} more"
+ },
+ "provisioning": {
+ "launchDetails": "Launch details",
+ "launchDetailsDescription": "Detailed team launch progress, live output and CLI logs."
+ }
+ },
+ "projectPath": {
+ "label": "Project",
+ "source": {
+ "claude": "Found by Claude",
+ "codex": "Found by Codex",
+ "mixed": "Found by Claude and Codex"
+ },
+ "deleted": {
+ "title": "Project folder no longer exists",
+ "label": "Deleted"
+ },
+ "mode": {
+ "projectList": "From project list",
+ "customPath": "Custom path"
+ },
+ "loadingProjects": "Loading projects...",
+ "selectProject": "Select a project...",
+ "searchPlaceholder": "Search project by name or path",
+ "empty": "Nothing found",
+ "selectFromList": "Select a project from the list",
+ "noProjects": "No projects found, switch to custom path.",
+ "customWorkingDirectory": "Custom working directory",
+ "browse": "Browse",
+ "createAutomatically": "If the directory does not exist, it will be created automatically."
+ },
+ "members": {
+ "badges": {
+ "worktree": "worktree"
+ },
+ "runtimeTelemetry": {
+ "title": "Local runtime load",
+ "description": "Parent and child processes only. Remote LLM inference is not included.",
+ "cpu": "CPU",
+ "memory": "Memory",
+ "summedRss": "summed RSS",
+ "sharedHost": "Shared OpenCode host metric. It is not exclusive to this member.",
+ "processTreeCapped": "Process tree was capped for this sample.",
+ "rssHint": "RSS can include shared pages, so it is best read as a load signal, not exclusive memory."
+ },
+ "editor": {
+ "title": "Members",
+ "addMember": "Add member",
+ "editAsJson": "Edit as JSON",
+ "runInSeparateWorktrees": "Run teammates in separate worktrees",
+ "agentTeamsMcpOnly": "Agent Teams MCP only",
+ "removedCount": "Removed ({{count}})",
+ "removedModelLockReason": "Removed members are kept for soft delete history. Restore them to edit settings.",
+ "memberNamesUnique": "Member names must be unique"
+ },
+ "stats": {
+ "computing": "Computing stats...",
+ "empty": "No stats available",
+ "lines": "Lines",
+ "linesInfo": "Approximate. Accurate for Edit and Write tools. Bash file writes are estimated from command patterns (heredoc, echo, sed) and may be underreported.",
+ "files": "Files",
+ "toolCalls": "Tool Calls",
+ "tokens": "Tokens",
+ "toolUsage": "Tool Usage",
+ "filesTouched": "Files Touched ({{count}})",
+ "viewAllChanges": "View All Changes",
+ "showLess": "Show less",
+ "moreFiles": "+{{count}} more",
+ "footer": "{{count}} sessions · computed {{computedAgo}}",
+ "footer_one": "{{count}} session · computed {{computedAgo}}",
+ "footer_few": "{{count}} sessions · computed {{computedAgo}}",
+ "footer_many": "{{count}} sessions · computed {{computedAgo}}",
+ "footer_other": "{{count}} sessions · computed {{computedAgo}}"
+ },
+ "logs": {
+ "searching": "Searching logs...",
+ "empty": "No logs found",
+ "waitingForTaskActivity": "Task is in progress - waiting for session activity (auto-refreshing)...",
+ "noTaskActivity": "No session activity for this task yet",
+ "noMemberActivity": "This member has no recorded session activity yet",
+ "leadSessionTooltip": "Full team lead session logs - useful for global orchestration context, not specific to this agent",
+ "memberSessionTooltip": "Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file",
+ "startedAt": "started {{time}}",
+ "active": "active",
+ "showDetails": "Show details",
+ "hideDetails": "Hide details",
+ "loadingDetails": "Loading details...",
+ "failedToLoadDetails": "Failed to load details"
+ },
+ "detail": {
+ "relaunchOpenCode": "Relaunch OpenCode",
+ "restart": "Restart",
+ "legacyLogsFallback": "Legacy Logs Fallback",
+ "copyDiagnostics": "Copy diagnostics",
+ "pid": "PID {{pid}}",
+ "removedAt": "Removed {{date}}",
+ "failedToRestartMember": "Failed to restart member",
+ "sendMessage": "Send Message",
+ "assignTask": "Assign Task",
+ "remove": "Remove"
+ },
+ "list": {
+ "loading": "Loading team members",
+ "unavailable": "Member roster unavailable",
+ "unavailableDescription": "{{count}} teammates are known from team metadata, but roster details are missing.",
+ "unavailableDescription_one": "{{count}} teammate is known from team metadata, but roster details are missing.",
+ "soloLeadOnly": "Solo team - lead only",
+ "removedCount": "Removed ({{count}})",
+ "unavailableDescription_few": "{{count}} teammates are known from team metadata, but roster details are missing.",
+ "unavailableDescription_many": "{{count}} teammates are known from team metadata, but roster details are missing.",
+ "unavailableDescription_other": "{{count}} teammates are known from team metadata, but roster details are missing."
+ },
+ "executionLog": {
+ "empty": "Nothing to display",
+ "emptyUserMessage": "{{time}} - (empty)",
+ "agentInstructions": "Agent instructions",
+ "memberTurn": "{{member}} turn",
+ "agentTurn": "Agent turn",
+ "turn": "turn"
+ },
+ "recentMessages": {
+ "latest": "Latest messages",
+ "latestForMember": "Latest messages - {{member}}",
+ "loadMore": "Load more",
+ "expand": "Expand",
+ "collapse": "Collapse"
+ },
+ "leadModel": {
+ "defaultModel": "Default",
+ "providerModelAria": "{{provider}} provider, {{model}}",
+ "leadShort": "lead",
+ "teamLead": "Team Lead",
+ "syncWithTeammates": "Sync model with teammates",
+ "anthropicTeamWide": "Anthropic team-wide",
+ "runtimeInheritance": "Lead runtime applies to teammates unless they set their own provider or model.",
+ "anthropicContextLimit": "The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates."
+ },
+ "runtimeLogs": {
+ "autoRefresh": "Auto-refresh",
+ "wrapLines": "Wrap lines",
+ "loadingTail": "Loading process log tail...",
+ "empty": "No process log file captured for this member yet."
+ },
+ "tasks": {
+ "empty": "No tasks assigned to this member"
+ },
+ "messages": {
+ "loadOlder": "Load older messages",
+ "filters": {
+ "all": "All",
+ "messages": "Messages",
+ "comments": "Comments"
+ },
+ "empty": {
+ "loading": "Loading activity...",
+ "noComments": "No comments for this member",
+ "noLoadedMessages": "No loaded messages for this member yet",
+ "noMessages": "No messages with this member",
+ "noLoadedActivity": "No loaded activity for this member yet",
+ "noActivity": "No activity with this member"
+ }
+ },
+ "actions": {
+ "openProfile": "Open profile",
+ "editRole": "Edit role",
+ "sendMessage": "Send message",
+ "assignTask": "Assign task"
+ },
+ "roleSelect": {
+ "customRolePlaceholder": "Enter custom role..."
+ }
+ },
+ "schedule": {
+ "count": "{{count}} schedules",
+ "count_one": "{{count}} schedule",
+ "count_other": "{{count}} schedules",
+ "nextRun": "Next: {{next}}",
+ "actions": {
+ "runNow": "Run now",
+ "edit": "Edit",
+ "pause": "Pause",
+ "resume": "Resume",
+ "delete": "Delete",
+ "addSchedule": "Add Schedule"
+ },
+ "runHistory": {
+ "loading": "Loading run history...",
+ "empty": "No runs yet"
+ },
+ "count_few": "{{count}} schedules",
+ "count_many": "{{count}} schedules",
+ "runLog": {
+ "title": "Run Log",
+ "exitCode": "exit {{code}}",
+ "retryCount": "retry {{count}}/{{max}}",
+ "stillRunning": "Task is still running...",
+ "loadingLogs": "Loading logs...",
+ "errors": "Errors",
+ "close": "Close"
+ },
+ "cron": {
+ "expression": "Cron expression",
+ "highFrequencyWarning": "High frequency schedule (less than 5 min interval)",
+ "nextRuns": "Next runs:",
+ "timezone": "Timezone",
+ "selectTimezone": "Select timezone",
+ "warmUpTime": "Warm-up time",
+ "warmUpDescription": "Prepares selected providers before scheduled execution",
+ "errors": {
+ "enterExpression": "Enter a cron expression",
+ "invalidExpression": "Invalid cron expression"
+ },
+ "presets": {
+ "everyHour": "Every hour",
+ "everySixHours": "Every 6 hours",
+ "dailyAtNine": "Daily at 9am",
+ "weekdaysAtNine": "Weekdays at 9am",
+ "mondayAtNine": "Monday at 9am",
+ "everyThirtyMinutes": "Every 30 min"
+ },
+ "warmUpOptions": {
+ "none": "No warm-up",
+ "fiveMinutes": "5 min",
+ "tenMinutes": "10 min",
+ "fifteenMinutes": "15 min",
+ "thirtyMinutes": "30 min"
+ }
+ },
+ "empty": {
+ "title": "No schedules yet",
+ "description": "Create a schedule to run Claude tasks automatically on a cron schedule."
+ },
+ "title": "Schedules",
+ "status": {
+ "active": "Active",
+ "paused": "Paused",
+ "disabled": "Disabled"
+ },
+ "runStatus": {
+ "pending": "Pending",
+ "warmingUp": "Warming up",
+ "warm": "Warm",
+ "running": "Running",
+ "completed": "Completed",
+ "failed": "Failed",
+ "interrupted": "Interrupted",
+ "cancelled": "Cancelled"
+ }
+ },
+ "openCodeContextConfigHint": {
+ "summary": "OpenCode local models can use an OpenCode context budget instead of prompt-only limits.",
+ "description": "Add matching limits to the OpenCode config for the provider and model used by this teammate. This helps OpenCode compact and prune before local models overflow their context window.",
+ "replacePrefix": "Replace",
+ "and": "and",
+ "replaceSuffix": "with the provider and model IDs from your OpenCode setup. Prompt instructions like",
+ "promptInstructionsSuffix": "are weaker because the request is assembled before the model reads them.",
+ "providerLimits": "Provider limits",
+ "compactionConfig": "Compaction config"
+ },
+ "sessions": {
+ "noProjectPath": "No project path linked",
+ "provisioningHint": "Sessions will appear after team provisioning",
+ "projectNotFound": "Project not found",
+ "loading": "Loading sessions...",
+ "empty": "No sessions found",
+ "showAllSessions": "Show for all sessions",
+ "lead": "lead",
+ "removeFilter": "Remove filter",
+ "filterBySession": "Filter by this session",
+ "openSession": "Open session",
+ "title": "Sessions"
+ },
+ "provisioning": {
+ "pid": "PID {{pid}}",
+ "cancel": "Cancel",
+ "moreWarningsHidden": "{{count}} more warnings hidden",
+ "diagnostics": "Diagnostics",
+ "liveOutput": "Live output",
+ "diagnosticsCopied": "Diagnostics copied",
+ "copyDiagnostics": "Copy diagnostics",
+ "copied": "Copied",
+ "noOutput": "No output captured yet.",
+ "cliLogs": "CLI logs",
+ "steps": {
+ "starting": "Starting",
+ "configuring": "Team setup",
+ "assembling": "Members joining",
+ "finalizing": "Finalizing"
+ },
+ "providerStatus": {
+ "status": {
+ "checking": "checking...",
+ "ready": "OK",
+ "notes": "OK (notes)",
+ "failed": "ERR",
+ "pending": "waiting"
+ },
+ "detailSummary": {
+ "cliBinaryMissing": "CLI binary missing",
+ "openCodeRuntimeMissing": "OpenCode runtime missing",
+ "openCodeWindowsAccessBlocked": "OpenCode Windows access blocked",
+ "openCodeNoOutput": "OpenCode runtime check returned no output",
+ "openCodeMcpUnreachable": "OpenCode app MCP unreachable",
+ "workingDirectoryMissing": "Working directory missing",
+ "cliBinaryCouldNotStart": "CLI binary could not be started",
+ "cliPreflightIncomplete": "CLI preflight did not complete",
+ "authenticationRequired": "Authentication required",
+ "runtimeProviderNotConfigured": "Runtime provider is not configured",
+ "cliPreflightFailed": "CLI preflight failed",
+ "selectedModelCompatible": "Selected model compatible",
+ "selectedModelCompatibilityPending": "Selected model compatibility pending",
+ "selectedModelAvailable": "Selected model available",
+ "selectedModelVerified": "Selected model verified",
+ "selectedModelUnavailable": "Selected model unavailable",
+ "selectedModelTimedOut": "Selected model verification timed out",
+ "selectedModelCheckFailed": "Selected model check failed",
+ "selectedModelDeferred": "Selected model verification deferred",
+ "selectedModelPingNotConfirmed": "Selected model ping not confirmed",
+ "readyWithNotes": "Ready with notes",
+ "needsAttention": "Needs attention"
+ },
+ "modelChecksSummary": "Selected model checks - {{details}}",
+ "modelParts": {
+ "unavailable": "{{count}} model unavailable",
+ "unavailable_one": "{{count}} model unavailable",
+ "unavailable_other": "{{count}} models unavailable",
+ "checkFailed": "{{count}} model check failed",
+ "checkFailed_one": "{{count}} model check failed",
+ "checkFailed_other": "{{count}} models check failed",
+ "timedOut": "{{count}} model timed out",
+ "timedOut_one": "{{count}} model timed out",
+ "timedOut_other": "{{count}} models timed out",
+ "deferred": "{{count}} verification deferred",
+ "deferred_one": "{{count}} verification deferred",
+ "deferred_other": "{{count}} verification deferred",
+ "pingNotConfirmed": "{{count}} ping not confirmed",
+ "pingNotConfirmed_one": "{{count}} ping not confirmed",
+ "pingNotConfirmed_other": "{{count}} ping not confirmed",
+ "compatibilityPending": "{{count}} compatible, deep verification pending",
+ "compatibilityPending_one": "{{count}} compatible, deep verification pending",
+ "compatibilityPending_other": "{{count}} compatible, deep verification pending",
+ "compatible": "{{count}} compatible",
+ "compatible_one": "{{count}} compatible",
+ "compatible_other": "{{count}} compatible",
+ "checking": "{{count}} checking",
+ "checking_one": "{{count}} checking",
+ "checking_other": "{{count}} checking",
+ "available": "{{count}} available",
+ "available_one": "{{count}} available",
+ "available_other": "{{count}} available",
+ "verified": "{{count}} verified",
+ "verified_one": "{{count}} verified",
+ "verified_other": "{{count}} verified",
+ "unavailable_few": "{{count}} models unavailable",
+ "unavailable_many": "{{count}} models unavailable",
+ "checkFailed_few": "{{count}} models check failed",
+ "checkFailed_many": "{{count}} models check failed",
+ "timedOut_few": "{{count}} models timed out",
+ "timedOut_many": "{{count}} models timed out",
+ "deferred_few": "{{count}} verification deferred",
+ "deferred_many": "{{count}} verification deferred",
+ "pingNotConfirmed_few": "{{count}} ping not confirmed",
+ "pingNotConfirmed_many": "{{count}} ping not confirmed",
+ "compatibilityPending_few": "{{count}} compatible, deep verification pending",
+ "compatibilityPending_many": "{{count}} compatible, deep verification pending",
+ "compatible_few": "{{count}} compatible",
+ "compatible_many": "{{count}} compatible",
+ "checking_few": "{{count}} checking",
+ "checking_many": "{{count}} checking",
+ "available_few": "{{count}} available",
+ "available_many": "{{count}} available",
+ "verified_few": "{{count}} verified",
+ "verified_many": "{{count}} verified"
+ },
+ "openProviderSettings": "Open {{provider}} settings",
+ "copied": "Copied",
+ "copyDiagnostics": "Copy diagnostics",
+ "deepVerificationPending": "Deep verification is still running. OpenCode free models may take around 20 seconds.",
+ "progress": {
+ "checkingSelectedProviders": "Checking selected providers in parallel...",
+ "checkingProvider": "Checking {{provider}} provider...",
+ "checkingProviders": "Checking {{providers}} providers..."
+ },
+ "failureHints": {
+ "openCodeAccessDenied": "Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.",
+ "openCodeBridgeNoOutput": "Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.",
+ "workingDirectoryMissing": "Choose an existing working directory, then reopen this dialog.",
+ "authenticationRequired": "Authenticate the required provider in Claude CLI, then reopen this dialog.",
+ "runtimeProviderNotConfigured": "Configure the selected provider runtime, then reopen this dialog.",
+ "openCodeRuntimeMissing": "Install or retry OpenCode runtime from the provider status card, then reopen this dialog.",
+ "openCodeAppMcpUnreachable": "Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.",
+ "cliBinaryMissing": "Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.",
+ "default": "Resolve the issue above, then reopen this dialog."
+ }
+ },
+ "presentation": {
+ "awaitingPermission": "{{count}} teammate awaiting permission approval",
+ "nameListWithMore": "{{names}}, +{{count}} more",
+ "waitingForOpenCode": "Waiting for OpenCode: {{names}}",
+ "bootstrapStalled": "Bootstrap stalled: {{names}}",
+ "bootstrapStalledWithOpenCodeWait": "{{stalled}}; Waiting for OpenCode: {{names}}",
+ "namedPendingDiagnostic": "{{label}}: {{names}}",
+ "countPendingDiagnostic": "{{count}} {{label}}",
+ "pendingLabels": {
+ "bootstrapStalled": "Bootstrap stalled",
+ "shellOnly": "Shell-only",
+ "waitingForBootstrap": "Waiting for bootstrap",
+ "bootstrapUnconfirmed": "Bootstrap unconfirmed",
+ "awaitingPermission": "Awaiting permission",
+ "waitingForRuntime": "Waiting for runtime",
+ "shellOnlyLower": "shell-only",
+ "waitingForBootstrapLower": "waiting for bootstrap",
+ "bootstrapUnconfirmedLower": "bootstrap unconfirmed",
+ "awaitingPermissionLower": "awaiting permission",
+ "waitingForRuntimeLower": "waiting for runtime"
+ },
+ "failed": {
+ "memberFailedToStart": "{{name}} failed to start",
+ "teammatesFailedToStart": "{{count}} teammates failed to start",
+ "teammatesFailedRatio": "{{count}}/{{total}} teammates failed to start"
+ },
+ "skipped": {
+ "memberSkipped": "{{name}} skipped for this launch",
+ "memberSkippedWithReason": "{{name}} skipped for this launch - {{reason}}",
+ "memberSkippedCompact": "{{name}} skipped",
+ "teammatesSkipped": "{{count}} teammates skipped",
+ "teammatesSkippedList": "Skipped teammates: {{list}}",
+ "teammatesSkippedRatio": "{{count}}/{{total}} teammates skipped for this launch"
+ },
+ "joining": {
+ "teammatesStillJoining": "{{count}} teammates still joining",
+ "teammatesConfirmedRatio": "{{count}}/{{total}} teammates confirmed"
+ },
+ "ready": {
+ "leadOnline": "Lead online",
+ "allTeammatesJoined": "All {{count}} teammates joined",
+ "teamProvisionedLeadOnline": "Team provisioned - lead online",
+ "teamProvisionedAllJoined": "Team provisioned - all {{count}} teammates joined",
+ "teamProvisionedStillJoining": "Team provisioned - teammates are still joining",
+ "launchFinishedWithErrors": "Launch finished with errors - {{count}}/{{total}} teammates failed to start",
+ "launchContinuedSkipped": "Launch continued - {{count}}/{{total}} teammates skipped",
+ "teamLaunchedLeadOnline": "Team launched - lead online",
+ "teamLaunchedAllJoined": "Team launched - all {{count}} teammates joined"
+ },
+ "panel": {
+ "launchFailed": "Launch failed",
+ "launchDetails": "Launch details",
+ "launchFinishedWithErrors": "Launch finished with errors",
+ "launchContinuedSkipped": "Launch continued with skipped teammates",
+ "coreTeamReady": "Core team ready",
+ "finishingLaunch": "Finishing launch",
+ "teamLaunched": "Team launched",
+ "launchingTeam": "Launching team"
+ }
+ }
+ },
+ "liveRuntimeStatus": {
+ "title": "Live runtime status",
+ "description": "Display-only heartbeat and launch state. Process controls remain below.",
+ "source": "source: {{source}}",
+ "lane": "{{lane}} lane",
+ "diagnosticOnly": "Diagnostic only",
+ "updated": "updated {{value}}",
+ "states": {
+ "running": "Running",
+ "starting": "Starting",
+ "waiting": "Waiting",
+ "degraded": "Needs attention",
+ "stopped": "Stopped",
+ "unknown": "Unknown"
+ }
+ },
+ "taskLogs": {
+ "exact": {
+ "title": "Exact Task Logs",
+ "loading": "Loading exact task logs...",
+ "description": "Exact transcript slices rendered with the same execution-log components used in Logs.",
+ "emptyTitle": "No exact task logs yet",
+ "emptyDescription": "Exact transcript bundles will appear here when explicit task-linked transcript metadata is available.",
+ "summaryOnly": "summary only"
+ },
+ "executionSessions": {
+ "title": "Execution Sessions",
+ "online": "Online",
+ "updating": "Updating...",
+ "description": "Legacy session-centric transcript browsing and previews."
+ },
+ "stream": {
+ "title": "Task Log Stream"
+ }
+ },
+ "kanban": {
+ "taskCard": {
+ "cancelTask": "Cancel task {{taskId}}",
+ "cancel": "Cancel",
+ "moveBackToTodoConfirm": "Move this task back to TODO and notify the team?",
+ "confirm": "Confirm",
+ "keep": "Keep",
+ "changesNeedAttention": "Changes need attention",
+ "changes": "Changes",
+ "deleteTask": "Delete task",
+ "taskLogsActive": "Task logs active",
+ "newTaskLogsArriving": "New task logs arriving",
+ "awaitingUser": "Awaiting user",
+ "awaitingLead": "Awaiting lead",
+ "blockedBy": "Blocked by",
+ "blocks": "Blocks",
+ "start": "Start",
+ "complete": "Complete",
+ "approve": "Approve",
+ "requestReview": "Request review",
+ "manualReview": "Manual review",
+ "requestChanges": "Request changes"
+ },
+ "filter": {
+ "title": "Filter tasks",
+ "session": "Session",
+ "allSessions": "All sessions",
+ "teammate": "Teammate",
+ "unassigned": "(unassigned)",
+ "column": "Column",
+ "clearAll": "Clear all"
+ },
+ "board": {
+ "addTask": "Add task",
+ "noTasks": "No tasks",
+ "showMore": "Show {{count}} more",
+ "hiddenCount": "{{count}} hidden",
+ "trash": "Trash",
+ "gridView": "Grid view",
+ "columnsView": "Columns view"
+ },
+ "trash": {
+ "title": "Trash",
+ "empty": "No deleted tasks",
+ "subject": "Subject",
+ "owner": "Owner",
+ "deleted": "Deleted",
+ "unassigned": "Unassigned",
+ "restoreTask": "Restore task",
+ "restore": "Restore",
+ "close": "Close"
+ },
+ "sort": {
+ "title": "Sort tasks",
+ "sortBy": "Sort by",
+ "reset": "Reset",
+ "options": {
+ "updatedAt": {
+ "label": "Last updated",
+ "description": "Recently updated first"
+ },
+ "createdAt": {
+ "label": "Created",
+ "description": "Newest first"
+ },
+ "owner": {
+ "label": "Owner",
+ "description": "Alphabetically by assignee"
+ },
+ "manual": {
+ "label": "Manual",
+ "description": "Drag-and-drop order"
+ }
+ }
+ },
+ "search": {
+ "clearSearch": "Clear search",
+ "tasks": "Tasks",
+ "createdAgo": "created {{time}}",
+ "updatedAgo": "updated {{time}}",
+ "placeholder": "Search tasks... (#id or text)"
+ },
+ "grid": {
+ "addTask": "Add task",
+ "noTasks": "No tasks"
+ },
+ "title": "Kanban",
+ "columns": {
+ "todo": "TODO",
+ "inProgress": "IN PROGRESS",
+ "review": "REVIEW",
+ "done": "DONE",
+ "approved": "APPROVED"
+ }
+ },
+ "worktreeGitReadiness": {
+ "checking": "Checking Git repository status for teammate worktrees...",
+ "ready": "Git worktrees are ready.",
+ "readyOnBranch": "Git worktrees are ready on branch {{branch}}.",
+ "needsSetup": "Worktree isolation needs Git setup",
+ "initialCommitNotice": "The initial commit action stages and commits all current files with message",
+ "initializeRepository": "Initialize Git repository",
+ "createInitialCommit": "Create initial commit",
+ "initialCommitMessage": "chore: initial commit"
+ },
+ "toolApproval": {
+ "settings": "Settings",
+ "autoAllowAllTools": "Auto-allow all tools",
+ "autoAllowFileEdits": "Auto-allow file edits (Edit, Write, NotebookEdit)",
+ "autoAllowSafeCommands": "Auto-allow safe commands (git, pnpm, npm, ls...)",
+ "onTimeout": "On timeout:",
+ "after": "after",
+ "secondsShort": "sec",
+ "timeoutActions": {
+ "wait": "Wait forever",
+ "allow": "Allow",
+ "deny": "Deny"
+ },
+ "submit": "Submit",
+ "allow": "Allow",
+ "deny": "Deny",
+ "allowAll": "Allow all",
+ "pendingCount": "{{count}} pending",
+ "autoActionIn": "Auto-{{action}} in {{time}}",
+ "diff": {
+ "previewChanges": "Preview changes",
+ "readingFile": "Reading file...",
+ "binaryFile": "Binary file - cannot preview",
+ "truncated": "File truncated at 2MB - diff may be incomplete",
+ "newFile": "New file"
+ }
+ },
+ "memberWorkSync": {
+ "details": {
+ "title": "Member work sync",
+ "actionableItems": "Actionable items",
+ "fingerprint": "Fingerprint",
+ "report": "Report",
+ "none": "none",
+ "shadowWouldNudge": "Shadow would nudge",
+ "yes": "yes",
+ "no": "no",
+ "moreActionableItems": "{{count}} more actionable item(s)",
+ "diagnostics": "Diagnostics: {{diagnostics}}"
+ },
+ "title": "Member work sync",
+ "loadingDiagnostics": "Loading member work sync diagnostics.",
+ "diagnosticsUnavailable": "Member work sync diagnostics are unavailable."
+ },
+ "advancedCli": {
+ "title": "Advanced",
+ "useWorktree": "Use worktree",
+ "recent": "Recent",
+ "commandPreview": "Command preview",
+ "customArguments": "Custom arguments",
+ "validate": "Validate",
+ "validation": {
+ "allFlagsValid": "All flags valid",
+ "unknownFlags": "Unknown: {{flags}}",
+ "protectedFlags": "Protected: {{flags}}",
+ "failed": "Validation failed"
+ },
+ "placeholders": {
+ "worktreeName": "worktree-name"
+ }
+ },
+ "processes": {
+ "ago": "{{time}} ago",
+ "stoppedAgo": "stopped {{time}} ago",
+ "running": "Running",
+ "stopped": "Stopped",
+ "stopProcess": "Stop process (SIGTERM)",
+ "kill": "Kill",
+ "openInBrowser": "Open in browser",
+ "open": "Open",
+ "pid": "PID{{pid}}",
+ "title": "CLI Processes"
+ },
+ "taskActivity": {
+ "loadingDetails": "Loading activity details...",
+ "contextUnavailable": "Detailed transcript context is no longer available for this activity.",
+ "loading": "Loading task activity...",
+ "lowSignalOnly": "No key task activity was found yet. Low-level execution details are available below in Task Log Stream.",
+ "empty": "No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.",
+ "title": "Task Activity",
+ "description": "Key explicit runtime activity linked to this task from transcript metadata."
+ },
+ "sendMessage": {
+ "title": "Send Message",
+ "description": "Send a direct message to a team member.",
+ "recipientLabel": "Recipient",
+ "selectMemberPlaceholder": "Select member...",
+ "messageLabel": "Message",
+ "placeholder": "Write your message... (Enter to send)",
+ "send": "Send",
+ "sending": "Sending...",
+ "charsLeft": "{{count}} chars left",
+ "saved": "Saved",
+ "attachments": {
+ "teamOnlineRequired": "Team must be online to attach files",
+ "recipientUnsupported": "Files can be sent to the team lead or OpenCode teammates",
+ "openCodeOnlineRequired": "Team must be online to attach files for OpenCode teammates",
+ "disabledHint": "File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.",
+ "attachFiles": "Attach files (paste or drag & drop)",
+ "unavailable": "Attachments are unavailable"
+ },
+ "quote": {
+ "remove": "Remove quote",
+ "replyingTo": "Replying to"
+ }
+ },
+ "taskComments": {
+ "cancelReply": "Cancel reply",
+ "replyingTo": "Replying to",
+ "placeholder": "Add a comment... (Enter to send)",
+ "attachFile": "Attach file (or paste)",
+ "voiceToText": "Voice to text",
+ "comment": "Comment",
+ "charsLeft": "{{count}} chars left",
+ "saved": "Saved",
+ "awaitingReplyFrom": "Awaiting reply from",
+ "or": "or"
+ },
+ "taskAttachments": {
+ "dropImageHere": "Drop image here",
+ "attachImage": "Attach image",
+ "pasteOrDragDrop": "or paste / drag-drop",
+ "fromOriginalMessage": "From original message",
+ "dropFilesHere": "Drop files here",
+ "loading": "Loading attachments..."
+ },
+ "permissions": {
+ "autoApproveAllTools": "Auto-approve all tools",
+ "autonomousModeDescription": "Autonomous mode: team tools execute without confirmation. Be cautious with untrusted code.",
+ "manualModeDescription": "Manual mode: you'll approve or deny each tool call in real time."
+ },
+ "memberLogStream": {
+ "tabs": {
+ "execution": "Execution",
+ "process": "Process"
+ },
+ "filters": {
+ "all": "All"
+ },
+ "logs": {
+ "title": "Logs",
+ "loading": "Loading member log stream...",
+ "emptyTitle": "No log stream entries were found for this member yet.",
+ "emptyDescription": "Member-scoped transcript or runtime logs will appear here when available."
+ }
+ },
+ "reviewDialog": {
+ "placeholder": "Describe what needs to change... (Enter to submit)",
+ "submit": "Submit",
+ "charsLeft": "{{count}} chars left",
+ "saved": "Saved",
+ "title": "Request Changes"
+ },
+ "dialogs": {
+ "actions": {
+ "openDashboard": "Open Dashboard",
+ "openTeam": "Open team",
+ "cancel": "Cancel"
+ },
+ "membersJson": {
+ "hide": "Hide JSON"
+ },
+ "optional": {
+ "badge": "Optional"
+ }
+ },
+ "runningTeams": {
+ "title": "Running Teams"
+ },
+ "layout": {
+ "maxPanesReached": "Maximum of {{count}} panes reached"
+ },
+ "codexReconnect": {
+ "description": "Your Codex session appears stale. Reconnect to continue.",
+ "useCode": "Use code"
+ },
+ "effortLevel": {
+ "label": "Effort level (optional)",
+ "maxDescription": "Max gives the model the most reasoning time for difficult tasks."
+ },
+ "contextLimit": {
+ "limitTo200k": "Limit context to 200K tokens",
+ "always200k": "(always 200K for this model)",
+ "tooltipContent": "Keeps launches within a 200K-token context window when supported.",
+ "tooltipTitle": "Context limit"
+ },
+ "roleSelect": {
+ "noRole": "No role",
+ "customRole": "Custom role...",
+ "searchPlaceholder": "Search roles...",
+ "empty": "No roles found.",
+ "reservedRole": "This role is reserved"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/common.json b/src/features/localization/renderer/locales/ru/common.json
new file mode 100644
index 00000000..74068192
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/common.json
@@ -0,0 +1,900 @@
+{
+ "actions": {
+ "cancel": "Отмена",
+ "close": "Закрыть",
+ "copied": "Скопировано",
+ "copyUrl": "Копировать URL",
+ "open": "Открыть",
+ "reveal": "Показать",
+ "retry": "Повторить",
+ "save": "Сохранить",
+ "showLess": "Свернуть",
+ "showMore": "Показать больше",
+ "refresh": "Обновить",
+ "reset": "Сбросить",
+ "copyToClipboard": "Скопировать в буфер",
+ "moreActions": "Ещё действия",
+ "closeDialog": "Закрыть диалог",
+ "goToDashboard": "На дашборд",
+ "or": "или",
+ "hide": "Скрыть",
+ "resetSelection": "Сбросить выбор"
+ },
+ "code": {
+ "line": "строка {{line}}",
+ "lines": "строки {{from}}-{{to}}",
+ "moreLines": "({{count}} строк ещё...)",
+ "moreLines_few": "({{count}} строки ещё...)",
+ "moreLines_many": "({{count}} строк ещё...)",
+ "moreLines_one": "({{count}} строка ещё...)",
+ "moreLines_other": "({{count}} строки ещё...)",
+ "code": "Код",
+ "preview": "Предпросмотр",
+ "markdownPreview": "Предпросмотр Markdown",
+ "linesParenthesized": "(строки {{from}}-{{to}})",
+ "mermaidSyntaxError": "Синтаксическая ошибка Mermaid"
+ },
+ "contextBadge": {
+ "badge": "Контекст",
+ "breakdown": {
+ "text": "Текст",
+ "thinking": "Thinking"
+ },
+ "detailsAria": "Детали инъекции контекста",
+ "sectionSummary": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_few": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_many": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_one": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sectionSummary_other": "{{title}} ({{count}}) ~{{tokens}} токенов",
+ "sections": {
+ "claudeMdFiles": "Файлы CLAUDE.md",
+ "mentionedFiles": "Упомянутые файлы",
+ "taskCoordination": "Координация задач",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Выводы инструментов",
+ "userMessages": "Сообщения пользователя"
+ },
+ "title": "Новый контекст, добавленный в этом ходе",
+ "tokenCount": "~{{tokens}} токенов",
+ "totalNewTokens": "Всего новых токенов",
+ "turn": "Ход {{turn}}"
+ },
+ "locales": {
+ "emptyMessage": "Язык не найден.",
+ "names": {
+ "en": "Английский",
+ "ru": "Русский",
+ "system": "Системный"
+ },
+ "searchPlaceholder": "Поиск языка...",
+ "selectPlaceholder": "Выберите язык интерфейса...",
+ "systemWithResolved": "Системный - {{locale}}"
+ },
+ "members": {
+ "emptyMessage": "Участники не найдены.",
+ "searchPlaceholder": "Поиск участников...",
+ "unassigned": "Не назначено",
+ "teammateFallback": "участник"
+ },
+ "providerRuntime": {
+ "codex": {
+ "install": {
+ "checking": "Проверка",
+ "downloading": "Загрузка",
+ "installCli": "Установить Codex CLI",
+ "installing": "Установка",
+ "retryInstall": "Повторить установку"
+ }
+ }
+ },
+ "search": {
+ "noMatchingSuggestions": "Подходящих вариантов нет",
+ "searching": "Поиск...",
+ "searchingFiles": "Поиск файлов...",
+ "findInConversation": "Найти в разговоре...",
+ "resultCount": "{{current}} из {{total}}",
+ "resultCountCapped": "{{current}} из {{total}}+",
+ "noResults": "Нет результатов",
+ "previousResultShortcut": "Предыдущий результат (Shift+Enter)",
+ "nextResultShortcut": "Следующий результат (Enter)",
+ "closeShortcut": "Закрыть (Esc)",
+ "nothingFound": "Ничего не найдено",
+ "placeholder": "Поиск..."
+ },
+ "schedules": {
+ "actions": {
+ "addSchedule": "Добавить расписание",
+ "clearFilters": "Сбросить фильтры",
+ "createSchedule": "Создать расписание",
+ "delete": "Удалить",
+ "edit": "Редактировать",
+ "pause": "Приостановить",
+ "resume": "Возобновить",
+ "runNow": "Запустить сейчас"
+ },
+ "empty": {
+ "description": "Создайте расписание в любой команде, чтобы автоматизировать выполнение Claude-задач через cron expressions. Расписания всех команд появятся здесь.",
+ "noMatches": "Нет расписаний под текущие фильтры",
+ "title": "Запланированных задач нет"
+ },
+ "filters": {
+ "allTeams": "Все команды"
+ },
+ "item": {
+ "loadingRunHistory": "Загрузка истории запусков...",
+ "nextRun": "Следующий запуск: {{value}}",
+ "noRunsYet": "Запусков пока нет"
+ },
+ "loading": "Загрузка расписаний...",
+ "searchPlaceholder": "Поиск расписаний...",
+ "status": {
+ "active": "Активные",
+ "all": "Все",
+ "disabled": "Отключённые",
+ "paused": "На паузе"
+ },
+ "title": "Расписания"
+ },
+ "sessions": {
+ "actions": {
+ "hide": "Скрыть",
+ "pin": "Закрепить",
+ "unhide": "Показать"
+ },
+ "empty": {
+ "noMatchingSessions": "Подходящих сессий нет",
+ "noMatchingSessionsDescription": "В этом проекте пока нет подходящих сессий.",
+ "noMatchingSessionsFiltered": "Попробуйте другой запрос или сбросьте фильтр provider.",
+ "noSessions": "Сессии не найдены",
+ "noSessionsDescription": "В этом проекте пока нет сессий",
+ "selectProject": "Выберите проект, чтобы посмотреть сессии"
+ },
+ "errors": {
+ "loading": "Ошибка загрузки сессий"
+ },
+ "loadedMatchingMore": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_few": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_many": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_one": "Загружена подходящая сессия: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadedMatchingMore_other": "Загружено подходящих сессий: {{count}} - прокрутите вниз, чтобы загрузить ещё.",
+ "loadingMore": "Загрузка следующих сессий...",
+ "pinned": "Закреплённые",
+ "scrollToLoadMore": "Прокрутите, чтобы загрузить ещё",
+ "search": {
+ "clear": "Очистить поиск сессий",
+ "placeholder": "Поиск сессий..."
+ },
+ "selection": {
+ "cancel": "Отменить выделение",
+ "exitMode": "Выйти из режима выделения",
+ "hideSelected": "Скрыть выбранные сессии",
+ "pinSelected": "Закрепить выбранные сессии",
+ "selectSessions": "Выбрать сессии",
+ "selected": "Выбрано: {{count}}",
+ "selected_few": "Выбрано: {{count}}",
+ "selected_many": "Выбрано: {{count}}",
+ "selected_one": "Выбрана: {{count}}",
+ "selected_other": "Выбрано: {{count}}",
+ "unhideSelected": "Показать выбранные сессии"
+ },
+ "sort": {
+ "byContext": "По контексту",
+ "byContextTooltip": "Сортировать по потреблению контекста",
+ "byRecentTooltip": "Сортировать по недавним",
+ "contextLoadedOnly": "Сортировка по контексту ранжирует только загруженные сессии."
+ },
+ "title": "Сессии",
+ "visibility": {
+ "hideHidden": "Скрыть скрытые сессии",
+ "showHidden": "Показать скрытые сессии"
+ },
+ "worktree": {
+ "switch": "Переключить worktree"
+ },
+ "failedToLoad": "Не удалось загрузить сессию",
+ "loading": "Загрузка сессии...",
+ "filter": {
+ "title": "Фильтр сессий"
+ },
+ "count": "{{count}} сессий",
+ "count_one": "{{count}} сессия",
+ "count_few": "{{count}} сессии",
+ "count_many": "{{count}} сессий",
+ "count_other": "{{count}} сессий",
+ "inProgress": "Сессия выполняется..."
+ },
+ "states": {
+ "loading": "Загрузка...",
+ "offline": "Офлайн",
+ "online": "Онлайн",
+ "unknown": "Неизвестно",
+ "error": "Ошибка"
+ },
+ "markdown": {
+ "imageFallback": "[Изображение: {{label}}]",
+ "largeContentNotice": "Контент очень большой ({{count}} символов). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_few": "Контент очень большой ({{count}} символа). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_many": "Контент очень большой ({{count}} символов). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_one": "Контент очень большой ({{count}} символ). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentNotice_other": "Контент очень большой ({{count}} символа). Показываем raw preview, чтобы интерфейс не завис.",
+ "largeContentTitle": "Большой контент показан как raw, чтобы интерфейс не завис",
+ "raw": "Raw",
+ "rawPreview": "Raw preview",
+ "renderMarkdown": "Отрендерить Markdown",
+ "showAll": "Показать всё",
+ "showMore": "Показать ещё",
+ "showRaw": "Показать raw",
+ "showingChars": "Показано {{shown}} / {{total}} символов"
+ },
+ "terminal": {
+ "checkOutputForDetails": "Подробности смотрите в выводе терминала выше",
+ "closingInSeconds": "Закрытие через {{count}} с...",
+ "closingInSeconds_few": "Закрытие через {{count}} с...",
+ "closingInSeconds_many": "Закрытие через {{count}} с...",
+ "closingInSeconds_one": "Закрытие через {{count}} с...",
+ "closingInSeconds_other": "Закрытие через {{count}} с...",
+ "completedSuccessfully": "Успешно завершено",
+ "exitCode": "(код выхода {{code}})",
+ "processFailed": "Процесс завершился с ошибкой",
+ "title": "Терминал"
+ },
+ "tokens": {
+ "accumulatedWithoutDuplication": "Накоплено по всей сессии без дублирования",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "costUsd": "Стоимость (USD)",
+ "inputTokens": "Входные токены",
+ "model": "Модель",
+ "outputTokens": "Выходные токены",
+ "phase": "Фаза {{phase}}/{{total}}",
+ "promptInputShare": "{{percent}}% от prompt input",
+ "taskCoordination": "Координация задач",
+ "thinkingText": "Thinking + Text",
+ "toolOutputs": "Выводы инструментов",
+ "total": "Итого",
+ "userMessages": "Сообщения пользователя",
+ "visibleContext": "Видимый контекст",
+ "includesClaudeMd": "вкл. CLAUDE.md ×{{count}}",
+ "claudeMd": "CLAUDE.md",
+ "mentionedFiles": "@files",
+ "percentValue": "({{percent}}%)",
+ "approxTokens": "~{{tokens}} токенов",
+ "approxTokensParenthesized": "(~{{tokens}})"
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Скопировать команду",
+ "createTeam": "Создать команду",
+ "deleteForever": "Удалить навсегда",
+ "deletePermanently": "Удалить окончательно",
+ "deleteTeam": "Удалить команду",
+ "launching": "Запуск...",
+ "launchTeam": "Запустить команду",
+ "relaunchTeam": "Перезапустить команду",
+ "restore": "Восстановить",
+ "restoreTeam": "Восстановить команду",
+ "retry": "Повторить",
+ "stopTeam": "Остановить команду",
+ "stopping": "Остановка..."
+ },
+ "status": {
+ "active": "Активно",
+ "deleted": "Удалено",
+ "launching": "Запуск...",
+ "offline": "Offline",
+ "partialFailure": "Запуск частично не удался",
+ "partialPending": "Bootstrap ожидает",
+ "partialSkipped": "Запуск пропустил участника",
+ "running": "Работает"
+ },
+ "partial": {
+ "pending": "Последний запуск ещё сверяется.",
+ "skipped": "В последнем запуске были пропущены teammates.",
+ "skippedWithCount": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "stopped": "Последний запуск остановился до подключения всех teammates.",
+ "stoppedWithCount": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_few": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_many": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_one": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_other": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates."
+ },
+ "noDescription": "Нет описания",
+ "solo": "Solo",
+ "membersCount": "Участников: {{count}}",
+ "membersCount_few": "Участников: {{count}}",
+ "membersCount_many": "Участников: {{count}}",
+ "membersCount_one": "Участник: {{count}}",
+ "membersCount_other": "Участников: {{count}}",
+ "all": "Все",
+ "moreCount": "+{{count}} ещё",
+ "moreCount_one": "+{{count}} ещё",
+ "moreCount_few": "+{{count}} ещё",
+ "moreCount_many": "+{{count}} ещё",
+ "moreCount_other": "+{{count}} ещё"
+ },
+ "runtimeProvider": {
+ "defaults": {
+ "scopeDescriptionAllProjects": "Default для всех проектов, у которых нет собственного OpenCode override.",
+ "scopeDescriptionProject": "Override только для выбранного проекта. Уже запущенные команды не изменяются.",
+ "setAllProjectsDefault": "Задать default для всех проектов",
+ "setProjectDefault": "Задать default для проекта",
+ "validationContext": "Validation context",
+ "projectOverrideContext": "Project override context",
+ "selectProjectHint": "Выберите проект перед тестированием local models или сохранением defaults.",
+ "allProjectsHint": "Тесты используют {{project}}. Default применяется, если у проекта нет своего override.",
+ "projectHint": "Сохранение изменит override только для {{project}}."
+ }
+ },
+ "sessionContext": {
+ "header": {
+ "title": "Контекст",
+ "closePanel": "Закрыть панель",
+ "phase": "Фаза:",
+ "current": "Текущая",
+ "view": "Вид:",
+ "category": "Категории",
+ "bySize": "По размеру"
+ },
+ "metrics": {
+ "unavailable": "Недоступно",
+ "contextUsed": "Использовано контекста",
+ "promptInput": "Вход prompt",
+ "visibleContext": "Видимый контекст",
+ "ofContext": "от контекста",
+ "ofPrompt": "от prompt",
+ "codexTelemetryUnavailable": "Текущий runtime пока не передаёт prompt-side usage для Codex, поэтому Prompt Input и Context Used остаются недоступными вместо фейкового нуля.",
+ "sessionCost": "Стоимость сессии:",
+ "parentPlus": "parent +",
+ "subagents": "subagents",
+ "details": "подробности"
+ },
+ "help": {
+ "contextUsed": {
+ "title": "Использовано контекста",
+ "description": "Prompt input плюс output tokens, которые сейчас занимают context window модели."
+ },
+ "promptInput": {
+ "title": "Вход prompt",
+ "description": "Tokens, отправленные модели перед генерацией. Для Claude это включает `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`."
+ },
+ "visibleContext": {
+ "title": "Видимый контекст",
+ "description": "Инспектируемая часть prompt input: файлы, CLAUDE.md, tool outputs, сообщения пользователя и похожие injections, которые можно оптимизировать напрямую."
+ },
+ "availability": {
+ "title": "Доступность",
+ "description": "Если provider runtime пока не отдаёт prompt-side usage, панель показывает metrics как unavailable, а не притворяется, что это ноль."
+ }
+ },
+ "items": {
+ "turn": "@Ход {{turn}}",
+ "tokensApprox": "~{{tokens}} токенов",
+ "toolsCount": "{{count}} инструментов",
+ "toolsCount_one": "{{count}} инструмент",
+ "toolsCount_few": "{{count}} инструмента",
+ "toolsCount_many": "{{count}} инструментов",
+ "toolsCount_other": "{{count}} инструментов",
+ "itemsCount": "{{count}} элементов",
+ "itemsCount_one": "{{count}} элемент",
+ "itemsCount_few": "{{count}} элемента",
+ "itemsCount_many": "{{count}} элементов",
+ "itemsCount_other": "{{count}} элементов",
+ "missing": "нет файла",
+ "thinking": "Размышление",
+ "text": "Текст"
+ },
+ "empty": "В этой сессии контекстные вставки не обнаружены",
+ "view": {
+ "grouped": "Группировано",
+ "flat": "Плоско"
+ },
+ "claudeMdFiles": "Файлы CLAUDE.md",
+ "mentionedFiles": "Упомянутые файлы"
+ },
+ "chat": {
+ "subagent": {
+ "fallbackName": "Subagent",
+ "shutdownConfirmed": "Shutdown подтверждён",
+ "summary": {
+ "tools": "{{count}} tools",
+ "tools_one": "{{count}} tool",
+ "tools_few": "{{count}} tools",
+ "tools_many": "{{count}} tools",
+ "tools_other": "{{count}} tools"
+ },
+ "meta": {
+ "type": "Тип",
+ "duration": "Длительность",
+ "model": "Модель",
+ "id": "ID"
+ },
+ "metrics": {
+ "contextWindow": "Context Window",
+ "contextUsage": "Использование контекста",
+ "mainContext": "Основной контекст",
+ "totalOutput": "Общий output",
+ "turns": "({{count}} turns)",
+ "turns_one": "({{count}} turn)",
+ "turns_few": "({{count}} turns)",
+ "turns_many": "({{count}} turns)",
+ "turns_other": "({{count}} turns)",
+ "subagentContext": "Контекст subagent",
+ "phase": "Фаза {{phase}}"
+ },
+ "trace": {
+ "title": "Execution trace"
+ }
+ },
+ "user": {
+ "you": "Вы",
+ "showMore": "Показать больше",
+ "showLess": "Показать меньше",
+ "backgroundTask": "Фоновая задача",
+ "exitCode": "exit {{code}}",
+ "imagesAttached": "прикреплено изображений: {{count}}",
+ "imagesAttached_one": "прикреплено {{count}} изображение",
+ "imagesAttached_few": "прикреплено {{count}} изображения",
+ "imagesAttached_many": "прикреплено изображений: {{count}}",
+ "imagesAttached_other": "прикреплено {{count}} изображения"
+ },
+ "compact": {
+ "toggle": "Переключить сжатое содержимое",
+ "contextCompacted": "Контекст сжат",
+ "freedTokens": "(освобождено {{tokens}})",
+ "phase": "Фаза {{phase}}",
+ "conversationCompacted": "Диалог сжат",
+ "summary": "Предыдущие сообщения были суммаризированы для экономии контекста. Полная история диалога сохранена в файле сессии.",
+ "compacted": "Сжато"
+ },
+ "executionTrace": {
+ "empty": "Нет элементов выполнения",
+ "nested": "Вложенный: {{name}}",
+ "input": "Ввод"
+ },
+ "items": {
+ "empty": "Нет элементов для отображения"
+ },
+ "tools": {
+ "teammateSpawned": "Участник запущен",
+ "shutdownRequested": "Запрошено завершение ->",
+ "noResultReceived": "Результат не получен",
+ "duration": "Длительность: {{duration}}",
+ "result": "Результат",
+ "write": {
+ "createdFile": "Файл создан",
+ "wroteToFile": "Записано в файл"
+ },
+ "skill": {
+ "instructions": "Инструкции навыка",
+ "unknown": "Неизвестный навык"
+ }
+ },
+ "lastOutput": {
+ "requestInterrupted": "Запрос прерван пользователем",
+ "planReadyForApproval": "План готов к подтверждению"
+ },
+ "empty": {
+ "icon": "💬",
+ "title": "История разговора пуста",
+ "description": "В этой сессии пока нет сообщений."
+ },
+ "context": {
+ "remainingPercent": "(осталось {{percent}}%)",
+ "count": "Контекст ({{count}})",
+ "count_one": "Контекст ({{count}})",
+ "count_few": "Контекст ({{count}})",
+ "count_many": "Контекст ({{count}})",
+ "count_other": "Контекст ({{count}})"
+ },
+ "scrollToBottom": "Прокрутить вниз",
+ "bottom": "Вниз",
+ "teammateMessage": {
+ "message": "Сообщение",
+ "resent": "Отправлено повторно",
+ "fallback": "Сообщение участника"
+ },
+ "system": {
+ "label": "Система"
+ }
+ },
+ "tmuxInstaller": {
+ "summaryTitle": "tmux не установлен",
+ "detectedOs": "Обнаруженная ОС: {{os}}",
+ "runtimePath": "Путь runtime: {{path}}",
+ "phase": "Фаза: {{phase}}",
+ "actions": {
+ "cancel": "Отмена",
+ "manualGuide": "Manual guide",
+ "hideSetupSteps": "Скрыть шаги настройки",
+ "showSetupSteps": "Показать шаги настройки ({{count}})",
+ "showSetupSteps_one": "Показать шаг настройки ({{count}})",
+ "showSetupSteps_few": "Показать шаги настройки ({{count}})",
+ "showSetupSteps_many": "Показать шаги настройки ({{count}})",
+ "showSetupSteps_other": "Показать шаги настройки ({{count}})",
+ "recheck": "Проверить снова"
+ },
+ "installerProgress": "Прогресс установки",
+ "input": {
+ "placeholder": "Отправить input в installer",
+ "send": "Отправить input",
+ "passwordNotice": "Password input отправляется напрямую в terminal installer и не добавляется в log output."
+ },
+ "details": {
+ "show": "Показать details",
+ "hide": "Скрыть details"
+ }
+ },
+ "commandPalette": {
+ "noRecentActivity": "Нет недавней активности",
+ "sessionsCount": "{{count}} sessions",
+ "sessionsCount_one": "{{count}} session",
+ "sessionsCount_few": "{{count}} sessions",
+ "sessionsCount_many": "{{count}} sessions",
+ "sessionsCount_other": "{{count}} sessions",
+ "mode": {
+ "searchProjects": "Поиск проектов",
+ "searchAcrossProjects": "Поиск по всем проектам",
+ "searchInProject": "Поиск в проекте"
+ },
+ "currentProject": "Текущий проект",
+ "global": "Global",
+ "placeholders": {
+ "projects": "Поиск проектов...",
+ "conversations": "Поиск conversations..."
+ },
+ "empty": {
+ "noProjectsForQuery": "Проекты по запросу \"{{query}}\" не найдены",
+ "noProjects": "Проекты не найдены",
+ "minChars": "Введите минимум 2 символа для поиска",
+ "noFastResults": "В недавних sessions нет быстрых результатов по запросу \"{{query}}\"",
+ "noResults": "Результаты по запросу \"{{query}}\" не найдены"
+ },
+ "footer": {
+ "projectsCount": "{{count}} проектов",
+ "projectsCount_one": "{{count}} проект",
+ "projectsCount_few": "{{count}} проекта",
+ "projectsCount_many": "{{count}} проектов",
+ "projectsCount_other": "{{count}} проектов",
+ "results": "{{count}} {{speed}}результатов",
+ "results_one": "{{count}} {{speed}}результат",
+ "results_few": "{{count}} {{speed}}результата",
+ "results_many": "{{count}} {{speed}}результатов",
+ "results_other": "{{count}} {{speed}}результатов",
+ "resultsAcrossProjects": "{{count}} {{speed}}результатов по всем проектам",
+ "resultsAcrossProjects_one": "{{count}} {{speed}}результат по всем проектам",
+ "resultsAcrossProjects_few": "{{count}} {{speed}}результата по всем проектам",
+ "resultsAcrossProjects_many": "{{count}} {{speed}}результатов по всем проектам",
+ "resultsAcrossProjects_other": "{{count}} {{speed}}результатов по всем проектам",
+ "fastPrefix": "fast ",
+ "typeToSearch": "Введите запрос",
+ "navigate": "навигация",
+ "select": "выбрать",
+ "open": "открыть",
+ "global": "global",
+ "close": "закрыть",
+ "upDownKey": "↑↓",
+ "escapeKey": "esc"
+ }
+ },
+ "tasksPanel": {
+ "title": "Задачи",
+ "searchPlaceholder": "Поиск задач...",
+ "pinned": "Закреплённые",
+ "groupByLabel": "Группировать:",
+ "groupByAria": "Группировка",
+ "groupModes": {
+ "none": "Нет",
+ "project": "Проект",
+ "time": "Время"
+ },
+ "showArchived": "Показать архивные",
+ "hideArchived": "Скрыть архивные",
+ "empty": {
+ "noMatchingTasks": "Нет подходящих задач",
+ "noTasks": "Задачи не найдены"
+ },
+ "teamLabel": "Команда: {{team}}",
+ "showMore": "Показать ещё",
+ "showLess": "Показать меньше",
+ "deleteConfirm": {
+ "title": "Удалить задачу",
+ "message": "Переместить задачу #{{taskId}} в корзину?",
+ "confirmLabel": "Удалить",
+ "cancelLabel": "Отмена"
+ },
+ "deleteFailed": {
+ "title": "Не удалось удалить задачу",
+ "fallbackMessage": "Произошла непредвиденная ошибка",
+ "confirmLabel": "OK"
+ },
+ "sort": {
+ "byTime": "По времени",
+ "byUnread": "По непрочитанным",
+ "byProject": "По проекту",
+ "byTeam": "По команде"
+ }
+ },
+ "toolViewer": {
+ "input": "Ввод",
+ "replaceAll": "(заменить все)",
+ "noInputRecorded": "Для этого вызова инструмента ввод не записан.",
+ "agent": {
+ "action": "действие",
+ "teammate": "участник",
+ "team": "команда",
+ "runtime": "runtime",
+ "type": "тип",
+ "startupInstructionsHidden": "Стартовые инструкции скрыты в интерфейсе."
+ }
+ },
+ "taskContextMenu": {
+ "unpin": "Открепить",
+ "pin": "Закрепить",
+ "rename": "Переименовать",
+ "markUnread": "Пометить непрочитанной",
+ "unarchive": "Разархивировать",
+ "archive": "Архивировать",
+ "deleteTask": "Удалить задачу"
+ },
+ "updateDialog": {
+ "closeDialog": "Закрыть диалог",
+ "updateAvailable": "Доступно обновление",
+ "updateReady": "Обновление готово",
+ "noReleaseNotes": "Описание релиза недоступно.",
+ "viewOnGitHub": "Открыть на GitHub",
+ "later": "Позже",
+ "restartNow": "Перезапустить сейчас",
+ "download": "Скачать"
+ },
+ "errorBoundary": {
+ "title": "Что-то пошло не так",
+ "description": "В приложении произошла непредвиденная ошибка. Можно попробовать перезагрузить страницу или сбросить состояние ошибки.",
+ "componentStack": "Стек компонентов",
+ "tryAgain": "Попробовать снова",
+ "copied": "Скопировано",
+ "copyErrorDetails": "Скопировать детали ошибки",
+ "reportBugOnGitHub": "Сообщить об ошибке на GitHub",
+ "reloadApp": "Перезагрузить приложение",
+ "diagnosticsNotice": "GitHub-отчёты и скопированная диагностика включают сообщение об ошибке, stack trace, версию приложения, активную вкладку, выбранную команду, контекст задачи и сведения окружения."
+ },
+ "runtimeBackendSelector": {
+ "label": "Runtime backend",
+ "resolved": "Определено: {{backend}}",
+ "current": "Текущий",
+ "recommended": "Рекомендуется",
+ "unavailable": "Недоступно",
+ "cannotSelectYet": "Этот backend пока нельзя выбрать.",
+ "auto": "Авто",
+ "autoCurrently": "Авто (сейчас: {{backend}})",
+ "audience": {
+ "internal": "Внутренний"
+ },
+ "states": {
+ "locked": "Заблокировано",
+ "disabled": "Отключено",
+ "authRequired": "Нужен вход",
+ "runtimeMissing": "Runtime отсутствует",
+ "degraded": "Есть проблемы",
+ "unavailable": "Недоступно"
+ }
+ },
+ "providerModelBadges": {
+ "checking": "Проверка",
+ "unavailable": "Недоступно",
+ "checkFailed": "Проверка не удалась",
+ "free": "Бесплатно",
+ "freeTooltip": "Передано метаданными OpenCode. Доступность и лимиты могут измениться."
+ },
+ "taskFilters": {
+ "status": "Статус",
+ "clearAll": "Очистить всё",
+ "selectAll": "Выбрать всё",
+ "team": "Команда",
+ "allTeams": "Все команды",
+ "searchTeams": "Искать команды...",
+ "noTeamsFound": "Команды не найдены",
+ "project": "Проект",
+ "allProjects": "Все проекты",
+ "searchProjects": "Искать проекты...",
+ "noProjects": "Проектов нет",
+ "comments": "Комментарии",
+ "apply": "Применить",
+ "read": {
+ "all": "Все",
+ "unread": "Непрочитанные",
+ "read": "Прочитанные"
+ },
+ "statusOptions": {
+ "todo": "TODO",
+ "inProgress": "В РАБОТЕ",
+ "needsFix": "НУЖНЫ ПРАВКИ",
+ "done": "ГОТОВО",
+ "review": "РЕВЬЮ",
+ "approved": "ОДОБРЕНО"
+ }
+ },
+ "sessionItem": {
+ "totalContext": "Всего контекста: {{tokens}} токенов",
+ "context": "Контекст: {{tokens}}",
+ "phase": "Фаза {{phase}}:",
+ "compactedTo": "(сжато до {{tokens}})"
+ },
+ "notifications": {
+ "row": {
+ "team": "команда",
+ "subagent": "subagent",
+ "markAsRead": "Пометить прочитанным",
+ "delete": "Удалить",
+ "viewInSession": "Открыть в сессии"
+ },
+ "title": "Уведомления",
+ "loading": "Загрузка уведомлений...",
+ "actions": {
+ "markFilteredAsRead": "Пометить отфильтрованные прочитанными",
+ "markAllAsRead": "Пометить все прочитанными",
+ "markFilteredRead": "Пометить фильтр",
+ "markAllRead": "Пометить все",
+ "clearFilteredNotifications": "Очистить отфильтрованные уведомления",
+ "clearAllNotifications": "Очистить все уведомления",
+ "clickToConfirm": "Нажмите для подтверждения",
+ "clearFiltered": "Очистить фильтр",
+ "clearAll": "Очистить все"
+ },
+ "counts": {
+ "unreadInFilter": "{{count}} непрочитанных в фильтре",
+ "unreadInFilter_one": "{{count}} непрочитанное в фильтре",
+ "unreadInFilter_few": "{{count}} непрочитанных в фильтре",
+ "unreadInFilter_many": "{{count}} непрочитанных в фильтре",
+ "unreadInFilter_other": "{{count}} непрочитанных в фильтре",
+ "inFilter": "{{count}} в фильтре",
+ "inFilter_one": "{{count}} в фильтре",
+ "inFilter_few": "{{count}} в фильтре",
+ "inFilter_many": "{{count}} в фильтре",
+ "inFilter_other": "{{count}} в фильтре",
+ "unread": "{{count}} непрочитанных",
+ "unread_one": "{{count}} непрочитанное",
+ "unread_few": "{{count}} непрочитанных",
+ "unread_many": "{{count}} непрочитанных",
+ "unread_other": "{{count}} непрочитанных",
+ "total": "{{count}} всего",
+ "total_one": "{{count}} всего",
+ "total_few": "{{count}} всего",
+ "total_many": "{{count}} всего",
+ "total_other": "{{count}} всего"
+ },
+ "filters": {
+ "other": "Другое"
+ },
+ "empty": {
+ "noMatching": "Подходящих уведомлений нет",
+ "noNotifications": "Уведомлений нет",
+ "tryDifferentFilter": "Попробуйте другой фильтр",
+ "allCaughtUp": "Все уведомления разобраны"
+ }
+ },
+ "updates": {
+ "restartToUpdate": "Перезапустить для обновления",
+ "updateApp": "Обновить приложение",
+ "downloadedRestartTooltip": "Обновление загружено, перезапустите приложение для применения",
+ "newVersionAvailable": "Доступна новая версия",
+ "updatingApp": "Обновление приложения",
+ "updateReady": "Обновление готово",
+ "restartNow": "Перезапустить сейчас"
+ },
+ "layout": {
+ "github": "GitHub",
+ "discord": "Discord",
+ "expandSidebar": "Развернуть боковую панель",
+ "collapseSidebarShortcut": "Свернуть боковую панель ({{shortcut}})",
+ "sidebarView": "Вид боковой панели",
+ "resizeSidebar": "Изменить ширину боковой панели",
+ "closeTab": "Закрыть вкладку",
+ "openedFromSearch": "Открыто из поиска",
+ "pinnedSession": "Закрепленная сессия",
+ "jumpToSection": "Перейти к разделу",
+ "newTab": "Новая вкладка",
+ "newTabDashboard": "Новая вкладка (дашборд)",
+ "refreshSession": "Обновить сессию",
+ "refreshSessionWithShortcut": "Обновить сессию ({{shortcut}})",
+ "loadingTab": "Загрузка вкладки",
+ "menu": {
+ "teams": "Команды",
+ "settings": "Настройки",
+ "extensions": "Расширения",
+ "search": "Поиск",
+ "schedules": "Расписания",
+ "docs": "Документация",
+ "exportMarkdown": "Экспорт в Markdown",
+ "exportJson": "Экспорт в JSON",
+ "exportPlainText": "Экспорт в обычный текст",
+ "analyzeSession": "Анализировать сессию"
+ },
+ "tabMenu": {
+ "closeTabs": "Закрыть {{count}} вкладок",
+ "closeTabs_one": "Закрыть {{count}} вкладку",
+ "closeTabs_few": "Закрыть {{count}} вкладки",
+ "closeTabs_many": "Закрыть {{count}} вкладок",
+ "closeTabs_other": "Закрыть {{count}} вкладки",
+ "closeTab": "Закрыть вкладку",
+ "closeOtherTabs": "Закрыть остальные вкладки",
+ "splitRight": "Разделить вправо",
+ "splitLeft": "Разделить влево",
+ "pinToSidebar": "Закрепить в боковой панели",
+ "unpinFromSidebar": "Открепить от боковой панели",
+ "hideFromSidebar": "Скрыть из боковой панели",
+ "unhideFromSidebar": "Вернуть в боковую панель",
+ "closeAllTabs": "Закрыть все вкладки"
+ },
+ "sections": {
+ "team": "Команда",
+ "sessions": "Сессии",
+ "kanban": "Канбан",
+ "claudeLogs": "Логи Claude",
+ "messages": "Сообщения"
+ }
+ },
+ "editorFormatting": {
+ "bold": "Жирный",
+ "italic": "Курсив",
+ "strike": "Зачеркнутый",
+ "code": "Код"
+ },
+ "diff": {
+ "changed": "Изменено",
+ "noChangesDetected": "Изменений нет"
+ },
+ "codexLogin": {
+ "copyLoginLinkAndCode": "Скопировать ссылку входа ChatGPT и код",
+ "copyLoginLink": "Скопировать ссылку входа ChatGPT",
+ "copyFailed": "Не удалось скопировать",
+ "copyLinkAndCode": "Скопировать ссылку + код",
+ "copyLink": "Скопировать ссылку",
+ "enterCodeOnLoginPage": "Введите этот код на странице входа ChatGPT"
+ },
+ "window": {
+ "minimize": "Свернуть",
+ "maximize": "Развернуть",
+ "restore": "Восстановить"
+ },
+ "context": {
+ "local": "Локально",
+ "switchingTo": "Переключение на {{workspace}}",
+ "loadingWorkspace": "Загрузка workspace",
+ "switchWorkspace": "Сменить рабочую область"
+ },
+ "repositories": {
+ "noneAvailable": "Репозитории недоступны",
+ "remove": "Удалить репозиторий"
+ },
+ "export": {
+ "session": "Экспортировать сессию",
+ "sessionTitle": "Экспорт сессии"
+ },
+ "brand": {
+ "claude": "Claude"
+ },
+ "sessionReport": {
+ "noSessionData": "Данные сессии недоступны",
+ "title": "Отчёт по сессии"
+ },
+ "sessionFilters": {
+ "project": {
+ "selectProject": "Выберите проект"
+ }
+ },
+ "tasks": {
+ "date": {
+ "updatedPrefix": "обн.",
+ "updatedYesterday": "обн. вчера",
+ "yesterday": "Вчера"
+ },
+ "reviewState": {
+ "needsFix": "Нужны правки"
+ },
+ "unassigned": "не назначено"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/dashboard.json b/src/features/localization/renderer/locales/ru/dashboard.json
new file mode 100644
index 00000000..2ff0e122
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/dashboard.json
@@ -0,0 +1,197 @@
+{
+ "cliStatus": {
+ "actions": {
+ "alreadyLoggedIn": "Уже вошли?",
+ "becomeSponsor": "Стать спонсором",
+ "cancel": "Отмена",
+ "checkNow": "Проверить сейчас",
+ "checkUpdates": "Проверить обновления",
+ "checking": "Проверка...",
+ "connect": "Подключить",
+ "extensions": "Расширения",
+ "login": "Войти",
+ "manage": "Управлять",
+ "manageProviders": "Управлять провайдерами",
+ "plan": "План",
+ "recheck": "Проверить снова",
+ "recheckProvider": "Проверить {{provider}} снова",
+ "retry": "Повторить",
+ "updateTo": "Обновить до v{{version}}",
+ "useCode": "Использовать код"
+ },
+ "atlas": {
+ "alt": "Atlas Cloud",
+ "description": "Atlas Cloud - full-modal AI inference platform, который даёт разработчикам единый AI API для доступа к video generation, image generation и LLM API. Вместо нескольких интеграций с вендорами вы подключаетесь один раз и получаете единый доступ к 300+ отобранным моделям во всех модальностях. Посмотрите новую coding plan promotion Atlas Cloud для более бюджетного API-доступа.",
+ "openCodeProvider": "OpenCode provider",
+ "plan": "Coding plan Atlas Cloud",
+ "sponsor": "Спонсор"
+ },
+ "errors": {
+ "checkStatusFailed": "Не удалось проверить статус CLI",
+ "installationFailed": "Установка не удалась",
+ "refreshFailed": "Не удалось проверить обновления. Проверьте сетевое подключение и повторите попытку.",
+ "runtimeUpdatedRefreshFailed": "Runtime обновлён, но не удалось обновить статус провайдера."
+ },
+ "hints": {
+ "backgroundStatus": "Статус {{runtime}} будет проверен в фоне.",
+ "codexApiKeyFallback": "{{hint}} API key fallback доступен, если переключить режим аутентификации.",
+ "codexAutoApiKey": "{{hint}} Auto продолжит использовать API key, пока ChatGPT не подключён.",
+ "codexFinishLogin": "Завершите вход ChatGPT в браузере. Если потребуется, введите показанный код.",
+ "codexNoActiveLogin": "Лимиты появятся только после того, как Codex CLI увидит активный ChatGPT account. Сейчас он сообщает, что активного входа ChatGPT нет.",
+ "codexNoActiveManagedSession": "Лимиты появятся только после того, как Codex CLI увидит активный ChatGPT account. Локальные данные account есть, но активная managed-сессия сейчас не выбрана.",
+ "codexReconnectNeeded": "Лимиты появятся только после того, как Codex обновит текущую выбранную ChatGPT-сессию. Сейчас локальную сессию нужно переподключить.",
+ "firstCheckSlow": "Первая проверка может занять до 30 секунд",
+ "loginRequiredForTeams": "Просмотр сессий и проектов работает без входа. Вход нужен только для запуска agent teams.",
+ "troubleshootTitle": "Если вы уверены, что уже вошли, попробуйте:"
+ },
+ "installer": {
+ "checkingLatest": "Проверка последней версии...",
+ "downloading": "Загрузка {{runtime}}...",
+ "installing": "Установка {{runtime}}...",
+ "success": "{{runtime}} успешно установлен, версия v{{version}}",
+ "verifying": "Проверка checksum..."
+ },
+ "labels": {
+ "apiKeyRequired": "Требуется API key",
+ "comingSoon": "Скоро",
+ "collapseProviderDetails": "Свернуть детали провайдера",
+ "expandProviderDetails": "Развернуть детали провайдера",
+ "generateLink": "Создать ссылку",
+ "loadingRateLimits": "Загрузка лимитов",
+ "loggedOut": "Провайдер отключён",
+ "loginAuthFailed": "Аутентификация не удалась",
+ "loginAuthUpdated": "Аутентификация обновлена",
+ "loginComplete": "Вход выполнен",
+ "loginFailed": "Войти не удалось",
+ "loginTitle": "Вход",
+ "logoutFailed": "Выход не удался",
+ "logoutTitle": "Выход",
+ "notLoggedIn": "Вход не выполнен",
+ "openLogin": "Открыть вход",
+ "providerActionRequired": "Требуется действие с провайдером",
+ "resets": "сброс {{time}}",
+ "runtimeLoginTitle": "Вход в {{runtime}}"
+ },
+ "loading": {
+ "aiProviders": "Проверка AI-провайдеров...",
+ "claudeCli": "Проверка Claude CLI..."
+ },
+ "provider": {
+ "authenticated": "Аутентифицировано",
+ "backend": "Backend: {{backend}}",
+ "checkingAuthentication": "Проверка аутентификации...",
+ "checkingProviders": "Проверка провайдеров...",
+ "configuredLocalCount": "{{count}} локальных настроено",
+ "configuredLocalCount_few": "{{count}} локальных настроено",
+ "configuredLocalCount_many": "{{count}} локальных настроено",
+ "configuredLocalCount_one": "{{count}} локальный настроен",
+ "configuredLocalCount_other": "{{count}} локальных настроено",
+ "configuredLocalTitle": "Локальные маршруты OpenCode, импортированные из вашей конфигурации OpenCode.",
+ "connectedCount": "Провайдеры: {{connected}}/{{denominator}} подключено",
+ "freeModels": "Бесплатные модели",
+ "freeModelsTitle": "OpenCode включает бесплатные варианты моделей, например Big Pickle, если они доступны в вашей настройке. OpenRouter через OpenCode тоже может показывать бесплатные модели, но не каждая модель OpenCode/OpenRouter бесплатна. Доступность и лимиты могут меняться.",
+ "loadingModels": "Загрузка моделей...",
+ "modelsUnavailable": "Модели недоступны для этой сборки runtime",
+ "runtime": "Runtime: {{runtime}}",
+ "verifiedCount": "{{count}} проверено",
+ "verifiedCount_few": "{{count}} проверено",
+ "verifiedCount_many": "{{count}} проверено",
+ "verifiedCount_one": "{{count}} проверен",
+ "verifiedCount_other": "{{count}} проверено",
+ "verifiedTitle": "Маршруты OpenCode с успешным proof выполнения."
+ },
+ "runtime": {
+ "configuredHealthCheckFailed": "Настроенный {{runtime}} не прошёл health check запуска.",
+ "configuredNotFound": "Настроенный {{runtime}} не найден.",
+ "foundButFailed": "{{runtime}} найден, но не запустился",
+ "healthCheckFailedDescription": "Приложение нашло настроенный {{runtime}}, но его startup health check не прошёл. Почините или переустановите его, затем повторите.",
+ "install": "Установить {{runtime}}",
+ "installRequiredDescription": "{{runtime}} нужен для provisioning команд и управления сессиями. Установите его, чтобы начать.",
+ "isRequired": "Требуется {{runtime}}",
+ "reinstall": "Переустановить {{runtime}}"
+ },
+ "runtimeInstall": {
+ "checking": "Проверка",
+ "codexTitle": "Установить Codex CLI в данные приложения",
+ "downloading": "Загрузка",
+ "downloadingPercent": "Загрузка {{percent}}%",
+ "install": "Установить",
+ "installing": "Установка",
+ "openCodeTitle": "Установить OpenCode runtime в данные приложения",
+ "retryInstall": "Повторить установку"
+ },
+ "troubleshoot": {
+ "again": "снова",
+ "authStatusCommand": "настроенную команду проверки статуса аутентификации CLI",
+ "checkLoggedIn": "- проверьте, показывает ли она \"Logged in\"",
+ "click": "Нажмите",
+ "loginCommand": "команду входа runtime",
+ "logoutCommand": "команду выхода runtime",
+ "openTerminal": "Откройте терминал и выполните:",
+ "reloginPrefix": "Если команда говорит, что вход выполнен, но приложение этого не видит, попробуйте:",
+ "sameRuntime": "Убедитесь, что CLI в терминале совпадает с runtime, который использует приложение",
+ "statusCacheHint": "- иногда статус кэшируется на несколько секунд",
+ "then": "затем"
+ },
+ "warnings": {
+ "multipleApiKeysMissing": "Один или несколько провайдеров работают в режиме API key, но API key не настроен. Откройте управление провайдерами, чтобы добавить ключи или переключить режим подключения.",
+ "multipleApiKeysNeedAttention": "Один или несколько провайдеров работают в режиме API key и требуют внимания. Откройте управление провайдерами, чтобы проверить сохранённые ключи или переключить режим подключения.",
+ "notAuthenticated": "{{runtime}} установлен, но вход не выполнен. Вход нужен для provisioning команд и AI-функций.",
+ "singleApiKeyMissing": "{{provider}} работает в режиме API key, но API key не настроен. Откройте управление провайдерами, чтобы добавить ключ или переключить режим подключения.",
+ "singleApiKeyNeedsAttention": "{{provider}} работает в режиме API key, но не подключён. Откройте управление провайдерами, чтобы проверить сохранённый ключ или переключить режим подключения."
+ }
+ },
+ "recentProjects": {
+ "selectFolderTitle": "Выбрать папку проекта",
+ "selectFolder": "Выбрать папку",
+ "failedToLoad": "Не удалось загрузить проекты",
+ "retry": "Повторить",
+ "noProjects": "Проекты не найдены",
+ "noMatches": "Нет совпадений для \"{{query}}\"",
+ "noRecentProjects": "Недавние проекты не найдены",
+ "emptyDescription": "Здесь появится недавняя активность Claude и Codex.",
+ "loadMore": "Загрузить ещё",
+ "card": {
+ "deleted": "Удалён",
+ "projectFolderMissing": "Папка проекта больше не существует",
+ "taskCounts": {
+ "active": "{{count}} активных",
+ "active_one": "{{count}} активная",
+ "active_few": "{{count}} активные",
+ "active_many": "{{count}} активных",
+ "active_other": "{{count}} активных",
+ "pending": "{{count}} ожидают",
+ "pending_one": "{{count}} ожидает",
+ "pending_few": "{{count}} ожидают",
+ "pending_many": "{{count}} ожидают",
+ "pending_other": "{{count}} ожидают",
+ "done": "{{count}} готово",
+ "done_one": "{{count}} готова",
+ "done_few": "{{count}} готово",
+ "done_many": "{{count}} готово",
+ "done_other": "{{count}} готово"
+ }
+ },
+ "title": "Недавние проекты",
+ "searchResults": "Результаты поиска",
+ "searchPlaceholder": "Поиск проектов..."
+ },
+ "actions": {
+ "selectTeam": "Выбрать команду",
+ "or": "или",
+ "clearSearch": "Очистить поиск"
+ },
+ "windowsAdmin": {
+ "title": "Рекомендуется режим администратора Windows",
+ "description": "Проверки рантайма OpenCode могут завершаться по таймауту, если Agent Teams AI не запущен с повышенными правами. Перезапустите приложение через Run as administrator перед запуском команд OpenCode."
+ },
+ "webPreview": {
+ "title": "Откройте desktop-приложение для полной функциональности",
+ "description": "Браузерная версия всё ещё в разработке. Действия с проектами, интеграции и live-статусы здесь могут быть ограничены. Используйте desktop-приложение для надёжного доступа ко всем возможностям."
+ },
+ "updateBanner": {
+ "newVersionAvailable": "Доступна новая версия",
+ "restartNow": "Перезапустить сейчас",
+ "viewDetails": "Подробнее"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/errors.json b/src/features/localization/renderer/locales/ru/errors.json
new file mode 100644
index 00000000..9db1e8e4
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/errors.json
@@ -0,0 +1,3 @@
+{
+ "fallback": "Что-то пошло не так."
+}
diff --git a/src/features/localization/renderer/locales/ru/extensions.json b/src/features/localization/renderer/locales/ru/extensions.json
new file mode 100644
index 00000000..a3f437d8
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/extensions.json
@@ -0,0 +1,684 @@
+{
+ "store": {
+ "actions": {
+ "addCustom": "Добавить custom",
+ "openDashboard": "Открыть Dashboard",
+ "refreshCatalog": "Обновить каталог"
+ },
+ "capabilities": {
+ "mcp": "MCP: {{status}}",
+ "plugins": "Plugins: {{status}}",
+ "skills": "Skills: {{status}}"
+ },
+ "desktopOnly": "Доступно только в desktop app.",
+ "provider": {
+ "checkingStatus": "Проверка статуса provider...",
+ "connected": "Подключён",
+ "loading": "Загрузка...",
+ "needsSetup": "Нужна настройка",
+ "readyToConfigure": "Готов к настройке",
+ "unsupported": "Не поддерживается"
+ },
+ "runtime": {
+ "checkingAvailabilityDescription": "Extensions требуют настроенный runtime для управления plugins, MCP servers, skills и provider connections.",
+ "checkingAvailabilityTitle": "Проверка доступности runtime для extensions",
+ "failedToStartDescription": "Extensions отключены, пока runtime не пройдёт startup health check. Откройте Dashboard, чтобы исправить или переустановить его.",
+ "failedToStartTitle": "Настроенный runtime найден, но не запустился",
+ "multimodelCapabilitiesDescription": "Поддержка providers может отличаться по секциям. Plugins показываются только там, где runtime явно сообщает поддержку.",
+ "multimodelCapabilitiesTitle": "Возможности multimodel runtime",
+ "needsSignInDescription": "{{runtime}} найден{{version}}, но установка plugins отключена, пока вы не войдёте через Dashboard.",
+ "needsSignInTitle": "{{runtime}} требует вход",
+ "notAvailableDescription": "Extensions отключены, пока runtime не установлен. Откройте Dashboard, установите его и повторите попытку.",
+ "notAvailableTitle": "Настроенный runtime недоступен",
+ "readyDescription": "Plugins можно устанавливать с этой страницы{{versionSuffix}}.",
+ "readyTitle": "{{runtime}} готов",
+ "requiredForMutations": "Настроенный runtime требуется для установки или удаления extensions. Установите или исправьте его через Dashboard."
+ },
+ "sessionsRestartWarning": "Запущенные сессии не увидят изменения extensions до перезапуска.",
+ "tabs": {
+ "apiKeys": {
+ "description": "Secret keys для online services. Добавьте их здесь, чтобы plugins, servers и integrations могли подключаться и работать.",
+ "label": "API Keys"
+ },
+ "mcpServers": {
+ "description": "Подключения к внешним tools и apps. Они позволяют runtime читать данные или выполнять действия за пределами приложения.",
+ "label": "MCP Servers"
+ },
+ "plugins": {
+ "description": "Небольшие add-ons для runtime. В multimodel mode сейчас применяются к Anthropic sessions, когда поддерживаются. Более широкая поддержка providers в разработке.",
+ "label": "Plugins"
+ },
+ "skills": {
+ "description": "Готовые инструкции для типовых задач. Они помогают runtime стабильнее выполнять повторяемые действия.",
+ "label": "Skills"
+ }
+ },
+ "title": "Extensions"
+ },
+ "pluginsPanel": {
+ "activeFilters": "Активно: {{count}}",
+ "activeFilters_few": "Активно: {{count}}",
+ "activeFilters_many": "Активно: {{count}}",
+ "activeFilters_one": "Активен: {{count}}",
+ "activeFilters_other": "Активно: {{count}}",
+ "browseByFit": "Подбор по назначению",
+ "capabilities": "Возможности",
+ "categories": "Категории",
+ "clearAllFilters": "Сбросить все фильтры",
+ "clearFilters": "Сбросить фильтры",
+ "counts": {
+ "capabilities": "Возможностей: {{count}}",
+ "capabilities_few": "Возможностей: {{count}}",
+ "capabilities_many": "Возможностей: {{count}}",
+ "capabilities_one": "Возможность: {{count}}",
+ "capabilities_other": "Возможностей: {{count}}",
+ "categories": "Категорий: {{count}}",
+ "categories_few": "Категории: {{count}}",
+ "categories_many": "Категорий: {{count}}",
+ "categories_one": "Категория: {{count}}",
+ "categories_other": "Категории: {{count}}",
+ "plugins": "Plugins: {{count}}",
+ "plugins_few": "Plugins: {{count}}",
+ "plugins_many": "Plugins: {{count}}",
+ "plugins_one": "Plugin: {{count}}",
+ "plugins_other": "Plugins: {{count}}"
+ },
+ "empty": {
+ "description": "Проверьте позже, когда появятся новые plugins",
+ "filteredDescription": "Попробуйте изменить поиск или критерии фильтра",
+ "filteredTitle": "Нет plugins под выбранные фильтры",
+ "title": "Plugins недоступны"
+ },
+ "filterDescription": "Сужайте каталог по категории, возможностям или статусу установки.",
+ "installedOnly": "Только установленные",
+ "providerSupportNotice": "Поддержка plugins сейчас гарантирована только для Anthropic (Claude) sessions. Мы работаем над поддержкой plugins во всех agents.",
+ "resultsUpdateInstantly": "Результаты обновляются сразу при изменении фильтров.",
+ "searchPlaceholder": "Поиск plugins...",
+ "selectedCount": "Выбрано: {{count}}",
+ "selectedCount_few": "Выбрано: {{count}}",
+ "selectedCount_many": "Выбрано: {{count}}",
+ "selectedCount_one": "Выбран: {{count}}",
+ "selectedCount_other": "Выбрано: {{count}}",
+ "showing": "Показано {{shown}} из {{total}} plugins",
+ "sort": {
+ "category": "Категория",
+ "nameAsc": "Имя A-Z",
+ "nameDesc": "Имя Z-A",
+ "popular": "Популярные"
+ }
+ },
+ "customMcp": {
+ "actions": {
+ "add": "Добавить",
+ "cancel": "Отмена",
+ "install": "Установить",
+ "installing": "Установка..."
+ },
+ "description": "Добавьте server вручную без каталога.",
+ "errors": {
+ "installFailed": "Не удалось установить",
+ "invalidServerName": "Некорректное имя server. Используйте латинские буквы, цифры, дефисы, подчёркивания и точки.",
+ "npmPackageRequired": "Требуется имя npm package",
+ "serverNameRequired": "Требуется имя server",
+ "serverUrlRequired": "Требуется URL server"
+ },
+ "fields": {
+ "environmentVariables": "Environment variables",
+ "headers": "Headers",
+ "npmPackage": "npm package",
+ "scope": "Scope",
+ "serverName": "Имя server",
+ "serverUrl": "URL server",
+ "transport": "Transport",
+ "transportType": "Тип transport",
+ "versionOptional": "Версия (необязательно)"
+ },
+ "title": "Добавить custom MCP server",
+ "transport": {
+ "httpSse": "HTTP / SSE",
+ "stdio": "Stdio (npm)"
+ },
+ "placeholders": {
+ "headerName": "Header-Name",
+ "envVarName": "ENV_VAR_NAME",
+ "serverName": "my-server",
+ "latest": "latest",
+ "value": "value",
+ "serverUrl": "https://api.example.com/mcp"
+ }
+ },
+ "mcpDetail": {
+ "auth": {
+ "remoteMayNeedHeaders": "Remote MCP servers могут требовать custom headers или API keys, даже если registry их не описывает. Если connection после установки не работает, проверьте provider docs.",
+ "required": "Этот server требует authentication"
+ },
+ "diagnostics": {
+ "launchTarget": "Launch Target"
+ },
+ "form": {
+ "autoFilled": "Заполнено автоматически",
+ "environmentVariables": "Environment variables",
+ "headers": "Headers",
+ "scope": "Scope",
+ "serverName": "Имя server"
+ },
+ "install": {
+ "httpTransport": "HTTP: {{transport}}",
+ "manualSetupDescription": "Этот server требует ручной настройки. Проверьте repository для инструкций по установке.",
+ "manualSetupRequired": "Требуется ручная настройка",
+ "npmPackage": "npm: {{package}}",
+ "manage": "Управление установкой",
+ "install": "Установить server"
+ },
+ "links": {
+ "glama": "Glama",
+ "repository": "Repository",
+ "website": "Website"
+ },
+ "metadata": {
+ "author": "Автор",
+ "githubStars": "GitHub Stars",
+ "hosting": "Hosting",
+ "installType": "Тип установки",
+ "license": "Лицензия",
+ "published": "Опубликовано",
+ "source": "Источник",
+ "updated": "Обновлено",
+ "version": "Версия"
+ },
+ "scope": {
+ "local": "Local",
+ "project": "Project"
+ },
+ "tools": {
+ "title": "Tools ({{count}})",
+ "title_few": "Tools ({{count}})",
+ "title_many": "Tools ({{count}})",
+ "title_one": "Tool ({{count}})",
+ "title_other": "Tools ({{count}})"
+ },
+ "placeholders": {
+ "serverName": "my-server"
+ }
+ },
+ "skillEditor": {
+ "actions": {
+ "cancel": "Отмена",
+ "createSkill": "Создать skill",
+ "preparing": "Подготовка...",
+ "reviewAndCreate": "Проверить и создать",
+ "reviewAndSave": "Проверить и сохранить",
+ "saveSkill": "Сохранить skill"
+ },
+ "advanced": {
+ "customDescription": "Этот skill использует собственный markdown-формат, поэтому редактируйте его напрямую здесь.",
+ "customTitle": "2. Редактор SKILL.md",
+ "description": "В большинстве случаев это можно пропустить. Открывайте только если нужен прямой контроль над raw markdown-файлом.",
+ "hide": "Скрыть расширенный редактор",
+ "resetFromStructuredFields": "Сбросить из структурированных полей",
+ "show": "Показать расширенный редактор",
+ "title": "4. Расширенный редактор SKILL.md"
+ },
+ "basics": {
+ "description": "Дайте skill понятное имя, выберите, кто может его использовать, и где он должен храниться.",
+ "title": "1. Основное"
+ },
+ "description": {
+ "create": "Опишите workflow простым языком, проверьте файлы, которые будут созданы, затем сохраните skill.",
+ "edit": "Обновите этот skill, проверьте итоговые изменения файлов, затем сохраните."
+ },
+ "extraFiles": {
+ "addedFiles": "Добавленные файлы:",
+ "assets": "Assets",
+ "assetsDescription": "Добавляйте screenshots или bundled media только если они помогают объяснить workflow.",
+ "description": "Добавляйте supporting docs, scripts или assets только если этот skill действительно в них нуждается.",
+ "lockedForEdits": "Root и folder заблокированы при редактировании",
+ "optionalDescription": "Добавьте starter files, которые попадут в review и будут записаны вместе с `SKILL.md`.",
+ "optionalTitle": "Дополнительные файлы",
+ "references": "References",
+ "referencesDescription": "Добавьте supporting docs, links или examples, которые runtime сможет использовать.",
+ "scripts": "Scripts",
+ "scriptsDescription": "Добавьте helper commands или setup notes. Внимательно проверьте перед публикацией skill.",
+ "title": "3. Дополнительные файлы"
+ },
+ "fields": {
+ "compatibility": "Compatibility",
+ "description": "Описание",
+ "folderName": "Имя folder",
+ "folderNameHint": "Мы предлагаем его автоматически из имени skill, чтобы review сразу работал корректно.",
+ "invocation": "Как его использовать",
+ "license": "Лицензия",
+ "name": "Имя skill",
+ "notes": "Дополнительные notes или guardrails",
+ "root": "Где хранить",
+ "scope": "Кто может использовать",
+ "steps": "Основные шаги",
+ "whenToUse": "Когда использовать"
+ },
+ "instructions": {
+ "description": "Эти секции генерируют skill-файл автоматически, поэтому markdown можно не редактировать вручную.",
+ "locked": "Структурированные поля заблокированы, потому что вы переключились на ручное редактирование `SKILL.md` ниже.",
+ "title": "2. Инструкции"
+ },
+ "invocation": {
+ "auto": "Можно использовать автоматически",
+ "manualOnly": "Только когда вы явно попросите"
+ },
+ "placeholders": {
+ "description": "С чем помогает этот skill",
+ "name": "Напишите короткое имя skill",
+ "notes": "Пример: отметьте отсутствующие тесты, регрессии и рискованные assumptions.",
+ "steps": "1. Проверьте релевантные файлы.\n2. Сначала объясните главный риск.\n3. Предложите самый безопасный fix.",
+ "whenToUse": "Пример: используйте это для code review или bug triage.",
+ "license": "MIT",
+ "compatibility": "claude-code, cursor"
+ },
+ "review": {
+ "creating": "Создание skill",
+ "hint": "Сначала проверьте изменения файлов, затем подтвердите сохранение на следующем шаге.",
+ "saving": "Сохранение skill"
+ },
+ "root": {
+ "codexOnly": " - только Codex",
+ "shared": " - общий"
+ },
+ "scope": {
+ "project": "Проект: {{project}}",
+ "projectUnavailable": "Проект недоступен",
+ "user": "Пользователь"
+ },
+ "title": {
+ "create": "Создать skill",
+ "edit": "Редактировать skill"
+ }
+ },
+ "skillDetail": {
+ "actions": {
+ "cancel": "Отмена",
+ "delete": "Удалить",
+ "deleteSkill": "Удалить skill",
+ "deleting": "Удаление...",
+ "editSkill": "Редактировать skill",
+ "openFolder": "Открыть папку",
+ "openSkillFile": "Открыть SKILL.md",
+ "retry": "Повторить"
+ },
+ "badges": {
+ "assets": "Assets",
+ "autoUse": "Auto use",
+ "hasScripts": "Есть scripts",
+ "manualUse": "Manual use",
+ "references": "References",
+ "storedIn": "Хранится в {{root}}"
+ },
+ "deleteDialog": {
+ "description": "Удалить этот skill и переместить его в Trash?",
+ "descriptionWithName": "Удалить \"{{name}}\" и переместить в Trash? При необходимости его можно восстановить из Trash.",
+ "title": "Удалить skill?"
+ },
+ "descriptionFallback": "Просмотр найденных metadata skill и raw instructions.",
+ "errors": {
+ "deleteFailed": "Не удалось удалить skill",
+ "loadFailed": "Не удалось загрузить этот skill."
+ },
+ "files": {
+ "advancedDetails": "Расширенные сведения о файле",
+ "assets": "Assets",
+ "references": "References",
+ "scripts": "Scripts",
+ "storedAt": "Хранится в"
+ },
+ "includes": {
+ "assets": "assets",
+ "instructionsOnly": "Только инструкции skill",
+ "references": "references",
+ "scripts": "scripts"
+ },
+ "invocation": {
+ "auto": "Запускается автоматически, когда подходит к задаче.",
+ "manualOnly": "Запускается только по явному запросу."
+ },
+ "issues": {
+ "bundledScripts": "Этот skill содержит bundled scripts",
+ "reviewCarefully": "Внимательно проверьте skill перед использованием"
+ },
+ "loading": "Загрузка сведений о skill...",
+ "scope": {
+ "personal": "Ваши личные skills",
+ "projectOnly": "Только этот проект"
+ },
+ "summary": {
+ "howUsed": "Как используется",
+ "included": "Что входит",
+ "whoCanUse": "Кто может использовать"
+ },
+ "titleFallback": "Сведения о skill"
+ },
+ "skillsPanel": {
+ "actions": {
+ "createSkill": "Создать skill",
+ "import": "Импорт"
+ },
+ "badges": {
+ "assets": "Assets",
+ "hasScripts": "Есть scripts",
+ "needsAttention": "Требует внимания",
+ "references": "References",
+ "storedIn": "Хранится в {{root}}"
+ },
+ "configuredRuntime": "настроенный runtime",
+ "counts": {
+ "codexOnly": "Codex only: {{count}}",
+ "codexOnly_few": "Codex only: {{count}}",
+ "codexOnly_many": "Codex only: {{count}}",
+ "codexOnly_one": "Codex only: {{count}}",
+ "codexOnly_other": "Codex only: {{count}}",
+ "personal": "Личных: {{count}}",
+ "personal_few": "Личных: {{count}}",
+ "personal_many": "Личных: {{count}}",
+ "personal_one": "Личный: {{count}}",
+ "personal_other": "Личных: {{count}}",
+ "project": "Проектных: {{count}}",
+ "project_few": "Проектных: {{count}}",
+ "project_many": "Проектных: {{count}}",
+ "project_one": "Проектный: {{count}}",
+ "project_other": "Проектных: {{count}}",
+ "shared": "Общих: {{count}}",
+ "shared_few": "Общих: {{count}}",
+ "shared_many": "Общих: {{count}}",
+ "shared_one": "Общий: {{count}}",
+ "shared_other": "Общих: {{count}}",
+ "total": "Всего: {{count}}",
+ "total_few": "Всего: {{count}}",
+ "total_many": "Всего: {{count}}",
+ "total_one": "Всего: {{count}}",
+ "total_other": "Всего: {{count}}"
+ },
+ "empty": {
+ "noMatches": "Skills под поиск не найдены",
+ "noMatchesDescription": "Попробуйте другой запрос или переключите фильтры.",
+ "noSkills": "Skills пока нет",
+ "noSkillsDescription": "Создайте первый skill для повторяемого workflow или импортируйте уже используемый."
+ },
+ "filters": {
+ "all": "Все skills",
+ "codexOnly": "Codex only",
+ "hasScripts": "Есть scripts",
+ "needsAttention": "Требует внимания",
+ "personal": "Личные",
+ "project": "Проектные",
+ "shared": "Общие"
+ },
+ "hero": {
+ "codexAvailable": "Используйте `.codex`, если skill должен оставаться только для Codex.",
+ "codexUnavailable": "Существующие `.codex` skills можно редактировать здесь, но для новых Codex-only skills нужен включённый Codex runtime.",
+ "description": "Skills - это переиспользуемые инструкции, которые помогают runtime стабильнее выполнять однотипные задачи.",
+ "guidance": "Используйте личные skills для привычек, нужных везде. Используйте проектные skills для workflows, которые имеют смысл только внутри одного codebase.",
+ "personalContext": "Сейчас показаны только ваши личные skills.",
+ "projectContext": "Показаны skills для {{project}} плюс ваши личные skills.",
+ "title": "Обучить повторяемой работе"
+ },
+ "invocation": {
+ "auto": "Запускается автоматически, когда подходит",
+ "manualOnly": "Запускается только по явному запросу"
+ },
+ "loading": {
+ "loading": "Загрузка skills...",
+ "refreshing": "Обновление skills..."
+ },
+ "runtimeAudience": "Shared skills в `.claude`, `.cursor` и `.agents` доступны для {{audience}}. Skills в `.codex` остаются Codex-only, когда поддержка Codex доступна.",
+ "scope": {
+ "project": "Этот проект",
+ "user": "Личный"
+ },
+ "searchPlaceholder": "Поиск по имени skill или назначению...",
+ "sections": {
+ "personal": {
+ "description": "Привычки и инструкции, которые должны быть доступны везде.",
+ "title": "Личные skills"
+ },
+ "project": {
+ "description": "Workflows, которые имеют смысл только для этого codebase.",
+ "title": "Проектные skills"
+ }
+ },
+ "sort": {
+ "label": "Сортировать skills",
+ "name": "Имя",
+ "recent": "Недавние"
+ },
+ "status": {
+ "hasScripts": "Содержит scripts, поэтому внимательно проверьте",
+ "needsAttention": "Требует внимания перед использованием",
+ "ready": "Готов к использованию"
+ },
+ "success": {
+ "created": "Skill успешно создан.",
+ "imported": "Skill успешно импортирован.",
+ "saved": "Skill успешно сохранён."
+ }
+ },
+ "pluginDetail": {
+ "unknown": "Неизвестно",
+ "metadata": {
+ "author": "Автор",
+ "category": "Категория",
+ "source": "Источник",
+ "version": "Версия",
+ "capabilities": "Возможности",
+ "installs": "Установки"
+ },
+ "scope": {
+ "label": "Scope:",
+ "options": {
+ "user": "User (global)",
+ "project": "Project (shared)",
+ "local": "Local (gitignored)"
+ }
+ },
+ "links": {
+ "homepage": "Homepage",
+ "contact": "Контакт"
+ },
+ "readme": {
+ "loading": "Загрузка README...",
+ "empty": "README недоступен."
+ }
+ },
+ "skillImport": {
+ "title": "Импорт skill",
+ "description": "Выберите существующую папку skill, проверьте, что будет скопировано, затем импортируйте её в одну из поддерживаемых локаций skills.",
+ "steps": {
+ "chooseFolder": {
+ "title": "1. Выберите папку skill",
+ "description": "Это должна быть папка, где уже есть файл `SKILL.md`, `Skill.md` или `skill.md`."
+ },
+ "location": {
+ "title": "2. Выберите, где хранить skill",
+ "description": "Личные skills работают везде. Проектные skills показываются только для одного codebase."
+ }
+ },
+ "fields": {
+ "sourceFolder": "Исходная папка",
+ "destinationFolderName": "Имя целевой папки",
+ "audience": "Кто может использовать",
+ "storage": "Где хранить"
+ },
+ "placeholders": {
+ "defaultFolderName": "По умолчанию имя исходной папки"
+ },
+ "actions": {
+ "browse": "Выбрать",
+ "cancel": "Отмена",
+ "preparing": "Подготовка...",
+ "reviewAndImport": "Проверить и импортировать",
+ "importSkill": "Импортировать skill",
+ "backToImport": "Назад к импорту"
+ },
+ "scope": {
+ "user": "User",
+ "project": "Project: {{project}}",
+ "projectUnavailable": "Project недоступен"
+ },
+ "rootSuffix": {
+ "codexOnly": " - Codex only",
+ "shared": " - Shared"
+ },
+ "reviewHint": "Сначала проверьте скопированные файлы, затем подтвердите импорт на следующем шаге.",
+ "reviewLabel": "Импорт этого skill",
+ "errors": {
+ "missingSkillFile": "Эта папка пока не похожа на skill. Нужен файл SKILL.md, Skill.md или skill.md.",
+ "symbolicLinks": "В этой папке есть symbolic links. Импортируйте реальные файлы вместо links.",
+ "tooManyFiles": "В этой папке skill слишком много файлов для одного импорта. Уберите лишние файлы и повторите попытку.",
+ "tooLarge": "Эта папка skill слишком большая для безопасного импорта. Уменьшите крупные assets и повторите попытку.",
+ "invalidFolderName": "Выберите более простое имя целевой папки: буквы, цифры, точки, дефисы или подчёркивания.",
+ "mustBeDirectory": "Выберите папку для импорта, а не отдельный файл.",
+ "reviewFailed": "Не удалось подготовить review изменений импорта",
+ "importFailed": "Не удалось импортировать skill"
+ }
+ },
+ "mcpPanel": {
+ "sort": {
+ "nameAsc": "Name A→Z",
+ "nameDesc": "Name Z→A",
+ "toolsDesc": "Больше всего tools"
+ },
+ "health": {
+ "title": "MCP health status",
+ "checkingViaRuntime": "Проверка установленных MCP servers через {{runtime}} ...",
+ "lastChecked": "Последняя проверка {{time}}",
+ "description": "Запустите diagnostics на этой странице, чтобы проверить подключение установленных MCP.",
+ "checking": "Проверка...",
+ "checkStatus": "Проверить status"
+ },
+ "diagnostics": {
+ "title": "Runtime MCP diagnostics",
+ "serversCount": "{{count}} servers",
+ "serversCount_one": "{{count}} server",
+ "serversCount_few": "{{count}} servers",
+ "serversCount_many": "{{count}} servers",
+ "serversCount_other": "{{count}} servers",
+ "waiting": "Ожидание результатов diagnostics...",
+ "disableReasons": {
+ "checkingRuntimeStatus": "Проверка runtime status...",
+ "checkingRuntimeAvailability": "Проверка доступности runtime...",
+ "runtimeFailedToStart": "Настроенный runtime найден, но не запустился. Откройте Dashboard, чтобы repair или reinstall его.",
+ "runtimeRequired": "Требуется настроенный runtime. Установите или repair его из Dashboard."
+ }
+ },
+ "searchPlaceholder": "Поиск MCP servers...",
+ "runtime": {
+ "notAvailable": "{{runtime}} недоступен",
+ "notInstalled": "{{runtime}} не установлен",
+ "requiredDescription": "MCP health checks требуют {{runtime}}. Перейдите в Dashboard, чтобы установить или repair его."
+ },
+ "empty": {
+ "searchTitle": "Servers не найдены",
+ "title": "MCP servers недоступны",
+ "searchDescription": "Попробуйте другой поисковый запрос",
+ "description": "Новые servers могут появиться позже"
+ },
+ "loadMore": "Загрузить ещё"
+ },
+ "apiKeys": {
+ "description": "Безопасно храните API keys для auto-fill при установке MCP servers.",
+ "storage": {
+ "osKeychain": "Keys шифруются через {{backend}} и хранятся с ограниченными file permissions (только owner).",
+ "localEncryption": "OS keychain недоступен - keys шифруются локально через AES-256. Для более сильной защиты установите keyring service (gnome-keyring, kwallet)."
+ },
+ "actions": {
+ "add": "Добавить API key",
+ "addFirst": "Добавить первый key",
+ "edit": "Редактировать"
+ },
+ "empty": {
+ "title": "API keys не сохранены",
+ "description": "Добавьте keys для auto-fill environment variables при установке MCP servers."
+ },
+ "form": {
+ "addTitle": "Добавить API-ключ",
+ "editTitle": "Редактировать API-ключ",
+ "addDescription": "Сохраните API-ключ для автозаполнения при установке MCP-серверов.",
+ "editDescription": "Обновите детали ключа. Значение нужно ввести заново.",
+ "keychainUnavailable": "OS keychain недоступен - ключи локально шифруются AES-256. Установите gnome-keyring для защиты на уровне ОС.",
+ "name": "Название",
+ "namePlaceholder": "например OpenAI Production",
+ "environmentVariableName": "Имя переменной окружения",
+ "envVarPlaceholder": "например OPENAI_API_KEY",
+ "value": "Значение",
+ "reenterValue": "Введите значение ключа заново",
+ "valuePlaceholder": "sk-...",
+ "scope": "Область",
+ "userScopeLabel": "Пользователь (глобально)",
+ "projectScopeLabel": "Проект: {{project}}",
+ "projectUnavailable": "Проект недоступен",
+ "boundTo": "Привязано к {{path}}",
+ "cancel": "Отмена",
+ "saving": "Сохранение...",
+ "update": "Обновить",
+ "save": "Сохранить",
+ "errors": {
+ "invalidEnvVarFormat": "Используйте буквы, цифры и подчёркивания. Должно начинаться с буквы или подчёркивания.",
+ "nameRequired": "Название обязательно",
+ "envVarRequired": "Имя переменной окружения обязательно",
+ "invalidEnvVar": "Некорректное имя переменной окружения",
+ "valueRequired": "Значение ключа обязательно",
+ "projectScopeRequiresProject": "API-ключи уровня проекта требуют активный проект",
+ "saveFailed": "Не удалось сохранить"
+ }
+ }
+ },
+ "skillReview": {
+ "title": "Проверка изменений навыка",
+ "description": "{{reviewLabel}} сначала показывает изменения файловой системы. Ничего не будет записано до подтверждения ниже.",
+ "noPreview": "Предпросмотр недоступен.",
+ "confirmPromptPrefix": "Проверьте diff ниже, затем нажмите",
+ "confirmPromptSuffix": "чтобы применить изменения.",
+ "noChanges": "Изменения файлов пока не обнаружены.",
+ "binaryBadge": "бинарный",
+ "binaryPreviewHidden": "Предпросмотр бинарного файла не показывается. Файл будет скопирован как есть.",
+ "summary": {
+ "fileChanges": "{{count}} изменений файлов",
+ "fileChanges_one": "{{count}} изменение файла",
+ "fileChanges_few": "{{count}} изменения файлов",
+ "fileChanges_many": "{{count}} изменений файлов",
+ "fileChanges_other": "{{count}} изменений файлов",
+ "new": "{{count}} новых",
+ "updated": "{{count}} обновлено",
+ "removed": "{{count}} удалено",
+ "binary": "{{count}} бинарных"
+ }
+ },
+ "mcpCard": {
+ "toolsCount": "{{count}} инструментов",
+ "toolsCount_one": "{{count}} инструмент",
+ "toolsCount_few": "{{count}} инструмента",
+ "toolsCount_many": "{{count}} инструментов",
+ "toolsCount_other": "{{count}} инструментов",
+ "envCount": "{{count}} env-переменных",
+ "envCount_one": "{{count}} env-переменная",
+ "envCount_few": "{{count}} env-переменные",
+ "envCount_many": "{{count}} env-переменных",
+ "envCount_other": "{{count}} env-переменных",
+ "auth": "Авторизация",
+ "byAuthor": "от {{author}}",
+ "hosting": {
+ "remote": "Удаленный",
+ "local": "Локальный",
+ "both": "Оба варианта"
+ },
+ "repository": "Репозиторий",
+ "website": "Сайт"
+ },
+ "installButton": {
+ "installing": "Установка...",
+ "removing": "Удаление...",
+ "done": "Готово",
+ "retry": "Повторить",
+ "uninstall": "Удалить",
+ "install": "Установить"
+ },
+ "pluginCard": {
+ "official": "Официальный"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/report.json b/src/features/localization/renderer/locales/ru/report.json
new file mode 100644
index 00000000..c5d958d1
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/report.json
@@ -0,0 +1,217 @@
+{
+ "cost": {
+ "breakdownTitle": "Разбивка стоимости (за 1M токенов)",
+ "cacheRead": "Cache Read",
+ "cacheWrite": "Cache Write",
+ "cost": "Стоимость",
+ "input": "Вход",
+ "noCommits": "коммитов нет",
+ "noLinesChanged": "изменённых строк нет",
+ "output": "Выход",
+ "parent": "Parent: {{cost}}",
+ "parentCost": "Стоимость parent-сессии",
+ "perCommit": "На коммит",
+ "perCommitFormula": "общая стоимость ÷ {{count}} коммит",
+ "perCommitFormula_few": "общая стоимость ÷ {{count}} коммита",
+ "perCommitFormula_many": "общая стоимость ÷ {{count}} коммитов",
+ "perCommitFormula_one": "общая стоимость ÷ {{count}} коммит",
+ "perCommitFormula_other": "общая стоимость ÷ {{count}} коммита",
+ "perLineChanged": "На изменённую строку",
+ "perLineFormula": "общая стоимость ÷ {{count}} строка",
+ "perLineFormula_few": "общая стоимость ÷ {{count}} строки",
+ "perLineFormula_many": "общая стоимость ÷ {{count}} строк",
+ "perLineFormula_one": "общая стоимость ÷ {{count}} строка",
+ "perLineFormula_other": "общая стоимость ÷ {{count}} строки",
+ "subagent": "Субагенты: {{cost}}",
+ "subagentCost": "Стоимость субагентов",
+ "title": "Анализ стоимости",
+ "total": "Итого"
+ },
+ "insights": {
+ "agent": "агент",
+ "agent_few": "агента",
+ "agent_many": "агентов",
+ "agent_one": "агент",
+ "agent_other": "агента",
+ "agentTree": "Дерево агентов ({{count}} {{unit}})",
+ "background": "(в фоне)",
+ "bashCommands": "Bash-команды",
+ "outOfScopeFindings": "Находки вне scope ({{count}})",
+ "questionsAsked": "Заданные вопросы ({{count}})",
+ "repeated": "Повторные",
+ "skillsInvoked": "Вызванные skills ({{count}})",
+ "taskDispatches": "Запуски Task ({{count}})",
+ "tasksCreated": "Созданные задачи ({{count}})",
+ "teamMode": "Командный режим",
+ "teams": "Команды: {{teams}}",
+ "title": "Инсайты сессии",
+ "total": "Всего",
+ "unique": "Уникальные",
+ "skillsInvoked_few": "Вызванные skills ({{count}})",
+ "skillsInvoked_many": "Вызванные skills ({{count}})",
+ "skillsInvoked_one": "Вызванные skills ({{count}})",
+ "skillsInvoked_other": "Вызванные skills ({{count}})",
+ "taskDispatches_few": "Запуски Task ({{count}})",
+ "taskDispatches_many": "Запуски Task ({{count}})",
+ "taskDispatches_one": "Запуски Task ({{count}})",
+ "taskDispatches_other": "Запуски Task ({{count}})",
+ "tasksCreated_few": "Созданные задачи ({{count}})",
+ "tasksCreated_many": "Созданные задачи ({{count}})",
+ "tasksCreated_one": "Созданные задачи ({{count}})",
+ "tasksCreated_other": "Созданные задачи ({{count}})",
+ "questionsAsked_few": "Заданные вопросы ({{count}})",
+ "questionsAsked_many": "Заданные вопросы ({{count}})",
+ "questionsAsked_one": "Заданные вопросы ({{count}})",
+ "questionsAsked_other": "Заданные вопросы ({{count}})",
+ "agentTree_few": "Дерево агентов ({{count}} {{unit}})",
+ "agentTree_many": "Дерево агентов ({{count}} {{unit}})",
+ "agentTree_one": "Дерево агентов ({{count}} {{unit}})",
+ "agentTree_other": "Дерево агентов ({{count}} {{unit}})",
+ "outOfScopeFindings_few": "Находки вне scope ({{count}})",
+ "outOfScopeFindings_many": "Находки вне scope ({{count}})",
+ "outOfScopeFindings_one": "Находки вне scope ({{count}})",
+ "outOfScopeFindings_other": "Находки вне scope ({{count}})",
+ "keyTakeaways": "Ключевые выводы"
+ },
+ "quality": {
+ "chars": "симв.",
+ "corrections": "Исправления",
+ "failed": "failed",
+ "fileReadRedundancy": "Повторное чтение файлов",
+ "firstMessage": "Первое сообщение",
+ "firstRun": "Первый запуск",
+ "frictionRate": "Уровень friction",
+ "lastRun": "Последний запуск",
+ "messagesBeforeWork": "Сообщений до работы",
+ "passed": "passed",
+ "promptQuality": "Качество промпта",
+ "readsPerUniqueFile": "Чтений на уникальный файл",
+ "snapshot": "snapshot",
+ "snapshot_few": "snapshots",
+ "snapshot_many": "snapshots",
+ "snapshot_one": "snapshot",
+ "snapshot_other": "snapshots",
+ "startupOverhead": "Startup overhead",
+ "testProgression": "Динамика тестов",
+ "title": "Сигналы качества",
+ "tokensBeforeWork": "Токенов до работы",
+ "totalReads": "Всего чтений",
+ "uniqueFiles": "Уникальные файлы",
+ "userMessages": "Сообщения пользователя",
+ "percentOfTotal": "% от общего"
+ },
+ "tokens": {
+ "apiCalls": "API-вызовы",
+ "cacheCreate": "Cache Create",
+ "cacheEfficiency": "Эффективность cache",
+ "cacheRead": "Cache Read",
+ "cacheReadPct": "Cache Read %",
+ "coldStart": "Cold Start",
+ "cost": "Стоимость",
+ "input": "Вход",
+ "model": "Модель",
+ "no": "Нет",
+ "output": "Выход",
+ "readWriteRatio": "R/W Ratio",
+ "title": "Использование токенов",
+ "total": "Итого",
+ "yes": "Да"
+ },
+ "subagents": {
+ "title": "Subagents",
+ "metrics": {
+ "count": "Количество",
+ "totalTokens": "Всего tokens",
+ "totalDuration": "Общая длительность",
+ "totalCost": "Общая стоимость"
+ },
+ "table": {
+ "description": "Описание",
+ "type": "Тип",
+ "tokens": "Tokens",
+ "duration": "Длительность",
+ "cost": "Стоимость"
+ }
+ },
+ "overview": {
+ "title": "Обзор",
+ "yes": "Да",
+ "no": "Нет",
+ "metrics": {
+ "duration": "Длительность",
+ "messages": "Сообщения",
+ "contextUsage": "Использование контекста",
+ "compactions": "Compactions",
+ "branch": "Branch",
+ "subagents": "Subagents",
+ "project": "Проект",
+ "sessionId": "Session ID"
+ }
+ },
+ "timeline": {
+ "title": "Timeline и активность",
+ "idleAnalysis": "Idle analysis",
+ "metrics": {
+ "idleGaps": "Idle gaps",
+ "totalIdle": "Всего idle",
+ "activeTime": "Активное время",
+ "idlePercent": "Idle %"
+ },
+ "modelSwitches": "Смены модели ({{count}})",
+ "modelSwitches_one": "Смена модели ({{count}})",
+ "modelSwitches_few": "Смены модели ({{count}})",
+ "modelSwitches_many": "Смены модели ({{count}})",
+ "modelSwitches_other": "Смены модели ({{count}})",
+ "messageNumber": "msg #{{number}}",
+ "keyEvents": "Ключевые события"
+ },
+ "tools": {
+ "title": "Использование инструментов",
+ "summary": "{{formattedCount}} вызовов всего по {{toolCount}} инструментам",
+ "columns": {
+ "tool": "Инструмент",
+ "calls": "Вызовы",
+ "errors": "Ошибки",
+ "successPercent": "Успех %",
+ "health": "Состояние"
+ }
+ },
+ "git": {
+ "title": "Git-активность",
+ "commits": "Коммиты",
+ "pushes": "Pushes",
+ "linesAdded": "Строк добавлено",
+ "linesRemoved": "Строк удалено",
+ "branchesCreated": "Созданные ветки"
+ },
+ "friction": {
+ "title": "Сигналы friction",
+ "rate": "Friction rate: {{rate}}%",
+ "correctionsCount": "исправлений: {{count}}",
+ "correctionsCount_one": "{{count}} исправление",
+ "correctionsCount_few": "исправлений: {{count}}",
+ "correctionsCount_many": "исправлений: {{count}}",
+ "correctionsCount_other": "исправлений: {{count}}",
+ "corrections": "Исправления",
+ "thrashingSignals": "Сигналы thrashing",
+ "repeatedBashCommands": "Повторяющиеся Bash-команды",
+ "reworkedFiles": "Переделанные файлы (3+ правки)"
+ },
+ "errors": {
+ "title": "Ошибки",
+ "permissionDenied": "Доступ запрещён",
+ "messageIndex": "сообщ. #{{index}}",
+ "input": "Ввод",
+ "error": "Ошибка",
+ "count": "ошибок: {{count}}",
+ "count_one": "{{count}} ошибка",
+ "count_few": "ошибок: {{count}}",
+ "count_many": "ошибок: {{count}}",
+ "count_other": "ошибок: {{count}}",
+ "permissionDenialCount": "permission denial: {{count}}",
+ "permissionDenialCount_one": "{{count}} permission denial",
+ "permissionDenialCount_few": "permission denial: {{count}}",
+ "permissionDenialCount_many": "permission denial: {{count}}",
+ "permissionDenialCount_other": "permission denial: {{count}}"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/settings.json b/src/features/localization/renderer/locales/ru/settings.json
new file mode 100644
index 00000000..6e346b9f
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/settings.json
@@ -0,0 +1,983 @@
+{
+ "tabs": {
+ "advanced": {
+ "description": "Расширенные параметры: экспорт и импорт конфигурации, сброс настроек и редактирование raw-конфигурации.",
+ "label": "Расширенные"
+ },
+ "general": {
+ "description": "Основные настройки приложения: тема, язык, плотность интерфейса и поведение при запуске.",
+ "label": "Общие"
+ },
+ "infoAriaLabel": "Что такое «{{label}}»?",
+ "notifications": {
+ "description": "Настройте, когда и как получать уведомления об активности агентов, завершении задач и ошибках.",
+ "label": "Уведомления"
+ }
+ },
+ "view": {
+ "description": "Управление настройками приложения",
+ "loading": "Загрузка настроек...",
+ "title": "Настройки"
+ },
+ "runtimeProvider": {
+ "actions": {
+ "cancel": "Отмена",
+ "test": "Тест"
+ },
+ "defaults": {
+ "allProjects": "Все проекты",
+ "allProjectsHint": "Тесты используют {{project}}. Default применяется, если у проекта нет своего override.",
+ "loadingContexts": "Загрузка contexts...",
+ "projectHint": "Сохранение изменит override только для {{project}}.",
+ "projectOverrideContext": "Project override context",
+ "scopeDescriptionAllProjects": "Default для всех проектов, у которых нет собственного OpenCode override.",
+ "scopeDescriptionProject": "Override только для выбранного проекта. Уже запущенные команды не изменяются.",
+ "selectProjectContext": "Выберите project context",
+ "selectProjectHint": "Выберите проект перед тестированием local models или сохранением defaults.",
+ "selectValidationContext": "Выберите validation context",
+ "setAllProjectsDefault": "Задать default для всех проектов",
+ "setProjectDefault": "Задать default для проекта",
+ "thisProject": "Этот проект",
+ "title": "OpenCode defaults",
+ "validationContext": "Validation context"
+ },
+ "diagnostics": {
+ "copied": "Diagnostics скопированы",
+ "copiedShort": "Скопировано",
+ "copy": "Скопировать diagnostics",
+ "hints": "Подсказки",
+ "likelyCause": "Вероятная причина:"
+ },
+ "models": {
+ "alreadyDefault": "Это уже выбранный OpenCode default.",
+ "empty": "Модели не найдены.",
+ "emptyFree": "Free models не найдены.",
+ "emptyRecommended": "Recommended models не найдены.",
+ "emptyRecommendedFree": "Recommended free models не найдены.",
+ "freeOnly": "Только free",
+ "launchableDescription": "Routes, которые можно тестировать или использовать в team picker: local config, free built-in models и текущий default.",
+ "launchableTitle": "Launchable OpenCode models",
+ "loadingRoutes": "Загрузка OpenCode model routes...",
+ "noRoutesMatch": "OpenCode model routes не найдены по запросу \"{{query}}\".",
+ "noneReported": "Launchable OpenCode model routes пока не получены. Настройте local route в OpenCode или используйте вкладку Providers для просмотра catalog providers.",
+ "recommendedOnly": "Только recommended",
+ "searchPlaceholder": "Поиск моделей",
+ "selectProjectBeforeTesting": "Выберите project context перед тестированием моделей.",
+ "selectProjectBeforeTestingDefaults": "Выберите project context перед тестированием или сохранением OpenCode defaults.",
+ "useInTeamPicker": "Использовать в team picker"
+ },
+ "providers": {
+ "catalog": "OpenCode provider catalog",
+ "countFallback": "OpenCode providers",
+ "description": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_few": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_many": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_one": "{{count}}. Connected и recommended providers показаны первыми.",
+ "description_other": "{{count}}. Connected и recommended providers показаны первыми.",
+ "loadMore": "Загрузить ещё providers",
+ "loading": "Загрузка OpenCode providers",
+ "noMatches": "Providers не найдены по поиску.",
+ "noneReported": "Managed runtime не сообщил OpenCode providers.",
+ "recommended": "Recommended",
+ "refreshCatalog": "Обновить catalog",
+ "searchPlaceholder": "Поиск providers"
+ },
+ "setup": {
+ "loading": "Загрузка provider setup..."
+ },
+ "summary": {
+ "defaultModel": "OpenCode default: {{model}}",
+ "loading": "Загрузка managed OpenCode runtime, connected providers и model defaults...",
+ "source": "Источник: {{source}}",
+ "title": "OpenCode runtime"
+ },
+ "tabs": {
+ "models": "Модели",
+ "providers": "Providers"
+ },
+ "modelRoutes": {
+ "searchPlaceholder": "Поиск маршрутов моделей"
+ },
+ "badges": {
+ "usedInTeamPicker": "Используется в выборе команды",
+ "free": "free",
+ "local": "local",
+ "configured": "настроено",
+ "connected": "подключено",
+ "verified": "проверено",
+ "needsTest": "нужен тест",
+ "failed": "ошибка",
+ "unknown": "неизвестно",
+ "default": "по умолчанию"
+ },
+ "compatibleEndpoint": {
+ "baseUrlPlaceholder": "http://localhost:1234"
+ }
+ },
+ "general": {
+ "agentLanguage": {
+ "description": "Язык общения с агентами",
+ "descriptionWithDetected": "Язык общения с агентами (определён: {{detected}})",
+ "emptyMessage": "Язык не найден.",
+ "label": "Язык",
+ "searchPlaceholder": "Поиск языка...",
+ "selectPlaceholder": "Выберите язык...",
+ "title": "Язык агентов"
+ },
+ "appLanguage": {
+ "description": "Язык интерфейса приложения.",
+ "label": "Язык",
+ "title": "Язык приложения"
+ },
+ "appearance": {
+ "autoExpandAIGroups": {
+ "description": "Автоматически раскрывать каждый ответ при открытии транскрипта или получении нового сообщения",
+ "label": "Раскрывать ответы AI по умолчанию"
+ },
+ "nativeTitleBar": {
+ "description": "Использовать стандартную системную рамку окна вместо кастомной панели заголовка",
+ "label": "Использовать системную панель заголовка",
+ "restartConfirm": {
+ "confirmLabel": "Перезапустить",
+ "message": "Чтобы применить изменение панели заголовка, приложение нужно перезапустить. Перезапустить сейчас?",
+ "title": "Требуется перезапуск"
+ }
+ },
+ "theme": {
+ "description": "Выберите предпочитаемую цветовую тему",
+ "label": "Тема",
+ "options": {
+ "dark": "Тёмная",
+ "light": "Светлая",
+ "system": "Системная"
+ }
+ },
+ "title": "Внешний вид"
+ },
+ "browserAccess": {
+ "serverMode": {
+ "description": "Запустить HTTP-сервер для доступа к интерфейсу из браузера или встраивания в iframe",
+ "label": "Включить режим сервера"
+ },
+ "title": "Доступ из браузера"
+ },
+ "localClaudeRoot": {
+ "actions": {
+ "selectFolder": "Выбрать папку",
+ "selectFolderManually": "Выбрать папку вручную",
+ "useAutoDetect": "Использовать автоопределение",
+ "useFolder": "Использовать папку",
+ "usePath": "Использовать путь",
+ "useThisPath": "Использовать этот путь",
+ "useWsl": "Используете Linux/WSL?"
+ },
+ "confirm": {
+ "noProjectsDir": {
+ "message": "В этой папке нет директории \"projects\". Всё равно продолжить?",
+ "title": "Директория projects не найдена"
+ },
+ "notClaudeDir": {
+ "message": "Эта папка называется \"{{folderName}}\", а не \".claude\". Всё равно продолжить?",
+ "title": "Выбранная папка не является .claude"
+ },
+ "noWslPaths": {
+ "message": "Не удалось автоматически найти WSL-дистрибутивы с данными Claude. Выбрать папку вручную?",
+ "title": "Пути Claude в WSL не найдены"
+ },
+ "wslNoProjectsDir": {
+ "message": "В \"{{path}}\" нет директории \"projects\". Всё равно продолжить?",
+ "title": "В пути WSL нет директории projects"
+ }
+ },
+ "current": {
+ "autoDetected": "Автоопределено: {{path}}",
+ "autoDetectedPath": "Используется автоопределённый путь",
+ "customPath": "Используется пользовательский путь",
+ "label": "Текущий локальный корень"
+ },
+ "description": "Выберите локальную папку, которая будет считаться корнем данных Claude",
+ "errors": {
+ "detectWslFailed": "Не удалось определить пути корня Claude в WSL",
+ "loadFailed": "Не удалось загрузить настройки локального корня Claude",
+ "updateFailed": "Не удалось обновить корень Claude"
+ },
+ "title": "Локальный корень Claude",
+ "wslModal": {
+ "closeAriaLabel": "Закрыть окно выбора пути WSL",
+ "description": "Найденные WSL-дистрибутивы и кандидаты корня Claude",
+ "noProjectsDir": "Директория projects не найдена",
+ "title": "Выберите корень Claude в WSL"
+ }
+ },
+ "privacy": {
+ "telemetry": {
+ "description": "Помогите улучшить приложение, отправляя анонимные отчёты о сбоях и производительности",
+ "label": "Отправлять отчёты о сбоях"
+ },
+ "title": "Приватность"
+ },
+ "server": {
+ "runningOn": "Запущено на",
+ "standaloneModeDescription": "Приложение работает в автономном режиме. HTTP-сервер всегда активен. Системные уведомления недоступны - триггеры уведомлений записываются только внутри приложения.",
+ "title": "Сервер"
+ },
+ "startup": {
+ "launchAtLogin": {
+ "description": "Автоматически запускать приложение при входе в систему",
+ "label": "Запускать при входе"
+ },
+ "showDockIcon": {
+ "description": "Показывать значок приложения в Dock (macOS)",
+ "label": "Показывать значок в Dock"
+ },
+ "title": "Запуск"
+ }
+ },
+ "notifications": {
+ "dev": {
+ "descriptionPrefix": "В режиме разработки уведомления могут работать некорректно. macOS определяет приложение как \"Electron\" (bundle ID",
+ "descriptionSuffix": "), а не как production-приложение. Проверьте разрешения в System Settings > Notifications > Electron.",
+ "title": "Режим разработки"
+ },
+ "ignoredRepositories": {
+ "description": "Уведомления из этих репозиториев будут игнорироваться",
+ "empty": "Игнорируемых репозиториев нет",
+ "selectPlaceholder": "Выберите репозиторий для игнорирования...",
+ "title": "Игнорируемые репозитории"
+ },
+ "settings": {
+ "enabled": {
+ "description": "Показывать системные уведомления об ошибках и событиях",
+ "label": "Включить системные уведомления"
+ },
+ "sound": {
+ "description": "Воспроизводить звук при появлении уведомлений",
+ "label": "Воспроизводить звук"
+ },
+ "subagentErrors": {
+ "description": "Обнаруживать ошибки в сессиях субагентов и уведомлять о них",
+ "label": "Включать ошибки субагентов"
+ },
+ "title": "Настройки уведомлений"
+ },
+ "snooze": {
+ "clear": "Снять паузу",
+ "description": "Временно приостановить уведомления",
+ "descriptionWithTime": "Приостановлено до {{time}}",
+ "label": "Приостановить уведомления",
+ "options": {
+ "15": "15 минут",
+ "30": "30 минут",
+ "60": "1 час",
+ "120": "2 часа",
+ "240": "4 часа",
+ "-1": "До завтра"
+ },
+ "selectDuration": "Выберите длительность..."
+ },
+ "taskCompletion": {
+ "description": "Получайте системные уведомления, когда Claude завершает задачи: звуки, баннеры и бейджи Dock/панели задач. Работает на macOS, Linux и Windows.",
+ "installPlugin": "Установить плагин claude-notifications-go",
+ "title": "Уведомления о завершении задач"
+ },
+ "team": {
+ "allTasksCompleted": {
+ "description": "Уведомлять, когда все задачи в команде переходят в статус completed",
+ "label": "Все задачи завершены"
+ },
+ "autoResumeOnRateLimit": {
+ "description": "Когда Claude сообщает время сброса лимита, запланировать follow-up для лида команды после восстановления лимита",
+ "label": "Автовозобновление после rate limit"
+ },
+ "clarifications": {
+ "description": "Показывать системные уведомления, когда задаче нужен ваш ввод",
+ "label": "Уведомления об уточнениях по задачам"
+ },
+ "crossTeamMessage": {
+ "description": "Уведомлять, когда приходит сообщение от другой команды",
+ "label": "Уведомления о сообщениях между командами"
+ },
+ "leadInbox": {
+ "description": "Уведомлять, когда участники команды отправляют сообщения лиду команды",
+ "label": "Уведомления inbox лида"
+ },
+ "statusChange": {
+ "description": "Показывать системные уведомления при изменении статуса задачи",
+ "label": "Уведомления об изменении статуса задач",
+ "onlySolo": {
+ "description": "Уведомлять только когда в команде нет участников",
+ "label": "Только в Solo-режиме"
+ },
+ "statuses": {
+ "description": "Какие целевые статусы вызывают уведомление",
+ "label": "Уведомлять по этим статусам",
+ "options": {
+ "approved": "Одобрено",
+ "completed": "Завершено",
+ "deleted": "Удалено",
+ "in_progress": "Запущено",
+ "needsFix": "Нужны исправления",
+ "pending": "Ожидание",
+ "review": "Ревью"
+ }
+ }
+ },
+ "taskComments": {
+ "description": "Показывать системные уведомления, когда агенты комментируют задачи",
+ "label": "Уведомления о комментариях к задачам"
+ },
+ "taskCreated": {
+ "description": "Показывать системные уведомления при создании новой задачи",
+ "label": "Уведомления о новых задачах"
+ },
+ "teamLaunched": {
+ "description": "Уведомлять, когда команда завершила запуск и готова к работе",
+ "label": "Уведомления о запуске команды"
+ },
+ "title": "Уведомления команды",
+ "toolApproval": {
+ "description": "Уведомлять, когда инструменту нужно ваше подтверждение (Allow/Deny), пока приложение не в фокусе",
+ "label": "Уведомления о подтверждении инструментов"
+ },
+ "userInbox": {
+ "description": "Уведомлять, когда участники команды отправляют сообщения вам",
+ "label": "Уведомления вашего inbox"
+ }
+ },
+ "test": {
+ "action": "Отправить тест",
+ "description": "Отправить тестовое уведомление, чтобы проверить доставку",
+ "failedToSend": "Не удалось отправить тестовое уведомление",
+ "label": "Тестовое уведомление",
+ "sending": "Отправка...",
+ "sent": "Отправлено!",
+ "unknownError": "Неизвестная ошибка"
+ }
+ },
+ "advanced": {
+ "about": {
+ "appIconAlt": "Значок приложения",
+ "description": "Собирайте команды AI-агентов, которые автономно работают параллельно, общаются между командами и управляют задачами на kanban-доске - со встроенным code review, живым мониторингом процессов и полной видимостью инструментов.",
+ "standalone": "Автономно",
+ "title": "О приложении",
+ "version": "Версия {{version}}"
+ },
+ "configuration": {
+ "editConfig": "Редактировать конфиг",
+ "exportConfig": "Экспортировать конфиг",
+ "importConfig": "Импортировать конфиг",
+ "openInEditor": "Открыть в редакторе",
+ "resetToDefaults": "Сбросить по умолчанию",
+ "title": "Конфигурация"
+ },
+ "updates": {
+ "available": "Доступна v{{version}}",
+ "check": "Проверить обновления",
+ "checking": "Проверка...",
+ "ready": "Обновление готово",
+ "unknownVersion": "неизвестная",
+ "upToDate": "Актуальная версия"
+ },
+ "appName": "Agent Teams AI"
+ },
+ "configEditor": {
+ "errors": {
+ "loadFailed": "Не удалось загрузить конфиг",
+ "saveFailed": "Не удалось сохранить конфиг"
+ },
+ "footer": {
+ "autoSave": "Изменения сохраняются автоматически после редактирования",
+ "toClose": "чтобы закрыть",
+ "escapeKey": "Esc"
+ },
+ "loading": "Загрузка конфига...",
+ "status": {
+ "invalidJson": "Некорректный JSON",
+ "saveFailed": "Сохранение не удалось",
+ "saved": "Сохранено",
+ "saving": "Сохранение..."
+ },
+ "title": "Редактирование конфигурации"
+ },
+ "notificationTriggers": {
+ "add": {
+ "cancel": "Отмена",
+ "submit": "Добавить триггер",
+ "title": "Добавить свой триггер"
+ },
+ "builtin": {
+ "description": "Стандартные триггеры, встроенные в приложение. Их можно включать или отключать и настраивать их паттерны.",
+ "title": "Встроенные триггеры"
+ },
+ "card": {
+ "builtinBadge": "Встроенный",
+ "collapseAriaLabel": "Свернуть",
+ "deleteAriaLabel": "Удалить триггер",
+ "editNameAriaLabel": "Редактировать имя",
+ "expandAriaLabel": "Развернуть"
+ },
+ "color": {
+ "customHexTitle": "Пользовательский HEX-цвет",
+ "invalidHex": "Некорректный HEX"
+ },
+ "configuration": {
+ "alertIfGreaterThan": "Уведомить если >",
+ "emptyPatternHint": "Оставьте пустым, чтобы совпадало с любым содержимым. Используется синтаксис JavaScript regex.",
+ "errorStatusDescription": "Срабатывает, когда выполнение инструмента сообщает об ошибке (is_error: true).",
+ "tokensUnit": "токенов",
+ "matchPatternPlaceholder": "например, error|failed|exception"
+ },
+ "custom": {
+ "description": "Создавайте собственные триггеры для уведомлений по конкретным паттернам или выводам инструментов.",
+ "empty": "Пользовательские триггеры пока не настроены.",
+ "title": "Пользовательские триггеры"
+ },
+ "errors": {
+ "invalidRegexPattern": "Некорректный regex-паттерн"
+ },
+ "fields": {
+ "contentType": "Тип содержимого",
+ "matchField": "Поле для поиска",
+ "matchPattern": "Паттерн совпадения (Regex)",
+ "scopeToolName": "Область / инструмент",
+ "scopeToolNameOptional": "Область / инструмент (необязательно)",
+ "threshold": "Порог",
+ "tokenType": "Тип токенов",
+ "triggerNamePlaceholder": "например, Ошибка сборки",
+ "triggerNameRequired": "Название триггера *"
+ },
+ "ignorePatterns": {
+ "hint": "Нажмите Enter, чтобы добавить. Уведомление пропускается, если совпал любой паттерн.",
+ "placeholder": "Добавить ignore regex...",
+ "removeAriaLabel": "Удалить ignore-паттерн",
+ "summary": "Дополнительно: правила исключения",
+ "title": "Ignore-паттерны (пропустить при совпадении)"
+ },
+ "options": {
+ "contentTypes": {
+ "text": "Текстовый вывод",
+ "thinking": "Thinking",
+ "tool_result": "Результат инструмента",
+ "tool_use": "Вызов инструмента"
+ },
+ "matchFields": {
+ "args": "Аргументы",
+ "command": "Команда",
+ "content": "Содержимое",
+ "description": "Описание",
+ "file_path": "Путь к файлу",
+ "fullInput": "Весь ввод (JSON)",
+ "glob": "Glob-фильтр",
+ "new_string": "Новая строка",
+ "old_string": "Старая строка",
+ "path": "Путь",
+ "pattern": "Паттерн",
+ "prompt": "Промпт",
+ "query": "Запрос",
+ "skill": "Название skill",
+ "subagent_type": "Тип субагента",
+ "text": "Текстовое содержимое",
+ "thinking": "Thinking-содержимое",
+ "url": "URL"
+ },
+ "modes": {
+ "content_match": "Паттерн в содержимом",
+ "error_status": "Ошибка выполнения",
+ "token_threshold": "Высокий расход токенов"
+ },
+ "tokenTypes": {
+ "input": "Входные токены",
+ "output": "Выходные токены",
+ "total": "Всего токенов"
+ },
+ "toolNames": {
+ "anyTool": "Любой инструмент"
+ }
+ },
+ "preview": {
+ "defaultTestTriggerName": "Тестовый триггер",
+ "detectedSuffix": "ошибок было бы обнаружено",
+ "more": "...и ещё {{count}}",
+ "more_few": "...и ещё {{count}}",
+ "more_many": "...и ещё {{count}}",
+ "more_one": "...и ещё {{count}}",
+ "more_other": "...и ещё {{count}}",
+ "testTrigger": "Проверить триггер",
+ "testing": "Проверка...",
+ "title": "Предпросмотр",
+ "truncatedWarning": "Поиск остановлен раньше времени (таймаут или лимит количества). Фактических совпадений может быть больше.",
+ "viewSession": "Открыть сессию"
+ },
+ "repositoryScope": {
+ "empty": "Репозитории не выбраны - триггер применяется ко всем репозиториям",
+ "hint": "Если репозитории выбраны, триггер срабатывает только для ошибок в этих репозиториях.",
+ "placeholder": "Выберите репозиторий для добавления...",
+ "summary": "Дополнительно: область репозиториев",
+ "title": "Ограничить репозиториями (применяется только к выбранным)"
+ },
+ "sections": {
+ "configuration": "Конфигурация",
+ "dotColor": "Цвет точки",
+ "generalInfo": "Основная информация",
+ "triggerCondition": "Условие триггера"
+ }
+ },
+ "workspaceProfiles": {
+ "actions": {
+ "addProfile": "Добавить профиль",
+ "cancel": "Отмена",
+ "deleteProfile": "Удалить профиль",
+ "editProfile": "Редактировать профиль",
+ "save": "Сохранить"
+ },
+ "authMethods": {
+ "agent": "SSH Agent",
+ "auto": "Auto (из SSH Config)",
+ "password": "Пароль",
+ "privateKey": "Приватный ключ"
+ },
+ "deleteConfirm": {
+ "confirmLabel": "Удалить",
+ "message": "Вы уверены, что хотите удалить \"{{name}}\"? Это действие нельзя отменить.",
+ "title": "Удалить профиль"
+ },
+ "description": "Сохраняйте SSH-профили для быстрого повторного подключения",
+ "empty": {
+ "description": "Добавьте SSH-профиль, чтобы быстро подключаться",
+ "title": "Сохранённых профилей нет"
+ },
+ "form": {
+ "authentication": "Аутентификация",
+ "host": "Хост",
+ "name": "Название",
+ "passwordPrompt": "Пароль будет запрошен при подключении.",
+ "port": "Порт",
+ "privateKeyPath": "Путь к приватному ключу",
+ "username": "Имя пользователя",
+ "namePlaceholder": "Мой сервер",
+ "hostPlaceholder": "hostname или IP",
+ "usernamePlaceholder": "user"
+ },
+ "loading": "Загрузка профилей...",
+ "title": "Профили рабочих окружений"
+ },
+ "connection": {
+ "actions": {
+ "connect": "Подключиться",
+ "connecting": "Подключение...",
+ "disconnect": "Отключиться",
+ "testConnection": "Проверить подключение",
+ "testing": "Проверка..."
+ },
+ "currentMode": {
+ "description": "Источник данных для файлов сессий",
+ "label": "Текущий режим",
+ "local": "Локально ({{path}})"
+ },
+ "description": "Подключитесь к удалённой машине, чтобы просматривать сессии Claude Code, запущенные там",
+ "form": {
+ "authentication": "Аутентификация",
+ "host": "Хост",
+ "password": "Пароль",
+ "port": "Порт",
+ "privateKeyPath": "Путь к приватному ключу",
+ "username": "Имя пользователя",
+ "hostPlaceholder": "hostname или alias из SSH config",
+ "usernamePlaceholder": "user"
+ },
+ "savedProfiles": {
+ "title": "Сохранённые профили"
+ },
+ "ssh": {
+ "title": "SSH-подключение"
+ },
+ "status": {
+ "connectedTo": "Подключено к {{host}}",
+ "remoteSessions": "Просмотр удалённых сессий через SSH"
+ },
+ "test": {
+ "failed": "Подключение не удалось: {{error}}",
+ "success": "Подключение успешно",
+ "unknownError": "Неизвестная ошибка"
+ },
+ "title": "Удалённое подключение"
+ },
+ "providerRuntime": {
+ "actions": {
+ "cancel": "Отмена",
+ "cancelLogin": "Отменить вход",
+ "connectChatGpt": "Подключить ChatGPT",
+ "delete": "Удалить",
+ "disable": "Отключить",
+ "disconnectAccount": "Отключить аккаунт",
+ "generateLink": "Создать ссылку",
+ "openLogin": "Открыть вход",
+ "reconnectAnthropic": "Переподключить Anthropic",
+ "refresh": "Обновить",
+ "replaceKey": "Заменить ключ",
+ "saveEndpoint": "Сохранить endpoint",
+ "saveKey": "Сохранить ключ",
+ "saving": "Сохранение...",
+ "setApiKey": "Задать API key",
+ "updateKey": "Обновить ключ",
+ "useCode": "Использовать код"
+ },
+ "apiKey": {
+ "loadingStoredCredentials": "Загрузка сохранённых credentials...",
+ "projectScope": "Проект",
+ "scope": "Scope",
+ "storedIn": "Хранится в {{backend}}",
+ "userScope": "Пользователь",
+ "storedInApp": "Сохранено в приложении",
+ "providers": {
+ "anthropic": {
+ "name": "Anthropic API Key",
+ "title": "API key",
+ "description": "Используйте прямой Anthropic API key для доступа с API-billing. Сессия подписки Anthropic останется доступной после переключения обратно.",
+ "placeholder": "sk-ant-..."
+ },
+ "codex": {
+ "name": "Codex API Key",
+ "title": "API key",
+ "description": "Используйте OpenAI API key как дополнительный способ аутентификации Codex. При переключении Codex в режим API key приложение отзеркалит OPENAI_API_KEY в CODEX_API_KEY для native launches.",
+ "placeholder": "sk-proj-..."
+ },
+ "gemini": {
+ "name": "Gemini API Key",
+ "title": "API access",
+ "description": "Используйте `GEMINI_API_KEY` для Gemini API backend. CLI SDK и ADC не требуют его.",
+ "placeholder": "AIza..."
+ }
+ }
+ },
+ "codex": {
+ "account": {
+ "appServer": "App-server: {{state}}",
+ "connected": "Подключено",
+ "description": "Управляйте локальной сессией Codex app-server account, которая используется для subscription-backed native launches.",
+ "loginInProgress": "Вход выполняется",
+ "plan": "План: {{plan}}",
+ "reconnectRequired": "Нужно переподключить",
+ "title": "ChatGPT account",
+ "hints": {
+ "autoUsesApiKeyUntilChatgpt": "{{message}} Auto продолжит использовать найденный API key, пока ChatGPT не подключён.",
+ "detectedApiKeyNeedsApiMode": "{{message}} Найденный API key используется только после переключения Codex в API key mode.",
+ "localArtifactsNoSession": "Codex CLI сейчас не видит активный ChatGPT account. Локальные данные Codex account есть, но активная managed session не выбрана. Лимиты появятся здесь только после того, как Codex CLI увидит аккаунт.",
+ "noActiveAccount": "Codex CLI сейчас не видит активный ChatGPT account. Лимиты появятся здесь только после того, как Codex CLI увидит аккаунт.",
+ "reconnectBeforeUsage": "В Codex локально выбран ChatGPT account, но текущей сессии нужно переподключение, прежде чем здесь загрузятся лимиты.",
+ "usageLimitsAfterReport": "Лимиты появятся здесь после того, как Codex сообщит их для подключённого ChatGPT account."
+ }
+ },
+ "install": {
+ "checking": "Проверка",
+ "downloading": "Загрузка",
+ "installCli": "Установить Codex CLI",
+ "installing": "Установка",
+ "retryInstall": "Повторить установку",
+ "title": "Установить Codex CLI в данные приложения"
+ },
+ "rateLimits": {
+ "credits": "Credits",
+ "creditsDescription": "Credits показываются отдельно от window-based subscription usage и могут быть недоступны для plan-backed ChatGPT-сессий.",
+ "noSecondaryWindow": "Codex не вернул secondary window для этого account snapshot.",
+ "notReported": "Не передано",
+ "primaryReset": "Сброс primary",
+ "primaryUsed": "Primary использовано",
+ "primaryWindow": "Primary window",
+ "remainingLeft": "{{value}} осталось",
+ "remainingUnknown": "Остаток неизвестен",
+ "secondaryReset": "Сброс secondary",
+ "secondaryUsed": "Secondary использовано",
+ "secondaryWindow": "Secondary window",
+ "usedQuotaNote": "Эти проценты показывают использованную квоту, а не остаток.",
+ "weeklyReset": "Сброс weekly",
+ "weeklyUsed": "Weekly использовано",
+ "weeklyUsedOneWeek": "Weekly использовано (1w)",
+ "weeklyWindow": "Weekly window",
+ "secondaryFallback": "secondary",
+ "secondaryWindowNote": " Weekly-лимиты показаны отдельно в окне {{window}}.",
+ "usageExplanationGeneric": "Показывает использованную квоту, а не остаток.",
+ "usageExplanationWindowOnly": "Показывает использованную квоту в текущем окне {{window}}, а не остаток.",
+ "usageExplanationWithRemaining": "Использовано {{used}} - примерно {{remaining}} осталось в текущем окне {{window}}."
+ }
+ },
+ "compatibleEndpoint": {
+ "authToken": "Auth token",
+ "authTokenMissing": "Auth token не настроен.",
+ "baseUrl": "Base URL",
+ "description": "Использовать локальный runtime endpoint, совместимый с Anthropic.",
+ "keepSavedToken": "Оставьте пустым, чтобы сохранить текущий token",
+ "title": "Локальный / compatible endpoint",
+ "tokenStatus": "Token {{status}}",
+ "validation": {
+ "baseUrlRequired": "Base URL обязателен",
+ "firstPartyAnthropic": "Для первого-party Anthropic используйте Auto, Subscription или API key",
+ "httpRequired": "Base URL должен использовать http:// или https://",
+ "invalidUrl": "Недопустимый URL",
+ "noCredentials": "Base URL не должен содержать credentials"
+ },
+ "status": {
+ "endpointDisabledTokenKept": "Endpoint отключён. Сохранённый token оставлен.",
+ "endpointSaved": "Endpoint сохранён",
+ "endpointSavedTokenMissing": "Endpoint сохранён. Auth token не настроен."
+ }
+ },
+ "connection": {
+ "authenticationMethod": "Метод аутентификации",
+ "descriptions": {
+ "anthropic": "Выберите, как запуски Anthropic из приложения проходят аутентификацию.",
+ "codex": "Выберите, должен ли Codex предпочитать ChatGPT subscription или API key при native runtime запуске.",
+ "gemini": "Настройте опциональный API-доступ. CLI SDK и ADC всё равно определяются автоматически.",
+ "opencode": "Аутентификация OpenCode и список провайдеров управляются runtime OpenCode."
+ },
+ "method": "Метод подключения",
+ "mode": "Режим: {{mode}}",
+ "selected": "Выбрано",
+ "switching": "Переключение...",
+ "title": "Подключение"
+ },
+ "connectionCards": {
+ "apiKey": {
+ "title": "API key"
+ },
+ "anthropic": {
+ "apiKeyDescription": "Использовать ANTHROPIC_API_KEY и биллинг Anthropic API.",
+ "autoDescription": "Использовать runtime-настройки Anthropic по умолчанию и лучший локальный credential.",
+ "hint": "Auto оставляет Anthropic на стандартном локальном выборе credentials.",
+ "subscriptionDescription": "Использовать локальную Anthropic sign-in сессию и subscription access.",
+ "subscriptionTitle": "Anthropic subscription"
+ },
+ "auto": {
+ "title": "Авто"
+ },
+ "codex": {
+ "apiKeyDescription": "Использовать OPENAI_API_KEY и CODEX_API_KEY billing для native Codex launches.",
+ "autoDescription": "Предпочитать ChatGPT account и subscription. API key mode использовать только при необходимости.",
+ "chatgptDescription": "Использовать подключённый ChatGPT account и Codex subscription.",
+ "chatgptTitle": "ChatGPT account",
+ "hint": "Codex всегда работает через native runtime. Auto предпочитает ChatGPT account перед API-key credentials."
+ }
+ },
+ "description": "Управляйте тем, как каждый провайдер подключается, и какой backend должен использовать multimodel runtime, если это поддерживается.",
+ "fastMode": {
+ "defaultOff": "По умолчанию выкл.",
+ "description": "Включать Claude Code Fast mode по умолчанию для новых запусков Anthropic-команд, когда выбранные модель и runtime это поддерживают.",
+ "disabledHint": "Новые Anthropic-запуски остаются на обычной скорости, если команда явно не включает Fast mode.",
+ "enabledHint": "Новые Anthropic-запуски будут запрашивать Fast mode по умолчанию, когда выбранная модель это поддерживает.",
+ "notExposed": "Этот Anthropic runtime не предоставляет Fast mode.",
+ "preferFast": "Предпочитать Fast",
+ "title": "Fast mode по умолчанию",
+ "unavailableForRuntime": "Fast mode сейчас недоступен для этого Anthropic runtime."
+ },
+ "alerts": {
+ "anthropicApiKeyMissing": "Выбран API key mode, но Anthropic API credential пока недоступен.",
+ "anthropicStoredKeyAvailable": "Сохранённый API key доступен, но запуски Anthropic из приложения используют его только после переключения в API key mode.",
+ "anthropicSubscriptionMissing": "Выбран Anthropic subscription mode. Войдите в Anthropic, чтобы использовать этого провайдера.",
+ "authTokenMissing": "Auth token не настроен. Многим локальным Anthropic-compatible endpoints нужен непустой token.",
+ "chatgptLoginPending": "Ожидание завершения входа в ChatGPT account...",
+ "chatgptLoginStarting": "Запуск входа в ChatGPT...",
+ "codexApiKeyMissing": "Выбран API key mode, но OPENAI_API_KEY или CODEX_API_KEY credential пока недоступен.",
+ "codexLocalArtifactsNoSession": "Codex CLI сейчас не видит активный ChatGPT account. Локальные данные Codex account есть, но активная managed session не выбрана.",
+ "codexNeedsReconnect": "В Codex локально выбран ChatGPT account, но текущей сессии нужно переподключение.",
+ "codexNoChatgptAccount": "Codex CLI сейчас не видит активный ChatGPT account. Подключите ChatGPT, чтобы использовать subscription.",
+ "codexNoCredential": "ChatGPT account или API key пока недоступны.",
+ "geminiApiUnavailable": "Gemini API сейчас недоступен. Настройте `GEMINI_API_KEY` здесь или используйте корректные Google ADC credentials.",
+ "withApiKeyFallback": "{{message}} Переключитесь в API key mode, чтобы использовать найденный API key."
+ },
+ "authModeDescriptions": {
+ "anthropic": {
+ "apiKey": "Принудительно использовать API key credential для Anthropic-запусков из приложения.",
+ "auto": "Использовать стандартное поведение runtime. Сохранённые API keys в приложении используются только после переключения в API key mode.",
+ "oauth": "Принудительно использовать локальную Anthropic subscription session для Anthropic-запусков из приложения."
+ },
+ "codex": {
+ "apiKey": "Принудительно использовать OPENAI_API_KEY / CODEX_API_KEY billing для native Codex launches.",
+ "auto": "Предпочитать ChatGPT account, когда он доступен. Переходить к API key mode только при необходимости.",
+ "chatgpt": "Принудительно использовать подключённый ChatGPT account и subscription для native Codex launches."
+ }
+ },
+ "progress": {
+ "applyingConnectionChanges": "Применение изменений подключения...",
+ "refreshingProviderStatus": "Обновление статуса провайдера...",
+ "savingCompatibleEndpoint": "Сохранение compatible endpoint...",
+ "switchingAnthropicSubscription": "Переключение на Anthropic subscription...",
+ "switchingApiKey": "Переключение на API key...",
+ "switchingApiKeyMode": "Переключение в API key mode...",
+ "switchingAuto": "Переключение на Авто...",
+ "switchingChatgpt": "Переключение в ChatGPT account mode..."
+ },
+ "provider": "Провайдер",
+ "runtime": {
+ "descriptions": {
+ "anthropic": "У Anthropic сейчас нет отдельного выбора runtime backend.",
+ "codex": "Codex теперь работает только через native runtime path.",
+ "gemini": "Выберите, какой Gemini runtime backend должен использовать multimodel.",
+ "opencode": "OpenCode использует собственный managed runtime host. Desktop сейчас показывает только статус."
+ },
+ "title": "Runtime",
+ "updating": "Обновление runtime..."
+ },
+ "runtimeSummary": "Runtime: {{runtime}}",
+ "status": {
+ "configured": "настроен",
+ "enabled": "Включено",
+ "notConfigured": "Не настроен",
+ "notSet": "не задан",
+ "off": "Выкл.",
+ "unknown": "Неизвестно"
+ },
+ "title": "Настройки провайдера",
+ "usage": {
+ "apiKey": "Используется API key",
+ "apiKeyRequired": "Нужен API key",
+ "compatibleEndpoint": "Используется compatible endpoint",
+ "notConnected": "Не подключено",
+ "usingMethod": "Используется {{method}}"
+ },
+ "errors": {
+ "apiKeyDeletedRefreshFailed": "API key удалён, но не удалось обновить статус провайдера.",
+ "apiKeySavedRefreshFailed": "API key сохранён, но не удалось обновить статус провайдера.",
+ "connectionUpdatedRefreshFailed": "Подключение обновлено, но не удалось обновить статус провайдера.",
+ "deleteApiKey": "Не удалось удалить API key",
+ "disableEndpoint": "Не удалось отключить endpoint",
+ "endpointDisabledRefreshFailed": "Endpoint отключён, но не удалось обновить статус провайдера.",
+ "endpointSavedRefreshFailed": "Endpoint сохранён, но не удалось обновить статус провайдера.",
+ "refreshCodexAccount": "Не удалось обновить Codex account",
+ "saveApiKey": "Не удалось сохранить API key",
+ "saveEndpoint": "Не удалось сохранить endpoint",
+ "updateAnthropicFastMode": "Не удалось обновить Anthropic Fast mode",
+ "updateConnection": "Не удалось обновить подключение",
+ "updateRuntimeBackend": "Не удалось обновить runtime backend",
+ "apiKeyRequired": "API key обязателен"
+ },
+ "connectionUi": {
+ "authMode": {
+ "auto": "Авто",
+ "oauth": "Подписка / OAuth",
+ "chatgpt": "Аккаунт ChatGPT",
+ "apiKey": "API key",
+ "anthropicSubscription": "Подписка Anthropic"
+ },
+ "authMethod": {
+ "apiKey": "API key",
+ "apiKeyHelper": "API key helper",
+ "oauth": "OAuth",
+ "claudeSubscription": "Подписка Claude",
+ "geminiCli": "Gemini CLI",
+ "googleAccount": "Аккаунт Google",
+ "serviceAccount": "service account"
+ },
+ "runtime": {
+ "codexNative": "Codex native",
+ "currentRuntime": "Текущий runtime",
+ "selectedRuntime": "Выбранный runtime",
+ "summary": "{{prefix}}: {{runtime}}"
+ },
+ "status": {
+ "checking": "Проверка...",
+ "checked": "Проверено",
+ "providerActivity": "Активность провайдеров",
+ "notConnected": "Не подключено",
+ "startingChatGptLogin": "Запускается вход в ChatGPT...",
+ "waitingForChatGptLogin": "Ожидание входа в аккаунт ChatGPT...",
+ "chatGptVerificationDegraded": "Аккаунт ChatGPT найден - проверка аккаунта сейчас работает в ограниченном режиме.",
+ "chatGptAccountReady": "Аккаунт ChatGPT готов",
+ "apiKeyReady": "API key готов",
+ "codexLocalAccountNeedsReconnect": "В Codex локально выбран аккаунт ChatGPT, но текущей сессии нужно переподключение.",
+ "codexNoActiveManagedSession": "Codex CLI сообщает, что активного входа ChatGPT нет. Локальные данные аккаунта Codex есть, но активная управляемая сессия не выбрана.",
+ "codexNoActiveChatGptLogin": "Codex CLI сообщает, что активного входа ChatGPT нет",
+ "connectChatGptForSubscription": "Подключите аккаунт ChatGPT, чтобы использовать подписку Codex.",
+ "codexNativeReady": "Codex native готов",
+ "codexNativeUnavailable": "Codex native недоступен",
+ "unavailableInCurrentRuntime": "Недоступно в текущем runtime",
+ "connectedViaApiKey": "Подключено через API key",
+ "apiKeyConfiguredNotVerified": "API key настроен, но ещё не проверен",
+ "apiKeyModeMissingCredential": "Выбран режим API key, но API key не настроен",
+ "connectedVia": "Подключено через {{method}}",
+ "unableToVerify": "Не удалось проверить"
+ },
+ "mode": {
+ "selectedAuth": "Выбранная аутентификация: {{authMode}}",
+ "preferredAuth": "Предпочитаемая аутентификация: {{authMode}}"
+ },
+ "credential": {
+ "apiKeyConfigured": "API key настроен",
+ "savedApiKeyAvailable": "Сохранённый API key доступен в Manage",
+ "apiKeyAlsoConfigured": "API key также настроен в Manage",
+ "apiKeyConfiguredInManage": "API key настроен в Manage",
+ "apiKeyFallbackInManage": "API key также доступен в Manage как fallback",
+ "availableAsFallback": "{{summary}} - доступен как fallback",
+ "savedApiKeyAvailableIfSwitch": "Сохранённый API key доступен в Manage, если переключиться в режим API key",
+ "availableIfSwitch": "{{summary}} - доступен при переключении в режим API key",
+ "autoWillUseUntilChatGpt": "{{summary}} - Auto будет использовать его, пока ChatGPT не подключён"
+ },
+ "actions": {
+ "connect": "Подключить",
+ "connectAnthropic": "Подключить Anthropic",
+ "connectChatGpt": "Подключить ChatGPT",
+ "disconnect": "Отключить",
+ "openLogin": "Открыть вход"
+ },
+ "disconnect": {
+ "anthropicTitle": "Отключить подписку Anthropic?",
+ "anthropic": "Это удалит локальную сессию подписки Anthropic из runtime Claude CLI.",
+ "anthropicWithApiKey": "Это удалит локальную сессию подписки Anthropic из runtime Claude CLI. Сохранённые API keys в Manage останутся доступными.",
+ "geminiTitle": "Отключить Gemini CLI?",
+ "gemini": "Это очистит локальные метаданные сессии Gemini CLI. Внешние ADC credentials и сохранённые API keys не удаляются."
+ }
+ }
+ },
+ "cliRuntime": {
+ "actions": {
+ "checkForUpdates": "Проверить обновления",
+ "checking": "Проверка...",
+ "extensions": "Расширения",
+ "installRuntime": "Установить {{runtime}}",
+ "manage": "Управлять",
+ "recheck": "Проверить снова",
+ "reinstallRuntime": "Переустановить {{runtime}}",
+ "retry": "Повторить",
+ "update": "Обновить"
+ },
+ "installer": {
+ "checkingLatest": "Проверка последней версии...",
+ "downloading": "Загрузка...",
+ "failed": "Установка не удалась",
+ "installed": "Установлено v{{version}}",
+ "installing": "Установка...",
+ "latest": "latest",
+ "verifying": "Проверка checksum..."
+ },
+ "labels": {
+ "multimodel": "Multimodel"
+ },
+ "loading": {
+ "aiProviders": "Проверка AI-провайдеров...",
+ "claudeCli": "Проверка Claude CLI..."
+ },
+ "provider": {
+ "backend": "Backend: {{backend}}",
+ "loadingModels": "Загрузка моделей...",
+ "modelsUnavailable": "Модели недоступны для этой сборки runtime",
+ "runtime": "Runtime: {{runtime}}"
+ },
+ "providerTerminal": {
+ "authFailed": "Аутентификация не удалась",
+ "authUpdated": "Аутентификация обновлена",
+ "loggedOut": "Провайдер отключён",
+ "login": "Вход",
+ "logout": "Выход",
+ "logoutFailed": "Выход не удался"
+ },
+ "status": {
+ "configuredNotFound": "Настроенный {{runtime}} не найден.",
+ "foundButFailed": "{{runtime}} найден, но не запустился",
+ "healthCheckFailed": "Настроенный {{runtime}} не прошёл health check запуска.",
+ "notInstalled": "{{runtime}} не установлен"
+ },
+ "title": "CLI Runtime"
+ },
+ "cliStatus": {
+ "versionUpgrade": "v{{current}} -> v{{latest}}"
+ }
+}
diff --git a/src/features/localization/renderer/locales/ru/team.json b/src/features/localization/renderer/locales/ru/team.json
new file mode 100644
index 00000000..357a7b52
--- /dev/null
+++ b/src/features/localization/renderer/locales/ru/team.json
@@ -0,0 +1,2415 @@
+{
+ "activity": {
+ "actions": {
+ "createTaskFromMessage": "Создать задачу из сообщения",
+ "expandMessage": "Развернуть сообщение",
+ "replyToMessage": "Ответить на сообщение",
+ "restartTeam": "Перезапустить команду"
+ },
+ "authError": {
+ "description": "Не удалось пройти authentication. Перезапуск команды обновит session и может исправить проблему. Если ошибка повторится, проверьте API credentials или попробуйте позже."
+ },
+ "automation": {
+ "reviewPickup": "Teammate получил просьбу забрать review",
+ "stallNudge": "Teammate получил просьбу продолжить stalled task",
+ "workSyncBody": "Teammate получил просьбу синхронизировать текущую работу"
+ },
+ "badges": {
+ "automation": "automation",
+ "bootstrap": "bootstrap",
+ "command": "command",
+ "comment": "Комментарий",
+ "live": "live",
+ "note": "note",
+ "rateLimited": "Rate limit",
+ "restart": "restart",
+ "result": "result",
+ "session": "session",
+ "stallNudge": "stall nudge",
+ "start": "start",
+ "workSync": "work sync"
+ },
+ "bootstrap": {
+ "acknowledged": "Bootstrap подтверждён",
+ "restarting": "Перезапуск teammate",
+ "starting": "Запуск teammate"
+ },
+ "rawJson": "Raw JSON",
+ "unread": "Непрочитано",
+ "thoughts": {
+ "count": "{{count}} мыслей",
+ "count_one": "{{count}} мысль",
+ "count_few": "{{count}} мысли",
+ "count_many": "{{count}} мыслей",
+ "count_other": "{{count}} мысли",
+ "expand": "Развернуть мысли",
+ "showMore": "Показать больше",
+ "showLess": "Показать меньше",
+ "toolSummary": "🔧 {{summary}}",
+ "titleForMember": "{{name}} - мысли"
+ },
+ "timeline": {
+ "loadingMessages": "Загрузка сообщений...",
+ "noMessages": "Нет сообщений",
+ "emptyHint": "Отправьте сообщение участнику, чтобы увидеть активность.",
+ "newSession": "Новая сессия",
+ "olderCount": "+{{count}} старых",
+ "olderCount_one": "+{{count}} старое",
+ "olderCount_few": "+{{count}} старых",
+ "olderCount_many": "+{{count}} старых",
+ "olderCount_other": "+{{count}} старых",
+ "showMore": "Показать ещё {{count}}",
+ "showAll": "Показать все"
+ },
+ "pendingReplies": {
+ "title": "Ожидают ответа",
+ "openMember": "Открыть участника",
+ "messageSentAwaitingReply": "Сообщение отправлено, ожидается ответ",
+ "awaitingReply": "ожидает ответа",
+ "externalTeam": "внешняя команда",
+ "crossTeamAwaitingReply": "Межкомандное сообщение отправлено, ожидается ответ",
+ "user": "пользователь",
+ "awaitingApproval": "ожидает подтверждения"
+ },
+ "reply": {
+ "replyingTo": "Ответ на",
+ "action": "Ответить"
+ },
+ "activeTasks": {
+ "inProgress": "В работе"
+ },
+ "expandDialog": {
+ "description": "Развёрнутый просмотр сообщения"
+ }
+ },
+ "create": {
+ "actions": {
+ "create": "Создать",
+ "creating": "Создание...",
+ "openExisting": "Открыть существующую команду",
+ "skipPreflightAndCreate": "Пропустить preflight и создать"
+ },
+ "conflict": {
+ "description": "Запуск двух команд в одной директории рискован - они могут конфликтовать при изменении одних и тех же файлов. Лучше выбрать другую директорию или git worktree для изоляции.",
+ "title": "Другая команда \"{{team}}\" уже работает в этой working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "copy": "Создать новую команду на основе существующей.",
+ "create": "Настройте команду и выберите, как она будет запускаться."
+ },
+ "errors": {
+ "nameExists": "Команда с таким именем уже существует",
+ "nameLaunching": "Команда с таким именем сейчас запускается",
+ "createConfigFailed": "Не удалось создать конфиг команды",
+ "loadProjectsFailed": "Не удалось загрузить проекты"
+ },
+ "fields": {
+ "color": "Цвет (optional)",
+ "description": "Описание (optional)",
+ "prompt": "Prompt для team lead (optional)",
+ "teamName": "Имя команды"
+ },
+ "launchAfterCreate": {
+ "description": "Сразу запустить команду через локальный Claude CLI.",
+ "label": "Выполнить команду после создания"
+ },
+ "localOnly": "Доступно только в локальном Electron mode.",
+ "onDisk": "На диске:",
+ "placeholders": {
+ "description": "Краткое описание назначения команды",
+ "prompt": "Инструкции для team lead во время provisioning..."
+ },
+ "saved": "Сохранено",
+ "solo": {
+ "description": "Будет запущен только team lead (main process) - teammates не будут созданы. Работает как обычная agent session в выбранном runtime (Claude Code, Codex, OpenCode, Gemini), но с доступом к task board для планирования. Экономит tokens за счёт отсутствия coordination overhead между teammates. Участников можно добавить позже в настройках команды.",
+ "label": "Solo team"
+ },
+ "title": {
+ "copy": "Копировать команду",
+ "create": "Создать команду"
+ },
+ "optional": {
+ "launchSettingsTitle": "Опциональные настройки запуска",
+ "launchSettingsDescription": "Промпт, безопасность и переопределения CLI находятся здесь, когда они нужны.",
+ "teamDetailsTitle": "Опциональные детали команды",
+ "teamDetailsDescription": "Основной поток остаётся компактным. Открывайте этот блок, когда нужен дополнительный контекст или свой цвет."
+ },
+ "prepare": {
+ "unsupportedPreload": "Текущая версия preload не поддерживает team:prepareProvisioning. Перезапустите dev app.",
+ "selectWorkingDirectory": "Выберите рабочую директорию, чтобы проверить окружение запуска.",
+ "someProvidersNeedAttention": "Некоторым выбранным providers нужно внимание.",
+ "readyWithNotes": "Все выбранные providers готовы, есть заметки.",
+ "ready": "Все выбранные providers готовы.",
+ "failed": "Не удалось подготовить выбранных providers",
+ "checkingProviders": "Проверка выбранных providers...",
+ "preparingEnvironment": "Подготовка окружения...",
+ "selectedProvidersReadyWithNotes": "Выбранные providers готовы (есть заметки)",
+ "selectedProvidersReady": "Выбранные providers готовы"
+ },
+ "validation": {
+ "nameMustContainLetterOrDigit": "Имя должно содержать хотя бы одну букву или цифру",
+ "nameTooLong": "Имя слишком длинное (максимум 128 символов)",
+ "selectWorkingDirectory": "Выберите рабочую директорию (cwd)",
+ "memberNameRequired": "Имя участника не может быть пустым",
+ "memberNameInvalid": "Имя участника должно начинаться с буквы или цифры и содержать только [a-zA-Z0-9._-], максимум 128 символов",
+ "memberNamesUnique": "Имена участников должны быть уникальными",
+ "openCodeLeadModelRequired": "Для lead на OpenCode нужно выбрать модель.",
+ "openCodeTeammateRequired": "Для lead на OpenCode нужен хотя бы один teammate OpenCode.",
+ "teamLaunching": "Команда сейчас запускается",
+ "teamNameExists": "Команда с таким именем уже существует",
+ "checkFormFields": "Проверьте поля формы"
+ }
+ },
+ "editTeam": {
+ "actions": {
+ "cancel": "Отмена",
+ "save": "Сохранить"
+ },
+ "addMemberLockReason": "Используйте отдельный диалог добавления участника, чтобы добавлять teammates во время работы команды.",
+ "description": "Измените имя, описание и цвет команды",
+ "errors": {
+ "changesSavedRefreshFailed": "Изменения команды сохранены, но не удалось обновить последний вид: {{message}}",
+ "liveRenameBlocked": "Нельзя переименовывать существующих teammates, пока команда работает. Переименованы: {{names}}",
+ "memberNameEmpty": "Имя участника не может быть пустым",
+ "memberNameInvalid": "Имя участника должно начинаться с буквы или цифры, использовать только [a-zA-Z0-9._-] и быть не длиннее 128 символов",
+ "memberNameNumericSuffix": "Имя участника \"{{name}}\" недоступно, потому что зарезервировано для auto-suffix Claude CLI. Используйте \"{{base}}\".",
+ "memberNameReserved": "Имя участника \"{{name}}\" зарезервировано",
+ "memberNamesUnique": "Перед сохранением имена участников должны быть уникальными",
+ "newLiveTeammates": "Добавляйте новых teammates через отдельный диалог добавления участника, пока команда работает. Edit Team поддерживает только изменение существующих teammates.",
+ "provisioning": "Настройки команды нельзя редактировать, пока provisioning ещё идёт. Дождитесь завершения запуска и попробуйте снова.",
+ "restartFailedMany": "Команда сохранена, но не удалось перезапустить этих teammates: {{failures}}",
+ "restartFailedOne": "Команда сохранена, но не удалось перезапустить этого teammate: {{failures}}",
+ "saveFailed": "Не удалось сохранить",
+ "settingsChanged": "Настройки команды изменились, пока диалог был открыт. Откройте его заново и проверьте актуальное состояние перед сохранением.",
+ "settingsSavedMembersAndRefreshFailed": "Настройки команды сохранены, но изменения участников не применились: {{message}}. Обновление тоже завершилось ошибкой: {{refreshError}}",
+ "settingsSavedMembersFailed": "Настройки команды сохранены, но изменения участников не применились: {{message}}",
+ "settingsSavedRefreshFailed": "Настройки команды сохранены, но не удалось обновить последний вид: {{message}}",
+ "teamNameEmpty": "Имя команды не может быть пустым",
+ "unsupportedMixedPrimaryMutation": "Live-изменения primary-owned teammates в mixed OpenCode teams пока не поддерживаются. Остановите команду, измените roster и запустите заново. Затронуты: {{names}}"
+ },
+ "fields": {
+ "colorOptional": "Цвет (необязательно)",
+ "description": "Описание",
+ "name": "Имя"
+ },
+ "memberRestartWarning": "Сохранение перезапустит этого teammate, чтобы применить изменения роли, workflow, worktree isolation, provider, model, effort или MCP access.",
+ "notices": {
+ "liveRenameBlocked": "Live-сохранение заблокировано, потому что существующие teammates были переименованы. Отмените эти identity changes или сначала остановите команду.",
+ "newLiveTeammates": "Новых teammates нельзя добавлять из Edit Team, пока команда работает. Используйте диалог добавления участника.",
+ "provisioning": "Provisioning команды ещё идёт. Редактирование временно заблокировано до завершения запуска.",
+ "restartMany": "Сохранение перезапустит или перезапустит заново этих teammates, чтобы применить изменения роли, workflow, worktree isolation, provider, model, effort или MCP access: {{names}}.",
+ "restartOne": "Сохранение перезапустит или перезапустит заново этого teammate, чтобы применить изменения роли, workflow, worktree isolation, provider, model, effort или MCP access: {{names}}.",
+ "unsupportedMixedPrimaryMutation": "Live-изменения или удаления primary-owned teammates в mixed OpenCode teams требуют остановки и повторного запуска команды: {{names}}."
+ },
+ "placeholders": {
+ "description": "Описание команды (необязательно)",
+ "teamName": "Имя команды"
+ },
+ "teamLead": {
+ "changeRuntime": "Изменить runtime lead",
+ "changeRuntimeDescription": "Откройте Relaunch Team, чтобы изменить provider, model или effort для lead.",
+ "modelLockReason": "Runtime team lead управляется через Relaunch Team.",
+ "readOnlyHint": "Имя и роль team lead здесь доступны только для чтения. Откройте runtime panel в строке lead, чтобы изменить provider, model или effort.",
+ "role": "Team Lead"
+ },
+ "title": "Редактировать команду"
+ },
+ "memberDraft": {
+ "actions": {
+ "remove": "Удалить участника",
+ "removeAria": "Удалить {{name}}",
+ "restore": "Восстановить участника",
+ "restoreAria": "Восстановить {{name}}"
+ },
+ "anthropicContext": {
+ "defaultSetting": "настройка контекста по умолчанию",
+ "description": "Контекст Anthropic действует на всю команду для этого запуска: {{mode}}. Используйте checkbox Limit context в runtime panel lead, чтобы изменить это.",
+ "limitEnabled": "лимит 200K включён"
+ },
+ "mcp": {
+ "buttonInherit": "MCP inherit",
+ "buttonScopes": "MCP scopes",
+ "chooseScopes": "Выбрать scopes",
+ "inheritLead": "Наследовать lead",
+ "lockedInfo": "Для всех teammates включён режим только Agent Teams MCP. Этот teammate запустится только с Agent Teams server.",
+ "mode": "MCP mode",
+ "scopes": {
+ "local": "local",
+ "project": "project",
+ "user": "user"
+ },
+ "serverNames": "Имена servers",
+ "settingInfo": "Agent Teams MCP запускает этого teammate только с Agent Teams server. Scope и allowlist modes применяются только к запуску этого teammate.",
+ "strictAllowlist": "Строгий allowlist",
+ "tooltip": "{{label}}: управление MCP inheritance policy этого участника",
+ "agentTeamsMcp": "Agent Teams MCP"
+ },
+ "model": {
+ "ariaLabel": "{{provider}} provider, {{model}}",
+ "currentLeadRuntime": "Текущий runtime lead",
+ "default": "По умолчанию",
+ "inheritedTooltip": "Provider, model и effort наследуются от lead, пока включена синхронизация.",
+ "leadSuffix": "{{label}} (lead)",
+ "liveDisabled": "Изменения provider, model и effort отключены, пока команда работает. Переподключите команду, чтобы применить их безопасно.",
+ "lockedActionFallback": "Изменения runtime lead открывают Relaunch Team, где можно обновить provider, model и effort.",
+ "restartWholeTeam": "Сохранение этих runtime changes перезапустит всю команду."
+ },
+ "nameAria": "Имя участника {{index}}",
+ "nameFallback": "участник {{index}}",
+ "noRole": "Без роли",
+ "removed": "Удалён",
+ "workflow": {
+ "addTooltip": "Добавить workflow teammate",
+ "editTooltip": "Редактировать workflow teammate",
+ "label": "Workflow (необязательно)",
+ "placeholder": "Как этот agent должен работать и взаимодействовать с другими...",
+ "saved": "Сохранено"
+ },
+ "worktree": {
+ "description": "Запускать этого teammate в отдельном git worktree. Apply/reject changes будут работать с этим worktree, а не с workspace lead.",
+ "label": "Worktree"
+ },
+ "addMembers": {
+ "title": "Добавить участников",
+ "description": "Добавить новых участников в {{teamName}}"
+ },
+ "placeholders": {
+ "name": "member-name",
+ "mcpServers": "github, sentry"
+ }
+ },
+ "detail": {
+ "actions": {
+ "add": "Добавить",
+ "cancel": "Отмена",
+ "delete": "Удалить",
+ "editCode": "Редактировать код",
+ "launch": "Запустить",
+ "remove": "Удалить",
+ "stop": "Остановить",
+ "task": "Задача",
+ "visualize": "Визуализировать"
+ },
+ "deleteTeam": {
+ "description": "Удалить команду \"{{team}}\"? Это действие необратимо. Все данные команды и задачи будут удалены.",
+ "title": "Удалить команду"
+ },
+ "draft": {
+ "descriptionPrefix": "Это draft-команда -",
+ "descriptionSuffix": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_few": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_many": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_one": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "descriptionSuffix_other": "настроена с {{count}} {{member}}, но ещё не provisioned через CLI. Нажмите «Запустить», чтобы выбрать модель и стартовать команду.",
+ "member": "участниками",
+ "member_few": "участниками",
+ "member_many": "участниками",
+ "member_one": "участником",
+ "member_other": "участниками",
+ "title": "Команда ещё не запущена"
+ },
+ "invalidTab": "Некорректная вкладка команды",
+ "kanbanSafeData": "Не удалось полностью загрузить kanban. Показаны безопасные данные.",
+ "loadFailed": "Не удалось загрузить команду",
+ "loading": "Загрузка команды",
+ "loadingSidebar": "Загрузка sidebar команды",
+ "offline": {
+ "offline": "Команда offline",
+ "partialFailed": "Последний запуск частично не удался",
+ "partialMissing": "Последний запуск частично не удался - {{missing}}/{{expected}} teammates не подключились",
+ "reconciling": "Последний запуск ещё сверяется"
+ },
+ "previous": "Предыдущие: {{paths}}",
+ "removeMember": {
+ "description": "Удалить \"{{member}}\" из команды? Задачи и сообщения сохранятся, но это имя нельзя будет использовать повторно.",
+ "title": "Удалить участника"
+ },
+ "sections": {
+ "team": "Команда"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Активно",
+ "launching": "Запуск...",
+ "running": "Работает"
+ },
+ "telemetry": {
+ "cpu": "CPU",
+ "memory": "Memory"
+ },
+ "tooltips": {
+ "deleteTeam": "Удалить команду",
+ "editTeam": "Редактировать команду",
+ "editUnavailableProvisioning": "Редактирование недоступно, пока provisioning ещё выполняется",
+ "openBuiltInEditor": "Открыть проект во встроенном редакторе",
+ "openTeamGraph": "Открыть граф команды",
+ "stopTeam": "Остановить команду"
+ },
+ "waitingForProvisioning": "Данные команды появятся после завершения provisioning",
+ "context": {
+ "title": "Контекст"
+ }
+ },
+ "review": {
+ "fileHeader": {
+ "actions": {
+ "accept": "Принять",
+ "discard": "Отменить",
+ "discardTooltip": "Отменить все правки в этом файле",
+ "keepMyDraft": "Оставить мой черновик",
+ "reject": "Отклонить",
+ "reloadFromDisk": "Перезагрузить с диска",
+ "restore": "Восстановить",
+ "restoreTooltip": "Создать или восстановить этот файл на диске из предпросмотра",
+ "saveFile": "Сохранить файл",
+ "saveFileTooltip": "Сохранить файл на диск"
+ },
+ "badges": {
+ "deleted": "УДАЛЁН",
+ "manualReview": "РУЧНОЙ REVIEW",
+ "new": "НОВЫЙ",
+ "worktree": "WORKTREE"
+ },
+ "contentSource": {
+ "disk-current": "Текущее состояние диска",
+ "file-history": "История файла",
+ "git-fallback": "Git fallback",
+ "ledger-exact": "Task ledger",
+ "ledger-snapshot": "Снимок ledger",
+ "snippet-reconstruction": "Реконструировано",
+ "unavailable": "Содержимое недоступно"
+ },
+ "contentUnavailable": {
+ "badge": "Содержимое недоступно",
+ "description": "В ledger записаны metadata для этого изменения, но полный текст недоступен. Обычно это binary, большой файл или hash-only content.",
+ "safety": "Автоматические accept/reject отключены для этого файла, чтобы избежать небезопасной записи на диск.",
+ "title": "Текстовое содержимое недоступно"
+ },
+ "disabled": {
+ "acceptRejectContentUnavailable": "Accept/Reject отключён, потому что полный текст недоступен.",
+ "acceptRejectMissingOnDisk": "Accept/Reject отключён, пока файл отсутствует на диске.",
+ "rejectBaselineUnavailable": "Reject отключён, потому что исходный baseline недоступен.",
+ "rejectContentUnavailable": "Reject отключён, потому что полный текст недоступен.",
+ "rejectManualLedgerReview": "Reject отключён, потому что это ledger-изменение содержит binary, большой или недоступный content."
+ },
+ "externalChange": {
+ "changedOnDisk": "Изменён на диске",
+ "deletedOnDisk": "Удалён на диске",
+ "recreatedOnDisk": "Создан заново на диске"
+ },
+ "missingOnDisk": {
+ "badge": "Отсутствует на диске",
+ "description": "Предпросмотр из agent logs всё ещё доступен, но filesystem не синхронизирован.",
+ "restorePrefix": "Нажмите",
+ "restoreSuffix": "чтобы записать preview content обратно на диск.",
+ "restoreUnavailable": "Полное содержимое файла недоступно для автоматического восстановления.",
+ "title": "Файл отсутствует на диске"
+ },
+ "pathChange": {
+ "from": "Из {{path}}",
+ "to": "В {{path}}"
+ },
+ "worktree": {
+ "isolated": "Изолированный worktree"
+ }
+ },
+ "toolbar": {
+ "stats": {
+ "pending": "{{count}} pending",
+ "pending_one": "{{count}} pending",
+ "pending_few": "{{count}} pending",
+ "pending_many": "{{count}} pending",
+ "pending_other": "{{count}} pending",
+ "accepted": "{{count}} accepted",
+ "accepted_one": "{{count}} accepted",
+ "accepted_few": "{{count}} accepted",
+ "accepted_many": "{{count}} accepted",
+ "accepted_other": "{{count}} accepted",
+ "rejected": "{{count}} rejected",
+ "rejected_one": "{{count}} rejected",
+ "rejected_few": "{{count}} rejected",
+ "rejected_many": "{{count}} rejected",
+ "rejected_other": "{{count}} rejected",
+ "acrossFiles": "в {{count}} файлах",
+ "acrossFiles_one": "в {{count}} файле",
+ "acrossFiles_few": "в {{count}} файлах",
+ "acrossFiles_many": "в {{count}} файлах",
+ "acrossFiles_other": "в {{count}} файлах",
+ "edited": "{{count}} edited",
+ "edited_one": "{{count}} edited",
+ "edited_few": "{{count}} edited",
+ "edited_many": "{{count}} edited",
+ "edited_other": "{{count}} edited"
+ },
+ "actions": {
+ "auto": "Auto",
+ "undo": "Отменить",
+ "acceptAll": "Принять всё",
+ "rejectAll": "Отклонить всё",
+ "applying": "Применение...",
+ "applyRejections": "Применить отклонения"
+ },
+ "tooltips": {
+ "autoOn": "Auto-mark файлов как viewed при прокрутке до конца (ON)",
+ "autoOff": "Auto-mark файлов как viewed при прокрутке до конца (OFF)",
+ "undo": "Отменить последнюю review operation (Ctrl+Z)",
+ "acceptAll": "Принять все изменения во всех файлах",
+ "rejectAll": "Отклонить все безопасно отклоняемые изменения во всех файлах",
+ "rejectAllDisabled": "У pending файлов нет безопасного original baseline для отклонения.",
+ "applyRejections": "Применить отклонённые hunks на диск; принятые изменения останутся как есть"
+ }
+ },
+ "diffError": {
+ "title": "Не удалось отрендерить diff view",
+ "unexpected": "При рендеринге diff произошла неожиданная ошибка.",
+ "actions": {
+ "retry": "Повторить"
+ },
+ "raw": {
+ "show": "Показать raw diff data",
+ "file": "Файл: {{file}}",
+ "original": "--- Original",
+ "modified": "+++ Modified",
+ "charsTotal": "... (всего символов: {{count}})",
+ "charsTotal_one": "... (всего {{count}} символ)",
+ "charsTotal_few": "... (всего {{count}} символа)",
+ "charsTotal_many": "... (всего {{count}} символов)",
+ "charsTotal_other": "... (всего {{count}} символов)"
+ }
+ },
+ "fileTree": {
+ "viewed": "Просмотрено",
+ "badges": {
+ "new": "new",
+ "deleted": "deleted"
+ },
+ "collapseFolder": "Свернуть {{name}}",
+ "expandFolder": "Развернуть {{name}}",
+ "empty": {
+ "noChangedFiles": "Нет изменённых файлов",
+ "noMatchingFiles": "Нет подходящих файлов"
+ },
+ "searchPlaceholder": "Поиск файлов…",
+ "filters": {
+ "unresolved": "Нерешённые",
+ "rejected": "Отклонённые",
+ "new": "Новые",
+ "clear": "Очистить"
+ }
+ },
+ "diffControls": {
+ "previousChunk": "Предыдущий chunk",
+ "nextChunk": "Следующий chunk",
+ "rejectChange": "Отклонить изменение (⌘N)",
+ "acceptChange": "Принять изменение (⌘Y)",
+ "undo": "Отменить",
+ "keep": "Оставить",
+ "rejectShortcut": "⌘N",
+ "acceptShortcut": "⌘Y"
+ },
+ "conflict": {
+ "title": "Обнаружен конфликт",
+ "description": "Файл был изменён после правок агента",
+ "cancel": "Отмена",
+ "saveResolution": "Сохранить решение",
+ "editManually": "Редактировать вручную",
+ "useOriginal": "Использовать исходный вариант",
+ "keepCurrent": "Оставить текущий вариант"
+ },
+ "fullDiffLoading": {
+ "titleOne": "Подготовка полного diff",
+ "titleMany": "Подготовка полных diff: {{count}}",
+ "subtitleForFile": "Финализируется точный editor diff для {{file}}.",
+ "subtitleCurrentFile": "Финализируется точный editor diff для текущего файла.",
+ "subtitleMany": "Определяются точные before/after baseline для загружаемых файлов.",
+ "previewsReady": "готово preview: {{count}}",
+ "previewsReady_one": "готово {{count}} preview",
+ "previewsReady_few": "готово preview: {{count}}",
+ "previewsReady_many": "готово preview: {{count}}",
+ "previewsReady_other": "готово preview: {{count}}",
+ "editorViewLoading": "Загружается editor view",
+ "filesInProgress": "файлов в процессе: {{count}}",
+ "filesInProgress_one": "{{count}} файл в процессе",
+ "filesInProgress_few": "файлов в процессе: {{count}}",
+ "filesInProgress_many": "файлов в процессе: {{count}}",
+ "filesInProgress_other": "файлов в процессе: {{count}}",
+ "filesReady": "готово файлов: {{ready}}/{{total}}",
+ "progressDescription": "Готово: {{ready}}, ещё загружается: {{loading}}. Preview diff остаются видимыми ниже, пока остальные baseline определяются.",
+ "singleDescription": "Preview diff остаются видимыми ниже, пока определяется точный baseline."
+ },
+ "fileMissingPrefix": "Файл отсутствует на диске. Этот diff может быть только предпросмотром из логов агента. Используйте",
+ "restore": "Восстановить",
+ "fileMissingSuffix": "чтобы создать файл на диске.",
+ "filePlaceholder": {
+ "loading": "Загрузка",
+ "description": "Подготовка полного editor diff для этого файла."
+ },
+ "loading": {
+ "diff": "DIFF",
+ "ledgerObjectsProcessed": "обработано ledger-объектов: {{count}}",
+ "ledgerObjectsProcessed_one": "обработан {{count}} ledger-объект",
+ "ledgerObjectsProcessed_few": "обработано {{count}} ledger-объекта",
+ "ledgerObjectsProcessed_many": "обработано {{count}} ledger-объектов",
+ "ledgerObjectsProcessed_other": "обработано {{count}} ledger-объектов",
+ "phases": {
+ "readingLedger": "Чтение task ledger...",
+ "resolvingFiles": "Определение состояния файлов...",
+ "checkingWorktree": "Проверка worktree...",
+ "preparingDiffs": "Подготовка review diff..."
+ }
+ },
+ "progress": {
+ "viewed": "{{viewed}}/{{total}} просмотрено"
+ },
+ "scope": {
+ "readMore": "Подробнее",
+ "tiers": {
+ "exact": {
+ "title": "Область задачи определена точно",
+ "detail": "В логе сессии найдены маркеры начала и завершения. Diff включает только изменения этой задачи - изменения других задач в тех же файлах исключены."
+ },
+ "endEstimated": {
+ "title": "Граница завершения оценена приблизительно",
+ "detail": "Найден только маркер начала - у задачи ещё нет маркера завершения. Показаны изменения от старта задачи до конца сессии. Если после неё в той же сессии выполнялись другие задачи, их изменения тоже могут попасть в diff."
+ },
+ "startEstimated": {
+ "title": "Граница начала оценена приблизительно",
+ "detail": "Найден только маркер завершения - начало работы не было зафиксировано. Если до этой задачи в той же сессии выполнялись другие задачи, их изменения в тех же файлах тоже могут попасть в diff."
+ },
+ "allSession": {
+ "title": "Показаны все изменения сессии",
+ "detail": "В логе сессии нет маркеров задачи. Нельзя изолировать конкретную задачу - показаны все изменения файлов за всю сессию, включая изменения других задач. Такое возможно со старыми версиями CLI или нестандартными workflows."
+ }
+ },
+ "ledger": {
+ "exact": {
+ "title": "Изменения зафиксированы task ledger",
+ "detail": "Orchestrator зафиксировал эти изменения файлов во время работы агента над задачей.",
+ "badge": "Точно по ledger"
+ },
+ "limited": {
+ "title": "Изменения зафиксированы с ограниченной проверяемостью",
+ "detail": "Orchestrator зафиксировал эти изменения для задачи, но хотя бы одно изменение пришло из snapshot или metadata-only источника. Проверяйте точные текстовые diff там, где они доступны; binary или недоступный контент может требовать ручного review.",
+ "mixedBadge": "Смешанная проверяемость",
+ "needsReviewBadge": "Нужен review"
+ }
+ },
+ "workInterval": {
+ "title": "Область задана сохранённым рабочим интервалом",
+ "detail": "Маркер начала задачи недоступен в логе сессии, поэтому diff ограничен рабочим интервалом задачи, сохранённым на доске.",
+ "badge": "По интервалу"
+ },
+ "confidence": {
+ "high": "Высокая уверенность",
+ "medium": "Средняя уверенность",
+ "low": "Низкая уверенность",
+ "bestEffort": "Best effort"
+ }
+ },
+ "shortcuts": {
+ "title": "Горячие клавиши",
+ "actions": {
+ "nextChange": "Следующее изменение",
+ "previousChange": "Предыдущее изменение",
+ "nextFile": "Следующий файл",
+ "previousFile": "Предыдущий файл",
+ "acceptChange": "Принять изменение",
+ "rejectChange": "Отклонить изменение",
+ "saveFile": "Сохранить файл",
+ "undo": "Отменить",
+ "redo": "Повторить",
+ "toggleShortcuts": "Показать/скрыть подсказки",
+ "closeDialog": "Закрыть диалог"
+ }
+ },
+ "timeline": {
+ "empty": "Нет событий редактирования",
+ "titleWithCount": "История правок ({{count}})"
+ },
+ "continuousScroll": {
+ "empty": "Нет изменений файлов для ревью"
+ },
+ "empty": {
+ "noSafeDiff": "Нет безопасного diff",
+ "noFileChangesRecorded": "Изменения файлов не записаны",
+ "noSafeDiffDescription": "Журнал задачи не предоставил безопасный diff файлов для этой задачи.",
+ "noSafeDiffDiagnosticsDescription": "Журнал задачи не предоставил безопасный diff файлов для этой задачи. Диагностика ниже объясняет причину.",
+ "noFileEventsYet": "В журнале задачи пока нет событий файлов.",
+ "noFileEvents": "В журнале задачи нет событий файлов."
+ }
+ },
+ "messages": {
+ "actions": {
+ "bottomSheetActions": "Действия нижней панели сообщений",
+ "collapseAll": "Свернуть все сообщения",
+ "collapseSheet": "Свернуть панель",
+ "expandAll": "Развернуть все сообщения",
+ "expandSheet": "Развернуть панель",
+ "floatComposer": "Открепить composer",
+ "floatMessagesComposer": "Открепить composer сообщений",
+ "hideSearch": "Скрыть поиск",
+ "loadOlder": "Загрузить старые сообщения",
+ "markAllRead": "Отметить всё прочитанным",
+ "messageActions": "Действия сообщений",
+ "moveMessagesToBottomSheet": "Переместить сообщения в нижнюю панель",
+ "moveMessagesToSidebar": "Переместить сообщения в sidebar",
+ "moveToBottomSheet": "Переместить в нижнюю панель",
+ "moveToInline": "Переместить inline",
+ "moveToSidebar": "Переместить в sidebar",
+ "panelActions": "Действия панели сообщений",
+ "searchMessages": "Искать сообщения"
+ },
+ "delivery": {
+ "copied": "Скопировано",
+ "copyDebugDetails": "Копировать debug details",
+ "details": "Детали",
+ "fields": {
+ "acceptanceUnknown": "acceptanceUnknown",
+ "delivered": "delivered",
+ "diagnostics": "diagnostics",
+ "ledgerStatus": "ledgerStatus",
+ "messageId": "messageId",
+ "providerId": "providerId",
+ "queuedBehindMessageId": "queuedBehindMessageId",
+ "reason": "reason",
+ "responsePending": "responsePending",
+ "responseState": "responseState",
+ "statusMessageId": "statusMessageId",
+ "userVisibleMessage": "userVisibleMessage",
+ "userVisibleNextReviewAt": "userVisibleNextReviewAt",
+ "userVisibleReasonCode": "userVisibleReasonCode",
+ "userVisibleState": "userVisibleState",
+ "visibleReplyCorrelation": "visibleReplyCorrelation",
+ "visibleReplyMessageId": "visibleReplyMessageId"
+ }
+ },
+ "panelMode": "Режим панели сообщений",
+ "title": "Сообщения",
+ "unread": {
+ "new": "Новых: {{count}}",
+ "new_few": "Новых: {{count}}",
+ "new_many": "Новых: {{count}}",
+ "new_one": "Новое: {{count}}",
+ "new_other": "Новых: {{count}}",
+ "unread": "Непрочитано: {{count}}",
+ "unread_few": "Непрочитано: {{count}}",
+ "unread_many": "Непрочитано: {{count}}",
+ "unread_one": "Непрочитанное: {{count}}",
+ "unread_other": "Непрочитано: {{count}}"
+ },
+ "filter": {
+ "ariaLabel": "Фильтровать сообщения",
+ "tooltip": "Фильтровать сообщения",
+ "from": "От",
+ "to": "Кому",
+ "noData": "Нет данных",
+ "showStatusUpdates": "Показывать status updates (idle/shutdown)",
+ "actions": {
+ "reset": "Сбросить",
+ "save": "Сохранить"
+ }
+ },
+ "status": {
+ "title": "Статус"
+ },
+ "actionMode": {
+ "label": "Режим действия"
+ },
+ "search": {
+ "placeholder": "Поиск..."
+ }
+ },
+ "modelSelector": {
+ "badges": {
+ "configured": "Настроено",
+ "connected": "Подключено",
+ "failed": "Ошибка",
+ "free": "Бесплатно",
+ "local": "Локально",
+ "needsTest": "Нужен тест",
+ "verified": "Проверено",
+ "unavailable": "Недоступно",
+ "issue": "Проблема"
+ },
+ "customModelId": "Custom model id",
+ "label": "Модель (optional)",
+ "multimodelRequired": "Codex и Gemini требуют Multimodel mode.",
+ "openCode": {
+ "allSources": "Все OpenCode sources",
+ "filterSource": "Фильтровать {{source}}",
+ "filterSources": "Фильтровать OpenCode sources",
+ "freeOnly": "Только бесплатные",
+ "freeTooltip": "OpenCode помечает эту модель как бесплатную.",
+ "loadingModels": "Загрузка моделей OpenCode...",
+ "noSourcesFound": "Sources не найдены.",
+ "recommendedOnly": "Только рекомендованные",
+ "searchSources": "Поиск sources",
+ "sourcesCount": "OpenCode sources: {{count}}",
+ "sourcesCount_few": "OpenCode sources: {{count}}",
+ "sourcesCount_many": "OpenCode sources: {{count}}",
+ "sourcesCount_one": "OpenCode source: {{count}}",
+ "sourcesCount_other": "OpenCode sources: {{count}}"
+ },
+ "reason": "Причина: {{reason}}",
+ "runtimeModelsSyncing": "Явные модели загружаются из текущего runtime. Default остаётся доступным, пока список синхронизируется.",
+ "fastMode": {
+ "codexLabel": "Быстрый режим (2x credits)",
+ "optionalLabel": "Быстрый режим (опционально)",
+ "defaultOff": "По умолчанию (выкл.)",
+ "fast": "Fast",
+ "off": "Выкл.",
+ "defaultFast": "По умолчанию (Fast)",
+ "defaultResolvesTo": "Сейчас default resolves to {{mode}}.",
+ "runtimeBackedHint": "Fast mode зависит от runtime и доступен только когда resolved Anthropic launch model его поддерживает."
+ },
+ "anthropicExtraUsage": {
+ "pricingDocs": "Открыть документацию Anthropic по ценам"
+ },
+ "searchModels": "Поиск моделей",
+ "defaultModel": "По умолчанию",
+ "empty": {
+ "noSearchMatches": "По этому поиску модели не найдены.",
+ "recommendedFreeOpenCode": "В текущем списке runtime нет рекомендованных бесплатных моделей OpenCode.",
+ "freeOpenCode": "В текущем списке runtime нет бесплатных моделей OpenCode.",
+ "recommendedOpenCode": "В текущем списке runtime нет рекомендованных моделей OpenCode.",
+ "noModels": "В текущем списке runtime нет моделей."
+ },
+ "openCodeStatus": {
+ "notReadyTitle": "OpenCode не готов к запуску команды",
+ "freeModelsAvailableTitle": "Доступны бесплатные модели OpenCode",
+ "providerNotConnectedTitle": "Provider OpenCode не подключён",
+ "readyTitle": "OpenCode готов",
+ "readyMessage": "OpenCode прошёл проверку готовности provider. Выберите его, чтобы использовать модели OpenCode для этой команды.",
+ "useOpenCode": "Использовать OpenCode",
+ "badges": {
+ "check": "Проверка",
+ "install": "Установить",
+ "free": "Free",
+ "setup": "Настроить"
+ },
+ "summary": {
+ "checking": "Статус OpenCode: проверка runtime",
+ "status": "Статус OpenCode: {{parts}}"
+ },
+ "summaryParts": {
+ "teamLaunchBlocked": "запуск команды заблокирован",
+ "providerOptional": "подключение provider необязательно",
+ "providerModelsNeedSetup": "provider-backed модели требуют настройки",
+ "teamLaunchReady": "запуск команды готов",
+ "runtimeDetected": "runtime найден",
+ "runtimeMissing": "runtime не найден",
+ "freeWithoutAuth": "free модели доступны без auth",
+ "providerConnected": "provider подключён",
+ "providerNotConnected": "provider не подключён"
+ },
+ "messages": {
+ "checking": "Приложение ещё проверяет OpenCode runtime. Дождитесь завершения проверки provider status и попробуйте снова.",
+ "unsupported": "OpenCode не установлен, не найден или найденный runtime не поддерживается. Установите или обновите OpenCode, затем обновите статус provider. Также можно использовать кнопку Install на домашней странице.",
+ "freeAvailable": "OpenCode найден. Можно использовать free модели OpenCode, например Big Pickle, без подключения provider. Подключайте provider только если нужны provider-backed модели.",
+ "noFreeListed": "OpenCode найден, но free модель OpenCode пока не указана. Обновите provider status или подключите provider в OpenCode для provider-backed моделей.",
+ "launchBlocked": "OpenCode установлен и authenticated, но готовность Agent Teams launch заблокирована.",
+ "ready": "OpenCode готов к запуску команды."
+ },
+ "loadingRuntime": "Статус OpenCode runtime ещё загружается."
+ },
+ "advisory": {
+ "pingNotConfirmed": "Ping не подтверждён",
+ "note": "Заметка"
+ },
+ "placeholders": {
+ "customModelId": "openai/gpt-oss-20b"
+ },
+ "routeGroups": {
+ "openCodeConfig": "Конфиг OpenCode",
+ "builtinFree": "Бесплатные встроенные",
+ "connectedProviders": "Подключённые providers",
+ "otherCatalog": "Другой каталог OpenCode"
+ },
+ "pricing": {
+ "free": "Бесплатно",
+ "inputShort": "in {{rate}}",
+ "outputShort": "out {{rate}}",
+ "perMillionSummary": "{{summary}} / 1M",
+ "inputTitle": "Input: {{rate}} за 1M tokens",
+ "outputTitle": "Output: {{rate}} за 1M tokens",
+ "cacheReadTitle": "Cache read: {{rate}} за 1M tokens",
+ "cacheWriteTitle": "Cache write: {{rate}} за 1M tokens"
+ },
+ "defaultTooltip": {
+ "anthropicCompatibleWithResolved": "Использует default model Anthropic-compatible endpoint.\nСейчас resolves to {{model}}.",
+ "anthropicCompatible": "Использует default model Anthropic-compatible endpoint.",
+ "anthropic": "Использует default model команды Claude.\nResolves to {{longContextModel}} с 1M context или {{limitedContextModel}} с 200K context, когда включён Limit context.",
+ "openCodeWithResolved": "Использует default model OpenCode.\nСейчас resolves to {{model}}.",
+ "openCode": "Использует runtime default model OpenCode.",
+ "runtime": "Использует runtime default для выбранного provider."
+ },
+ "multimodelOff": "Multimodel выключен",
+ "unavailableInRuntime": "Недоступно в текущем runtime"
+ },
+ "taskDetail": {
+ "actions": {
+ "cancel": "Отмена",
+ "delete": "Удалить",
+ "markResolved": "Отметить решённым",
+ "save": "Сохранить"
+ },
+ "attachments": {
+ "commentAttachment": "Вложение комментария",
+ "fromComments": "Из комментариев",
+ "preview": "Предпросмотр {{filename}}"
+ },
+ "changes": {
+ "badges": {
+ "attention": "требует внимания",
+ "noSafeDiff": "нет безопасного diff"
+ },
+ "empty": {
+ "noFileChangesRecorded": "Изменения файлов не записаны",
+ "noFileChangesRecordedYet": "Изменения файлов пока не записаны",
+ "noReviewableChangesRecovered": "Не удалось восстановить изменения файлов для review",
+ "noSafeDiffAvailable": "Безопасный diff недоступен"
+ },
+ "loadFailed": "Не удалось загрузить сводку изменений задачи",
+ "loading": "Загрузка изменений...",
+ "fileCount": "Файлов: {{count}}",
+ "fileCount_few": "Файла: {{count}}",
+ "fileCount_many": "Файлов: {{count}}",
+ "fileCount_one": "Файл: {{count}}",
+ "fileCount_other": "Файла: {{count}}",
+ "fileRowsHidden": "Скрыто строк файлов: {{count}}",
+ "fileRowsHidden_few": "Скрыто строки файлов: {{count}}",
+ "fileRowsHidden_many": "Скрыто строк файлов: {{count}}",
+ "fileRowsHidden_one": "Скрыта строка файла: {{count}}",
+ "fileRowsHidden_other": "Скрыто строки файлов: {{count}}",
+ "moreDiagnostics": "Ещё диагностик: {{count}}",
+ "moreDiagnostics_few": "Ещё диагностики: {{count}}",
+ "moreDiagnostics_many": "Ещё диагностик: {{count}}",
+ "moreDiagnostics_one": "Ещё диагностика: {{count}}",
+ "moreDiagnostics_other": "Ещё диагностики: {{count}}",
+ "moreFiles": "Ещё файлов: {{count}}",
+ "moreFiles_few": "Ещё файла: {{count}}",
+ "moreFiles_many": "Ещё файлов: {{count}}",
+ "moreFiles_one": "Ещё файл: {{count}}",
+ "moreFiles_other": "Ещё файла: {{count}}",
+ "openInEditor": "Открыть в редакторе",
+ "openTask": "Открыть задачу {{subject}}",
+ "refresh": "Обновить изменения",
+ "refreshFailed": "Не удалось обновить: {{error}}",
+ "refreshing": "Обновление",
+ "refreshingChanges": "Обновление изменений...",
+ "refreshTeamChanges": "Обновить изменения команды",
+ "refreshShort": "Обновить",
+ "reviewDiff": "Открыть diff для review",
+ "reviewTaskDiff": "Открыть diff задачи для review",
+ "scannedCandidateTasks": "Просканировано {{requested}} из {{eligible}} candidate tasks",
+ "tasksDeferred": "Отложено задач за этот проход: {{count}}",
+ "tasksDeferred_few": "Отложено задачи за этот проход: {{count}}",
+ "tasksDeferred_many": "Отложено задач за этот проход: {{count}}",
+ "tasksDeferred_one": "Отложена задача за этот проход: {{count}}",
+ "tasksDeferred_other": "Отложено задачи за этот проход: {{count}}",
+ "title": "Изменения"
+ },
+ "clarification": {
+ "awaitingLead": "Ожидается уточнение от team lead",
+ "awaitingUser": "Ожидается уточнение от вас"
+ },
+ "description": {
+ "add": "Добавить описание...",
+ "edit": "Редактировать описание",
+ "placeholder": "Описание задачи (поддерживает markdown)"
+ },
+ "loading": {
+ "fetchingTeamData": "Загрузка данных команды",
+ "title": "Загрузка задачи..."
+ },
+ "logs": {
+ "newArriving": "Поступают новые task logs"
+ },
+ "notFound": "Задача не найдена",
+ "related": {
+ "blockedBy": "Заблокировано",
+ "blocks": "Блокирует",
+ "linkedFrom": "Ссылаются из",
+ "links": "Связано",
+ "title": "Связанные задачи"
+ },
+ "review": {
+ "reviewer": "Reviewer: {{reviewer}}"
+ },
+ "sections": {
+ "attachments": "Вложения",
+ "changes": "Изменения",
+ "comments": "Комментарии",
+ "description": "Описание",
+ "taskLogs": "Логи задачи",
+ "workflowHistory": "История workflow"
+ },
+ "unassigned": "Не назначено",
+ "workflow": {
+ "implementationTimeTitle": "Время реализации по сохранённым рабочим интервалам",
+ "inProgressTime": "Время в работе {{duration}}"
+ },
+ "comments": {
+ "renderLimit": "Показаны последние {{formattedCount}} комментариев, чтобы интерфейс оставался отзывчивым.",
+ "badges": {
+ "approved": "Одобрено",
+ "reviewRequested": "Запрошен review"
+ },
+ "unknownTime": "неизвестное время",
+ "actions": {
+ "reply": "Ответить",
+ "replyToComment": "Ответить на комментарий",
+ "showMore": "Показать ещё комментарии ({{visible}}/{{total}})",
+ "cancelReply": "Отменить ответ",
+ "comment": "Комментировать"
+ },
+ "attachments": {
+ "previewAlt": "Предпросмотр вложения",
+ "downloadFailed": "Не удалось скачать"
+ },
+ "replyingTo": "Ответ на",
+ "input": {
+ "placeholder": "Добавьте комментарий... (Enter для отправки)",
+ "charsLeft": "Осталось символов: {{count}}",
+ "charsLeft_one": "Остался {{count}} символ",
+ "charsLeft_few": "Осталось {{count}} символа",
+ "charsLeft_many": "Осталось {{count}} символов",
+ "charsLeft_other": "Осталось {{count}} символов"
+ }
+ },
+ "workflowTimeline": {
+ "empty": "История workflow не записана",
+ "currentImplementationInterval": "Текущий интервал реализации",
+ "implementationIntervalEnded": "Интервал реализации завершился на этом переходе",
+ "runningPrefix": "идёт ",
+ "createdAs": "Создано как",
+ "by": "от",
+ "reassigned": "Переназначено",
+ "assignedTo": "Назначено",
+ "unassignedFrom": "Снято с",
+ "ownerChanged": "Владелец изменён",
+ "reviewRequested": "Запрошен review",
+ "reviewStarted": "Review начат",
+ "changesRequested": "Запрошены изменения",
+ "approved": "Одобрено",
+ "unknownEvent": "Неизвестное событие"
+ },
+ "reviewStates": {
+ "approved": "Одобрено",
+ "needsFix": "Нужны правки",
+ "inReview": "На review"
+ }
+ },
+ "tasks": {
+ "createTask": {
+ "assignee": "Исполнитель",
+ "assigneeOptional": "Исполнитель (необязательно)",
+ "blockedByOptional": "Блокируется задачами (необязательно)",
+ "blockedBySummary": "Задачу будут блокировать: {{tasks}}",
+ "cancel": "Отмена",
+ "create": "Создать",
+ "creating": "Создание...",
+ "description": "Задача будет создана в директории tasks/ команды и появится на Kanban-доске.",
+ "descriptionOptional": "Описание (необязательно)",
+ "detailsPlaceholder": "Детали задачи (поддерживается Markdown)",
+ "hideOptionalFields": "Скрыть дополнительные поля",
+ "offlineNotice": {
+ "after": "- запустите команду, чтобы начать выполнение.",
+ "before": "Команда офлайн. Задача будет добавлена в"
+ },
+ "promptOptional": "Prompt для исполнителя (необязательно)",
+ "promptPlaceholder": "Дополнительные инструкции для участника команды...",
+ "relatedOptional": "Связанные задачи (необязательно)",
+ "relatedSummary": "Связанные: {{tasks}}",
+ "saved": "Сохранено",
+ "searchTasks": "Поиск задач...",
+ "selectMember": "Выберите участника",
+ "selectMemberOptional": "Выберите участника...",
+ "showOptionalFields": "Показать дополнительные поля",
+ "startImmediately": "Запустить сразу",
+ "startOfflineHint": "Команда офлайн. Сначала запустите команду, чтобы сразу стартовать задачи.",
+ "subject": "Тема",
+ "subjectPlaceholder": "Что нужно сделать?",
+ "title": "Создать задачу",
+ "todo": "TODO"
+ },
+ "list": {
+ "columns": {
+ "blockedBy": "Заблокировано",
+ "blocks": "Блокирует",
+ "id": "ID",
+ "owner": "Владелец",
+ "status": "Статус",
+ "subject": "Тема"
+ },
+ "empty": "В этой команде нет задач",
+ "filters": {
+ "allOwners": "Все владельцы",
+ "allStatuses": "Все статусы",
+ "ownerAria": "Фильтр задач по владельцу",
+ "statusAria": "Фильтр задач по статусу"
+ },
+ "showing": "Показано {{shown}} из {{total}}"
+ },
+ "status": {
+ "completed": "completed",
+ "deleted": "deleted",
+ "inProgress": "in_progress",
+ "pending": "pending"
+ },
+ "statusSummary": {
+ "progressAria": "Задачи: выполнено {{completed}}/{{total}}",
+ "inProgress": "{{count}} в работе",
+ "inProgress_one": "{{count}} в работе",
+ "inProgress_few": "{{count}} в работе",
+ "inProgress_many": "{{count}} в работе",
+ "inProgress_other": "{{count}} в работе",
+ "pending": "{{count}} ожидают",
+ "pending_one": "{{count}} ожидает",
+ "pending_few": "{{count}} ожидают",
+ "pending_many": "{{count}} ожидают",
+ "pending_other": "{{count}} ожидают",
+ "completed": "{{count}} выполнено",
+ "completed_one": "{{count}} выполнена",
+ "completed_few": "{{count}} выполнено",
+ "completed_many": "{{count}} выполнено",
+ "completed_other": "{{count}} выполнено"
+ },
+ "unassigned": "Не назначено",
+ "teamPrefix": "Команда:",
+ "openTask": "Открыть задачу",
+ "deleteConfirm": {
+ "title": "Удалить задачу",
+ "message": "Переместить задачу #{{taskId}} в корзину?",
+ "confirmLabel": "Удалить",
+ "cancelLabel": "Отмена"
+ }
+ },
+ "editor": {
+ "actions": {
+ "cancel": "Отмена",
+ "closeEditor": "Закрыть редактор",
+ "closeTab": "Закрыть вкладку",
+ "closeTooltip": "Закрыть редактор (Esc)",
+ "discard": "Отменить изменения",
+ "discardAndClose": "Отменить и закрыть",
+ "keep": "Оставить",
+ "keepMine": "Оставить мои изменения",
+ "keyboardShortcuts": "Горячие клавиши",
+ "overwrite": "Перезаписать",
+ "refreshAria": "Обновить (F5)",
+ "refreshTooltip": "Обновить git status (F5)",
+ "reload": "Перезагрузить",
+ "retry": "Повторить",
+ "save": "Сохранить",
+ "saveAllAndClose": "Сохранить всё и закрыть"
+ },
+ "ariaLabel": "Редактор проекта",
+ "dialogs": {
+ "conflictDescription": "Файл был изменён извне после открытия. Перезаписать его вашими изменениями?",
+ "conflictTitle": "Конфликт сохранения",
+ "unsavedDescription": "Есть несохранённые изменения. Что сделать?",
+ "unsavedFileDescription": "В этом файле есть несохранённые изменения. Что сделать?",
+ "unsavedTitle": "Несохранённые изменения"
+ },
+ "newFile": {
+ "validation": {
+ "nameRequired": "Имя не может быть пустым",
+ "invalidName": "Недопустимое имя",
+ "invalidCharacters": "Имя содержит недопустимые символы",
+ "nameTooLong": "Имя слишком длинное"
+ },
+ "placeholders": {
+ "fileName": "Имя файла...",
+ "folderName": "Имя папки..."
+ },
+ "aria": {
+ "newFileName": "Имя нового файла",
+ "newFolderName": "Имя новой папки"
+ }
+ },
+ "draftRecovered": "Восстановлены несохранённые изменения из предыдущей сессии.",
+ "externalChange": {
+ "changed": "Файл изменён на диске.",
+ "deleted": "Файла больше нет на диске."
+ },
+ "saveFailed": "Не удалось сохранить: {{error}}",
+ "sidebar": {
+ "explorer": "Проводник",
+ "hide": "Скрыть sidebar",
+ "hideWithShortcut": "Скрыть sidebar ({{shortcut}})",
+ "show": "Показать sidebar",
+ "showWithShortcut": "Показать sidebar ({{shortcut}})"
+ },
+ "searchInFiles": {
+ "title": "Поиск по файлам",
+ "closeSearch": "Закрыть поиск",
+ "closeSearchShortcut": "Закрыть поиск (Esc)",
+ "searchPlaceholder": "Искать...",
+ "matchCase": "Учитывать регистр",
+ "matchCaseToggle": "Aa",
+ "noResults": "Ничего не найдено",
+ "resultsSummary": "{{count}} совпадений в {{fileCount}} файлах",
+ "resultsSummary_one": "{{count}} совпадение в {{fileCount}} файлах",
+ "resultsSummary_few": "{{count}} совпадения в {{fileCount}} файлах",
+ "resultsSummary_many": "{{count}} совпадений в {{fileCount}} файлах",
+ "resultsSummary_other": "{{count}} совпадений в {{fileCount}} файлах",
+ "truncated": "(обрезано)"
+ },
+ "fileTree": {
+ "failedToLoadFiles": "Не удалось загрузить файлы: {{error}}",
+ "loading": "Загрузка файлов...",
+ "empty": "Файлы не найдены",
+ "dropForProjectRoot": "Перетащите сюда для корня проекта",
+ "moveToTrash": "Переместить в корзину",
+ "moveToTrashConfirm": "Переместить \"{{name}}\" в корзину?",
+ "cancel": "Отмена"
+ },
+ "goToLine": {
+ "title": "Перейти к строке",
+ "position": "(текущая: {{current}}, всего: {{total}})",
+ "placeholder": "Номер строки, +смещение, -смещение или %",
+ "go": "Перейти"
+ },
+ "searchPanel": {
+ "previousMatch": "Предыдущее совпадение",
+ "nextMatch": "Следующее совпадение",
+ "close": "Закрыть",
+ "replacePlaceholder": "Замена",
+ "replace": "Заменить",
+ "replaceNext": "Заменить следующее",
+ "all": "Все",
+ "replaceAll": "Заменить все"
+ },
+ "statusBar": {
+ "position": "Стр {{line}}, Кол {{col}}",
+ "enableWatcher": "Включить отслеживание файлов",
+ "disableWatcher": "Отключить отслеживание файлов",
+ "watch": "следить",
+ "watching": "слежение",
+ "watchExternalChanges": "Следить за внешними изменениями",
+ "disableExternalWatcher": "Отключить отслеживание внешних изменений",
+ "encodingUtf8": "UTF-8",
+ "spaces": "Пробелы: {{count}}"
+ },
+ "imagePreview": {
+ "loading": "Загрузка предпросмотра...",
+ "openFullSize": "Открыть полноразмерный предпросмотр",
+ "openSystemViewer": "Открыть в системном просмотрщике"
+ },
+ "quickOpen": {
+ "title": "Быстрое открытие",
+ "searchPlaceholder": "Поиск файлов по имени...",
+ "loading": "Загрузка файлов...",
+ "empty": "Файлы не найдены"
+ },
+ "errorBoundary": {
+ "crashed": "Редактор упал",
+ "unknownError": "Неизвестная ошибка"
+ },
+ "binaryPlaceholder": {
+ "file": "Бинарный файл ({{size}})"
+ },
+ "unsavedChanges": "Несохранённые изменения",
+ "empty": {
+ "selectFile": "Выберите файл в дереве, чтобы редактировать"
+ },
+ "search": {
+ "toggleReplace": "Переключить замену",
+ "placeholder": "Поиск"
+ },
+ "shortcuts": {
+ "title": "Горячие клавиши",
+ "groups": {
+ "fileOperations": "Операции с файлами",
+ "search": "Поиск",
+ "navigation": "Навигация",
+ "editing": "Редактирование",
+ "markdown": "Markdown",
+ "general": "Общее"
+ },
+ "actions": {
+ "quickOpen": "Быстрое открытие",
+ "save": "Сохранить",
+ "saveAll": "Сохранить всё",
+ "closeTab": "Закрыть вкладку",
+ "findInFile": "Найти в файле",
+ "searchInFiles": "Поиск по файлам",
+ "goToLine": "Перейти к строке",
+ "nextTab": "Следующая вкладка",
+ "previousTab": "Предыдущая вкладка",
+ "cycleTabs": "Переключать вкладки",
+ "toggleSidebar": "Переключить боковую панель",
+ "undo": "Отменить",
+ "redo": "Повторить",
+ "selectNextMatch": "Выбрать следующее совпадение",
+ "toggleComment": "Переключить комментарий",
+ "splitPreview": "Разделённый предпросмотр",
+ "fullPreview": "Полный предпросмотр",
+ "closeEditor": "Закрыть редактор"
+ }
+ },
+ "toolbar": {
+ "enableWordWrap": "Включить перенос строк",
+ "disableWordWrap": "Отключить перенос строк",
+ "closeSplitPreview": "Закрыть разделённый предпросмотр",
+ "closePreview": "Закрыть предпросмотр"
+ }
+ },
+ "launch": {
+ "actions": {
+ "createSchedule": "Создать расписание",
+ "creating": "Создание...",
+ "goToDashboard": "Перейти в Dashboard",
+ "launchTeam": "Запустить команду",
+ "launching": "Запуск...",
+ "relaunchTeam": "Перезапустить команду",
+ "relaunching": "Перезапуск...",
+ "saveChanges": "Сохранить изменения",
+ "saving": "Сохранение..."
+ },
+ "billing": {
+ "prefix": "С 15 июня 2026 года Anthropic списывает использование",
+ "readArticle": "Открыть статью Anthropic",
+ "suffix": "и Agent SDK из ежемесячного Agent SDK credit отдельно от интерактивных лимитов Claude Code. Credit обновляется каждый billing cycle, неиспользованный остаток не переносится."
+ },
+ "conflict": {
+ "description": "Запуск двух команд в одной директории рискован - они могут конфликтовать при изменении одних и тех же файлов. Лучше выбрать другую директорию или git worktree для изоляции.",
+ "title": "Другая команда \"{{team}}\" уже работает в этой working directory",
+ "workingDirectory": "Working directory:"
+ },
+ "description": {
+ "createSchedule": "Настроить автоматическое выполнение Claude task",
+ "createScheduleForTeam": "Настроить автоматические запуски для команды \"{{team}}\"",
+ "editSchedule": "Редактирование расписания для команды \"{{team}}\"",
+ "launchPrefix": "Запустить команду",
+ "launchSuffix": "через локальный Claude CLI.",
+ "relaunchPrefix": "Остановить текущий запуск для",
+ "relaunchSuffix": "и запустить его заново через локальный Claude CLI."
+ },
+ "prepare": {
+ "action": {
+ "launch": "запуск",
+ "relaunch": "перезапуск"
+ },
+ "blocked": "Runtime environment недоступен - {{action}} заблокирован",
+ "checkingProviders": "Проверка выбранных providers...",
+ "failed": "Не удалось подготовить выбранных providers",
+ "preflight": "Pre-flight проверка, чтобы поймать ошибки до действия: {{action}}",
+ "preparingEnvironment": "Подготовка environment...",
+ "ready": "Все выбранные providers готовы.",
+ "readyWithNotes": "Все выбранные providers готовы, есть заметки.",
+ "unsupportedPreload": "Текущая версия preload не поддерживает team:prepareProvisioning. Перезапустите dev app.",
+ "selectWorkingDirectory": "Выберите рабочую директорию, чтобы проверить окружение запуска.",
+ "someProvidersNeedAttention": "Некоторым выбранным providers нужно внимание."
+ },
+ "prompt": {
+ "label": "Prompt",
+ "oneShotPrefix": "Этот prompt будет передан в",
+ "oneShotSuffix": "для one-shot выполнения",
+ "saved": "Сохранено",
+ "schedulePlaceholder": "Инструкции для Claude, которые нужно выполнить по расписанию...",
+ "teamLeadOptional": "Prompt для team lead (optional)",
+ "teamLeadPlaceholder": "Инструкции для team lead..."
+ },
+ "providerChanged": "Provider изменён с {{from}} на {{to}}. Предыдущая lead session не будет возобновлена, lead начнёт с fresh context, чтобы новый runtime применился корректно.",
+ "relaunchFreshSession": "Team relaunch запускает fresh lead session. Durable team state, task board и настройки участников будут rehydrated в launch prompt.",
+ "relaunchWarning": {
+ "description": "Сохранение этих настроек остановит текущий team process, сохранит обновлённый roster и снова запустит команду с новым runtime.",
+ "title": "Relaunch перезапустит текущий team run"
+ },
+ "schedule": {
+ "labelOptional": "Label (optional)",
+ "labelPlaceholder": "Например: Daily code review, Nightly tests...",
+ "maxBudgetUsd": "Max budget (USD)",
+ "maxTurns": "Max turns",
+ "noLimit": "Без лимита",
+ "noMatches": "Команды не найдены по поиску.",
+ "noTeams": "Нет доступных команд. Сначала создайте команду.",
+ "searchTeams": "Поиск команд...",
+ "selectTeam": "Выберите команду...",
+ "team": "Команда",
+ "title": "Расписание"
+ },
+ "title": {
+ "createSchedule": "Создать расписание",
+ "editSchedule": "Редактировать расписание",
+ "launch": "Запуск команды",
+ "relaunch": "Перезапуск команды"
+ },
+ "errors": {
+ "loadProjectsFailed": "Не удалось загрузить проекты",
+ "saveScheduleFailed": "Не удалось сохранить расписание",
+ "relaunchFailed": "Не удалось перезапустить команду",
+ "launchFailed": "Не удалось запустить команду"
+ },
+ "validation": {
+ "openCodeLeadModelRequired": "Для lead на OpenCode нужно выбрать модель.",
+ "openCodeTeammateRequired": "Для lead на OpenCode нужен хотя бы один teammate OpenCode.",
+ "selectWorkingDirectory": "Выберите рабочую директорию (cwd)",
+ "fixMemberNames": "Исправьте имена участников перед запуском",
+ "memberNamesUnique": "Имена участников должны быть уникальными перед запуском"
+ },
+ "optionalSettings": {
+ "relaunchTitle": "Настройки перезапуска",
+ "title": "Дополнительные настройки запуска",
+ "relaunchDescription": "Проверьте состав и runtime lead перед перезапуском команды.",
+ "description": "Оставьте запуск сфокусированным на пути проекта и раскрывайте этот блок только когда нужен дополнительный контроль."
+ }
+ },
+ "list": {
+ "actions": {
+ "copyTeam": "Скопировать команду",
+ "createTeam": "Создать команду",
+ "deleteForever": "Удалить навсегда",
+ "deletePermanently": "Удалить окончательно",
+ "deleteTeam": "Удалить команду",
+ "launching": "Запуск...",
+ "launchTeam": "Запустить команду",
+ "relaunchTeam": "Перезапустить команду",
+ "restore": "Восстановить",
+ "restoreTeam": "Восстановить команду",
+ "retry": "Повторить",
+ "stopTeam": "Остановить команду",
+ "stopping": "Остановка..."
+ },
+ "electronOnly": {
+ "description": "В browser mode доступ к локальным директориям `~/.claude/teams` недоступен.",
+ "title": "Команды доступны только в Electron mode"
+ },
+ "empty": {
+ "description": "Создайте команду здесь, чтобы начать. Она автоматически появится в списке.",
+ "localOnly": "Создание команд доступно только в локальном Electron mode.",
+ "title": "Команды не найдены"
+ },
+ "filter": {
+ "clearAll": "Очистить всё",
+ "label": "Фильтровать команды",
+ "projectPriority": "Приоритет проекта",
+ "status": "Статус"
+ },
+ "loadFailed": "Не удалось загрузить команды",
+ "loading": "Загрузка команд...",
+ "localOnly": "Доступно только в локальном Electron mode.",
+ "membersCount": "Участников: {{count}}",
+ "membersCount_few": "Участников: {{count}}",
+ "membersCount_many": "Участников: {{count}}",
+ "membersCount_one": "Участник: {{count}}",
+ "membersCount_other": "Участников: {{count}}",
+ "noDescription": "Нет описания",
+ "noMatches": "Нет команд по текущим фильтрам",
+ "partial": {
+ "pending": "Последний запуск ещё сверяется.",
+ "skipped": "В последнем запуске были пропущены teammates.",
+ "skippedWithCount": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_few": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_many": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "skippedWithCount_one": "Последний запуск пропустил {{count}}/{{expected}} teammate.",
+ "skippedWithCount_other": "Последний запуск пропустил {{count}}/{{expected}} teammates.",
+ "stopped": "Последний запуск остановился до подключения всех teammates.",
+ "stoppedWithCount": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_few": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_many": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates.",
+ "stoppedWithCount_one": "Последний запуск остановился до подключения {{count}}/{{expected}} teammate.",
+ "stoppedWithCount_other": "Последний запуск остановился до подключения {{count}}/{{expected}} teammates."
+ },
+ "searchPlaceholder": "Поиск команд...",
+ "sections": {
+ "otherTeams": "Другие команды",
+ "projectTeams": "Команды для {{project}}",
+ "selectedProject": "выбранного проекта"
+ },
+ "solo": "Solo",
+ "status": {
+ "active": "Активно",
+ "deleted": "Удалено",
+ "launching": "Запуск...",
+ "offline": "Offline",
+ "partialFailure": "Запуск частично не удался",
+ "partialPending": "Bootstrap ожидает",
+ "partialSkipped": "Запуск пропустил участника",
+ "running": "Работает"
+ },
+ "title": "Выбор команды",
+ "trash": "Корзина ({{count}})",
+ "trash_few": "Корзина ({{count}})",
+ "trash_many": "Корзина ({{count}})",
+ "trash_one": "Корзина ({{count}})",
+ "trash_other": "Корзина ({{count}})",
+ "deleteDraft": {
+ "title": "Удалить черновик",
+ "message": "Удалить черновик команды «{{teamName}}»? Это действие нельзя отменить.",
+ "confirmLabel": "Удалить",
+ "cancelLabel": "Отмена"
+ },
+ "moveToTrash": {
+ "title": "Переместить в корзину",
+ "message": "Переместить команду «{{teamName}}» в корзину? Её можно будет восстановить позже.",
+ "confirmLabel": "В корзину",
+ "cancelLabel": "Отмена"
+ },
+ "deleteForever": {
+ "title": "Удалить навсегда",
+ "message": "Удалить команду «{{teamName}}» навсегда? Все данные будут потеряны.",
+ "confirmLabel": "Удалить навсегда",
+ "cancelLabel": "Отмена"
+ }
+ },
+ "messageComposer": {
+ "crossTeam": {
+ "hint": "Совет: cross-team сообщения идут team lead целевой команды. Если ответ должен вернуться вашему team lead, а не вам, явно напишите это в сообщении."
+ },
+ "attachments": {
+ "attachFiles": "Прикрепить файлы (paste или drag & drop)",
+ "unavailable": "Вложения недоступны",
+ "disabledHint": "Файлы можно отправлять online team lead и online OpenCode teammates. Удалите вложения или смените получателя.",
+ "restrictions": {
+ "crossTeam": "Файловые вложения не поддерживаются для cross-team сообщений",
+ "teamOffline": "Команда должна быть online, чтобы прикреплять файлы",
+ "unsupportedRecipient": "Файлы можно отправлять team lead или OpenCode teammates",
+ "openCodeOffline": "Команда должна быть online, чтобы прикреплять файлы для OpenCode teammates",
+ "sending": "Дождитесь завершения текущей отправки перед добавлением файлов",
+ "maximumReached": "Достигнут лимит вложений",
+ "leadOnly": "Файлы можно отправлять только team lead"
+ }
+ },
+ "slash": {
+ "restrictions": {
+ "attachments": "Slash commands требуют live team lead и не отправляются с вложениями",
+ "crossTeam": "Slash commands можно запускать только на team lead текущей команды",
+ "notLead": "Slash commands можно отправлять только team lead",
+ "leadOffline": "Slash commands требуют, чтобы team lead был online"
+ }
+ },
+ "status": {
+ "reusedCrossTeamRequest": "Повторно использован недавний cross-team request",
+ "teamOffline": "Команда offline"
+ },
+ "input": {
+ "charsLeft": "Осталось символов: {{count}}",
+ "charsLeft_one": "Остался {{count}} символ",
+ "charsLeft_few": "Осталось {{count}} символа",
+ "charsLeft_many": "Осталось {{count}} символов",
+ "charsLeft_other": "Осталось {{count}} символов",
+ "teamLaunchingPlaceholder": "Команда запускается... сообщение будет поставлено в очередь inbox delivery.",
+ "crossTeamPlaceholder": "Cross-team сообщение для {{team}}...",
+ "teamFallback": "команда",
+ "placeholder": "Напишите сообщение... (Enter для отправки, Shift+Enter для новой строки)",
+ "slashTip": "Совет: используйте \"/\", чтобы запускать любые Claude commands."
+ },
+ "teamSelector": {
+ "thisTeam": "Эта команда",
+ "current": "текущая",
+ "online": "online",
+ "offline": "offline",
+ "onlineTitle": "Online",
+ "offlineTitle": "Offline"
+ },
+ "recipient": {
+ "select": "Выбрать...",
+ "searchPlaceholder": "Поиск...",
+ "noResults": "Ничего не найдено"
+ },
+ "actions": {
+ "voiceToText": "Voice to text",
+ "send": "Отправить",
+ "sendingUnavailableLaunching": "Отправка недоступна, пока команда запускается"
+ }
+ },
+ "claudeLogs": {
+ "filter": {
+ "ariaLabel": "Фильтровать Claude logs",
+ "tooltip": "Фильтровать logs",
+ "sections": {
+ "stream": "Stream",
+ "content": "Content"
+ },
+ "kinds": {
+ "output": "Output",
+ "thinking": "Thinking",
+ "tool": "Tool calls"
+ },
+ "actions": {
+ "reset": "Сбросить",
+ "save": "Сохранить"
+ },
+ "streams": {
+ "stdout": "stdout",
+ "stderr": "stderr"
+ }
+ },
+ "rawLineCount": "сырых строк: {{formattedCount}}",
+ "rawLineCount_one": "{{formattedCount}} сырая строка",
+ "rawLineCount_few": "сырых строки: {{formattedCount}}",
+ "rawLineCount_many": "сырых строк: {{formattedCount}}",
+ "rawLineCount_other": "сырых строки: {{formattedCount}}",
+ "rawLinesCaptured": "{{count}} записано",
+ "emptyRawLogs": "{{count}}; среди них пока нет assistant/tool output.",
+ "noLogsYet": "Логов пока нет.",
+ "teamNotRunning": "Команда не запущена.",
+ "searchPlaceholder": "Искать в логах...",
+ "clearSearch": "Очистить поиск",
+ "newCount": "+{{count}} новых",
+ "loading": "Загрузка...",
+ "showMore": "Показать больше",
+ "noLogsCaptured": "Логи не записаны.",
+ "noMatchingLogs": "Подходящих логов нет.",
+ "openFullscreen": "Открыть логи на весь экран",
+ "fullscreen": "На весь экран",
+ "viewingFullscreen": "Просмотр в полноэкранном режиме",
+ "logsTitle": "Логи"
+ },
+ "agentGraph": {
+ "popover": {
+ "externalTeam": "Внешняя команда",
+ "process": {
+ "startedBy": "Запущено:",
+ "at": "Время:",
+ "openUrl": "Открыть URL"
+ },
+ "overflow": {
+ "hiddenTasks": "Скрытые задачи",
+ "empty": "Нет доступных скрытых задач."
+ },
+ "member": {
+ "lead": "Lead",
+ "workingOn": "работает над",
+ "recentTools": "Недавние tools",
+ "spawn": {
+ "waitingToStart": "ожидает запуска",
+ "starting": "запускается",
+ "failed": "ошибка"
+ },
+ "state": {
+ "active": "активен",
+ "idle": "ожидает",
+ "offline": "offline",
+ "runningTool": "запускает tool"
+ },
+ "activeTool": {
+ "running": "Tool выполняется",
+ "failed": "Tool завершился ошибкой",
+ "finished": "Tool завершён"
+ },
+ "actions": {
+ "message": "Сообщение",
+ "profile": "Профиль",
+ "task": "Задача"
+ }
+ }
+ },
+ "logPreview": {
+ "logs": "Логи",
+ "loading": "Загрузка логов",
+ "more": "+{{count}} ещё",
+ "more_one": "+{{count}} ещё",
+ "more_few": "+{{count}} ещё",
+ "more_many": "+{{count}} ещё",
+ "more_other": "+{{count}} ещё"
+ },
+ "blockingEdge": {
+ "title": "Блокирующая зависимость",
+ "blocks": "блокирует",
+ "close": "Закрыть",
+ "blockingHiddenTasks": "Скрытые блокирующие задачи",
+ "blockedHiddenTasks": "Скрытые заблокированные задачи"
+ },
+ "activityHud": {
+ "activity": "Активность",
+ "noRecentActivity": "Недавней активности нет",
+ "more": "+{{count}} ещё",
+ "more_one": "+{{count}} ещё",
+ "more_few": "+{{count}} ещё",
+ "more_many": "+{{count}} ещё",
+ "more_other": "+{{count}} ещё"
+ },
+ "provisioning": {
+ "launchDetails": "Детали запуска",
+ "launchDetailsDescription": "Подробный прогресс запуска команды, live-вывод и логи CLI."
+ }
+ },
+ "projectPath": {
+ "label": "Проект",
+ "source": {
+ "claude": "Найдено Claude",
+ "codex": "Найдено Codex",
+ "mixed": "Найдено Claude и Codex"
+ },
+ "deleted": {
+ "title": "Папка проекта больше не существует",
+ "label": "Удалён"
+ },
+ "mode": {
+ "projectList": "Из списка проектов",
+ "customPath": "Свой путь"
+ },
+ "loadingProjects": "Загрузка проектов...",
+ "selectProject": "Выберите проект...",
+ "searchPlaceholder": "Поиск проекта по имени или пути",
+ "empty": "Ничего не найдено",
+ "selectFromList": "Выберите проект из списка",
+ "noProjects": "Проекты не найдены, переключитесь на свой путь.",
+ "customWorkingDirectory": "Custom working directory",
+ "browse": "Выбрать",
+ "createAutomatically": "Если директории нет, она будет создана автоматически."
+ },
+ "members": {
+ "badges": {
+ "worktree": "worktree"
+ },
+ "runtimeTelemetry": {
+ "title": "Локальная нагрузка runtime",
+ "description": "Только parent и child processes. Remote LLM inference не учитывается.",
+ "cpu": "CPU",
+ "memory": "Память",
+ "summedRss": "суммарный RSS",
+ "sharedHost": "Метрика shared OpenCode host. Она не эксклюзивна для этого участника.",
+ "processTreeCapped": "Process tree был ограничен для этого sample.",
+ "rssHint": "RSS может включать shared pages, поэтому это лучше читать как сигнал нагрузки, а не эксклюзивную память."
+ },
+ "editor": {
+ "title": "Участники",
+ "addMember": "Добавить участника",
+ "editAsJson": "Редактировать как JSON",
+ "runInSeparateWorktrees": "Запускать участников в отдельных worktree",
+ "agentTeamsMcpOnly": "Только Agent Teams MCP",
+ "removedCount": "Удалённые ({{count}})",
+ "removedModelLockReason": "Удалённые участники сохранены для истории soft delete. Восстановите их, чтобы редактировать настройки.",
+ "memberNamesUnique": "Имена участников должны быть уникальными"
+ },
+ "stats": {
+ "computing": "Расчёт статистики...",
+ "empty": "Статистика недоступна",
+ "lines": "Строки",
+ "linesInfo": "Приблизительно. Точно для инструментов Edit и Write. Записи файлов через Bash оцениваются по паттернам команд (heredoc, echo, sed) и могут быть занижены.",
+ "files": "Файлы",
+ "toolCalls": "Вызовы инструментов",
+ "tokens": "Токены",
+ "toolUsage": "Использование инструментов",
+ "filesTouched": "Затронутые файлы ({{count}})",
+ "viewAllChanges": "Показать все изменения",
+ "showLess": "Показать меньше",
+ "moreFiles": "+{{count}} ещё",
+ "footer": "{{count}} сессий · рассчитано {{computedAgo}}",
+ "footer_one": "{{count}} сессия · рассчитано {{computedAgo}}",
+ "footer_few": "{{count}} сессии · рассчитано {{computedAgo}}",
+ "footer_many": "{{count}} сессий · рассчитано {{computedAgo}}",
+ "footer_other": "{{count}} сессии · рассчитано {{computedAgo}}"
+ },
+ "logs": {
+ "searching": "Поиск логов...",
+ "empty": "Логи не найдены",
+ "waitingForTaskActivity": "Задача выполняется - ожидаем активность сессии (автообновление)...",
+ "noTaskActivity": "Для этой задачи пока нет активности сессии",
+ "noMemberActivity": "У этого участника пока нет записанной активности сессии",
+ "leadSessionTooltip": "Полные логи сессии team lead - полезны для общего orchestration-контекста, не специфичного для этого агента",
+ "memberSessionTooltip": "Полные логи постоянной сессии участника - полезны, когда работа идёт в корневой member session, а не в subagent-файле",
+ "startedAt": "начато {{time}}",
+ "active": "активно",
+ "showDetails": "Показать детали",
+ "hideDetails": "Скрыть детали",
+ "loadingDetails": "Загрузка деталей...",
+ "failedToLoadDetails": "Не удалось загрузить детали"
+ },
+ "detail": {
+ "relaunchOpenCode": "Перезапустить OpenCode",
+ "restart": "Перезапустить",
+ "legacyLogsFallback": "Fallback legacy-логов",
+ "copyDiagnostics": "Скопировать диагностику",
+ "pid": "PID {{pid}}",
+ "removedAt": "Удалён {{date}}",
+ "failedToRestartMember": "Не удалось перезапустить участника",
+ "sendMessage": "Отправить сообщение",
+ "assignTask": "Назначить задачу",
+ "remove": "Удалить"
+ },
+ "list": {
+ "loading": "Загрузка участников команды",
+ "unavailable": "Состав участников недоступен",
+ "unavailableDescription": "{{count}} участников известны из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_one": "{{count}} участник известен из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_few": "{{count}} участника известны из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_many": "{{count}} участников известны из метаданных команды, но детали состава отсутствуют.",
+ "unavailableDescription_other": "{{count}} участника известны из метаданных команды, но детали состава отсутствуют.",
+ "soloLeadOnly": "Solo team - только lead",
+ "removedCount": "Удалённые ({{count}})"
+ },
+ "executionLog": {
+ "empty": "Нечего показать",
+ "emptyUserMessage": "{{time}} - (пусто)",
+ "agentInstructions": "Инструкции агента",
+ "memberTurn": "{{member}} turn",
+ "agentTurn": "Ход агента",
+ "turn": "ход"
+ },
+ "recentMessages": {
+ "latest": "Последние сообщения",
+ "latestForMember": "Последние сообщения - {{member}}",
+ "loadMore": "Загрузить ещё",
+ "expand": "Развернуть",
+ "collapse": "Свернуть"
+ },
+ "leadModel": {
+ "defaultModel": "По умолчанию",
+ "providerModelAria": "Провайдер {{provider}}, модель {{model}}",
+ "leadShort": "лид",
+ "teamLead": "Лид команды",
+ "syncWithTeammates": "Синхронизировать модель с участниками",
+ "anthropicTeamWide": "Anthropic для всей команды",
+ "runtimeInheritance": "Рантайм лида применяется к участникам, если они не задали собственного провайдера или модель.",
+ "anthropicContextLimit": "Лимит контекста 200K действует на всю команду для рантаймов Anthropic в этом запуске, включая кастомных участников Anthropic."
+ },
+ "runtimeLogs": {
+ "autoRefresh": "Автообновление",
+ "wrapLines": "Перенос строк",
+ "loadingTail": "Загрузка хвоста лога процесса...",
+ "empty": "Лог процесса для этого участника пока не сохранен."
+ },
+ "tasks": {
+ "empty": "У этого участника нет назначенных задач"
+ },
+ "messages": {
+ "loadOlder": "Загрузить более старые сообщения",
+ "filters": {
+ "all": "Все",
+ "messages": "Сообщения",
+ "comments": "Комментарии"
+ },
+ "empty": {
+ "loading": "Загрузка активности...",
+ "noComments": "У этого участника нет комментариев",
+ "noLoadedMessages": "Загруженных сообщений этого участника пока нет",
+ "noMessages": "Сообщений с этим участником нет",
+ "noLoadedActivity": "Загруженной активности этого участника пока нет",
+ "noActivity": "Активности с этим участником нет"
+ }
+ },
+ "actions": {
+ "openProfile": "Открыть профиль",
+ "editRole": "Редактировать роль",
+ "sendMessage": "Отправить сообщение",
+ "assignTask": "Назначить задачу"
+ },
+ "roleSelect": {
+ "customRolePlaceholder": "Введите свою роль..."
+ }
+ },
+ "schedule": {
+ "count": "{{count}} schedules",
+ "count_one": "{{count}} schedule",
+ "count_few": "{{count}} schedules",
+ "count_many": "{{count}} schedules",
+ "count_other": "{{count}} schedules",
+ "nextRun": "Next: {{next}}",
+ "actions": {
+ "runNow": "Запустить сейчас",
+ "edit": "Редактировать",
+ "pause": "Пауза",
+ "resume": "Возобновить",
+ "delete": "Удалить",
+ "addSchedule": "Добавить schedule"
+ },
+ "runHistory": {
+ "loading": "Загрузка run history...",
+ "empty": "Запусков ещё нет"
+ },
+ "runLog": {
+ "title": "Лог запуска",
+ "exitCode": "exit {{code}}",
+ "retryCount": "retry {{count}}/{{max}}",
+ "stillRunning": "Задача ещё выполняется...",
+ "loadingLogs": "Загрузка логов...",
+ "errors": "Ошибки",
+ "close": "Закрыть"
+ },
+ "cron": {
+ "expression": "Cron-выражение",
+ "highFrequencyWarning": "Высокая частота расписания (интервал меньше 5 минут)",
+ "nextRuns": "Следующие запуски:",
+ "timezone": "Часовой пояс",
+ "selectTimezone": "Выберите часовой пояс",
+ "warmUpTime": "Время прогрева",
+ "warmUpDescription": "Подготавливает выбранных провайдеров перед запуском по расписанию",
+ "errors": {
+ "enterExpression": "Введите cron-выражение",
+ "invalidExpression": "Некорректное cron-выражение"
+ },
+ "presets": {
+ "everyHour": "Каждый час",
+ "everySixHours": "Каждые 6 часов",
+ "dailyAtNine": "Ежедневно в 9:00",
+ "weekdaysAtNine": "По будням в 9:00",
+ "mondayAtNine": "В понедельник в 9:00",
+ "everyThirtyMinutes": "Каждые 30 минут"
+ },
+ "warmUpOptions": {
+ "none": "Без прогрева",
+ "fiveMinutes": "5 мин",
+ "tenMinutes": "10 мин",
+ "fifteenMinutes": "15 мин",
+ "thirtyMinutes": "30 мин"
+ }
+ },
+ "empty": {
+ "title": "Расписаний пока нет",
+ "description": "Создайте расписание, чтобы автоматически запускать задачи Claude по cron."
+ },
+ "title": "Расписания",
+ "status": {
+ "active": "Активно",
+ "paused": "Пауза",
+ "disabled": "Отключено"
+ },
+ "runStatus": {
+ "pending": "Ожидает",
+ "warmingUp": "Прогрев",
+ "warm": "Готово к запуску",
+ "running": "Выполняется",
+ "completed": "Завершено",
+ "failed": "Ошибка",
+ "interrupted": "Прервано",
+ "cancelled": "Отменено"
+ }
+ },
+ "openCodeContextConfigHint": {
+ "summary": "Локальные модели OpenCode могут использовать бюджет контекста OpenCode вместо ограничений только в промпте.",
+ "description": "Добавьте соответствующие лимиты в конфиг OpenCode для провайдера и модели этого участника. Это поможет OpenCode выполнять compact и prune до того, как локальная модель переполнит контекстное окно.",
+ "replacePrefix": "Замените",
+ "and": "и",
+ "replaceSuffix": "на ID провайдера и модели из вашей настройки OpenCode. Инструкции в промпте вроде",
+ "promptInstructionsSuffix": "слабее, потому что запрос собирается до того, как модель их прочитает.",
+ "providerLimits": "Лимиты провайдера",
+ "compactionConfig": "Конфиг compaction"
+ },
+ "sessions": {
+ "noProjectPath": "Путь проекта не привязан",
+ "provisioningHint": "Сессии появятся после provisioning команды",
+ "projectNotFound": "Проект не найден",
+ "loading": "Загрузка сессий...",
+ "empty": "Сессии не найдены",
+ "showAllSessions": "Показать для всех сессий",
+ "lead": "lead",
+ "removeFilter": "Убрать фильтр",
+ "filterBySession": "Фильтровать по этой сессии",
+ "openSession": "Открыть сессию",
+ "title": "Сессии"
+ },
+ "provisioning": {
+ "pid": "PID {{pid}}",
+ "cancel": "Отменить",
+ "moreWarningsHidden": "Скрыто ещё {{count}} предупреждений",
+ "diagnostics": "Диагностика",
+ "liveOutput": "Живой вывод",
+ "diagnosticsCopied": "Диагностика скопирована",
+ "copyDiagnostics": "Скопировать диагностику",
+ "copied": "Скопировано",
+ "noOutput": "Вывод пока не записан.",
+ "cliLogs": "Логи CLI",
+ "steps": {
+ "starting": "Запуск",
+ "configuring": "Настройка команды",
+ "assembling": "Подключение участников",
+ "finalizing": "Завершение"
+ },
+ "providerStatus": {
+ "status": {
+ "checking": "проверка...",
+ "ready": "OK",
+ "notes": "OK (есть заметки)",
+ "failed": "ERR",
+ "pending": "ожидание"
+ },
+ "detailSummary": {
+ "cliBinaryMissing": "CLI binary не найден",
+ "openCodeRuntimeMissing": "OpenCode runtime отсутствует",
+ "openCodeWindowsAccessBlocked": "Доступ OpenCode в Windows заблокирован",
+ "openCodeNoOutput": "Проверка OpenCode runtime не вернула вывод",
+ "openCodeMcpUnreachable": "OpenCode app MCP недоступен",
+ "workingDirectoryMissing": "Working directory отсутствует",
+ "cliBinaryCouldNotStart": "CLI binary не удалось запустить",
+ "cliPreflightIncomplete": "CLI preflight не завершился",
+ "authenticationRequired": "Требуется аутентификация",
+ "runtimeProviderNotConfigured": "Runtime provider не настроен",
+ "cliPreflightFailed": "CLI preflight завершился с ошибкой",
+ "selectedModelCompatible": "Выбранная модель совместима",
+ "selectedModelCompatibilityPending": "Совместимость выбранной модели ещё проверяется",
+ "selectedModelAvailable": "Выбранная модель доступна",
+ "selectedModelVerified": "Выбранная модель проверена",
+ "selectedModelUnavailable": "Выбранная модель недоступна",
+ "selectedModelTimedOut": "Проверка выбранной модели истекла по таймауту",
+ "selectedModelCheckFailed": "Проверка выбранной модели не удалась",
+ "selectedModelDeferred": "Проверка выбранной модели отложена",
+ "selectedModelPingNotConfirmed": "Ping выбранной модели не подтверждён",
+ "readyWithNotes": "Готово с заметками",
+ "needsAttention": "Требует внимания"
+ },
+ "modelChecksSummary": "Проверки выбранных моделей - {{details}}",
+ "modelParts": {
+ "unavailable": "{{count}} модель недоступна",
+ "unavailable_one": "{{count}} модель недоступна",
+ "unavailable_few": "{{count}} модели недоступны",
+ "unavailable_many": "{{count}} моделей недоступно",
+ "unavailable_other": "{{count}} модели недоступны",
+ "checkFailed": "{{count}} проверка модели не удалась",
+ "checkFailed_one": "{{count}} проверка модели не удалась",
+ "checkFailed_few": "{{count}} проверки моделей не удались",
+ "checkFailed_many": "{{count}} проверок моделей не удались",
+ "checkFailed_other": "{{count}} проверки моделей не удались",
+ "timedOut": "{{count}} модель по таймауту",
+ "timedOut_one": "{{count}} модель по таймауту",
+ "timedOut_few": "{{count}} модели по таймауту",
+ "timedOut_many": "{{count}} моделей по таймауту",
+ "timedOut_other": "{{count}} модели по таймауту",
+ "deferred": "{{count}} проверка отложена",
+ "deferred_one": "{{count}} проверка отложена",
+ "deferred_few": "{{count}} проверки отложены",
+ "deferred_many": "{{count}} проверок отложено",
+ "deferred_other": "{{count}} проверки отложены",
+ "pingNotConfirmed": "{{count}} ping не подтверждён",
+ "pingNotConfirmed_one": "{{count}} ping не подтверждён",
+ "pingNotConfirmed_few": "{{count}} ping не подтверждены",
+ "pingNotConfirmed_many": "{{count}} ping не подтверждены",
+ "pingNotConfirmed_other": "{{count}} ping не подтверждены",
+ "compatibilityPending": "{{count}} совместима, глубокая проверка продолжается",
+ "compatibilityPending_one": "{{count}} совместима, глубокая проверка продолжается",
+ "compatibilityPending_few": "{{count}} совместимы, глубокая проверка продолжается",
+ "compatibilityPending_many": "{{count}} совместимы, глубокая проверка продолжается",
+ "compatibilityPending_other": "{{count}} совместимы, глубокая проверка продолжается",
+ "compatible": "{{count}} совместима",
+ "compatible_one": "{{count}} совместима",
+ "compatible_few": "{{count}} совместимы",
+ "compatible_many": "{{count}} совместимы",
+ "compatible_other": "{{count}} совместимы",
+ "checking": "{{count}} проверяется",
+ "checking_one": "{{count}} проверяется",
+ "checking_few": "{{count}} проверяются",
+ "checking_many": "{{count}} проверяются",
+ "checking_other": "{{count}} проверяются",
+ "available": "{{count}} доступна",
+ "available_one": "{{count}} доступна",
+ "available_few": "{{count}} доступны",
+ "available_many": "{{count}} доступны",
+ "available_other": "{{count}} доступны",
+ "verified": "{{count}} проверена",
+ "verified_one": "{{count}} проверена",
+ "verified_few": "{{count}} проверены",
+ "verified_many": "{{count}} проверены",
+ "verified_other": "{{count}} проверены"
+ },
+ "openProviderSettings": "Открыть настройки {{provider}}",
+ "copied": "Скопировано",
+ "copyDiagnostics": "Скопировать диагностику",
+ "deepVerificationPending": "Глубокая проверка ещё выполняется. Бесплатные модели OpenCode могут проверяться около 20 секунд.",
+ "progress": {
+ "checkingSelectedProviders": "Проверка выбранных провайдеров параллельно...",
+ "checkingProvider": "Проверка провайдера {{provider}}...",
+ "checkingProviders": "Проверка провайдеров {{providers}}..."
+ },
+ "failureHints": {
+ "openCodeAccessDenied": "Исправьте права на папку или перенесите проект в папку, доступную пользователю для записи. Запуск от администратора - только временный обходной путь.",
+ "openCodeBridgeNoOutput": "Перезапустите приложение и runtime OpenCode, затем повторите. Если повторится, скопируйте diagnostics.",
+ "workingDirectoryMissing": "Выберите существующую рабочую папку, затем откройте этот диалог заново.",
+ "authenticationRequired": "Авторизуйте нужного провайдера в Claude CLI, затем откройте этот диалог заново.",
+ "runtimeProviderNotConfigured": "Настройте выбранный provider runtime, затем откройте этот диалог заново.",
+ "openCodeRuntimeMissing": "Установите или повторите запуск runtime OpenCode из карточки статуса провайдера, затем откройте этот диалог заново.",
+ "openCodeAppMcpUnreachable": "Повторите launch, чтобы обновить OpenCode app MCP bridge. Если повторится, перезапустите приложение и runtime OpenCode.",
+ "cliBinaryMissing": "Убедитесь, что локальный бинарь Claude CLI существует и может запускаться, затем откройте этот диалог заново.",
+ "default": "Исправьте проблему выше, затем откройте этот диалог заново."
+ }
+ },
+ "presentation": {
+ "awaitingPermission": "{{count}} участник(ов) ожидает подтверждения permission",
+ "nameListWithMore": "{{names}}, +{{count}} ещё",
+ "waitingForOpenCode": "Ожидание OpenCode: {{names}}",
+ "bootstrapStalled": "Bootstrap завис: {{names}}",
+ "bootstrapStalledWithOpenCodeWait": "{{stalled}}; ожидание OpenCode: {{names}}",
+ "namedPendingDiagnostic": "{{label}}: {{names}}",
+ "countPendingDiagnostic": "{{count}} - {{label}}",
+ "pendingLabels": {
+ "bootstrapStalled": "Bootstrap завис",
+ "shellOnly": "Только shell",
+ "waitingForBootstrap": "Ожидание bootstrap",
+ "bootstrapUnconfirmed": "Bootstrap не подтверждён",
+ "awaitingPermission": "Ожидание permission",
+ "waitingForRuntime": "Ожидание runtime",
+ "shellOnlyLower": "только shell",
+ "waitingForBootstrapLower": "ожидает bootstrap",
+ "bootstrapUnconfirmedLower": "bootstrap не подтверждён",
+ "awaitingPermissionLower": "ожидает permission",
+ "waitingForRuntimeLower": "ожидает runtime"
+ },
+ "failed": {
+ "memberFailedToStart": "{{name}} не запустился",
+ "teammatesFailedToStart": "{{count}} участник(ов) не запустилось",
+ "teammatesFailedRatio": "{{count}}/{{total}} участник(ов) не запустилось"
+ },
+ "skipped": {
+ "memberSkipped": "{{name}} пропущен для этого запуска",
+ "memberSkippedWithReason": "{{name}} пропущен для этого запуска - {{reason}}",
+ "memberSkippedCompact": "{{name}} пропущен",
+ "teammatesSkipped": "{{count}} участник(ов) пропущено",
+ "teammatesSkippedList": "Пропущенные участники: {{list}}",
+ "teammatesSkippedRatio": "{{count}}/{{total}} участник(ов) пропущено для этого запуска"
+ },
+ "joining": {
+ "teammatesStillJoining": "{{count}} участник(ов) ещё подключается",
+ "teammatesConfirmedRatio": "{{count}}/{{total}} участников подтверждено"
+ },
+ "ready": {
+ "leadOnline": "Lead online",
+ "allTeammatesJoined": "Все участники подключились: {{count}}",
+ "teamProvisionedLeadOnline": "Команда подготовлена - lead online",
+ "teamProvisionedAllJoined": "Команда подготовлена - все участники подключились: {{count}}",
+ "teamProvisionedStillJoining": "Команда подготовлена - участники ещё подключаются",
+ "launchFinishedWithErrors": "Запуск завершён с ошибками - {{count}}/{{total}} участник(ов) не запустилось",
+ "launchContinuedSkipped": "Запуск продолжен - {{count}}/{{total}} участник(ов) пропущено",
+ "teamLaunchedLeadOnline": "Команда запущена - lead online",
+ "teamLaunchedAllJoined": "Команда запущена - все участники подключились: {{count}}"
+ },
+ "panel": {
+ "launchFailed": "Запуск не удался",
+ "launchDetails": "Детали запуска",
+ "launchFinishedWithErrors": "Запуск завершён с ошибками",
+ "launchContinuedSkipped": "Запуск продолжен с пропущенными участниками",
+ "coreTeamReady": "Основная команда готова",
+ "finishingLaunch": "Завершение запуска",
+ "teamLaunched": "Команда запущена",
+ "launchingTeam": "Запуск команды"
+ }
+ }
+ },
+ "liveRuntimeStatus": {
+ "title": "Статус live runtime",
+ "description": "Информационный heartbeat и состояние запуска. Управление процессами находится ниже.",
+ "source": "источник: {{source}}",
+ "lane": "lane {{lane}}",
+ "diagnosticOnly": "Только для диагностики",
+ "updated": "обновлено {{value}}",
+ "states": {
+ "running": "Работает",
+ "starting": "Запускается",
+ "waiting": "Ожидает",
+ "degraded": "Требует внимания",
+ "stopped": "Остановлен",
+ "unknown": "Неизвестно"
+ }
+ },
+ "taskLogs": {
+ "exact": {
+ "title": "Точные логи задачи",
+ "loading": "Загрузка точных логов задачи...",
+ "description": "Точные фрагменты transcript отображаются теми же компонентами execution-log, что и в логах.",
+ "emptyTitle": "Точных логов задачи пока нет",
+ "emptyDescription": "Точные transcript bundles появятся здесь, когда будут доступны явные метаданные transcript, связанные с задачей.",
+ "summaryOnly": "только summary"
+ },
+ "executionSessions": {
+ "title": "Сессии выполнения",
+ "online": "Онлайн",
+ "updating": "Обновление...",
+ "description": "Просмотр и предпросмотр старых транскриптов, сгруппированных по сессиям."
+ },
+ "stream": {
+ "title": "Поток логов задачи"
+ }
+ },
+ "kanban": {
+ "taskCard": {
+ "cancelTask": "Отменить задачу {{taskId}}",
+ "cancel": "Отменить",
+ "moveBackToTodoConfirm": "Переместить задачу обратно в TODO и уведомить команду?",
+ "confirm": "Подтвердить",
+ "keep": "Оставить",
+ "changesNeedAttention": "Изменения требуют внимания",
+ "changes": "Изменения",
+ "deleteTask": "Удалить задачу",
+ "taskLogsActive": "Логи задачи активны",
+ "newTaskLogsArriving": "Поступают новые логи задачи",
+ "awaitingUser": "Ожидает пользователя",
+ "awaitingLead": "Ожидает lead",
+ "blockedBy": "Заблокировано",
+ "blocks": "Блокирует",
+ "start": "Начать",
+ "complete": "Завершить",
+ "approve": "Одобрить",
+ "requestReview": "Запросить ревью",
+ "manualReview": "Ручное ревью",
+ "requestChanges": "Запросить правки"
+ },
+ "filter": {
+ "title": "Фильтр задач",
+ "session": "Сессия",
+ "allSessions": "Все сессии",
+ "teammate": "Участник",
+ "unassigned": "(не назначено)",
+ "column": "Колонка",
+ "clearAll": "Очистить всё"
+ },
+ "board": {
+ "addTask": "Добавить задачу",
+ "noTasks": "Нет задач",
+ "showMore": "Показать ещё {{count}}",
+ "hiddenCount": "Скрыто: {{count}}",
+ "trash": "Корзина",
+ "gridView": "Вид сеткой",
+ "columnsView": "Вид колонками"
+ },
+ "trash": {
+ "title": "Корзина",
+ "empty": "Удалённых задач нет",
+ "subject": "Тема",
+ "owner": "Исполнитель",
+ "deleted": "Удалено",
+ "unassigned": "Не назначено",
+ "restoreTask": "Восстановить задачу",
+ "restore": "Восстановить",
+ "close": "Закрыть"
+ },
+ "sort": {
+ "title": "Сортировка задач",
+ "sortBy": "Сортировать по",
+ "reset": "Сбросить",
+ "options": {
+ "updatedAt": {
+ "label": "Последнее обновление",
+ "description": "Сначала недавно обновлённые"
+ },
+ "createdAt": {
+ "label": "Создано",
+ "description": "Сначала новые"
+ },
+ "owner": {
+ "label": "Исполнитель",
+ "description": "По имени исполнителя"
+ },
+ "manual": {
+ "label": "Вручную",
+ "description": "Порядок drag-and-drop"
+ }
+ }
+ },
+ "search": {
+ "clearSearch": "Очистить поиск",
+ "tasks": "Задачи",
+ "createdAgo": "создано {{time}}",
+ "updatedAgo": "обновлено {{time}}",
+ "placeholder": "Поиск задач... (#id или текст)"
+ },
+ "grid": {
+ "addTask": "Добавить задачу",
+ "noTasks": "Нет задач"
+ },
+ "title": "Канбан",
+ "columns": {
+ "todo": "TODO",
+ "inProgress": "В РАБОТЕ",
+ "review": "РЕВЬЮ",
+ "done": "ГОТОВО",
+ "approved": "ОДОБРЕНО"
+ }
+ },
+ "worktreeGitReadiness": {
+ "checking": "Проверка Git-репозитория для worktree участников...",
+ "ready": "Git worktree готовы.",
+ "readyOnBranch": "Git worktree готовы на ветке {{branch}}.",
+ "needsSetup": "Для worktree isolation нужна настройка Git",
+ "initialCommitNotice": "Действие initial commit добавит в индекс и закоммитит все текущие файлы с сообщением",
+ "initializeRepository": "Инициализировать Git-репозиторий",
+ "createInitialCommit": "Создать initial commit",
+ "initialCommitMessage": "chore: initial commit"
+ },
+ "toolApproval": {
+ "settings": "Настройки",
+ "autoAllowAllTools": "Автоматически разрешать все инструменты",
+ "autoAllowFileEdits": "Автоматически разрешать правки файлов (Edit, Write, NotebookEdit)",
+ "autoAllowSafeCommands": "Автоматически разрешать безопасные команды (git, pnpm, npm, ls...)",
+ "onTimeout": "При таймауте:",
+ "after": "через",
+ "secondsShort": "сек",
+ "timeoutActions": {
+ "wait": "Ждать всегда",
+ "allow": "Разрешить",
+ "deny": "Отклонить"
+ },
+ "submit": "Отправить",
+ "allow": "Разрешить",
+ "deny": "Отклонить",
+ "allowAll": "Разрешить всё",
+ "pendingCount": "ожидает: {{count}}",
+ "autoActionIn": "Авто-{{action}} через {{time}}",
+ "diff": {
+ "previewChanges": "Предпросмотр изменений",
+ "readingFile": "Чтение файла...",
+ "binaryFile": "Бинарный файл - предпросмотр невозможен",
+ "truncated": "Файл обрезан на 2MB - diff может быть неполным",
+ "newFile": "Новый файл"
+ }
+ },
+ "memberWorkSync": {
+ "details": {
+ "title": "Синхронизация работы участника",
+ "actionableItems": "Действия",
+ "fingerprint": "Fingerprint",
+ "report": "Отчёт",
+ "none": "нет",
+ "shadowWouldNudge": "Shadow отправил бы nudge",
+ "yes": "да",
+ "no": "нет",
+ "moreActionableItems": "Ещё действий: {{count}}",
+ "diagnostics": "Диагностика: {{diagnostics}}"
+ },
+ "title": "Синхронизация работы участника",
+ "loadingDiagnostics": "Загрузка диагностики синхронизации работы участника.",
+ "diagnosticsUnavailable": "Диагностика синхронизации работы участника недоступна."
+ },
+ "advancedCli": {
+ "title": "Дополнительно",
+ "useWorktree": "Использовать worktree",
+ "recent": "Недавние",
+ "commandPreview": "Предпросмотр команды",
+ "customArguments": "Пользовательские аргументы",
+ "validate": "Проверить",
+ "validation": {
+ "allFlagsValid": "Все флаги корректны",
+ "unknownFlags": "Неизвестные: {{flags}}",
+ "protectedFlags": "Защищённые: {{flags}}",
+ "failed": "Проверка не удалась"
+ },
+ "placeholders": {
+ "worktreeName": "worktree-name"
+ }
+ },
+ "processes": {
+ "ago": "{{time}} назад",
+ "stoppedAgo": "остановлен {{time}} назад",
+ "running": "Работает",
+ "stopped": "Остановлен",
+ "stopProcess": "Остановить процесс (SIGTERM)",
+ "kill": "Убить",
+ "openInBrowser": "Открыть в браузере",
+ "open": "Открыть",
+ "pid": "PID{{pid}}",
+ "title": "Процессы CLI"
+ },
+ "taskActivity": {
+ "loadingDetails": "Загрузка деталей активности...",
+ "contextUnavailable": "Детальный transcript-контекст для этой активности больше недоступен.",
+ "loading": "Загрузка активности задачи...",
+ "lowSignalOnly": "Ключевая активность задачи пока не найдена. Низкоуровневые детали выполнения доступны ниже в Task Log Stream.",
+ "empty": "Явная активность задачи пока не найдена в доступных transcript. Более старые или эвристические логи сессий могут быть доступны ниже в Execution Sessions.",
+ "title": "Активность задачи",
+ "description": "Ключевая runtime-активность, связанная с задачей через transcript metadata."
+ },
+ "sendMessage": {
+ "title": "Отправить сообщение",
+ "description": "Отправить прямое сообщение участнику команды.",
+ "recipientLabel": "Получатель",
+ "selectMemberPlaceholder": "Выберите участника...",
+ "messageLabel": "Сообщение",
+ "placeholder": "Напишите сообщение... (Enter для отправки)",
+ "send": "Отправить",
+ "sending": "Отправка...",
+ "charsLeft": "осталось символов: {{count}}",
+ "saved": "Сохранено",
+ "attachments": {
+ "teamOnlineRequired": "Команда должна быть онлайн, чтобы прикреплять файлы",
+ "recipientUnsupported": "Файлы можно отправлять лиду команды или участникам OpenCode",
+ "openCodeOnlineRequired": "Команда должна быть онлайн, чтобы прикреплять файлы для участников OpenCode",
+ "disabledHint": "Файлы поддерживаются для онлайн-лида команды и онлайн-участников OpenCode. Удалите вложения или смените получателя.",
+ "attachFiles": "Прикрепить файлы (вставка или drag & drop)",
+ "unavailable": "Вложения недоступны"
+ },
+ "quote": {
+ "remove": "Удалить цитату",
+ "replyingTo": "Ответ для"
+ }
+ },
+ "taskComments": {
+ "cancelReply": "Отменить ответ",
+ "replyingTo": "Ответ для",
+ "placeholder": "Добавьте комментарий... (Enter для отправки)",
+ "attachFile": "Прикрепить файл (или вставить)",
+ "voiceToText": "Голос в текст",
+ "comment": "Комментарий",
+ "charsLeft": "осталось символов: {{count}}",
+ "saved": "Сохранено",
+ "awaitingReplyFrom": "Ожидается ответ от",
+ "or": "или"
+ },
+ "taskAttachments": {
+ "dropImageHere": "Перетащите изображение сюда",
+ "attachImage": "Прикрепить изображение",
+ "pasteOrDragDrop": "или вставьте / перетащите",
+ "fromOriginalMessage": "Из исходного сообщения",
+ "dropFilesHere": "Перетащите файлы сюда",
+ "loading": "Загрузка вложений..."
+ },
+ "permissions": {
+ "autoApproveAllTools": "Автоодобрение всех инструментов",
+ "autonomousModeDescription": "Автономный режим: инструменты команды выполняются без подтверждения. Будьте осторожны с недоверенным кодом.",
+ "manualModeDescription": "Ручной режим: вы будете одобрять или отклонять каждый вызов инструмента в реальном времени."
+ },
+ "memberLogStream": {
+ "tabs": {
+ "execution": "Выполнение",
+ "process": "Процесс"
+ },
+ "filters": {
+ "all": "Все"
+ },
+ "logs": {
+ "title": "Логи",
+ "loading": "Загрузка потока логов участника...",
+ "emptyTitle": "Для этого участника пока не найдено записей потока логов.",
+ "emptyDescription": "Транскрипт участника и runtime-логи появятся здесь, когда будут доступны."
+ }
+ },
+ "reviewDialog": {
+ "placeholder": "Опишите, что нужно изменить... (Enter для отправки)",
+ "submit": "Отправить",
+ "charsLeft": "осталось символов: {{count}}",
+ "saved": "Сохранено",
+ "title": "Запросить изменения"
+ },
+ "dialogs": {
+ "actions": {
+ "openDashboard": "Открыть дашборд",
+ "openTeam": "Открыть команду",
+ "cancel": "Отмена"
+ },
+ "membersJson": {
+ "hide": "Скрыть JSON"
+ },
+ "optional": {
+ "badge": "Опционально"
+ }
+ },
+ "runningTeams": {
+ "title": "Активные команды"
+ },
+ "layout": {
+ "maxPanesReached": "Достигнут максимум панелей: {{count}}"
+ },
+ "codexReconnect": {
+ "description": "Сессия Codex выглядит устаревшей. Переподключитесь, чтобы продолжить.",
+ "useCode": "Использовать код"
+ },
+ "effortLevel": {
+ "label": "Уровень усилий (опционально)",
+ "maxDescription": "Max даёт модели больше всего времени на рассуждение для сложных задач."
+ },
+ "contextLimit": {
+ "limitTo200k": "Ограничить контекст до 200K токенов",
+ "always200k": "(для этой модели всегда 200K)",
+ "tooltipContent": "Ограничивает запуск окном контекста 200K токенов, когда это поддерживается.",
+ "tooltipTitle": "Лимит контекста"
+ },
+ "roleSelect": {
+ "noRole": "Без роли",
+ "customRole": "Своя роль...",
+ "searchPlaceholder": "Поиск ролей...",
+ "empty": "Роли не найдены.",
+ "reservedRole": "Эта роль зарезервирована"
+ }
+}
diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts
new file mode 100644
index 00000000..f77efa56
--- /dev/null
+++ b/src/features/localization/renderer/resources.d.ts
@@ -0,0 +1,5402 @@
+// This file is automatically generated by i18next-cli. Do not edit manually.
+export default interface Resources {
+ common: {
+ actions: {
+ cancel: 'Cancel';
+ close: 'Close';
+ closeDialog: 'Close dialog';
+ copied: 'Copied';
+ copyToClipboard: 'Copy to clipboard';
+ copyUrl: 'Copy URL';
+ goToDashboard: 'Go to Dashboard';
+ hide: 'Hide';
+ moreActions: 'More actions';
+ open: 'Open';
+ or: 'or';
+ refresh: 'Refresh';
+ reset: 'Reset';
+ resetSelection: 'Reset selection';
+ retry: 'Retry';
+ reveal: 'Reveal';
+ save: 'Save';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ };
+ brand: {
+ claude: 'Claude';
+ };
+ chat: {
+ bottom: 'Bottom';
+ compact: {
+ compacted: 'Compacted';
+ contextCompacted: 'Context compacted';
+ conversationCompacted: 'Conversation Compacted';
+ freedTokens: '({{tokens}} freed)';
+ phase: 'Phase {{phase}}';
+ summary: 'Previous messages were summarized to save context. The full conversation history is preserved in the session file.';
+ toggle: 'Toggle compacted content';
+ };
+ context: {
+ count: 'Context ({{count}})';
+ count_few: 'Context ({{count}})';
+ count_many: 'Context ({{count}})';
+ count_one: 'Context ({{count}})';
+ count_other: 'Context ({{count}})';
+ remainingPercent: '({{percent}}% left)';
+ };
+ empty: {
+ description: 'This session does not contain any messages yet.';
+ icon: '💬';
+ title: 'No conversation history';
+ };
+ executionTrace: {
+ empty: 'No execution items';
+ input: 'Input';
+ nested: 'Nested: {{name}}';
+ };
+ items: {
+ empty: 'No items to display';
+ };
+ lastOutput: {
+ planReadyForApproval: 'Plan Ready for Approval';
+ requestInterrupted: 'Request interrupted by user';
+ };
+ scrollToBottom: 'Scroll to bottom';
+ subagent: {
+ fallbackName: 'Subagent';
+ meta: {
+ duration: 'Duration';
+ id: 'ID';
+ model: 'Model';
+ type: 'Type';
+ };
+ metrics: {
+ contextUsage: 'Context Usage';
+ contextWindow: 'Context Window';
+ mainContext: 'Main Context';
+ phase: 'Phase {{phase}}';
+ subagentContext: 'Subagent Context';
+ totalOutput: 'Total Output';
+ turns: '({{count}} turns)';
+ turns_few: '({{count}} turns)';
+ turns_many: '({{count}} turns)';
+ turns_one: '({{count}} turn)';
+ turns_other: '({{count}} turns)';
+ };
+ shutdownConfirmed: 'Shutdown confirmed';
+ summary: {
+ tools: '{{count}} tools';
+ tools_few: '{{count}} tools';
+ tools_many: '{{count}} tools';
+ tools_one: '{{count}} tool';
+ tools_other: '{{count}} tools';
+ };
+ trace: {
+ title: 'Execution Trace';
+ };
+ };
+ system: {
+ label: 'System';
+ };
+ teammateMessage: {
+ fallback: 'Teammate message';
+ message: 'Message';
+ resent: 'Resent';
+ };
+ tools: {
+ duration: 'Duration: {{duration}}';
+ noResultReceived: 'No result received';
+ result: 'Result';
+ shutdownRequested: 'Shutdown requested ->';
+ skill: {
+ instructions: 'Skill Instructions';
+ unknown: 'Unknown Skill';
+ };
+ teammateSpawned: 'Teammate spawned';
+ write: {
+ createdFile: 'Created file';
+ wroteToFile: 'Wrote to file';
+ };
+ };
+ user: {
+ backgroundTask: 'Background task';
+ exitCode: 'exit {{code}}';
+ imagesAttached: '{{count}} images attached';
+ imagesAttached_few: '{{count}} images attached';
+ imagesAttached_many: '{{count}} images attached';
+ imagesAttached_one: '{{count}} image attached';
+ imagesAttached_other: '{{count}} images attached';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ you: 'You';
+ };
+ };
+ code: {
+ code: 'Code';
+ line: 'line {{line}}';
+ lines: 'lines {{from}}-{{to}}';
+ linesParenthesized: '(lines {{from}}-{{to}})';
+ markdownPreview: 'Markdown Preview';
+ mermaidSyntaxError: 'Mermaid syntax error';
+ moreLines: '({{count}} more lines...)';
+ moreLines_few: '({{count}} more lines...)';
+ moreLines_many: '({{count}} more lines...)';
+ moreLines_one: '({{count}} more line...)';
+ moreLines_other: '({{count}} more lines...)';
+ preview: 'Preview';
+ };
+ codexLogin: {
+ copyFailed: 'Copy failed';
+ copyLink: 'Copy link';
+ copyLinkAndCode: 'Copy link + code';
+ copyLoginLink: 'Copy ChatGPT login link';
+ copyLoginLinkAndCode: 'Copy ChatGPT login link and code';
+ enterCodeOnLoginPage: 'Enter this code on the ChatGPT login page';
+ };
+ commandPalette: {
+ currentProject: 'Current project';
+ empty: {
+ minChars: 'Type at least 2 characters to search';
+ noFastResults: 'No fast results in recent sessions for "{{query}}"';
+ noProjects: 'No projects found';
+ noProjectsForQuery: 'No projects found for "{{query}}"';
+ noResults: 'No results found for "{{query}}"';
+ };
+ footer: {
+ close: 'close';
+ escapeKey: 'esc';
+ fastPrefix: 'fast ';
+ global: 'global';
+ navigate: 'navigate';
+ open: 'open';
+ projectsCount: '{{count}} projects';
+ projectsCount_few: '{{count}} projects';
+ projectsCount_many: '{{count}} projects';
+ projectsCount_one: '{{count}} project';
+ projectsCount_other: '{{count}} projects';
+ results: '{{count}} {{speed}}results';
+ resultsAcrossProjects: '{{count}} {{speed}}results across all projects';
+ resultsAcrossProjects_few: '{{count}} {{speed}}results across all projects';
+ resultsAcrossProjects_many: '{{count}} {{speed}}results across all projects';
+ resultsAcrossProjects_one: '{{count}} {{speed}}result across all projects';
+ resultsAcrossProjects_other: '{{count}} {{speed}}results across all projects';
+ results_few: '{{count}} {{speed}}results';
+ results_many: '{{count}} {{speed}}results';
+ results_one: '{{count}} {{speed}}result';
+ results_other: '{{count}} {{speed}}results';
+ select: 'select';
+ typeToSearch: 'Type to search';
+ upDownKey: '↑↓';
+ };
+ global: 'Global';
+ mode: {
+ searchAcrossProjects: 'Search across all projects';
+ searchInProject: 'Search in project';
+ searchProjects: 'Search projects';
+ };
+ noRecentActivity: 'No recent activity';
+ placeholders: {
+ conversations: 'Search conversations...';
+ projects: 'Search projects...';
+ };
+ sessionsCount: '{{count}} sessions';
+ sessionsCount_few: '{{count}} sessions';
+ sessionsCount_many: '{{count}} sessions';
+ sessionsCount_one: '{{count}} session';
+ sessionsCount_other: '{{count}} sessions';
+ };
+ context: {
+ loadingWorkspace: 'Loading workspace';
+ local: 'Local';
+ switchWorkspace: 'Switch Workspace';
+ switchingTo: 'Switching to {{workspace}}';
+ };
+ contextBadge: {
+ badge: 'Context';
+ breakdown: {
+ text: 'Text';
+ thinking: 'Thinking';
+ };
+ detailsAria: 'Context injection details';
+ sectionSummary: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_few: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_many: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_one: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sectionSummary_other: '{{title}} ({{count}}) ~{{tokens}} tokens';
+ sections: {
+ claudeMdFiles: 'CLAUDE.md Files';
+ mentionedFiles: 'Mentioned Files';
+ taskCoordination: 'Task Coordination';
+ thinkingText: 'Thinking + Text';
+ toolOutputs: 'Tool Outputs';
+ userMessages: 'User Messages';
+ };
+ title: 'New Context Injected In This Turn';
+ tokenCount: '~{{tokens}} tokens';
+ totalNewTokens: 'Total new tokens';
+ turn: 'Turn {{turn}}';
+ };
+ diff: {
+ changed: 'Changed';
+ noChangesDetected: 'No changes detected';
+ };
+ editorFormatting: {
+ bold: 'Bold';
+ code: 'Code';
+ italic: 'Italic';
+ strike: 'Strike';
+ };
+ errorBoundary: {
+ componentStack: 'Component Stack';
+ copied: 'Copied';
+ copyErrorDetails: 'Copy Error Details';
+ description: 'An unexpected error occurred in the application. You can try reloading the page or resetting the error state.';
+ diagnosticsNotice: 'GitHub bug reports and copied diagnostics include the error message, stack traces, app version, active tab, selected team, task context, and environment details.';
+ reloadApp: 'Reload App';
+ reportBugOnGitHub: 'Report Bug on GitHub';
+ title: 'Something went wrong';
+ tryAgain: 'Try Again';
+ };
+ export: {
+ session: 'Export session';
+ sessionTitle: 'Export Session';
+ };
+ layout: {
+ closeTab: 'Close tab';
+ collapseSidebarShortcut: 'Collapse sidebar ({{shortcut}})';
+ discord: 'Discord';
+ expandSidebar: 'Expand sidebar';
+ github: 'GitHub';
+ jumpToSection: 'Jump to section';
+ loadingTab: 'Loading tab';
+ menu: {
+ analyzeSession: 'Analyze Session';
+ docs: 'Docs';
+ exportJson: 'Export as JSON';
+ exportMarkdown: 'Export as Markdown';
+ exportPlainText: 'Export as Plain Text';
+ extensions: 'Extensions';
+ schedules: 'Schedules';
+ search: 'Search';
+ settings: 'Settings';
+ teams: 'Teams';
+ };
+ newTab: 'New tab';
+ newTabDashboard: 'New tab (Dashboard)';
+ openedFromSearch: 'Opened from search';
+ pinnedSession: 'Pinned session';
+ refreshSession: 'Refresh session';
+ refreshSessionWithShortcut: 'Refresh Session ({{shortcut}})';
+ resizeSidebar: 'Resize sidebar';
+ sections: {
+ claudeLogs: 'Claude Logs';
+ kanban: 'Kanban';
+ messages: 'Messages';
+ sessions: 'Sessions';
+ team: 'Team';
+ };
+ sidebarView: 'Sidebar view';
+ tabMenu: {
+ closeAllTabs: 'Close All Tabs';
+ closeOtherTabs: 'Close Other Tabs';
+ closeTab: 'Close Tab';
+ closeTabs: 'Close {{count}} Tabs';
+ closeTabs_few: 'Close {{count}} Tabs';
+ closeTabs_many: 'Close {{count}} Tabs';
+ closeTabs_one: 'Close {{count}} Tab';
+ closeTabs_other: 'Close {{count}} Tabs';
+ hideFromSidebar: 'Hide from Sidebar';
+ pinToSidebar: 'Pin to Sidebar';
+ splitLeft: 'Split Left';
+ splitRight: 'Split Right';
+ unhideFromSidebar: 'Unhide from Sidebar';
+ unpinFromSidebar: 'Unpin from Sidebar';
+ };
+ };
+ list: {
+ actions: {
+ copyTeam: 'Copy team';
+ createTeam: 'Create Team';
+ deleteForever: 'Delete forever';
+ deletePermanently: 'Delete permanently';
+ deleteTeam: 'Delete team';
+ launchTeam: 'Launch team';
+ launching: 'Launching...';
+ relaunchTeam: 'Relaunch team';
+ restore: 'Restore';
+ restoreTeam: 'Restore team';
+ retry: 'Retry';
+ stopTeam: 'Stop team';
+ stopping: 'Stopping...';
+ };
+ all: 'All';
+ membersCount: 'Members: {{count}}';
+ membersCount_few: 'Members: {{count}}';
+ membersCount_many: 'Members: {{count}}';
+ membersCount_one: 'Member: {{count}}';
+ membersCount_other: 'Members: {{count}}';
+ moreCount: '+{{count}} more';
+ moreCount_few: '+{{count}} more';
+ moreCount_many: '+{{count}} more';
+ moreCount_one: '+{{count}} more';
+ moreCount_other: '+{{count}} more';
+ noDescription: 'No description';
+ partial: {
+ pending: 'Last launch is still reconciling.';
+ skipped: 'Last launch has skipped teammates.';
+ skippedWithCount: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_few: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_many: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_one: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_other: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ stopped: 'Last launch stopped before all teammates joined.';
+ stoppedWithCount: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_few: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_many: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_one: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_other: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ };
+ solo: 'Solo';
+ status: {
+ active: 'Active';
+ deleted: 'Deleted';
+ launching: 'Launching...';
+ offline: 'Offline';
+ partialFailure: 'Launch failed partway';
+ partialPending: 'Bootstrap pending';
+ partialSkipped: 'Launch skipped member';
+ running: 'Running';
+ };
+ };
+ locales: {
+ emptyMessage: 'No language found.';
+ names: {
+ en: 'English';
+ ru: 'Russian';
+ system: 'System';
+ };
+ searchPlaceholder: 'Search language...';
+ selectPlaceholder: 'Select app language...';
+ systemWithResolved: 'System - {{locale}}';
+ };
+ markdown: {
+ imageFallback: '[Image: {{label}}]';
+ largeContentNotice: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_few: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_many: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_one: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentNotice_other: 'Content is very large ({{count}} chars). Showing raw preview to keep the UI responsive.';
+ largeContentTitle: 'Large content is shown as raw to prevent UI freeze';
+ raw: 'Raw';
+ rawPreview: 'Raw preview';
+ renderMarkdown: 'Render markdown';
+ showAll: 'Show all';
+ showMore: 'Show more';
+ showRaw: 'Show raw';
+ showingChars: 'Showing {{shown}} / {{total}} chars';
+ };
+ members: {
+ emptyMessage: 'No members found.';
+ searchPlaceholder: 'Search members...';
+ teammateFallback: 'teammate';
+ unassigned: 'Unassigned';
+ };
+ notifications: {
+ actions: {
+ clearAll: 'Clear all';
+ clearAllNotifications: 'Clear all notifications';
+ clearFiltered: 'Clear filtered';
+ clearFilteredNotifications: 'Clear filtered notifications';
+ clickToConfirm: 'Click to confirm';
+ markAllAsRead: 'Mark all as read';
+ markAllRead: 'Mark all read';
+ markFilteredAsRead: 'Mark filtered as read';
+ markFilteredRead: 'Mark filtered read';
+ };
+ counts: {
+ inFilter: '{{count}} in filter';
+ inFilter_few: '{{count}} in filter';
+ inFilter_many: '{{count}} in filter';
+ inFilter_one: '{{count}} in filter';
+ inFilter_other: '{{count}} in filter';
+ total: '{{count}} total';
+ total_few: '{{count}} total';
+ total_many: '{{count}} total';
+ total_one: '{{count}} total';
+ total_other: '{{count}} total';
+ unread: '{{count}} unread';
+ unreadInFilter: '{{count}} unread in filter';
+ unreadInFilter_few: '{{count}} unread in filter';
+ unreadInFilter_many: '{{count}} unread in filter';
+ unreadInFilter_one: '{{count}} unread in filter';
+ unreadInFilter_other: '{{count}} unread in filter';
+ unread_few: '{{count}} unread';
+ unread_many: '{{count}} unread';
+ unread_one: '{{count}} unread';
+ unread_other: '{{count}} unread';
+ };
+ empty: {
+ allCaughtUp: "You're all caught up!";
+ noMatching: 'No matching notifications';
+ noNotifications: 'No notifications';
+ tryDifferentFilter: 'Try a different filter';
+ };
+ filters: {
+ other: 'Other';
+ };
+ loading: 'Loading notifications...';
+ row: {
+ delete: 'Delete';
+ markAsRead: 'Mark as read';
+ subagent: 'subagent';
+ team: 'team';
+ viewInSession: 'View in session';
+ };
+ title: 'Notifications';
+ };
+ providerModelBadges: {
+ checkFailed: 'Check failed';
+ checking: 'Checking';
+ free: 'Free';
+ freeTooltip: 'Reported by OpenCode metadata. Availability and limits may change.';
+ unavailable: 'Unavailable';
+ };
+ providerRuntime: {
+ codex: {
+ install: {
+ checking: 'Checking';
+ downloading: 'Downloading';
+ installCli: 'Install Codex CLI';
+ installing: 'Installing';
+ retryInstall: 'Retry install';
+ };
+ };
+ };
+ repositories: {
+ noneAvailable: 'No repositories available';
+ remove: 'Remove repository';
+ };
+ runtimeBackendSelector: {
+ audience: {
+ internal: 'Internal';
+ };
+ auto: 'Auto';
+ autoCurrently: 'Auto (currently: {{backend}})';
+ cannotSelectYet: 'This backend cannot be selected yet.';
+ current: 'Current';
+ label: 'Runtime backend';
+ recommended: 'Recommended';
+ resolved: 'Resolved: {{backend}}';
+ states: {
+ authRequired: 'Auth required';
+ degraded: 'Degraded';
+ disabled: 'Disabled';
+ locked: 'Locked';
+ runtimeMissing: 'Runtime missing';
+ unavailable: 'Unavailable';
+ };
+ unavailable: 'Unavailable';
+ };
+ runtimeProvider: {
+ defaults: {
+ allProjectsHint: 'Tests use {{project}}. Default applies unless a project has an override.';
+ projectHint: 'Saving overrides only {{project}}.';
+ projectOverrideContext: 'Project override context';
+ scopeDescriptionAllProjects: 'Default for every project that does not have its own OpenCode override.';
+ scopeDescriptionProject: 'Override only the selected project. Running teams are not changed.';
+ selectProjectHint: 'Select a project before testing local models or saving defaults.';
+ setAllProjectsDefault: 'Set all-projects default';
+ setProjectDefault: 'Set project default';
+ validationContext: 'Validation context';
+ };
+ };
+ schedules: {
+ actions: {
+ addSchedule: 'Add Schedule';
+ clearFilters: 'Clear filters';
+ createSchedule: 'Create Schedule';
+ delete: 'Delete';
+ edit: 'Edit';
+ pause: 'Pause';
+ resume: 'Resume';
+ runNow: 'Run now';
+ };
+ empty: {
+ description: 'Create a schedule on any team to automate Claude task execution with cron expressions. Schedules from all teams will appear here.';
+ noMatches: 'No schedules match the current filters';
+ title: 'No scheduled tasks';
+ };
+ filters: {
+ allTeams: 'All teams';
+ };
+ item: {
+ loadingRunHistory: 'Loading run history...';
+ nextRun: 'Next: {{value}}';
+ noRunsYet: 'No runs yet';
+ };
+ loading: 'Loading schedules...';
+ searchPlaceholder: 'Search schedules...';
+ status: {
+ active: 'Active';
+ all: 'All';
+ disabled: 'Disabled';
+ paused: 'Paused';
+ };
+ title: 'Schedules';
+ };
+ search: {
+ closeShortcut: 'Close (Esc)';
+ findInConversation: 'Find in conversation...';
+ nextResultShortcut: 'Next result (Enter)';
+ noMatchingSuggestions: 'No matching suggestions';
+ noResults: 'No results';
+ nothingFound: 'Nothing found';
+ placeholder: 'Search...';
+ previousResultShortcut: 'Previous result (Shift+Enter)';
+ resultCount: '{{current}} of {{total}}';
+ resultCountCapped: '{{current}} of {{total}}+';
+ searching: 'Searching...';
+ searchingFiles: 'Searching files...';
+ };
+ sessionContext: {
+ claudeMdFiles: 'CLAUDE.md Files';
+ empty: 'No context injections detected in this session';
+ header: {
+ bySize: 'By Size';
+ category: 'Category';
+ closePanel: 'Close panel';
+ current: 'Current';
+ phase: 'Phase:';
+ title: 'Context';
+ view: 'View:';
+ };
+ help: {
+ availability: {
+ description: 'If a provider runtime does not expose prompt-side usage yet, the panel shows metrics as unavailable instead of pretending they are zero.';
+ title: 'Availability';
+ };
+ contextUsed: {
+ description: "Prompt input plus output tokens currently occupying the model's context window.";
+ title: 'Context Used';
+ };
+ promptInput: {
+ description: 'Tokens sent to the model before generation. For Claude this includes `input_tokens + cache_creation_input_tokens + cache_read_input_tokens`.';
+ title: 'Prompt Input';
+ };
+ visibleContext: {
+ description: 'The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user messages, and similar injections that you can optimize directly.';
+ title: 'Visible Context';
+ };
+ };
+ items: {
+ itemsCount: '{{count}} items';
+ itemsCount_few: '{{count}} items';
+ itemsCount_many: '{{count}} items';
+ itemsCount_one: '{{count}} item';
+ itemsCount_other: '{{count}} items';
+ missing: 'missing';
+ text: 'Text';
+ thinking: 'Thinking';
+ tokensApprox: '~{{tokens}} tokens';
+ toolsCount: '{{count}} tools';
+ toolsCount_few: '{{count}} tools';
+ toolsCount_many: '{{count}} tools';
+ toolsCount_one: '{{count}} tool';
+ toolsCount_other: '{{count}} tools';
+ turn: '@Turn {{turn}}';
+ };
+ mentionedFiles: 'Mentioned Files';
+ metrics: {
+ codexTelemetryUnavailable: 'Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt Input and Context Used stay unavailable instead of showing a fake zero.';
+ contextUsed: 'Context Used';
+ details: 'details';
+ ofContext: 'of context';
+ ofPrompt: 'of prompt';
+ parentPlus: 'parent +';
+ promptInput: 'Prompt Input';
+ sessionCost: 'Session Cost:';
+ subagents: 'subagents';
+ unavailable: 'Unavailable';
+ visibleContext: 'Visible Context';
+ };
+ view: {
+ flat: 'Flat';
+ grouped: 'Grouped';
+ };
+ };
+ sessionFilters: {
+ project: {
+ selectProject: 'Select Project';
+ };
+ };
+ sessionItem: {
+ compactedTo: '(compacted to {{tokens}})';
+ context: 'Context: {{tokens}}';
+ phase: 'Phase {{phase}}:';
+ totalContext: 'Total Context: {{tokens}} tokens';
+ };
+ sessionReport: {
+ noSessionData: 'No session data available';
+ title: 'Session Report';
+ };
+ sessions: {
+ actions: {
+ hide: 'Hide';
+ pin: 'Pin';
+ unhide: 'Unhide';
+ };
+ count: '{{count}} sessions';
+ count_few: '{{count}} sessions';
+ count_many: '{{count}} sessions';
+ count_one: '{{count}} session';
+ count_other: '{{count}} sessions';
+ empty: {
+ noMatchingSessions: 'No matching sessions';
+ noMatchingSessionsDescription: 'This project has no matching sessions yet.';
+ noMatchingSessionsFiltered: 'Try another query or reset the provider filter.';
+ noSessions: 'No sessions found';
+ noSessionsDescription: 'This project has no sessions yet';
+ selectProject: 'Select a project to view sessions';
+ };
+ errors: {
+ loading: 'Error loading sessions';
+ };
+ failedToLoad: 'Failed to load session';
+ filter: {
+ title: 'Filter sessions';
+ };
+ inProgress: 'Session is in progress...';
+ loadedMatchingMore: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_few: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_many: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_one: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loadedMatchingMore_other: '{{count}} matching sessions loaded so far - scroll down to load more.';
+ loading: 'Loading session...';
+ loadingMore: 'Loading more sessions...';
+ pinned: 'Pinned';
+ scrollToLoadMore: 'Scroll to load more';
+ search: {
+ clear: 'Clear session search';
+ placeholder: 'Search sessions...';
+ };
+ selection: {
+ cancel: 'Cancel selection';
+ exitMode: 'Exit selection mode';
+ hideSelected: 'Hide selected sessions';
+ pinSelected: 'Pin selected sessions';
+ selectSessions: 'Select sessions';
+ selected: '{{count}} selected';
+ selected_few: '{{count}} selected';
+ selected_many: '{{count}} selected';
+ selected_one: '{{count}} selected';
+ selected_other: '{{count}} selected';
+ unhideSelected: 'Unhide selected sessions';
+ };
+ sort: {
+ byContext: 'By Context';
+ byContextTooltip: 'Sort by context consumption';
+ byRecentTooltip: 'Sort by recent';
+ contextLoadedOnly: 'Context sorting only ranks loaded sessions.';
+ };
+ title: 'Sessions';
+ visibility: {
+ hideHidden: 'Hide hidden sessions';
+ showHidden: 'Show hidden sessions';
+ };
+ worktree: {
+ switch: 'Switch Worktree';
+ };
+ };
+ states: {
+ error: 'Error';
+ loading: 'Loading...';
+ offline: 'Offline';
+ online: 'Online';
+ unknown: 'Unknown';
+ };
+ taskContextMenu: {
+ archive: 'Archive';
+ deleteTask: 'Delete task';
+ markUnread: 'Mark as unread';
+ pin: 'Pin';
+ rename: 'Rename';
+ unarchive: 'Unarchive';
+ unpin: 'Unpin';
+ };
+ taskFilters: {
+ allProjects: 'All Projects';
+ allTeams: 'All teams';
+ apply: 'Apply';
+ clearAll: 'Clear all';
+ comments: 'Comments';
+ noProjects: 'No projects';
+ noTeamsFound: 'No teams found';
+ project: 'Project';
+ read: {
+ all: 'All';
+ read: 'Read';
+ unread: 'Unread';
+ };
+ searchProjects: 'Search projects...';
+ searchTeams: 'Search teams...';
+ selectAll: 'Select all';
+ status: 'Status';
+ statusOptions: {
+ approved: 'APPROVED';
+ done: 'DONE';
+ inProgress: 'IN PROGRESS';
+ needsFix: 'NEEDS FIXES';
+ review: 'REVIEW';
+ todo: 'TODO';
+ };
+ team: 'Team';
+ };
+ tasks: {
+ date: {
+ updatedPrefix: 'upd';
+ updatedYesterday: 'upd yesterday';
+ yesterday: 'Yesterday';
+ };
+ reviewState: {
+ needsFix: 'Needs Fixes';
+ };
+ unassigned: 'unassigned';
+ };
+ tasksPanel: {
+ deleteConfirm: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete';
+ message: 'Move task #{{taskId}} to trash?';
+ title: 'Delete task';
+ };
+ deleteFailed: {
+ confirmLabel: 'OK';
+ fallbackMessage: 'An unexpected error occurred';
+ title: 'Failed to delete task';
+ };
+ empty: {
+ noMatchingTasks: 'No matching tasks';
+ noTasks: 'No tasks found';
+ };
+ groupByAria: 'Group by';
+ groupByLabel: 'Group by:';
+ groupModes: {
+ none: 'None';
+ project: 'Project';
+ time: 'Time';
+ };
+ hideArchived: 'Hide archived';
+ pinned: 'Pinned';
+ searchPlaceholder: 'Search tasks...';
+ showArchived: 'Show archived';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ sort: {
+ byProject: 'By project';
+ byTeam: 'By team';
+ byTime: 'By time';
+ byUnread: 'By unread';
+ };
+ teamLabel: 'Team: {{team}}';
+ title: 'Tasks';
+ };
+ terminal: {
+ checkOutputForDetails: 'Check terminal output above for details';
+ closingInSeconds: 'Closing in {{count}}s...';
+ closingInSeconds_few: 'Closing in {{count}}s...';
+ closingInSeconds_many: 'Closing in {{count}}s...';
+ closingInSeconds_one: 'Closing in {{count}}s...';
+ closingInSeconds_other: 'Closing in {{count}}s...';
+ completedSuccessfully: 'Completed successfully';
+ exitCode: '(exit code {{code}})';
+ processFailed: 'Process failed';
+ title: 'Terminal';
+ };
+ tmuxInstaller: {
+ actions: {
+ cancel: 'Cancel';
+ hideSetupSteps: 'Hide setup steps';
+ manualGuide: 'Manual guide';
+ recheck: 'Re-check';
+ showSetupSteps: 'Show setup steps ({{count}})';
+ showSetupSteps_few: 'Show setup steps ({{count}})';
+ showSetupSteps_many: 'Show setup steps ({{count}})';
+ showSetupSteps_one: 'Show setup step ({{count}})';
+ showSetupSteps_other: 'Show setup steps ({{count}})';
+ };
+ details: {
+ hide: 'Hide details';
+ show: 'Show details';
+ };
+ detectedOs: 'Detected OS: {{os}}';
+ input: {
+ passwordNotice: 'Password input is sent directly to the installer terminal and is not added to the log output.';
+ placeholder: 'Send input to the installer';
+ send: 'Send input';
+ };
+ installerProgress: 'Installer progress';
+ phase: 'Phase: {{phase}}';
+ runtimePath: 'Runtime path: {{path}}';
+ summaryTitle: 'tmux is not installed';
+ };
+ tokens: {
+ accumulatedWithoutDuplication: 'Accumulated across entire session without duplication';
+ approxTokens: '~{{tokens}} tokens';
+ approxTokensParenthesized: '(~{{tokens}})';
+ cacheRead: 'Cache Read';
+ cacheWrite: 'Cache Write';
+ claudeMd: 'CLAUDE.md';
+ costUsd: 'Cost (USD)';
+ includesClaudeMd: 'incl. CLAUDE.md ×{{count}}';
+ inputTokens: 'Input Tokens';
+ mentionedFiles: '@files';
+ model: 'Model';
+ outputTokens: 'Output Tokens';
+ percentValue: '({{percent}}%)';
+ phase: 'Phase {{phase}}/{{total}}';
+ promptInputShare: '{{percent}}% of prompt input';
+ taskCoordination: 'Task Coordination';
+ thinkingText: 'Thinking + Text';
+ toolOutputs: 'Tool Outputs';
+ total: 'Total';
+ userMessages: 'User Messages';
+ visibleContext: 'Visible Context';
+ };
+ toolViewer: {
+ agent: {
+ action: 'action';
+ runtime: 'runtime';
+ startupInstructionsHidden: 'Startup instructions are hidden in the UI.';
+ team: 'team';
+ teammate: 'teammate';
+ type: 'type';
+ };
+ input: 'Input';
+ noInputRecorded: 'No input recorded for this tool call.';
+ replaceAll: '(replace all)';
+ };
+ updateDialog: {
+ closeDialog: 'Close dialog';
+ download: 'Download';
+ later: 'Later';
+ noReleaseNotes: 'No release notes available.';
+ restartNow: 'Restart now';
+ updateAvailable: 'Update available';
+ updateReady: 'Update Ready';
+ viewOnGitHub: 'View on GitHub';
+ };
+ updates: {
+ downloadedRestartTooltip: 'Update downloaded, restart to apply';
+ newVersionAvailable: 'New version available';
+ restartNow: 'Restart now';
+ restartToUpdate: 'Restart to update';
+ updateApp: 'Update app';
+ updateReady: 'Update ready';
+ updatingApp: 'Updating app';
+ };
+ window: {
+ maximize: 'Maximize';
+ minimize: 'Minimize';
+ restore: 'Restore';
+ };
+ };
+ dashboard: {
+ actions: {
+ clearSearch: 'Clear search';
+ or: 'or';
+ selectTeam: 'Select Team';
+ };
+ cliStatus: {
+ actions: {
+ alreadyLoggedIn: 'Already logged in?';
+ becomeSponsor: 'Become a sponsor';
+ cancel: 'Cancel';
+ checkNow: 'Check now';
+ checkUpdates: 'Check for Updates';
+ checking: 'Checking...';
+ connect: 'Connect';
+ extensions: 'Extensions';
+ login: 'Login';
+ manage: 'Manage';
+ manageProviders: 'Manage Providers';
+ plan: 'Plan';
+ recheck: 'Re-check';
+ recheckProvider: 'Re-check {{provider}}';
+ retry: 'Retry';
+ updateTo: 'Update to v{{version}}';
+ useCode: 'Use code';
+ };
+ atlas: {
+ alt: 'Atlas Cloud';
+ description: "Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.";
+ openCodeProvider: 'OpenCode provider';
+ plan: 'Atlas Cloud coding plan';
+ sponsor: 'Sponsor';
+ };
+ errors: {
+ checkStatusFailed: 'Failed to check CLI status';
+ installationFailed: 'Installation failed';
+ refreshFailed: 'Failed to check for updates. Check your network connection and try again.';
+ runtimeUpdatedRefreshFailed: 'Runtime updated, but failed to refresh provider status.';
+ };
+ hints: {
+ backgroundStatus: '{{runtime}} status will be checked in the background.';
+ codexApiKeyFallback: '{{hint}} API key fallback is available if you switch auth mode.';
+ codexAutoApiKey: '{{hint}} Auto will keep using the API key until ChatGPT is connected.';
+ codexFinishLogin: 'Finish ChatGPT login in the browser. Enter the shown code if prompted.';
+ codexNoActiveLogin: 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.';
+ codexNoActiveManagedSession: 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.';
+ codexReconnectNeeded: 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.';
+ firstCheckSlow: 'First check may take up to 30 seconds';
+ loginRequiredForTeams: 'Browsing sessions and projects works without login. Login is only needed to run agent teams.';
+ troubleshootTitle: "If you're sure you're logged in, try these steps:";
+ };
+ installer: {
+ checkingLatest: 'Checking latest version...';
+ downloading: 'Downloading {{runtime}}...';
+ installing: 'Installing {{runtime}}...';
+ success: 'Successfully installed {{runtime}} v{{version}}';
+ verifying: 'Verifying checksum...';
+ };
+ labels: {
+ apiKeyRequired: 'API key required';
+ collapseProviderDetails: 'Collapse provider details';
+ comingSoon: 'Coming soon';
+ expandProviderDetails: 'Expand provider details';
+ generateLink: 'Generate link';
+ loadingRateLimits: 'Rate limits loading';
+ loggedOut: 'Provider logged out';
+ loginAuthFailed: 'Authentication failed';
+ loginAuthUpdated: 'Authentication updated';
+ loginComplete: 'Login complete';
+ loginFailed: 'Login failed';
+ loginTitle: 'Login';
+ logoutFailed: 'Logout failed';
+ logoutTitle: 'Logout';
+ notLoggedIn: 'Not logged in';
+ openLogin: 'Open login';
+ providerActionRequired: 'Provider action required';
+ resets: 'resets {{time}}';
+ runtimeLoginTitle: '{{runtime}} Login';
+ };
+ loading: {
+ aiProviders: 'Checking AI Providers...';
+ claudeCli: 'Checking Claude CLI...';
+ };
+ provider: {
+ authenticated: 'Authenticated';
+ backend: 'Backend: {{backend}}';
+ checkingAuthentication: 'Checking authentication...';
+ checkingProviders: 'Checking providers...';
+ configuredLocalCount: '{{count}} configured local';
+ configuredLocalCount_few: '{{count}} configured local';
+ configuredLocalCount_many: '{{count}} configured local';
+ configuredLocalCount_one: '{{count}} configured local';
+ configuredLocalCount_other: '{{count}} configured local';
+ configuredLocalTitle: 'Local OpenCode routes imported from your OpenCode config.';
+ connectedCount: 'Providers: {{connected}}/{{denominator}} connected';
+ freeModels: 'Free models';
+ freeModelsTitle: 'OpenCode includes free model options such as Big Pickle when available in your setup. OpenRouter through OpenCode can also expose free models, but not every OpenCode/OpenRouter model is free. Availability and limits may change.';
+ loadingModels: 'Loading models...';
+ modelsUnavailable: 'Models unavailable for this runtime build';
+ runtime: 'Runtime: {{runtime}}';
+ verifiedCount: '{{count}} verified';
+ verifiedCount_few: '{{count}} verified';
+ verifiedCount_many: '{{count}} verified';
+ verifiedCount_one: '{{count}} verified';
+ verifiedCount_other: '{{count}} verified';
+ verifiedTitle: 'OpenCode routes with a successful execution proof.';
+ };
+ runtime: {
+ configuredHealthCheckFailed: 'The configured {{runtime}} failed its startup health check.';
+ configuredNotFound: 'The configured {{runtime}} was not found.';
+ foundButFailed: '{{runtime}} was found but failed to start';
+ healthCheckFailedDescription: 'The app found the configured {{runtime}}, but its startup health check failed. Repair or reinstall it, then retry.';
+ install: 'Install {{runtime}}';
+ installRequiredDescription: '{{runtime}} is required for team provisioning and session management. Install it to get started.';
+ isRequired: '{{runtime}} is required';
+ reinstall: 'Reinstall {{runtime}}';
+ };
+ runtimeInstall: {
+ checking: 'Checking';
+ codexTitle: 'Install Codex CLI into app data';
+ downloading: 'Downloading';
+ downloadingPercent: 'Downloading {{percent}}%';
+ install: 'Install';
+ installing: 'Installing';
+ openCodeTitle: 'Install OpenCode runtime into app data';
+ retryInstall: 'Retry install';
+ };
+ troubleshoot: {
+ again: 'again';
+ authStatusCommand: 'your configured CLI auth status command';
+ checkLoggedIn: '- check if it shows "Logged in"';
+ click: 'Click';
+ loginCommand: 'the runtime login command';
+ logoutCommand: 'the runtime logout command';
+ openTerminal: 'Open your terminal and run:';
+ reloginPrefix: "If it says logged in but the app doesn't see it, try:";
+ sameRuntime: 'Make sure the CLI in your terminal is the same runtime the app uses';
+ statusCacheHint: '- sometimes the status is cached for a few seconds';
+ then: 'then';
+ };
+ warnings: {
+ multipleApiKeysMissing: 'One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.';
+ multipleApiKeysNeedAttention: 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.';
+ notAuthenticated: '{{runtime}} is installed but you are not authenticated. Login is required for team provisioning and AI features.';
+ singleApiKeyMissing: '{{provider}} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.';
+ singleApiKeyNeedsAttention: '{{provider}} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.';
+ };
+ };
+ recentProjects: {
+ card: {
+ deleted: 'Deleted';
+ projectFolderMissing: 'Project folder no longer exists';
+ taskCounts: {
+ active: '{{count}} active';
+ active_few: '{{count}} active';
+ active_many: '{{count}} active';
+ active_one: '{{count}} active';
+ active_other: '{{count}} active';
+ done: '{{count}} done';
+ done_few: '{{count}} done';
+ done_many: '{{count}} done';
+ done_one: '{{count}} done';
+ done_other: '{{count}} done';
+ pending: '{{count}} pending';
+ pending_few: '{{count}} pending';
+ pending_many: '{{count}} pending';
+ pending_one: '{{count}} pending';
+ pending_other: '{{count}} pending';
+ };
+ };
+ emptyDescription: 'Recent Claude and Codex activity will appear here.';
+ failedToLoad: 'Failed to load projects';
+ loadMore: 'Load more';
+ noMatches: 'No matches for "{{query}}"';
+ noProjects: 'No projects found';
+ noRecentProjects: 'No recent projects found';
+ retry: 'Retry';
+ searchPlaceholder: 'Search projects...';
+ searchResults: 'Search Results';
+ selectFolder: 'Select Folder';
+ selectFolderTitle: 'Select a project folder';
+ title: 'Recent Projects';
+ };
+ updateBanner: {
+ newVersionAvailable: 'New version available';
+ restartNow: 'Restart now';
+ viewDetails: 'View details';
+ };
+ webPreview: {
+ description: 'The browser version is still in development. Project actions, integrations, and live status updates may be limited here. Use the desktop app to access all features reliably.';
+ title: 'Open the desktop app for full functionality';
+ };
+ windowsAdmin: {
+ description: 'OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app with Run as administrator before launching OpenCode teams.';
+ title: 'Windows Administrator mode recommended';
+ };
+ };
+ errors: {
+ fallback: 'Something went wrong.';
+ };
+ extensions: {
+ apiKeys: {
+ actions: {
+ add: 'Add API Key';
+ addFirst: 'Add your first key';
+ edit: 'Edit';
+ };
+ description: 'Securely store API keys for auto-filling when installing MCP servers.';
+ empty: {
+ description: 'Add keys to auto-fill environment variables when installing MCP servers.';
+ title: 'No API keys saved';
+ };
+ form: {
+ addDescription: 'Store an API key for auto-filling in MCP server installations.';
+ addTitle: 'Add API Key';
+ boundTo: 'Bound to {{path}}';
+ cancel: 'Cancel';
+ editDescription: 'Update the key details. You must re-enter the value.';
+ editTitle: 'Edit API Key';
+ envVarPlaceholder: 'e.g. OPENAI_API_KEY';
+ environmentVariableName: 'Environment Variable Name';
+ errors: {
+ envVarRequired: 'Environment variable name is required';
+ invalidEnvVar: 'Invalid environment variable name';
+ invalidEnvVarFormat: 'Use letters, digits, underscores. Must start with a letter or underscore.';
+ nameRequired: 'Name is required';
+ projectScopeRequiresProject: 'Project-scoped API keys require an active project';
+ saveFailed: 'Failed to save';
+ valueRequired: 'Key value is required';
+ };
+ keychainUnavailable: 'OS keychain unavailable - keys encrypted with AES-256 locally. Install gnome-keyring for OS-level protection.';
+ name: 'Name';
+ namePlaceholder: 'e.g. OpenAI Production';
+ projectScopeLabel: 'Project: {{project}}';
+ projectUnavailable: 'Project unavailable';
+ reenterValue: 'Re-enter key value';
+ save: 'Save';
+ saving: 'Saving...';
+ scope: 'Scope';
+ update: 'Update';
+ userScopeLabel: 'User (global)';
+ value: 'Value';
+ valuePlaceholder: 'sk-...';
+ };
+ storage: {
+ localEncryption: 'OS keychain unavailable - keys are encrypted locally with AES-256. For stronger protection, install a keyring service (gnome-keyring, kwallet).';
+ osKeychain: 'Keys are encrypted via {{backend}} and stored with restricted file permissions (owner-only).';
+ };
+ };
+ customMcp: {
+ actions: {
+ add: 'Add';
+ cancel: 'Cancel';
+ install: 'Install';
+ installing: 'Installing...';
+ };
+ description: 'Add a server manually without the catalog.';
+ errors: {
+ installFailed: 'Install failed';
+ invalidServerName: 'Invalid server name. Use alphanumeric characters, dashes, underscores, dots.';
+ npmPackageRequired: 'npm package name is required';
+ serverNameRequired: 'Server name is required';
+ serverUrlRequired: 'Server URL is required';
+ };
+ fields: {
+ environmentVariables: 'Environment Variables';
+ headers: 'Headers';
+ npmPackage: 'npm Package';
+ scope: 'Scope';
+ serverName: 'Server Name';
+ serverUrl: 'Server URL';
+ transport: 'Transport';
+ transportType: 'Transport Type';
+ versionOptional: 'Version (optional)';
+ };
+ placeholders: {
+ envVarName: 'ENV_VAR_NAME';
+ headerName: 'Header-Name';
+ latest: 'latest';
+ serverName: 'my-server';
+ serverUrl: 'https://api.example.com/mcp';
+ value: 'value';
+ };
+ title: 'Add Custom MCP Server';
+ transport: {
+ httpSse: 'HTTP / SSE';
+ stdio: 'Stdio (npm)';
+ };
+ };
+ installButton: {
+ done: 'Done';
+ install: 'Install';
+ installing: 'Installing...';
+ removing: 'Removing...';
+ retry: 'Retry';
+ uninstall: 'Uninstall';
+ };
+ mcpCard: {
+ auth: 'Auth';
+ byAuthor: 'by {{author}}';
+ envCount: '{{count}} envs';
+ envCount_few: '{{count}} envs';
+ envCount_many: '{{count}} envs';
+ envCount_one: '{{count}} env';
+ envCount_other: '{{count}} envs';
+ hosting: {
+ both: 'Both';
+ local: 'Local';
+ remote: 'Remote';
+ };
+ repository: 'Repository';
+ toolsCount: '{{count}} tools';
+ toolsCount_few: '{{count}} tools';
+ toolsCount_many: '{{count}} tools';
+ toolsCount_one: '{{count}} tool';
+ toolsCount_other: '{{count}} tools';
+ website: 'Website';
+ };
+ mcpDetail: {
+ auth: {
+ remoteMayNeedHeaders: 'Remote MCP servers may still require custom headers or API keys even when the registry does not describe them. If connection fails after install, check the provider docs.';
+ required: 'This server requires authentication';
+ };
+ diagnostics: {
+ launchTarget: 'Launch Target';
+ };
+ form: {
+ autoFilled: 'Auto-filled';
+ environmentVariables: 'Environment Variables';
+ headers: 'Headers';
+ scope: 'Scope';
+ serverName: 'Server Name';
+ };
+ install: {
+ httpTransport: 'HTTP: {{transport}}';
+ install: 'Install Server';
+ manage: 'Manage Installation';
+ manualSetupDescription: 'This server requires manual setup. Check the repository for installation instructions.';
+ manualSetupRequired: 'Manual setup required';
+ npmPackage: 'npm: {{package}}';
+ };
+ links: {
+ glama: 'Glama';
+ repository: 'Repository';
+ website: 'Website';
+ };
+ metadata: {
+ author: 'Author';
+ githubStars: 'GitHub Stars';
+ hosting: 'Hosting';
+ installType: 'Install Type';
+ license: 'License';
+ published: 'Published';
+ source: 'Source';
+ updated: 'Updated';
+ version: 'Version';
+ };
+ placeholders: {
+ serverName: 'my-server';
+ };
+ scope: {
+ local: 'Local';
+ project: 'Project';
+ };
+ tools: {
+ title: 'Tools ({{count}})';
+ title_few: 'Tools ({{count}})';
+ title_many: 'Tools ({{count}})';
+ title_one: 'Tools ({{count}})';
+ title_other: 'Tools ({{count}})';
+ };
+ };
+ mcpPanel: {
+ diagnostics: {
+ disableReasons: {
+ checkingRuntimeAvailability: 'Checking runtime availability...';
+ checkingRuntimeStatus: 'Checking runtime status...';
+ runtimeFailedToStart: 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
+ runtimeRequired: 'The configured runtime is required. Install or repair it from the Dashboard.';
+ };
+ serversCount: '{{count}} servers';
+ serversCount_few: '{{count}} servers';
+ serversCount_many: '{{count}} servers';
+ serversCount_one: '{{count}} server';
+ serversCount_other: '{{count}} servers';
+ title: 'Runtime MCP Diagnostics';
+ waiting: 'Waiting for diagnostics results...';
+ };
+ empty: {
+ description: 'Check back later for new servers';
+ searchDescription: 'Try a different search term';
+ searchTitle: 'No servers found';
+ title: 'No MCP servers available';
+ };
+ health: {
+ checkStatus: 'Check Status';
+ checking: 'Checking...';
+ checkingViaRuntime: 'Checking installed MCP servers via {{runtime}} ...';
+ description: 'Run diagnostics from this page to verify installed MCP connectivity.';
+ lastChecked: 'Last checked {{time}}';
+ title: 'MCP Health Status';
+ };
+ loadMore: 'Load more';
+ runtime: {
+ notAvailable: '{{runtime}} not available';
+ notInstalled: '{{runtime}} not installed';
+ requiredDescription: 'MCP health checks require {{runtime}}. Go to the Dashboard to install or repair it.';
+ };
+ searchPlaceholder: 'Search MCP servers...';
+ sort: {
+ nameAsc: 'Name A→Z';
+ nameDesc: 'Name Z→A';
+ toolsDesc: 'Most tools';
+ };
+ };
+ pluginCard: {
+ official: 'Official';
+ };
+ pluginDetail: {
+ links: {
+ contact: 'Contact';
+ homepage: 'Homepage';
+ };
+ metadata: {
+ author: 'Author';
+ capabilities: 'Capabilities';
+ category: 'Category';
+ installs: 'Installs';
+ source: 'Source';
+ version: 'Version';
+ };
+ readme: {
+ empty: 'No README available.';
+ loading: 'Loading README...';
+ };
+ scope: {
+ label: 'Scope:';
+ options: {
+ local: 'Local (gitignored)';
+ project: 'Project (shared)';
+ user: 'User (global)';
+ };
+ };
+ unknown: 'Unknown';
+ };
+ pluginsPanel: {
+ activeFilters: '{{count}} active';
+ activeFilters_few: '{{count}} active';
+ activeFilters_many: '{{count}} active';
+ activeFilters_one: '{{count}} active';
+ activeFilters_other: '{{count}} active';
+ browseByFit: 'Browse by fit';
+ capabilities: 'Capabilities';
+ categories: 'Categories';
+ clearAllFilters: 'Clear all filters';
+ clearFilters: 'Clear filters';
+ counts: {
+ capabilities: '{{count}} capabilities';
+ capabilities_few: '{{count}} capabilities';
+ capabilities_many: '{{count}} capabilities';
+ capabilities_one: '{{count}} capabilities';
+ capabilities_other: '{{count}} capabilities';
+ categories: '{{count}} categories';
+ categories_few: '{{count}} categories';
+ categories_many: '{{count}} categories';
+ categories_one: '{{count}} categories';
+ categories_other: '{{count}} categories';
+ plugins: '{{count}} plugins';
+ plugins_few: '{{count}} plugins';
+ plugins_many: '{{count}} plugins';
+ plugins_one: '{{count}} plugins';
+ plugins_other: '{{count}} plugins';
+ };
+ empty: {
+ description: 'Check back later for new plugins';
+ filteredDescription: 'Try adjusting your search or filter criteria';
+ filteredTitle: 'No plugins match your filters';
+ title: 'No plugins available';
+ };
+ filterDescription: 'Narrow the catalog by category, capability, or installed state.';
+ installedOnly: 'Installed only';
+ providerSupportNotice: "Plugin support is currently guaranteed for Anthropic (Claude) sessions only. We're working to support plugins across all agents.";
+ resultsUpdateInstantly: 'Results update instantly as you refine filters.';
+ searchPlaceholder: 'Search plugins...';
+ selectedCount: '{{count}} selected';
+ selectedCount_few: '{{count}} selected';
+ selectedCount_many: '{{count}} selected';
+ selectedCount_one: '{{count}} selected';
+ selectedCount_other: '{{count}} selected';
+ showing: 'Showing {{shown}} of {{total}} plugins';
+ sort: {
+ category: 'Category';
+ nameAsc: 'Name A-Z';
+ nameDesc: 'Name Z-A';
+ popular: 'Popular';
+ };
+ };
+ skillDetail: {
+ actions: {
+ cancel: 'Cancel';
+ delete: 'Delete';
+ deleteSkill: 'Delete Skill';
+ deleting: 'Deleting...';
+ editSkill: 'Edit Skill';
+ openFolder: 'Open Folder';
+ openSkillFile: 'Open SKILL.md';
+ retry: 'Retry';
+ };
+ badges: {
+ assets: 'Assets';
+ autoUse: 'Auto use';
+ hasScripts: 'Has scripts';
+ manualUse: 'Manual use';
+ references: 'References';
+ storedIn: 'Stored in {{root}}';
+ };
+ deleteDialog: {
+ description: 'Delete this skill and move it to Trash?';
+ descriptionWithName: 'Delete "{{name}}" and move it to Trash? You can restore it later from Trash if needed.';
+ title: 'Delete skill?';
+ };
+ descriptionFallback: 'Inspect discovered skill metadata and raw instructions.';
+ errors: {
+ deleteFailed: 'Failed to delete skill';
+ loadFailed: 'Unable to load this skill.';
+ };
+ files: {
+ advancedDetails: 'Advanced file details';
+ assets: 'Assets';
+ references: 'References';
+ scripts: 'Scripts';
+ storedAt: 'Stored at';
+ };
+ includes: {
+ assets: 'assets';
+ instructionsOnly: 'Just the skill instructions';
+ references: 'references';
+ scripts: 'scripts';
+ };
+ invocation: {
+ auto: 'Runs automatically when it matches the task.';
+ manualOnly: 'Only runs when you explicitly ask for it.';
+ };
+ issues: {
+ bundledScripts: 'This skill includes bundled scripts';
+ reviewCarefully: 'Review this skill carefully before using it';
+ };
+ loading: 'Loading skill details...';
+ scope: {
+ personal: 'Your personal skills';
+ projectOnly: 'This project only';
+ };
+ summary: {
+ howUsed: 'How it is used';
+ included: 'What comes with it';
+ whoCanUse: 'Who can use it';
+ };
+ titleFallback: 'Skill details';
+ };
+ skillEditor: {
+ actions: {
+ cancel: 'Cancel';
+ createSkill: 'Create Skill';
+ preparing: 'Preparing...';
+ reviewAndCreate: 'Review And Create';
+ reviewAndSave: 'Review And Save';
+ saveSkill: 'Save Skill';
+ };
+ advanced: {
+ customDescription: 'This skill uses a custom markdown format, so edit it directly here.';
+ customTitle: '2. SKILL.md editor';
+ description: 'Most people can skip this. Open it only if you want direct control over the raw markdown file.';
+ hide: 'Hide Advanced Editor';
+ resetFromStructuredFields: 'Reset From Structured Fields';
+ show: 'Show Advanced Editor';
+ title: '4. Advanced SKILL.md editor';
+ };
+ basics: {
+ description: 'Give this skill a clear name, choose who can use it, and decide where it should live.';
+ title: '1. Basics';
+ };
+ description: {
+ create: 'Describe the workflow in plain language, review the files that will be created, then save it.';
+ edit: 'Update this skill, review the resulting file changes, then save it.';
+ };
+ extraFiles: {
+ addedFiles: 'Added files:';
+ assets: 'Assets';
+ assetsDescription: 'Add screenshots or bundled media only if they help explain the workflow.';
+ description: 'Add supporting docs, scripts, or assets only if this skill really needs them.';
+ lockedForEdits: 'Root and folder are locked for edits';
+ optionalDescription: 'Add starter files that will be included in the review and written together with `SKILL.md`.';
+ optionalTitle: 'Optional files';
+ references: 'References';
+ referencesDescription: 'Add supporting docs, links, or examples the runtime can look at.';
+ scripts: 'Scripts';
+ scriptsDescription: 'Add helper commands or setup notes. Review carefully before sharing this skill.';
+ title: '3. Extra files';
+ };
+ fields: {
+ compatibility: 'Compatibility';
+ description: 'Description';
+ folderName: 'Folder name';
+ folderNameHint: 'We suggest this automatically from the skill name so review works right away.';
+ invocation: 'How it should be used';
+ license: 'License';
+ name: 'Skill name';
+ notes: 'Extra notes or guardrails';
+ root: 'Where to store it';
+ scope: 'Who can use it';
+ steps: 'Main steps to follow';
+ whenToUse: 'When to reach for this';
+ };
+ instructions: {
+ description: 'These sections generate the skill file for you, so you do not need to edit markdown unless you want to.';
+ locked: 'Structured fields are locked because you switched to manual `SKILL.md` editing below.';
+ title: '2. Instructions';
+ };
+ invocation: {
+ auto: 'Can be used automatically';
+ manualOnly: 'Only when you ask for it';
+ };
+ placeholders: {
+ compatibility: 'claude-code, cursor';
+ description: 'What this skill helps with';
+ license: 'MIT';
+ name: 'Write concise skill name';
+ notes: 'Example: Call out missing tests, regressions, and risky assumptions.';
+ steps: '1. Inspect the relevant files.\n2. Explain the main risk first.\n3. Suggest the safest fix.';
+ whenToUse: 'Example: Use this when the task is a code review or bug triage request.';
+ };
+ review: {
+ creating: 'Creating a skill';
+ hint: 'Review the file changes first, then confirm save in the next step.';
+ saving: 'Saving this skill';
+ };
+ root: {
+ codexOnly: ' - Codex only';
+ shared: ' - Shared';
+ };
+ scope: {
+ project: 'Project: {{project}}';
+ projectUnavailable: 'Project unavailable';
+ user: 'User';
+ };
+ title: {
+ create: 'Create skill';
+ edit: 'Edit skill';
+ };
+ };
+ skillImport: {
+ actions: {
+ backToImport: 'Back To Import';
+ browse: 'Browse';
+ cancel: 'Cancel';
+ importSkill: 'Import Skill';
+ preparing: 'Preparing...';
+ reviewAndImport: 'Review And Import';
+ };
+ description: 'Pick an existing skill folder, review what will be copied, then import it into one of your supported skill locations.';
+ errors: {
+ importFailed: 'Failed to import skill';
+ invalidFolderName: 'Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.';
+ missingSkillFile: 'This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.';
+ mustBeDirectory: 'Choose a folder to import, not a single file.';
+ reviewFailed: 'Failed to review import changes';
+ symbolicLinks: 'This folder contains symbolic links. Import the real files instead of links.';
+ tooLarge: 'This skill folder is too large to import safely. Trim large assets and try again.';
+ tooManyFiles: 'This skill folder is too large to import at once. Remove extra files and try again.';
+ };
+ fields: {
+ audience: 'Who can use it';
+ destinationFolderName: 'Destination folder name';
+ sourceFolder: 'Source folder';
+ storage: 'Where to store it';
+ };
+ placeholders: {
+ defaultFolderName: 'Defaults to source folder name';
+ };
+ reviewHint: 'Review the copied files first, then confirm the import in the next step.';
+ reviewLabel: 'Importing this skill';
+ rootSuffix: {
+ codexOnly: ' - Codex only';
+ shared: ' - Shared';
+ };
+ scope: {
+ project: 'Project: {{project}}';
+ projectUnavailable: 'Project unavailable';
+ user: 'User';
+ };
+ steps: {
+ chooseFolder: {
+ description: 'This should be a folder that already contains a `SKILL.md`, `Skill.md`, or `skill.md` file.';
+ title: '1. Choose a skill folder';
+ };
+ location: {
+ description: 'Personal skills work everywhere. Project skills only show up for one codebase.';
+ title: '2. Decide where it belongs';
+ };
+ };
+ title: 'Import skill';
+ };
+ skillReview: {
+ binaryBadge: 'binary';
+ binaryPreviewHidden: 'Binary file preview is not shown. The file will be copied as-is.';
+ confirmPromptPrefix: 'Review the diff below, then use';
+ confirmPromptSuffix: 'to apply these changes.';
+ description: '{{reviewLabel}} previews the filesystem changes first. Nothing is written until you confirm below.';
+ noChanges: 'No file changes detected yet.';
+ noPreview: 'No preview available.';
+ summary: {
+ binary: '{{count}} binary';
+ fileChanges: '{{count}} file changes';
+ fileChanges_few: '{{count}} file changes';
+ fileChanges_many: '{{count}} file changes';
+ fileChanges_one: '{{count}} file change';
+ fileChanges_other: '{{count}} file changes';
+ new: '{{count}} new';
+ removed: '{{count}} removed';
+ updated: '{{count}} updated';
+ };
+ title: 'Review skill changes';
+ };
+ skillsPanel: {
+ actions: {
+ createSkill: 'Create Skill';
+ import: 'Import';
+ };
+ badges: {
+ assets: 'Assets';
+ hasScripts: 'Has scripts';
+ needsAttention: 'Needs attention';
+ references: 'References';
+ storedIn: 'Stored in {{root}}';
+ };
+ configuredRuntime: 'the configured runtime';
+ counts: {
+ codexOnly: '{{count}} Codex only';
+ codexOnly_few: '{{count}} Codex only';
+ codexOnly_many: '{{count}} Codex only';
+ codexOnly_one: '{{count}} Codex only';
+ codexOnly_other: '{{count}} Codex only';
+ personal: '{{count}} personal';
+ personal_few: '{{count}} personal';
+ personal_many: '{{count}} personal';
+ personal_one: '{{count}} personal';
+ personal_other: '{{count}} personal';
+ project: '{{count}} project';
+ project_few: '{{count}} project';
+ project_many: '{{count}} project';
+ project_one: '{{count}} project';
+ project_other: '{{count}} project';
+ shared: '{{count}} shared';
+ shared_few: '{{count}} shared';
+ shared_many: '{{count}} shared';
+ shared_one: '{{count}} shared';
+ shared_other: '{{count}} shared';
+ total: '{{count}} total';
+ total_few: '{{count}} total';
+ total_many: '{{count}} total';
+ total_one: '{{count}} total';
+ total_other: '{{count}} total';
+ };
+ empty: {
+ noMatches: 'No skills match your search';
+ noMatchesDescription: 'Try a different search term or switch filters.';
+ noSkills: 'No skills yet';
+ noSkillsDescription: 'Create your first skill to teach a repeatable workflow, or import one you already use.';
+ };
+ filters: {
+ all: 'All skills';
+ codexOnly: 'Codex only';
+ hasScripts: 'Has scripts';
+ needsAttention: 'Needs attention';
+ personal: 'Personal';
+ project: 'Project';
+ shared: 'Shared';
+ };
+ hero: {
+ codexAvailable: 'Use `.codex` when a skill should stay Codex-only.';
+ codexUnavailable: 'Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.';
+ description: 'Skills are reusable instructions that help the runtime handle the same kind of task more consistently.';
+ guidance: 'Use personal skills for habits you want everywhere. Use project skills for workflows that only make sense inside one codebase.';
+ personalContext: 'You are seeing only your personal skills right now.';
+ projectContext: 'You are seeing skills for {{project}} plus your personal skills.';
+ title: 'Teach repeatable work';
+ };
+ invocation: {
+ auto: 'Runs automatically when it fits';
+ manualOnly: 'Only runs when you explicitly ask for it';
+ };
+ loading: {
+ loading: 'Loading skills...';
+ refreshing: 'Refreshing skills...';
+ };
+ runtimeAudience: 'Shared skills in `.claude`, `.cursor`, and `.agents` are available to {{audience}}. Skills stored in `.codex` stay Codex-only when Codex support is available.';
+ scope: {
+ project: 'This project';
+ user: 'Personal';
+ };
+ searchPlaceholder: 'Search by skill name or what it helps with...';
+ sections: {
+ personal: {
+ description: 'Habits and instructions you want available everywhere.';
+ title: 'Personal skills';
+ };
+ project: {
+ description: 'Workflows that only make sense for this codebase.';
+ title: 'Project skills';
+ };
+ };
+ sort: {
+ label: 'Sort skills';
+ name: 'Name';
+ recent: 'Recent';
+ };
+ status: {
+ hasScripts: 'Includes scripts, so review it carefully';
+ needsAttention: 'Needs attention before you rely on it';
+ ready: 'Ready to use';
+ };
+ success: {
+ created: 'Skill created successfully.';
+ imported: 'Skill imported successfully.';
+ saved: 'Skill saved successfully.';
+ };
+ };
+ store: {
+ actions: {
+ addCustom: 'Add Custom';
+ openDashboard: 'Open Dashboard';
+ refreshCatalog: 'Refresh catalog';
+ };
+ capabilities: {
+ mcp: 'MCP: {{status}}';
+ plugins: 'Plugins: {{status}}';
+ skills: 'Skills: {{status}}';
+ };
+ desktopOnly: 'Available in the desktop app only.';
+ provider: {
+ checkingStatus: 'Checking provider status...';
+ connected: 'Connected';
+ loading: 'Loading...';
+ needsSetup: 'Needs setup';
+ readyToConfigure: 'Ready to configure';
+ unsupported: 'Unsupported';
+ };
+ runtime: {
+ checkingAvailabilityDescription: 'Extensions need the configured runtime to manage plugins, MCP servers, skills, and provider connections.';
+ checkingAvailabilityTitle: 'Checking extensions runtime availability';
+ failedToStartDescription: 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.';
+ failedToStartTitle: 'The configured runtime was found but failed to start';
+ multimodelCapabilitiesDescription: 'Provider support can differ by section. Plugins are shown only where the runtime explicitly declares support.';
+ multimodelCapabilitiesTitle: 'Multimodel runtime capabilities';
+ needsSignInDescription: '{{runtime}} was found{{version}}, but plugin installs are disabled until you sign in from the Dashboard.';
+ needsSignInTitle: '{{runtime}} needs sign-in';
+ notAvailableDescription: 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.';
+ notAvailableTitle: 'The configured runtime is not available';
+ readyDescription: 'Plugins can be installed from this page{{versionSuffix}}.';
+ readyTitle: '{{runtime}} is ready';
+ requiredForMutations: 'The configured runtime is required to install or uninstall extensions. Install or repair it from the Dashboard.';
+ };
+ sessionsRestartWarning: "Running sessions won't pick up extension changes until restarted.";
+ tabs: {
+ apiKeys: {
+ description: 'Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.';
+ label: 'API Keys';
+ };
+ mcpServers: {
+ description: 'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.';
+ label: 'MCP Servers';
+ };
+ plugins: {
+ description: 'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.';
+ label: 'Plugins';
+ };
+ skills: {
+ description: 'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.';
+ label: 'Skills';
+ };
+ };
+ title: 'Extensions';
+ };
+ };
+ report: {
+ cost: {
+ breakdownTitle: 'Cost Breakdown (per 1M tokens)';
+ cacheRead: 'Cache Read';
+ cacheWrite: 'Cache Write';
+ cost: 'Cost';
+ input: 'Input';
+ noCommits: 'no commits';
+ noLinesChanged: 'no lines changed';
+ output: 'Output';
+ parent: 'Parent: {{cost}}';
+ parentCost: 'Parent Cost';
+ perCommit: 'Per Commit';
+ perCommitFormula: 'total cost ÷ {{count}} commit';
+ perCommitFormula_few: 'total cost ÷ {{count}} commits';
+ perCommitFormula_many: 'total cost ÷ {{count}} commits';
+ perCommitFormula_one: 'total cost ÷ {{count}} commit';
+ perCommitFormula_other: 'total cost ÷ {{count}} commits';
+ perLineChanged: 'Per Line Changed';
+ perLineFormula: 'total cost ÷ {{count}} line';
+ perLineFormula_few: 'total cost ÷ {{count}} lines';
+ perLineFormula_many: 'total cost ÷ {{count}} lines';
+ perLineFormula_one: 'total cost ÷ {{count}} line';
+ perLineFormula_other: 'total cost ÷ {{count}} lines';
+ subagent: 'Subagent: {{cost}}';
+ subagentCost: 'Subagent Cost';
+ title: 'Cost Analysis';
+ total: 'Total';
+ };
+ errors: {
+ count: '{{count}} errors';
+ count_few: '{{count}} errors';
+ count_many: '{{count}} errors';
+ count_one: '{{count}} error';
+ count_other: '{{count}} errors';
+ error: 'Error';
+ input: 'Input';
+ messageIndex: 'msg #{{index}}';
+ permissionDenialCount: '{{count}} permission denials';
+ permissionDenialCount_few: '{{count}} permission denials';
+ permissionDenialCount_many: '{{count}} permission denials';
+ permissionDenialCount_one: '{{count}} permission denial';
+ permissionDenialCount_other: '{{count}} permission denials';
+ permissionDenied: 'Permission Denied';
+ title: 'Errors';
+ };
+ friction: {
+ corrections: 'Corrections';
+ correctionsCount: '{{count}} corrections';
+ correctionsCount_few: '{{count}} corrections';
+ correctionsCount_many: '{{count}} corrections';
+ correctionsCount_one: '{{count}} correction';
+ correctionsCount_other: '{{count}} corrections';
+ rate: 'Friction Rate: {{rate}}%';
+ repeatedBashCommands: 'Repeated Bash Commands';
+ reworkedFiles: 'Reworked Files (3+ edits)';
+ thrashingSignals: 'Thrashing Signals';
+ title: 'Friction Signals';
+ };
+ git: {
+ branchesCreated: 'Branches Created';
+ commits: 'Commits';
+ linesAdded: 'Lines Added';
+ linesRemoved: 'Lines Removed';
+ pushes: 'Pushes';
+ title: 'Git Activity';
+ };
+ insights: {
+ agent: 'agent';
+ agentTree: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_few: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_many: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_one: 'Agent Tree ({{count}} {{unit}})';
+ agentTree_other: 'Agent Tree ({{count}} {{unit}})';
+ agent_few: 'agents';
+ agent_many: 'agents';
+ agent_one: 'agent';
+ agent_other: 'agents';
+ background: '(background)';
+ bashCommands: 'Bash Commands';
+ keyTakeaways: 'Key Takeaways';
+ outOfScopeFindings: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_few: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_many: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_one: 'Out-of-Scope Findings ({{count}})';
+ outOfScopeFindings_other: 'Out-of-Scope Findings ({{count}})';
+ questionsAsked: 'Questions Asked ({{count}})';
+ questionsAsked_few: 'Questions Asked ({{count}})';
+ questionsAsked_many: 'Questions Asked ({{count}})';
+ questionsAsked_one: 'Questions Asked ({{count}})';
+ questionsAsked_other: 'Questions Asked ({{count}})';
+ repeated: 'Repeated';
+ skillsInvoked: 'Skills Invoked ({{count}})';
+ skillsInvoked_few: 'Skills Invoked ({{count}})';
+ skillsInvoked_many: 'Skills Invoked ({{count}})';
+ skillsInvoked_one: 'Skills Invoked ({{count}})';
+ skillsInvoked_other: 'Skills Invoked ({{count}})';
+ taskDispatches: 'Task Dispatches ({{count}})';
+ taskDispatches_few: 'Task Dispatches ({{count}})';
+ taskDispatches_many: 'Task Dispatches ({{count}})';
+ taskDispatches_one: 'Task Dispatches ({{count}})';
+ taskDispatches_other: 'Task Dispatches ({{count}})';
+ tasksCreated: 'Tasks Created ({{count}})';
+ tasksCreated_few: 'Tasks Created ({{count}})';
+ tasksCreated_many: 'Tasks Created ({{count}})';
+ tasksCreated_one: 'Tasks Created ({{count}})';
+ tasksCreated_other: 'Tasks Created ({{count}})';
+ teamMode: 'Team Mode';
+ teams: 'Teams: {{teams}}';
+ title: 'Session Insights';
+ total: 'Total';
+ unique: 'Unique';
+ };
+ overview: {
+ metrics: {
+ branch: 'Branch';
+ compactions: 'Compactions';
+ contextUsage: 'Context Usage';
+ duration: 'Duration';
+ messages: 'Messages';
+ project: 'Project';
+ sessionId: 'Session ID';
+ subagents: 'Subagents';
+ };
+ no: 'No';
+ title: 'Overview';
+ yes: 'Yes';
+ };
+ quality: {
+ chars: 'chars';
+ corrections: 'Corrections';
+ failed: 'failed';
+ fileReadRedundancy: 'File Read Redundancy';
+ firstMessage: 'First Message';
+ firstRun: 'First Run';
+ frictionRate: 'Friction Rate';
+ lastRun: 'Last Run';
+ messagesBeforeWork: 'Messages Before Work';
+ passed: 'passed';
+ percentOfTotal: '% of Total';
+ promptQuality: 'Prompt Quality';
+ readsPerUniqueFile: 'Reads/Unique File';
+ snapshot: 'snapshot';
+ snapshot_few: 'snapshots';
+ snapshot_many: 'snapshots';
+ snapshot_one: 'snapshot';
+ snapshot_other: 'snapshots';
+ startupOverhead: 'Startup Overhead';
+ testProgression: 'Test Progression';
+ title: 'Quality Signals';
+ tokensBeforeWork: 'Tokens Before Work';
+ totalReads: 'Total Reads';
+ uniqueFiles: 'Unique Files';
+ userMessages: 'User Messages';
+ };
+ subagents: {
+ metrics: {
+ count: 'Count';
+ totalCost: 'Total Cost';
+ totalDuration: 'Total Duration';
+ totalTokens: 'Total Tokens';
+ };
+ table: {
+ cost: 'Cost';
+ description: 'Description';
+ duration: 'Duration';
+ tokens: 'Tokens';
+ type: 'Type';
+ };
+ title: 'Subagents';
+ };
+ timeline: {
+ idleAnalysis: 'Idle Analysis';
+ keyEvents: 'Key Events';
+ messageNumber: 'msg #{{number}}';
+ metrics: {
+ activeTime: 'Active Time';
+ idleGaps: 'Idle Gaps';
+ idlePercent: 'Idle %';
+ totalIdle: 'Total Idle';
+ };
+ modelSwitches: 'Model Switches ({{count}})';
+ modelSwitches_few: 'Model Switches ({{count}})';
+ modelSwitches_many: 'Model Switches ({{count}})';
+ modelSwitches_one: 'Model Switches ({{count}})';
+ modelSwitches_other: 'Model Switches ({{count}})';
+ title: 'Timeline & Activity';
+ };
+ tokens: {
+ apiCalls: 'API Calls';
+ cacheCreate: 'Cache Create';
+ cacheEfficiency: 'Cache Efficiency';
+ cacheRead: 'Cache Read';
+ cacheReadPct: 'Cache Read %';
+ coldStart: 'Cold Start';
+ cost: 'Cost';
+ input: 'Input';
+ model: 'Model';
+ no: 'No';
+ output: 'Output';
+ readWriteRatio: 'R/W Ratio';
+ title: 'Token Usage';
+ total: 'Total';
+ yes: 'Yes';
+ };
+ tools: {
+ columns: {
+ calls: 'Calls';
+ errors: 'Errors';
+ health: 'Health';
+ successPercent: 'Success %';
+ tool: 'Tool';
+ };
+ summary: '{{formattedCount}} total calls across {{toolCount}} tools';
+ title: 'Tool Usage';
+ };
+ };
+ settings: {
+ advanced: {
+ about: {
+ appIconAlt: 'App icon';
+ description: 'Assemble AI agent teams that work autonomously in parallel, communicate across teams, and manage tasks on a kanban board - with built-in code review, live process monitoring, and full tool visibility.';
+ standalone: 'Standalone';
+ title: 'About';
+ version: 'Version {{version}}';
+ };
+ appName: 'Agent Teams AI';
+ configuration: {
+ editConfig: 'Edit Config';
+ exportConfig: 'Export Config';
+ importConfig: 'Import Config';
+ openInEditor: 'Open in Editor';
+ resetToDefaults: 'Reset to Defaults';
+ title: 'Configuration';
+ };
+ updates: {
+ available: 'v{{version}} available';
+ check: 'Check for Updates';
+ checking: 'Checking...';
+ ready: 'Update ready';
+ unknownVersion: 'unknown';
+ upToDate: 'Up to date';
+ };
+ };
+ cliRuntime: {
+ actions: {
+ checkForUpdates: 'Check for Updates';
+ checking: 'Checking...';
+ extensions: 'Extensions';
+ installRuntime: 'Install {{runtime}}';
+ manage: 'Manage';
+ recheck: 'Re-check';
+ reinstallRuntime: 'Reinstall {{runtime}}';
+ retry: 'Retry';
+ update: 'Update';
+ };
+ installer: {
+ checkingLatest: 'Checking latest version...';
+ downloading: 'Downloading...';
+ failed: 'Installation failed';
+ installed: 'Installed v{{version}}';
+ installing: 'Installing...';
+ latest: 'latest';
+ verifying: 'Verifying checksum...';
+ };
+ labels: {
+ multimodel: 'Multimodel';
+ };
+ loading: {
+ aiProviders: 'Checking AI Providers...';
+ claudeCli: 'Checking Claude CLI...';
+ };
+ provider: {
+ backend: 'Backend: {{backend}}';
+ loadingModels: 'Loading models...';
+ modelsUnavailable: 'Models unavailable for this runtime build';
+ runtime: 'Runtime: {{runtime}}';
+ };
+ providerTerminal: {
+ authFailed: 'Authentication failed';
+ authUpdated: 'Authentication updated';
+ loggedOut: 'Provider logged out';
+ login: 'Login';
+ logout: 'Logout';
+ logoutFailed: 'Logout failed';
+ };
+ status: {
+ configuredNotFound: 'The configured {{runtime}} was not found.';
+ foundButFailed: '{{runtime}} was found but failed to start';
+ healthCheckFailed: 'The configured {{runtime}} failed its startup health check.';
+ notInstalled: '{{runtime}} not installed';
+ };
+ title: 'CLI Runtime';
+ };
+ cliStatus: {
+ versionUpgrade: 'v{{current}} -> v{{latest}}';
+ };
+ configEditor: {
+ errors: {
+ loadFailed: 'Failed to load config';
+ saveFailed: 'Failed to save config';
+ };
+ footer: {
+ autoSave: 'Changes auto-save after editing';
+ escapeKey: 'Esc';
+ toClose: 'to close';
+ };
+ loading: 'Loading config...';
+ status: {
+ invalidJson: 'Invalid JSON';
+ saveFailed: 'Save failed';
+ saved: 'Saved';
+ saving: 'Saving...';
+ };
+ title: 'Edit Configuration';
+ };
+ connection: {
+ actions: {
+ connect: 'Connect';
+ connecting: 'Connecting...';
+ disconnect: 'Disconnect';
+ testConnection: 'Test Connection';
+ testing: 'Testing...';
+ };
+ currentMode: {
+ description: 'Data source for session files';
+ label: 'Current Mode';
+ local: 'Local ({{path}})';
+ };
+ description: 'Connect to a remote machine to view Claude Code sessions running there';
+ form: {
+ authentication: 'Authentication';
+ host: 'Host';
+ hostPlaceholder: 'hostname or SSH config alias';
+ password: 'Password';
+ port: 'Port';
+ privateKeyPath: 'Private Key Path';
+ username: 'Username';
+ usernamePlaceholder: 'user';
+ };
+ savedProfiles: {
+ title: 'Saved Profiles';
+ };
+ ssh: {
+ title: 'SSH Connection';
+ };
+ status: {
+ connectedTo: 'Connected to {{host}}';
+ remoteSessions: 'Viewing remote sessions via SSH';
+ };
+ test: {
+ failed: 'Connection failed: {{error}}';
+ success: 'Connection successful';
+ unknownError: 'Unknown error';
+ };
+ title: 'Remote Connection';
+ };
+ general: {
+ agentLanguage: {
+ description: 'Language for agent communication';
+ descriptionWithDetected: 'Language for agent communication (detected: {{detected}})';
+ emptyMessage: 'No language found.';
+ label: 'Language';
+ searchPlaceholder: 'Search language...';
+ selectPlaceholder: 'Select language...';
+ title: 'Agent Language';
+ };
+ appLanguage: {
+ description: 'Language for the application interface.';
+ label: 'Language';
+ title: 'App Language';
+ };
+ appearance: {
+ autoExpandAIGroups: {
+ description: 'Automatically expand each response turn when opening a transcript or receiving a new message';
+ label: 'Expand AI responses by default';
+ };
+ nativeTitleBar: {
+ description: 'Use the default system window frame instead of the custom title bar';
+ label: 'Use native title bar';
+ restartConfirm: {
+ confirmLabel: 'Restart';
+ message: 'The app needs to restart to apply the title bar change. Restart now?';
+ title: 'Restart required';
+ };
+ };
+ theme: {
+ description: 'Choose your preferred color theme';
+ label: 'Theme';
+ options: {
+ dark: 'Dark';
+ light: 'Light';
+ system: 'System';
+ };
+ };
+ title: 'Appearance';
+ };
+ browserAccess: {
+ serverMode: {
+ description: 'Start an HTTP server to access the UI from a browser or embed in iframes';
+ label: 'Enable server mode';
+ };
+ title: 'Browser Access';
+ };
+ localClaudeRoot: {
+ actions: {
+ selectFolder: 'Select Folder';
+ selectFolderManually: 'Select Folder Manually';
+ useAutoDetect: 'Use Auto-Detect';
+ useFolder: 'Use Folder';
+ usePath: 'Use Path';
+ useThisPath: 'Use This Path';
+ useWsl: 'Using Linux/WSL?';
+ };
+ confirm: {
+ noProjectsDir: {
+ message: 'This folder does not contain a "projects" directory. Continue anyway?';
+ title: 'No projects directory found';
+ };
+ noWslPaths: {
+ message: 'Could not find WSL distros with Claude data automatically. Select folder manually?';
+ title: 'No WSL Claude paths found';
+ };
+ notClaudeDir: {
+ message: 'This folder is named "{{folderName}}", not ".claude". Continue anyway?';
+ title: 'Selected folder is not .claude';
+ };
+ wslNoProjectsDir: {
+ message: '"{{path}}" does not contain a "projects" directory. Continue anyway?';
+ title: 'WSL path missing projects directory';
+ };
+ };
+ current: {
+ autoDetected: 'Auto-detected: {{path}}';
+ autoDetectedPath: 'Using auto-detected path';
+ customPath: 'Using custom path';
+ label: 'Current Local Root';
+ };
+ description: 'Choose which local folder is treated as your Claude data root';
+ errors: {
+ detectWslFailed: 'Failed to detect WSL Claude root paths';
+ loadFailed: 'Failed to load local Claude root settings';
+ updateFailed: 'Failed to update Claude root';
+ };
+ title: 'Local Claude Root';
+ wslModal: {
+ closeAriaLabel: 'Close WSL path modal';
+ description: 'Detected WSL distributions and Claude root candidates';
+ noProjectsDir: 'No projects directory detected';
+ title: 'Select WSL Claude Root';
+ };
+ };
+ privacy: {
+ telemetry: {
+ description: 'Help improve the app by sending anonymous crash and performance data';
+ label: 'Send crash reports';
+ };
+ title: 'Privacy';
+ };
+ server: {
+ runningOn: 'Running on';
+ standaloneModeDescription: 'Running in standalone mode. The HTTP server is always active. System notifications are not available - notification triggers are logged in-app only.';
+ title: 'Server';
+ };
+ startup: {
+ launchAtLogin: {
+ description: 'Automatically start the app when you log in';
+ label: 'Launch at login';
+ };
+ showDockIcon: {
+ description: 'Display the app icon in the dock (macOS)';
+ label: 'Show dock icon';
+ };
+ title: 'Startup';
+ };
+ };
+ notificationTriggers: {
+ add: {
+ cancel: 'Cancel';
+ submit: 'Add Trigger';
+ title: 'Add Custom Trigger';
+ };
+ builtin: {
+ description: 'Default triggers that come with the application. You can enable or disable them and customize their patterns.';
+ title: 'Built-in Triggers';
+ };
+ card: {
+ builtinBadge: 'Builtin';
+ collapseAriaLabel: 'Collapse';
+ deleteAriaLabel: 'Delete trigger';
+ editNameAriaLabel: 'Edit name';
+ expandAriaLabel: 'Expand';
+ };
+ color: {
+ customHexTitle: 'Custom hex color';
+ invalidHex: 'Invalid hex';
+ };
+ configuration: {
+ alertIfGreaterThan: 'Alert if >';
+ emptyPatternHint: 'Leave empty to match all content. Uses JavaScript regex syntax.';
+ errorStatusDescription: 'Triggers when a tool execution reports an error (is_error: true).';
+ matchPatternPlaceholder: 'e.g., error|failed|exception';
+ tokensUnit: 'tokens';
+ };
+ custom: {
+ description: 'Create your own triggers to get notified for specific patterns or tool outputs.';
+ empty: 'No custom triggers configured yet.';
+ title: 'Custom Triggers';
+ };
+ errors: {
+ invalidRegexPattern: 'Invalid regex pattern';
+ };
+ fields: {
+ contentType: 'Content Type';
+ matchField: 'Match Field';
+ matchPattern: 'Match Pattern (Regex)';
+ scopeToolName: 'Scope / Tool Name';
+ scopeToolNameOptional: 'Scope / Tool Name (optional)';
+ threshold: 'Threshold';
+ tokenType: 'Token Type';
+ triggerNamePlaceholder: 'e.g., Build Failure Alert';
+ triggerNameRequired: 'Trigger Name *';
+ };
+ ignorePatterns: {
+ hint: 'Press Enter to add. Notification is skipped if any pattern matches.';
+ placeholder: 'Add ignore regex...';
+ removeAriaLabel: 'Remove ignore pattern';
+ summary: 'Advanced: Exclusion Rules';
+ title: 'Ignore Patterns (skip if matches)';
+ };
+ options: {
+ contentTypes: {
+ text: 'Text Output';
+ thinking: 'Thinking';
+ tool_result: 'Tool Result';
+ tool_use: 'Tool Use';
+ };
+ matchFields: {
+ args: 'Arguments';
+ command: 'Command';
+ content: 'Content';
+ description: 'Description';
+ file_path: 'File Path';
+ fullInput: 'Full Input (JSON)';
+ glob: 'Glob Filter';
+ new_string: 'New String';
+ old_string: 'Old String';
+ path: 'Path';
+ pattern: 'Pattern';
+ prompt: 'Prompt';
+ query: 'Query';
+ skill: 'Skill Name';
+ subagent_type: 'Subagent Type';
+ text: 'Text Content';
+ thinking: 'Thinking Content';
+ url: 'URL';
+ };
+ modes: {
+ content_match: 'Content Pattern';
+ error_status: 'Execution Error';
+ token_threshold: 'High Token Usage';
+ };
+ tokenTypes: {
+ input: 'Input Tokens';
+ output: 'Output Tokens';
+ total: 'Total Tokens';
+ };
+ toolNames: {
+ anyTool: 'Any Tool';
+ };
+ };
+ preview: {
+ defaultTestTriggerName: 'Test Trigger';
+ detectedSuffix: 'errors would have been detected';
+ more: '...and {{count}} more';
+ more_few: '...and {{count}} more';
+ more_many: '...and {{count}} more';
+ more_one: '...and {{count}} more';
+ more_other: '...and {{count}} more';
+ testTrigger: 'Test Trigger';
+ testing: 'Testing...';
+ title: 'Preview';
+ truncatedWarning: 'Search stopped early (timeout or count limit). Actual matches may be higher.';
+ viewSession: 'View Session';
+ };
+ repositoryScope: {
+ empty: 'No repositories selected - trigger applies to all repositories';
+ hint: 'When repositories are selected, this trigger only fires for errors in those repositories.';
+ placeholder: 'Select repository to add...';
+ summary: 'Advanced: Repository Scope';
+ title: 'Limit to Repositories (applies only to selected repositories)';
+ };
+ sections: {
+ configuration: 'Configuration';
+ dotColor: 'Dot Color';
+ generalInfo: 'General Info';
+ triggerCondition: 'Trigger Condition';
+ };
+ };
+ notifications: {
+ dev: {
+ descriptionPrefix: 'Notifications may not work in development mode. macOS identifies the app as "Electron" (bundle ID';
+ descriptionSuffix: ') instead of the production app name. Check System Settings > Notifications > Electron to verify permissions.';
+ title: 'Dev Mode';
+ };
+ ignoredRepositories: {
+ description: 'Notifications from these repositories will be ignored';
+ empty: 'No repositories ignored';
+ selectPlaceholder: 'Select repository to ignore...';
+ title: 'Ignored Repositories';
+ };
+ settings: {
+ enabled: {
+ description: 'Show system notifications for errors and events';
+ label: 'Enable System Notifications';
+ };
+ sound: {
+ description: 'Play a sound when notifications appear';
+ label: 'Play sound';
+ };
+ subagentErrors: {
+ description: 'Detect and notify about errors in subagent sessions';
+ label: 'Include subagent errors';
+ };
+ title: 'Notification Settings';
+ };
+ snooze: {
+ clear: 'Clear Snooze';
+ description: 'Temporarily pause notifications';
+ descriptionWithTime: 'Snoozed until {{time}}';
+ label: 'Snooze notifications';
+ options: {
+ '-1': 'Until tomorrow';
+ '120': '2 hours';
+ '15': '15 minutes';
+ '240': '4 hours';
+ '30': '30 minutes';
+ '60': '1 hour';
+ };
+ selectDuration: 'Select duration...';
+ };
+ taskCompletion: {
+ description: 'Get native OS notifications when Claude finishes tasks - sounds, banners, and Dock/taskbar badges. Works on macOS, Linux, and Windows.';
+ installPlugin: 'Install claude-notifications-go plugin';
+ title: 'Task Completion Notifications';
+ };
+ team: {
+ allTasksCompleted: {
+ description: 'Notify when every task in a team reaches completed status';
+ label: 'All tasks completed';
+ };
+ autoResumeOnRateLimit: {
+ description: 'When Claude reports a reset time, schedule a follow-up nudge for the team lead after the limit resets';
+ label: 'Auto-resume after rate limit';
+ };
+ clarifications: {
+ description: 'Show native OS notifications when a task needs your input';
+ label: 'Task clarification notifications';
+ };
+ crossTeamMessage: {
+ description: 'Notify when a message arrives from another team';
+ label: 'Cross-team message notifications';
+ };
+ leadInbox: {
+ description: 'Notify when teammates send messages to the team lead';
+ label: 'Lead inbox notifications';
+ };
+ statusChange: {
+ description: "Show native OS notifications when a task's status changes";
+ label: 'Task status change notifications';
+ onlySolo: {
+ description: 'Notify only when the team has no teammates';
+ label: 'Only in Solo mode';
+ };
+ statuses: {
+ description: 'Which target statuses trigger a notification';
+ label: 'Notify on these statuses';
+ options: {
+ approved: 'Approved';
+ completed: 'Completed';
+ deleted: 'Deleted';
+ in_progress: 'Started';
+ needsFix: 'Needs Fixes';
+ pending: 'Pending';
+ review: 'Review';
+ };
+ };
+ };
+ taskComments: {
+ description: 'Show native OS notifications when agents comment on tasks';
+ label: 'Task comment notifications';
+ };
+ taskCreated: {
+ description: 'Show native OS notifications when a new task is created';
+ label: 'Task created notifications';
+ };
+ teamLaunched: {
+ description: 'Notify when a team finishes launching and is ready';
+ label: 'Team launched notifications';
+ };
+ title: 'Team Notifications';
+ toolApproval: {
+ description: 'Notify when a tool needs your approval (Allow/Deny) while the app is not focused';
+ label: 'Tool approval notifications';
+ };
+ userInbox: {
+ description: 'Notify when teammates send messages to you';
+ label: 'User inbox notifications';
+ };
+ };
+ test: {
+ action: 'Send Test';
+ description: 'Send a test notification to verify delivery';
+ failedToSend: 'Failed to send test notification';
+ label: 'Test notification';
+ sending: 'Sending...';
+ sent: 'Sent!';
+ unknownError: 'Unknown error';
+ };
+ };
+ providerRuntime: {
+ actions: {
+ cancel: 'Cancel';
+ cancelLogin: 'Cancel login';
+ connectChatGpt: 'Connect ChatGPT';
+ delete: 'Delete';
+ disable: 'Disable';
+ disconnectAccount: 'Disconnect account';
+ generateLink: 'Generate link';
+ openLogin: 'Open login';
+ reconnectAnthropic: 'Reconnect Anthropic';
+ refresh: 'Refresh';
+ replaceKey: 'Replace key';
+ saveEndpoint: 'Save endpoint';
+ saveKey: 'Save key';
+ saving: 'Saving...';
+ setApiKey: 'Set API key';
+ updateKey: 'Update key';
+ useCode: 'Use code';
+ };
+ alerts: {
+ anthropicApiKeyMissing: 'API key mode is selected, but no Anthropic API credential is available yet.';
+ anthropicStoredKeyAvailable: 'A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.';
+ anthropicSubscriptionMissing: 'Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.';
+ authTokenMissing: 'Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.';
+ chatgptLoginPending: 'Waiting for ChatGPT account login to finish...';
+ chatgptLoginStarting: 'Starting ChatGPT login...';
+ codexApiKeyMissing: 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.';
+ codexLocalArtifactsNoSession: 'Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.';
+ codexNeedsReconnect: 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
+ codexNoChatgptAccount: 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.';
+ codexNoCredential: 'No ChatGPT account or API key is available yet.';
+ geminiApiUnavailable: 'Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use valid Google ADC credentials.';
+ withApiKeyFallback: '{{message}} Switch to API key mode to use the detected API key.';
+ };
+ apiKey: {
+ loadingStoredCredentials: 'Loading stored credentials...';
+ projectScope: 'Project';
+ providers: {
+ anthropic: {
+ description: 'Use a direct Anthropic API key for API-billed access. Your Anthropic subscription session stays available when you switch back.';
+ name: 'Anthropic API Key';
+ placeholder: 'sk-ant-...';
+ title: 'API key';
+ };
+ codex: {
+ description: 'Use an OpenAI API key as a secondary Codex auth path. If you switch Codex to API key mode, the app will mirror OPENAI_API_KEY into CODEX_API_KEY for native launches.';
+ name: 'Codex API Key';
+ placeholder: 'sk-proj-...';
+ title: 'API key';
+ };
+ gemini: {
+ description: 'Use `GEMINI_API_KEY` for the Gemini API backend. CLI SDK and ADC do not require it.';
+ name: 'Gemini API Key';
+ placeholder: 'AIza...';
+ title: 'API access';
+ };
+ };
+ scope: 'Scope';
+ storedIn: 'Stored in {{backend}}';
+ storedInApp: 'Stored in app';
+ userScope: 'User';
+ };
+ authModeDescriptions: {
+ anthropic: {
+ apiKey: 'Force app-launched Anthropic sessions to use an API key credential.';
+ auto: 'Use the runtime default behavior. Saved API keys in this app are only used after you switch to API key mode.';
+ oauth: 'Force app-launched Anthropic sessions to use the local Anthropic subscription session.';
+ };
+ codex: {
+ apiKey: 'Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.';
+ auto: 'Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.';
+ chatgpt: 'Force native Codex launches to use your connected ChatGPT account and subscription.';
+ };
+ };
+ codex: {
+ account: {
+ appServer: 'App-server: {{state}}';
+ connected: 'Connected';
+ description: 'Manage the local Codex app-server account session that powers subscription-backed native launches.';
+ hints: {
+ autoUsesApiKeyUntilChatgpt: '{{message}} Auto will keep using the detected API key until ChatGPT is connected.';
+ detectedApiKeyNeedsApiMode: '{{message}} The detected API key is only used after you switch Codex to API key mode.';
+ localArtifactsNoSession: 'Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.';
+ noActiveAccount: 'Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.';
+ reconnectBeforeUsage: 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.';
+ usageLimitsAfterReport: 'Usage limits appear here after Codex reports them for the connected ChatGPT account.';
+ };
+ loginInProgress: 'Login in progress';
+ plan: 'Plan: {{plan}}';
+ reconnectRequired: 'Reconnect required';
+ title: 'ChatGPT account';
+ };
+ install: {
+ checking: 'Checking';
+ downloading: 'Downloading';
+ installCli: 'Install Codex CLI';
+ installing: 'Installing';
+ retryInstall: 'Retry install';
+ title: 'Install Codex CLI into app data';
+ };
+ rateLimits: {
+ credits: 'Credits';
+ creditsDescription: 'Credits are shown separately from window-based subscription usage and may be unavailable for plan-backed ChatGPT sessions.';
+ noSecondaryWindow: 'Codex did not return a secondary window for this account snapshot.';
+ notReported: 'Not reported';
+ primaryReset: 'Primary reset';
+ primaryUsed: 'Primary used';
+ primaryWindow: 'Primary window';
+ remainingLeft: '{{value}} left';
+ remainingUnknown: 'Remaining unknown';
+ secondaryFallback: 'secondary';
+ secondaryReset: 'Secondary reset';
+ secondaryUsed: 'Secondary used';
+ secondaryWindow: 'Secondary window';
+ secondaryWindowNote: ' Weekly limits are shown separately in the {{window}} window.';
+ usageExplanationGeneric: 'Shows used quota, not remaining quota.';
+ usageExplanationWindowOnly: 'Shows used quota in the current {{window}} window, not remaining quota.';
+ usageExplanationWithRemaining: '{{used}} used - about {{remaining}} left in the current {{window}} window.';
+ usedQuotaNote: 'These percentages show used quota, not remaining quota.';
+ weeklyReset: 'Weekly reset';
+ weeklyUsed: 'Weekly used';
+ weeklyUsedOneWeek: 'Weekly used (1w)';
+ weeklyWindow: 'Weekly window';
+ };
+ };
+ compatibleEndpoint: {
+ authToken: 'Auth token';
+ authTokenMissing: 'Auth token is not configured.';
+ baseUrl: 'Base URL';
+ description: 'Use an Anthropic-compatible local runtime endpoint.';
+ keepSavedToken: 'Leave blank to keep saved token';
+ status: {
+ endpointDisabledTokenKept: 'Endpoint disabled. Saved token was kept.';
+ endpointSaved: 'Endpoint saved';
+ endpointSavedTokenMissing: 'Endpoint saved. Auth token is not configured.';
+ };
+ title: 'Local / compatible endpoint';
+ tokenStatus: 'Token {{status}}';
+ validation: {
+ baseUrlRequired: 'Base URL is required';
+ firstPartyAnthropic: 'Use Auto, Subscription, or API key for first-party Anthropic';
+ httpRequired: 'Base URL must use http:// or https://';
+ invalidUrl: 'Invalid URL';
+ noCredentials: 'Base URL must not include credentials';
+ };
+ };
+ connection: {
+ authenticationMethod: 'Authentication method';
+ descriptions: {
+ anthropic: 'Choose how app-launched Anthropic sessions authenticate.';
+ codex: 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.';
+ gemini: 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
+ opencode: 'OpenCode authentication and provider inventory are managed by the OpenCode runtime.';
+ };
+ method: 'Connection method';
+ mode: 'Mode: {{mode}}';
+ selected: 'Selected';
+ switching: 'Switching...';
+ title: 'Connection';
+ };
+ connectionCards: {
+ anthropic: {
+ apiKeyDescription: 'Use ANTHROPIC_API_KEY and Anthropic API billing.';
+ autoDescription: 'Use Anthropic runtime defaults and the best local credential available.';
+ hint: 'Auto keeps Anthropic on its default local credential resolution.';
+ subscriptionDescription: 'Use your local Anthropic sign-in session and subscription access.';
+ subscriptionTitle: 'Anthropic subscription';
+ };
+ apiKey: {
+ title: 'API key';
+ };
+ auto: {
+ title: 'Auto';
+ };
+ codex: {
+ apiKeyDescription: 'Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.';
+ autoDescription: 'Prefer your ChatGPT account and subscription. Use API key mode only if needed.';
+ chatgptDescription: 'Use your connected ChatGPT account and Codex subscription.';
+ chatgptTitle: 'ChatGPT account';
+ hint: 'Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials.';
+ };
+ };
+ connectionUi: {
+ actions: {
+ connect: 'Connect';
+ connectAnthropic: 'Connect Anthropic';
+ connectChatGpt: 'Connect ChatGPT';
+ disconnect: 'Disconnect';
+ openLogin: 'Open Login';
+ };
+ authMethod: {
+ apiKey: 'API key';
+ apiKeyHelper: 'API key helper';
+ claudeSubscription: 'Claude subscription';
+ geminiCli: 'Gemini CLI';
+ googleAccount: 'Google account';
+ oauth: 'OAuth';
+ serviceAccount: 'service account';
+ };
+ authMode: {
+ anthropicSubscription: 'Anthropic subscription';
+ apiKey: 'API key';
+ auto: 'Auto';
+ chatgpt: 'ChatGPT account';
+ oauth: 'Subscription / OAuth';
+ };
+ credential: {
+ apiKeyAlsoConfigured: 'API key also configured in Manage';
+ apiKeyConfigured: 'API key is configured';
+ apiKeyConfiguredInManage: 'API key is configured in Manage';
+ apiKeyFallbackInManage: 'API key also available in Manage as fallback';
+ autoWillUseUntilChatGpt: '{{summary}} - Auto will use this until ChatGPT is connected';
+ availableAsFallback: '{{summary}} - available as fallback';
+ availableIfSwitch: '{{summary}} - available if you switch to API key mode';
+ savedApiKeyAvailable: 'Saved API key available in Manage';
+ savedApiKeyAvailableIfSwitch: 'Saved API key available in Manage if you switch to API key mode';
+ };
+ disconnect: {
+ anthropic: 'This removes the local Anthropic subscription session from the Claude CLI runtime.';
+ anthropicTitle: 'Disconnect Anthropic subscription?';
+ anthropicWithApiKey: 'This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.';
+ gemini: 'This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed.';
+ geminiTitle: 'Disconnect Gemini CLI?';
+ };
+ mode: {
+ preferredAuth: 'Preferred auth: {{authMode}}';
+ selectedAuth: 'Selected auth: {{authMode}}';
+ };
+ runtime: {
+ codexNative: 'Codex native';
+ currentRuntime: 'Current runtime';
+ selectedRuntime: 'Selected runtime';
+ summary: '{{prefix}}: {{runtime}}';
+ };
+ status: {
+ apiKeyConfiguredNotVerified: 'API key configured, but not verified yet';
+ apiKeyModeMissingCredential: 'API key mode selected, but no API key is configured';
+ apiKeyReady: 'API key ready';
+ chatGptAccountReady: 'ChatGPT account ready';
+ chatGptVerificationDegraded: 'ChatGPT account detected - account verification is currently degraded.';
+ checked: 'Checked';
+ checking: 'Checking...';
+ codexLocalAccountNeedsReconnect: 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
+ codexNativeReady: 'Codex native ready';
+ codexNativeUnavailable: 'Codex native unavailable';
+ codexNoActiveChatGptLogin: 'Codex CLI reports no active ChatGPT login';
+ codexNoActiveManagedSession: 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.';
+ connectChatGptForSubscription: 'Connect a ChatGPT account to use your Codex subscription.';
+ connectedVia: 'Connected via {{method}}';
+ connectedViaApiKey: 'Connected via API key';
+ notConnected: 'Not connected';
+ providerActivity: 'Provider Activity';
+ startingChatGptLogin: 'Starting ChatGPT login...';
+ unableToVerify: 'Unable to verify';
+ unavailableInCurrentRuntime: 'Unavailable in current runtime';
+ waitingForChatGptLogin: 'Waiting for ChatGPT account login...';
+ };
+ };
+ description: 'Manage how each provider connects and, when supported, which backend the multimodel runtime should use.';
+ errors: {
+ apiKeyDeletedRefreshFailed: 'API key deleted, but failed to refresh provider status.';
+ apiKeyRequired: 'API key is required';
+ apiKeySavedRefreshFailed: 'API key saved, but failed to refresh provider status.';
+ connectionUpdatedRefreshFailed: 'Connection updated, but failed to refresh provider status.';
+ deleteApiKey: 'Failed to delete API key';
+ disableEndpoint: 'Failed to disable endpoint';
+ endpointDisabledRefreshFailed: 'Endpoint disabled, but failed to refresh provider status.';
+ endpointSavedRefreshFailed: 'Endpoint saved, but failed to refresh provider status.';
+ refreshCodexAccount: 'Failed to refresh Codex account';
+ saveApiKey: 'Failed to save API key';
+ saveEndpoint: 'Failed to save endpoint';
+ updateAnthropicFastMode: 'Failed to update Anthropic Fast mode';
+ updateConnection: 'Failed to update connection';
+ updateRuntimeBackend: 'Failed to update runtime backend';
+ };
+ fastMode: {
+ defaultOff: 'Default Off';
+ description: 'Apply Claude Code Fast mode by default for new Anthropic team launches when the resolved model and runtime allow it.';
+ disabledHint: 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.';
+ enabledHint: 'New Anthropic launches will request Fast mode by default when the resolved model supports it.';
+ notExposed: 'This Anthropic runtime does not expose Fast mode.';
+ preferFast: 'Prefer Fast';
+ title: 'Fast mode default';
+ unavailableForRuntime: 'Fast mode is currently unavailable for this Anthropic runtime.';
+ };
+ progress: {
+ applyingConnectionChanges: 'Applying connection changes...';
+ refreshingProviderStatus: 'Refreshing provider status...';
+ savingCompatibleEndpoint: 'Saving compatible endpoint...';
+ switchingAnthropicSubscription: 'Switching to Anthropic subscription...';
+ switchingApiKey: 'Switching to API key...';
+ switchingApiKeyMode: 'Switching to API key mode...';
+ switchingAuto: 'Switching to Auto...';
+ switchingChatgpt: 'Switching to ChatGPT account mode...';
+ };
+ provider: 'Provider';
+ runtime: {
+ descriptions: {
+ anthropic: 'Anthropic currently has no separate runtime backend selector.';
+ codex: 'Codex now runs only through the native runtime path.';
+ gemini: 'Choose which Gemini runtime backend multimodel should use.';
+ opencode: 'OpenCode uses its own managed runtime host. Desktop currently exposes status only.';
+ };
+ title: 'Runtime';
+ updating: 'Updating runtime...';
+ };
+ runtimeSummary: 'Runtime: {{runtime}}';
+ status: {
+ configured: 'configured';
+ enabled: 'Enabled';
+ notConfigured: 'Not configured';
+ notSet: 'not set';
+ off: 'Off';
+ unknown: 'Unknown';
+ };
+ title: 'Provider Settings';
+ usage: {
+ apiKey: 'Using API key';
+ apiKeyRequired: 'API key required';
+ compatibleEndpoint: 'Using compatible endpoint';
+ notConnected: 'Not connected';
+ usingMethod: 'Using {{method}}';
+ };
+ };
+ runtimeProvider: {
+ actions: {
+ cancel: 'Cancel';
+ test: 'Test';
+ };
+ badges: {
+ configured: 'configured';
+ connected: 'connected';
+ default: 'default';
+ failed: 'failed';
+ free: 'free';
+ local: 'local';
+ needsTest: 'needs test';
+ unknown: 'unknown';
+ usedInTeamPicker: 'Used in team picker';
+ verified: 'verified';
+ };
+ compatibleEndpoint: {
+ baseUrlPlaceholder: 'http://localhost:1234';
+ };
+ defaults: {
+ allProjects: 'All projects';
+ allProjectsHint: 'Tests use {{project}}. Default applies unless a project has an override.';
+ loadingContexts: 'Loading contexts...';
+ projectHint: 'Saving overrides only {{project}}.';
+ projectOverrideContext: 'Project override context';
+ scopeDescriptionAllProjects: 'Default for every project that does not have its own OpenCode override.';
+ scopeDescriptionProject: 'Override only the selected project. Running teams are not changed.';
+ selectProjectContext: 'Select project context';
+ selectProjectHint: 'Select a project before testing local models or saving defaults.';
+ selectValidationContext: 'Select validation context';
+ setAllProjectsDefault: 'Set all-projects default';
+ setProjectDefault: 'Set project default';
+ thisProject: 'This project';
+ title: 'OpenCode defaults';
+ validationContext: 'Validation context';
+ };
+ diagnostics: {
+ copied: 'Diagnostics copied';
+ copiedShort: 'Copied';
+ copy: 'Copy diagnostics';
+ hints: 'Hints';
+ likelyCause: 'Likely cause:';
+ };
+ modelRoutes: {
+ searchPlaceholder: 'Search model routes';
+ };
+ models: {
+ alreadyDefault: 'This is already the selected OpenCode default.';
+ empty: 'No models found.';
+ emptyFree: 'No free models found.';
+ emptyRecommended: 'No recommended models found.';
+ emptyRecommendedFree: 'No recommended free models found.';
+ freeOnly: 'Free only';
+ launchableDescription: 'Routes you can test or use in the team picker: local config, free built-in models, and current default.';
+ launchableTitle: 'Launchable OpenCode models';
+ loadingRoutes: 'Loading OpenCode model routes...';
+ noRoutesMatch: 'No OpenCode model routes match "{{query}}".';
+ noneReported: 'No launchable OpenCode model routes were reported yet. Configure a local route in OpenCode or use the Providers tab to inspect catalog providers.';
+ recommendedOnly: 'Recommended only';
+ searchPlaceholder: 'Search models';
+ selectProjectBeforeTesting: 'Select a project context before testing models.';
+ selectProjectBeforeTestingDefaults: 'Select a project context before testing or saving OpenCode defaults.';
+ useInTeamPicker: 'Use in team picker';
+ };
+ providers: {
+ catalog: 'OpenCode provider catalog';
+ countFallback: 'OpenCode providers';
+ description: '{{count}}. Connected and recommended providers are shown first.';
+ description_few: '{{count}}. Connected and recommended providers are shown first.';
+ description_many: '{{count}}. Connected and recommended providers are shown first.';
+ description_one: '{{count}}. Connected and recommended providers are shown first.';
+ description_other: '{{count}}. Connected and recommended providers are shown first.';
+ loadMore: 'Load more providers';
+ loading: 'Loading OpenCode providers';
+ noMatches: 'No providers match that search.';
+ noneReported: 'No OpenCode providers reported by the managed runtime.';
+ recommended: 'Recommended';
+ refreshCatalog: 'Refresh catalog';
+ searchPlaceholder: 'Search providers';
+ };
+ setup: {
+ loading: 'Loading provider setup...';
+ };
+ summary: {
+ defaultModel: 'OpenCode default: {{model}}';
+ loading: 'Loading managed OpenCode runtime, connected providers, and model defaults...';
+ source: 'Source: {{source}}';
+ title: 'OpenCode runtime';
+ };
+ tabs: {
+ models: 'Models';
+ providers: 'Providers';
+ };
+ };
+ tabs: {
+ advanced: {
+ description: 'Power-user options: export/import config, reset defaults, and raw configuration editing.';
+ label: 'Advanced';
+ };
+ general: {
+ description: 'Core app preferences like theme, language, display density, and startup behavior.';
+ label: 'General';
+ };
+ infoAriaLabel: 'What is {{label}}?';
+ notifications: {
+ description: 'Control when and how you get notified about agent activity, task completions, and errors.';
+ label: 'Notifications';
+ };
+ };
+ view: {
+ description: 'Manage your app preferences';
+ loading: 'Loading settings...';
+ title: 'Settings';
+ };
+ workspaceProfiles: {
+ actions: {
+ addProfile: 'Add Profile';
+ cancel: 'Cancel';
+ deleteProfile: 'Delete profile';
+ editProfile: 'Edit profile';
+ save: 'Save';
+ };
+ authMethods: {
+ agent: 'SSH Agent';
+ auto: 'Auto (from SSH Config)';
+ password: 'Password';
+ privateKey: 'Private Key';
+ };
+ deleteConfirm: {
+ confirmLabel: 'Delete';
+ message: 'Are you sure you want to delete "{{name}}"? This cannot be undone.';
+ title: 'Delete Profile';
+ };
+ description: 'Save SSH connection profiles for quick reconnection';
+ empty: {
+ description: 'Add an SSH profile to connect quickly';
+ title: 'No saved profiles';
+ };
+ form: {
+ authentication: 'Authentication';
+ host: 'Host';
+ hostPlaceholder: 'hostname or IP';
+ name: 'Name';
+ namePlaceholder: 'My Server';
+ passwordPrompt: 'You will be prompted for the password when connecting.';
+ port: 'Port';
+ privateKeyPath: 'Private Key Path';
+ username: 'Username';
+ usernamePlaceholder: 'user';
+ };
+ loading: 'Loading profiles...';
+ title: 'Workspace Profiles';
+ };
+ };
+ team: {
+ activity: {
+ actions: {
+ createTaskFromMessage: 'Create task from message';
+ expandMessage: 'Expand message';
+ replyToMessage: 'Reply to message';
+ restartTeam: 'Restart team';
+ };
+ activeTasks: {
+ inProgress: 'In progress';
+ };
+ authError: {
+ description: 'Authentication failed. Restarting the team will refresh the session and may resolve this issue. If the problem persists, check your API credentials or try again later.';
+ };
+ automation: {
+ reviewPickup: 'Asked teammate to pick up review';
+ stallNudge: 'Asked teammate to continue stalled task';
+ workSyncBody: 'Asked teammate to sync current work';
+ };
+ badges: {
+ automation: 'automation';
+ bootstrap: 'bootstrap';
+ command: 'command';
+ comment: 'Comment';
+ live: 'live';
+ note: 'note';
+ rateLimited: 'Rate Limited';
+ restart: 'restart';
+ result: 'result';
+ session: 'session';
+ stallNudge: 'stall nudge';
+ start: 'start';
+ workSync: 'work sync';
+ };
+ bootstrap: {
+ acknowledged: 'Bootstrap acknowledged';
+ restarting: 'Restarting teammate';
+ starting: 'Starting teammate';
+ };
+ expandDialog: {
+ description: 'Expanded message view';
+ };
+ pendingReplies: {
+ awaitingApproval: 'awaiting approval';
+ awaitingReply: 'awaiting reply';
+ crossTeamAwaitingReply: 'Cross-team message sent, awaiting reply';
+ externalTeam: 'external team';
+ messageSentAwaitingReply: 'Message sent, awaiting reply';
+ openMember: 'Open member';
+ title: 'Awaiting replies';
+ user: 'user';
+ };
+ rawJson: 'Raw JSON';
+ reply: {
+ action: 'Reply';
+ replyingTo: 'Replying to';
+ };
+ thoughts: {
+ count: '{{count}} thoughts';
+ count_few: '{{count}} thoughts';
+ count_many: '{{count}} thoughts';
+ count_one: '{{count}} thought';
+ count_other: '{{count}} thoughts';
+ expand: 'Expand thoughts';
+ showLess: 'Show less';
+ showMore: 'Show more';
+ titleForMember: '{{name}} - thoughts';
+ toolSummary: '🔧 {{summary}}';
+ };
+ timeline: {
+ emptyHint: 'Send a message to a member to see activity.';
+ loadingMessages: 'Loading messages...';
+ newSession: 'New session';
+ noMessages: 'No messages';
+ olderCount: '+{{count}} older';
+ olderCount_few: '+{{count}} older';
+ olderCount_many: '+{{count}} older';
+ olderCount_one: '+{{count}} older';
+ olderCount_other: '+{{count}} older';
+ showAll: 'Show all';
+ showMore: 'Show {{count}} more';
+ };
+ unread: 'Unread';
+ };
+ advancedCli: {
+ commandPreview: 'Command preview';
+ customArguments: 'Custom arguments';
+ placeholders: {
+ worktreeName: 'worktree-name';
+ };
+ recent: 'Recent';
+ title: 'Advanced';
+ useWorktree: 'Use worktree';
+ validate: 'Validate';
+ validation: {
+ allFlagsValid: 'All flags valid';
+ failed: 'Validation failed';
+ protectedFlags: 'Protected: {{flags}}';
+ unknownFlags: 'Unknown: {{flags}}';
+ };
+ };
+ agentGraph: {
+ activityHud: {
+ activity: 'Activity';
+ more: '+{{count}} more';
+ more_few: '+{{count}} more';
+ more_many: '+{{count}} more';
+ more_one: '+{{count}} more';
+ more_other: '+{{count}} more';
+ noRecentActivity: 'No recent activity';
+ };
+ blockingEdge: {
+ blockedHiddenTasks: 'Blocked hidden tasks';
+ blockingHiddenTasks: 'Blocking hidden tasks';
+ blocks: 'blocks';
+ close: 'Close';
+ title: 'Blocking Dependency';
+ };
+ logPreview: {
+ loading: 'Loading logs';
+ logs: 'Logs';
+ more: '+{{count}} more';
+ more_few: '+{{count}} more';
+ more_many: '+{{count}} more';
+ more_one: '+{{count}} more';
+ more_other: '+{{count}} more';
+ };
+ popover: {
+ externalTeam: 'External team';
+ member: {
+ actions: {
+ message: 'Message';
+ profile: 'Profile';
+ task: 'Task';
+ };
+ activeTool: {
+ failed: 'Tool failed';
+ finished: 'Tool finished';
+ running: 'Running tool';
+ };
+ lead: 'Lead';
+ recentTools: 'Recent tools';
+ spawn: {
+ failed: 'failed';
+ starting: 'starting';
+ waitingToStart: 'waiting to start';
+ };
+ state: {
+ active: 'active';
+ idle: 'idle';
+ offline: 'offline';
+ runningTool: 'running tool';
+ };
+ workingOn: 'working on';
+ };
+ overflow: {
+ empty: 'No hidden tasks available.';
+ hiddenTasks: 'Hidden tasks';
+ };
+ process: {
+ at: 'At:';
+ openUrl: 'Open URL';
+ startedBy: 'Started by:';
+ };
+ };
+ provisioning: {
+ launchDetails: 'Launch details';
+ launchDetailsDescription: 'Detailed team launch progress, live output and CLI logs.';
+ };
+ };
+ claudeLogs: {
+ clearSearch: 'Clear search';
+ emptyRawLogs: '{{count}}; none are assistant/tool output yet.';
+ filter: {
+ actions: {
+ reset: 'Reset';
+ save: 'Save';
+ };
+ ariaLabel: 'Filter Claude logs';
+ kinds: {
+ output: 'Output';
+ thinking: 'Thinking';
+ tool: 'Tool calls';
+ };
+ sections: {
+ content: 'Content';
+ stream: 'Stream';
+ };
+ streams: {
+ stderr: 'stderr';
+ stdout: 'stdout';
+ };
+ tooltip: 'Filter logs';
+ };
+ fullscreen: 'Fullscreen';
+ loading: 'Loading...';
+ logsTitle: 'Logs';
+ newCount: '+{{count}} new';
+ noLogsCaptured: 'No logs captured.';
+ noLogsYet: 'No logs yet.';
+ noMatchingLogs: 'No matching logs.';
+ openFullscreen: 'Open fullscreen logs';
+ rawLineCount: '{{formattedCount}} raw lines';
+ rawLineCount_few: '{{formattedCount}} raw lines';
+ rawLineCount_many: '{{formattedCount}} raw lines';
+ rawLineCount_one: '{{formattedCount}} raw line';
+ rawLineCount_other: '{{formattedCount}} raw lines';
+ rawLinesCaptured: '{{count}} captured';
+ searchPlaceholder: 'Search logs...';
+ showMore: 'Show more';
+ teamNotRunning: 'Team is not running.';
+ viewingFullscreen: 'Viewing in fullscreen mode';
+ };
+ codexReconnect: {
+ description: 'Your Codex session appears stale. Reconnect to continue.';
+ useCode: 'Use code';
+ };
+ contextLimit: {
+ always200k: '(always 200K for this model)';
+ limitTo200k: 'Limit context to 200K tokens';
+ tooltipContent: 'Keeps launches within a 200K-token context window when supported.';
+ tooltipTitle: 'Context limit';
+ };
+ create: {
+ actions: {
+ create: 'Create';
+ creating: 'Creating...';
+ openExisting: 'Open Existing Team';
+ skipPreflightAndCreate: 'Skip preflight and create';
+ };
+ conflict: {
+ description: 'Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.';
+ title: 'Another team "{{team}}" is already running for this working directory';
+ workingDirectory: 'Working directory:';
+ };
+ description: {
+ copy: 'Create a new team based on an existing one.';
+ create: 'Set up your team and choose how it starts.';
+ };
+ errors: {
+ createConfigFailed: 'Failed to create team config';
+ loadProjectsFailed: 'Failed to load projects';
+ nameExists: 'Team name already exists';
+ nameLaunching: 'A team with this name is currently launching';
+ };
+ fields: {
+ color: 'Color (optional)';
+ description: 'Description (optional)';
+ prompt: 'Prompt for team lead (optional)';
+ teamName: 'Team name';
+ };
+ launchAfterCreate: {
+ description: 'Start the team immediately via local Claude CLI.';
+ label: 'Run command after create';
+ };
+ localOnly: 'Available only in local Electron mode.';
+ onDisk: 'On disk:';
+ optional: {
+ launchSettingsDescription: 'Prompt, safety, and CLI overrides live here when you need them.';
+ launchSettingsTitle: 'Optional launch settings';
+ teamDetailsDescription: 'Keep the default flow compact and only open this when you want extra context or a custom color.';
+ teamDetailsTitle: 'Optional team details';
+ };
+ placeholders: {
+ description: 'Brief description of the team purpose';
+ prompt: 'Instructions for the team lead during provisioning...';
+ };
+ prepare: {
+ checkingProviders: 'Checking selected providers...';
+ failed: 'Failed to prepare selected providers';
+ preparingEnvironment: 'Preparing environment...';
+ ready: 'All selected providers are ready.';
+ readyWithNotes: 'All selected providers are ready, with notes.';
+ selectWorkingDirectory: 'Select a working directory to validate the launch environment.';
+ selectedProvidersReady: 'Selected providers ready';
+ selectedProvidersReadyWithNotes: 'Selected providers ready (with notes)';
+ someProvidersNeedAttention: 'Some selected providers need attention.';
+ unsupportedPreload: 'Current preload version does not support team:prepareProvisioning. Restart the dev app.';
+ };
+ saved: 'Saved';
+ solo: {
+ description: 'Only the team lead (main process) will be started - no teammates will be spawned. Works like a regular agent session in your chosen runtime (Claude Code, Codex, OpenCode, Gemini) but with access to the task board for planning. Saves tokens by avoiding teammate coordination overhead. You can add members later from the team settings.';
+ label: 'Solo team';
+ };
+ title: {
+ copy: 'Copy Team';
+ create: 'Create Team';
+ };
+ validation: {
+ checkFormFields: 'Check form fields';
+ memberNameInvalid: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
+ memberNameRequired: 'Member name cannot be empty';
+ memberNamesUnique: 'Member names must be unique';
+ nameMustContainLetterOrDigit: 'Name must contain at least one letter or digit';
+ nameTooLong: 'Name is too long (max 128 chars)';
+ openCodeLeadModelRequired: 'OpenCode lead requires a selected model.';
+ openCodeTeammateRequired: 'OpenCode lead requires at least one OpenCode teammate.';
+ selectWorkingDirectory: 'Select working directory (cwd)';
+ teamLaunching: 'Team is currently launching';
+ teamNameExists: 'Team name already exists';
+ };
+ };
+ detail: {
+ actions: {
+ add: 'Add';
+ cancel: 'Cancel';
+ delete: 'Delete';
+ editCode: 'Edit code';
+ launch: 'Launch';
+ remove: 'Remove';
+ stop: 'Stop';
+ task: 'Task';
+ visualize: 'Visualize';
+ };
+ context: {
+ title: 'Context';
+ };
+ deleteTeam: {
+ description: 'Delete team "{{team}}"? This action is irreversible. All team data and tasks will be deleted.';
+ title: 'Delete team';
+ };
+ draft: {
+ descriptionPrefix: 'This is a draft team -';
+ descriptionSuffix: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_few: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_many: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_one: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ descriptionSuffix_other: "has been configured with {{count}} {{member}} but hasn't been provisioned by CLI yet. Click Launch to select a model and start the team.";
+ member: 'members';
+ member_few: 'members';
+ member_many: 'members';
+ member_one: 'member';
+ member_other: 'members';
+ title: 'Team not launched yet';
+ };
+ invalidTab: 'Invalid team tab';
+ kanbanSafeData: 'Failed to fully load kanban. Displaying safe data.';
+ loadFailed: 'Failed to load team';
+ loading: 'Loading team';
+ loadingSidebar: 'Loading team sidebar';
+ offline: {
+ offline: 'Team is offline';
+ partialFailed: 'Last launch failed partway';
+ partialMissing: 'Last launch failed partway - {{missing}}/{{expected}} teammates did not join';
+ reconciling: 'Last launch is still reconciling';
+ };
+ previous: 'Previous: {{paths}}';
+ removeMember: {
+ description: 'Remove "{{member}}" from the team? Tasks and messages will be preserved, but this name cannot be reused.';
+ title: 'Remove member';
+ };
+ sections: {
+ team: 'Team';
+ };
+ solo: 'Solo';
+ status: {
+ active: 'Active';
+ launching: 'Launching...';
+ running: 'Running';
+ };
+ telemetry: {
+ cpu: 'CPU';
+ memory: 'Memory';
+ };
+ tooltips: {
+ deleteTeam: 'Delete team';
+ editTeam: 'Edit team';
+ editUnavailableProvisioning: 'Edit team is unavailable while provisioning is still in progress';
+ openBuiltInEditor: 'Open project in built-in editor';
+ openTeamGraph: 'Open team graph';
+ stopTeam: 'Stop team';
+ };
+ waitingForProvisioning: 'Team data will appear once provisioning completes';
+ };
+ dialogs: {
+ actions: {
+ cancel: 'Cancel';
+ openDashboard: 'Open Dashboard';
+ openTeam: 'Open team';
+ };
+ membersJson: {
+ hide: 'Hide JSON';
+ };
+ optional: {
+ badge: 'Optional';
+ };
+ };
+ editTeam: {
+ actions: {
+ cancel: 'Cancel';
+ save: 'Save';
+ };
+ addMemberLockReason: 'Use the dedicated Add member dialog to add new teammates while the team is live.';
+ description: 'Change team name, description and color';
+ errors: {
+ changesSavedRefreshFailed: 'Team changes were saved, but failed to refresh the latest view: {{message}}';
+ liveRenameBlocked: 'Existing teammates cannot be renamed while the team is live. renamed: {{names}}';
+ memberNameEmpty: 'Member name cannot be empty';
+ memberNameInvalid: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
+ memberNameNumericSuffix: 'Member name "{{name}}" is not allowed (reserved for Claude CLI auto-suffix). Use "{{base}}" instead.';
+ memberNameReserved: 'Member name "{{name}}" is reserved';
+ memberNamesUnique: 'Member names must be unique before saving';
+ newLiveTeammates: 'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.';
+ provisioning: 'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.';
+ restartFailedMany: 'Team saved, but failed to restart these teammates: {{failures}}';
+ restartFailedOne: 'Team saved, but failed to restart this teammate: {{failures}}';
+ saveFailed: 'Failed to save';
+ settingsChanged: 'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.';
+ settingsSavedMembersAndRefreshFailed: 'Team settings were saved, but member changes failed: {{message}}. Refresh also failed: {{refreshError}}';
+ settingsSavedMembersFailed: 'Team settings were saved, but member changes failed: {{message}}';
+ settingsSavedRefreshFailed: 'Team settings were saved, but failed to refresh the latest view: {{message}}';
+ teamNameEmpty: 'Team name cannot be empty';
+ unsupportedMixedPrimaryMutation: 'Live edits to primary-owned teammates in mixed OpenCode teams are not supported yet. Stop the team, edit the roster, then relaunch. Affected: {{names}}';
+ };
+ fields: {
+ colorOptional: 'Color (optional)';
+ description: 'Description';
+ name: 'Name';
+ };
+ memberRestartWarning: 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.';
+ notices: {
+ liveRenameBlocked: 'Live save is blocked because existing teammates were renamed. Revert those identity changes or stop the team first.';
+ newLiveTeammates: 'New teammates cannot be added from Edit Team while the team is live. Use the Add member dialog instead.';
+ provisioning: 'Team provisioning is still in progress. Editing is temporarily locked until launch finishes.';
+ restartMany: 'Saving will restart or relaunch these teammates to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.';
+ restartOne: 'Saving will restart or relaunch this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes: {{names}}.';
+ unsupportedMixedPrimaryMutation: 'Live edits/removals for primary-owned teammates in mixed OpenCode teams require stopping and relaunching the team: {{names}}.';
+ };
+ placeholders: {
+ description: 'Team description (optional)';
+ teamName: 'Team name';
+ };
+ teamLead: {
+ changeRuntime: 'Change lead runtime';
+ changeRuntimeDescription: 'Open Relaunch Team to change the lead provider, model, or effort.';
+ modelLockReason: 'Team lead runtime is managed from Relaunch Team.';
+ readOnlyHint: 'Team lead name and role stay read-only here. Open the runtime panel on the lead row to change provider, model, or effort.';
+ role: 'Team Lead';
+ };
+ title: 'Edit Team';
+ };
+ editor: {
+ actions: {
+ cancel: 'Cancel';
+ closeEditor: 'Close editor';
+ closeTab: 'Close tab';
+ closeTooltip: 'Close editor (Esc)';
+ discard: 'Discard';
+ discardAndClose: 'Discard & Close';
+ keep: 'Keep';
+ keepMine: 'Keep mine';
+ keyboardShortcuts: 'Keyboard shortcuts';
+ overwrite: 'Overwrite';
+ refreshAria: 'Refresh (F5)';
+ refreshTooltip: 'Refresh git status (F5)';
+ reload: 'Reload';
+ retry: 'Retry';
+ save: 'Save';
+ saveAllAndClose: 'Save All & Close';
+ };
+ ariaLabel: 'Project Editor';
+ binaryPlaceholder: {
+ file: 'Binary file ({{size}})';
+ };
+ dialogs: {
+ conflictDescription: 'The file has been modified externally since you opened it. Overwrite with your changes?';
+ conflictTitle: 'Save Conflict';
+ unsavedDescription: 'You have unsaved changes. What would you like to do?';
+ unsavedFileDescription: 'This file has unsaved changes. What would you like to do?';
+ unsavedTitle: 'Unsaved Changes';
+ };
+ draftRecovered: 'Recovered unsaved changes from a previous session.';
+ empty: {
+ selectFile: 'Select a file from the tree to edit';
+ };
+ errorBoundary: {
+ crashed: 'Editor crashed';
+ unknownError: 'Unknown error';
+ };
+ externalChange: {
+ changed: 'File changed on disk.';
+ deleted: 'File no longer exists on disk.';
+ };
+ fileTree: {
+ cancel: 'Cancel';
+ dropForProjectRoot: 'Drop here for project root';
+ empty: 'No files found';
+ failedToLoadFiles: 'Failed to load files: {{error}}';
+ loading: 'Loading files...';
+ moveToTrash: 'Move to Trash';
+ moveToTrashConfirm: 'Move "{{name}}" to Trash?';
+ };
+ goToLine: {
+ go: 'Go';
+ placeholder: 'Line number, +offset, -offset, or %';
+ position: '(current: {{current}}, total: {{total}})';
+ title: 'Go to Line';
+ };
+ imagePreview: {
+ loading: 'Loading preview...';
+ openFullSize: 'Open full-size preview';
+ openSystemViewer: 'Open in System Viewer';
+ };
+ newFile: {
+ aria: {
+ newFileName: 'New file name';
+ newFolderName: 'New folder name';
+ };
+ placeholders: {
+ fileName: 'File name...';
+ folderName: 'Folder name...';
+ };
+ validation: {
+ invalidCharacters: 'Name contains invalid characters';
+ invalidName: 'Invalid name';
+ nameRequired: 'Name cannot be empty';
+ nameTooLong: 'Name is too long';
+ };
+ };
+ quickOpen: {
+ empty: 'No files found';
+ loading: 'Loading files...';
+ searchPlaceholder: 'Search files by name...';
+ title: 'Quick Open';
+ };
+ saveFailed: 'Save failed: {{error}}';
+ search: {
+ placeholder: 'Search';
+ toggleReplace: 'Toggle Replace';
+ };
+ searchInFiles: {
+ closeSearch: 'Close search';
+ closeSearchShortcut: 'Close search (Esc)';
+ matchCase: 'Match Case';
+ matchCaseToggle: 'Aa';
+ noResults: 'No results found';
+ resultsSummary: '{{count}} matches in {{fileCount}} files';
+ resultsSummary_few: '{{count}} matches in {{fileCount}} files';
+ resultsSummary_many: '{{count}} matches in {{fileCount}} files';
+ resultsSummary_one: '{{count}} match in {{fileCount}} files';
+ resultsSummary_other: '{{count}} matches in {{fileCount}} files';
+ searchPlaceholder: 'Search...';
+ title: 'Search in Files';
+ truncated: '(truncated)';
+ };
+ searchPanel: {
+ all: 'All';
+ close: 'Close';
+ nextMatch: 'Next Match';
+ previousMatch: 'Previous Match';
+ replace: 'Replace';
+ replaceAll: 'Replace All';
+ replaceNext: 'Replace Next';
+ replacePlaceholder: 'Replace';
+ };
+ shortcuts: {
+ actions: {
+ closeEditor: 'Close Editor';
+ closeTab: 'Close Tab';
+ cycleTabs: 'Cycle Tabs';
+ findInFile: 'Find in File';
+ fullPreview: 'Full Preview';
+ goToLine: 'Go to Line';
+ nextTab: 'Next Tab';
+ previousTab: 'Previous Tab';
+ quickOpen: 'Quick Open';
+ redo: 'Redo';
+ save: 'Save';
+ saveAll: 'Save All';
+ searchInFiles: 'Search in Files';
+ selectNextMatch: 'Select Next Match';
+ splitPreview: 'Split Preview';
+ toggleComment: 'Toggle Comment';
+ toggleSidebar: 'Toggle Sidebar';
+ undo: 'Undo';
+ };
+ groups: {
+ editing: 'Editing';
+ fileOperations: 'File Operations';
+ general: 'General';
+ markdown: 'Markdown';
+ navigation: 'Navigation';
+ search: 'Search';
+ };
+ title: 'Keyboard Shortcuts';
+ };
+ sidebar: {
+ explorer: 'Explorer';
+ hide: 'Hide sidebar';
+ hideWithShortcut: 'Hide sidebar ({{shortcut}})';
+ show: 'Show sidebar';
+ showWithShortcut: 'Show sidebar ({{shortcut}})';
+ };
+ statusBar: {
+ disableExternalWatcher: 'Disable external change watcher';
+ disableWatcher: 'Disable file watcher';
+ enableWatcher: 'Enable file watcher';
+ encodingUtf8: 'UTF-8';
+ position: 'Ln {{line}}, Col {{col}}';
+ spaces: 'Spaces: {{count}}';
+ watch: 'watch';
+ watchExternalChanges: 'Watch for external changes';
+ watching: 'watching';
+ };
+ toolbar: {
+ closePreview: 'Close preview';
+ closeSplitPreview: 'Close split preview';
+ disableWordWrap: 'Disable word wrap';
+ enableWordWrap: 'Enable word wrap';
+ };
+ unsavedChanges: 'Unsaved changes';
+ };
+ effortLevel: {
+ label: 'Effort level (optional)';
+ maxDescription: 'Max gives the model the most reasoning time for difficult tasks.';
+ };
+ kanban: {
+ board: {
+ addTask: 'Add task';
+ columnsView: 'Columns view';
+ gridView: 'Grid view';
+ hiddenCount: '{{count}} hidden';
+ noTasks: 'No tasks';
+ showMore: 'Show {{count}} more';
+ trash: 'Trash';
+ };
+ columns: {
+ approved: 'APPROVED';
+ done: 'DONE';
+ inProgress: 'IN PROGRESS';
+ review: 'REVIEW';
+ todo: 'TODO';
+ };
+ filter: {
+ allSessions: 'All sessions';
+ clearAll: 'Clear all';
+ column: 'Column';
+ session: 'Session';
+ teammate: 'Teammate';
+ title: 'Filter tasks';
+ unassigned: '(unassigned)';
+ };
+ grid: {
+ addTask: 'Add task';
+ noTasks: 'No tasks';
+ };
+ search: {
+ clearSearch: 'Clear search';
+ createdAgo: 'created {{time}}';
+ placeholder: 'Search tasks... (#id or text)';
+ tasks: 'Tasks';
+ updatedAgo: 'updated {{time}}';
+ };
+ sort: {
+ options: {
+ createdAt: {
+ description: 'Newest first';
+ label: 'Created';
+ };
+ manual: {
+ description: 'Drag-and-drop order';
+ label: 'Manual';
+ };
+ owner: {
+ description: 'Alphabetically by assignee';
+ label: 'Owner';
+ };
+ updatedAt: {
+ description: 'Recently updated first';
+ label: 'Last updated';
+ };
+ };
+ reset: 'Reset';
+ sortBy: 'Sort by';
+ title: 'Sort tasks';
+ };
+ taskCard: {
+ approve: 'Approve';
+ awaitingLead: 'Awaiting lead';
+ awaitingUser: 'Awaiting user';
+ blockedBy: 'Blocked by';
+ blocks: 'Blocks';
+ cancel: 'Cancel';
+ cancelTask: 'Cancel task {{taskId}}';
+ changes: 'Changes';
+ changesNeedAttention: 'Changes need attention';
+ complete: 'Complete';
+ confirm: 'Confirm';
+ deleteTask: 'Delete task';
+ keep: 'Keep';
+ manualReview: 'Manual review';
+ moveBackToTodoConfirm: 'Move this task back to TODO and notify the team?';
+ newTaskLogsArriving: 'New task logs arriving';
+ requestChanges: 'Request changes';
+ requestReview: 'Request review';
+ start: 'Start';
+ taskLogsActive: 'Task logs active';
+ };
+ title: 'Kanban';
+ trash: {
+ close: 'Close';
+ deleted: 'Deleted';
+ empty: 'No deleted tasks';
+ owner: 'Owner';
+ restore: 'Restore';
+ restoreTask: 'Restore task';
+ subject: 'Subject';
+ title: 'Trash';
+ unassigned: 'Unassigned';
+ };
+ };
+ launch: {
+ actions: {
+ createSchedule: 'Create Schedule';
+ creating: 'Creating...';
+ goToDashboard: 'Go to Dashboard';
+ launchTeam: 'Launch team';
+ launching: 'Launching...';
+ relaunchTeam: 'Relaunch team';
+ relaunching: 'Relaunching...';
+ saveChanges: 'Save Changes';
+ saving: 'Saving...';
+ };
+ billing: {
+ prefix: 'Starting June 15, 2026, Anthropic bills';
+ readArticle: 'Read Anthropic article';
+ suffix: 'and Agent SDK usage from the monthly Agent SDK credit, separate from interactive Claude Code limits. The credit resets each billing cycle and unused credit does not roll over.';
+ };
+ conflict: {
+ description: 'Running two teams in the same directory is risky - they may conflict editing the same files. Consider using a different directory or a git worktree for isolation.';
+ title: 'Another team "{{team}}" is already running for this working directory';
+ workingDirectory: 'Working directory:';
+ };
+ description: {
+ createSchedule: 'Schedule automatic Claude task execution';
+ createScheduleForTeam: 'Schedule automatic runs for team "{{team}}"';
+ editSchedule: 'Editing schedule for team "{{team}}"';
+ launchPrefix: 'Start team';
+ launchSuffix: 'via local Claude CLI.';
+ relaunchPrefix: 'Stop the current run for';
+ relaunchSuffix: 'and start it again via local Claude CLI.';
+ };
+ errors: {
+ launchFailed: 'Failed to launch team';
+ loadProjectsFailed: 'Failed to load projects';
+ relaunchFailed: 'Failed to relaunch team';
+ saveScheduleFailed: 'Failed to save schedule';
+ };
+ optionalSettings: {
+ description: 'Keep the launch flow focused on the project path and only expand this when you want extra control.';
+ relaunchDescription: 'Review the roster and lead runtime before restarting the team.';
+ relaunchTitle: 'Relaunch settings';
+ title: 'Optional launch settings';
+ };
+ prepare: {
+ action: {
+ launch: 'launch';
+ relaunch: 'relaunch';
+ };
+ blocked: 'Runtime environment is not available - {{action}} is blocked';
+ checkingProviders: 'Checking selected providers...';
+ failed: 'Failed to prepare selected providers';
+ preflight: 'Pre-flight check to catch errors before {{action}}';
+ preparingEnvironment: 'Preparing environment...';
+ ready: 'All selected providers are ready.';
+ readyWithNotes: 'All selected providers are ready, with notes.';
+ selectWorkingDirectory: 'Select a working directory to validate the launch environment.';
+ someProvidersNeedAttention: 'Some selected providers need attention.';
+ unsupportedPreload: 'Current preload version does not support team:prepareProvisioning. Restart the dev app.';
+ };
+ prompt: {
+ label: 'Prompt';
+ oneShotPrefix: 'This prompt will be passed to';
+ oneShotSuffix: 'for one-shot execution';
+ saved: 'Saved';
+ schedulePlaceholder: 'Instructions for Claude to execute on schedule...';
+ teamLeadOptional: 'Prompt for team lead (optional)';
+ teamLeadPlaceholder: 'Instructions for team lead...';
+ };
+ providerChanged: 'Provider changed from {{from}} to {{to}}. The previous lead session will not be resumed, and the lead will start with fresh context so the new runtime is applied correctly.';
+ relaunchFreshSession: 'Team relaunch starts a fresh lead session. Durable team state, task board, and member configuration are rehydrated into the launch prompt.';
+ relaunchWarning: {
+ description: 'Saving these settings will stop the current team process, persist the updated roster, and launch the team again with the new runtime.';
+ title: 'Relaunch will restart the current team run';
+ };
+ schedule: {
+ labelOptional: 'Label (optional)';
+ labelPlaceholder: 'e.g., Daily code review, Nightly tests...';
+ maxBudgetUsd: 'Max budget (USD)';
+ maxTurns: 'Max turns';
+ noLimit: 'No limit';
+ noMatches: 'No teams match your search.';
+ noTeams: 'No teams available. Create a team first.';
+ searchTeams: 'Search teams...';
+ selectTeam: 'Select a team...';
+ team: 'Team';
+ title: 'Schedule';
+ };
+ title: {
+ createSchedule: 'Create Schedule';
+ editSchedule: 'Edit Schedule';
+ launch: 'Launch Team';
+ relaunch: 'Relaunch Team';
+ };
+ validation: {
+ fixMemberNames: 'Fix member names before launch';
+ memberNamesUnique: 'Member names must be unique before launch';
+ openCodeLeadModelRequired: 'OpenCode lead requires a selected model.';
+ openCodeTeammateRequired: 'OpenCode lead requires at least one OpenCode teammate.';
+ selectWorkingDirectory: 'Select working directory (cwd)';
+ };
+ };
+ layout: {
+ maxPanesReached: 'Maximum of {{count}} panes reached';
+ };
+ list: {
+ actions: {
+ copyTeam: 'Copy team';
+ createTeam: 'Create Team';
+ deleteForever: 'Delete forever';
+ deletePermanently: 'Delete permanently';
+ deleteTeam: 'Delete team';
+ launchTeam: 'Launch team';
+ launching: 'Launching...';
+ relaunchTeam: 'Relaunch team';
+ restore: 'Restore';
+ restoreTeam: 'Restore team';
+ retry: 'Retry';
+ stopTeam: 'Stop team';
+ stopping: 'Stopping...';
+ };
+ deleteDraft: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete';
+ message: 'Delete draft team "{{teamName}}"? This cannot be undone.';
+ title: 'Delete draft';
+ };
+ deleteForever: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete forever';
+ message: 'Delete team "{{teamName}}" permanently? All data will be lost.';
+ title: 'Delete permanently';
+ };
+ electronOnly: {
+ description: 'In browser mode, access to local `~/.claude/teams` directories is not available.';
+ title: 'Teams is only available in Electron mode';
+ };
+ empty: {
+ description: 'Create a team here to get started. It will show up in the list automatically.';
+ localOnly: 'Team creation is only available in local Electron mode.';
+ title: 'No teams found';
+ };
+ filter: {
+ clearAll: 'Clear all';
+ label: 'Filter teams';
+ projectPriority: 'Project priority';
+ status: 'Status';
+ };
+ loadFailed: 'Failed to load teams';
+ loading: 'Loading teams...';
+ localOnly: 'Only available in local Electron mode.';
+ membersCount: 'Members: {{count}}';
+ membersCount_few: 'Members: {{count}}';
+ membersCount_many: 'Members: {{count}}';
+ membersCount_one: 'Member: {{count}}';
+ membersCount_other: 'Members: {{count}}';
+ moveToTrash: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Move to trash';
+ message: 'Move team "{{teamName}}" to trash? You can restore it later.';
+ title: 'Move to trash';
+ };
+ noDescription: 'No description';
+ noMatches: 'No teams matching current filters';
+ partial: {
+ pending: 'Last launch is still reconciling.';
+ skipped: 'Last launch has skipped teammates.';
+ skippedWithCount: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_few: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_many: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ skippedWithCount_one: 'Last launch skipped {{count}}/{{expected}} teammate.';
+ skippedWithCount_other: 'Last launch skipped {{count}}/{{expected}} teammates.';
+ stopped: 'Last launch stopped before all teammates joined.';
+ stoppedWithCount: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_few: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_many: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ stoppedWithCount_one: 'Last launch stopped before {{count}}/{{expected}} teammate joined.';
+ stoppedWithCount_other: 'Last launch stopped before {{count}}/{{expected}} teammates joined.';
+ };
+ searchPlaceholder: 'Search teams...';
+ sections: {
+ otherTeams: 'Other teams';
+ projectTeams: 'Teams for {{project}}';
+ selectedProject: 'selected project';
+ };
+ solo: 'Solo';
+ status: {
+ active: 'Active';
+ deleted: 'Deleted';
+ launching: 'Launching...';
+ offline: 'Offline';
+ partialFailure: 'Launch failed partway';
+ partialPending: 'Bootstrap pending';
+ partialSkipped: 'Launch skipped member';
+ running: 'Running';
+ };
+ title: 'Select Team';
+ trash: 'Trash ({{count}})';
+ trash_few: 'Trash ({{count}})';
+ trash_many: 'Trash ({{count}})';
+ trash_one: 'Trash ({{count}})';
+ trash_other: 'Trash ({{count}})';
+ };
+ liveRuntimeStatus: {
+ description: 'Display-only heartbeat and launch state. Process controls remain below.';
+ diagnosticOnly: 'Diagnostic only';
+ lane: '{{lane}} lane';
+ source: 'source: {{source}}';
+ states: {
+ degraded: 'Needs attention';
+ running: 'Running';
+ starting: 'Starting';
+ stopped: 'Stopped';
+ unknown: 'Unknown';
+ waiting: 'Waiting';
+ };
+ title: 'Live runtime status';
+ updated: 'updated {{value}}';
+ };
+ memberDraft: {
+ actions: {
+ remove: 'Remove member';
+ removeAria: 'Remove {{name}}';
+ restore: 'Restore member';
+ restoreAria: 'Restore {{name}}';
+ };
+ addMembers: {
+ description: 'Add new members to {{teamName}}';
+ title: 'Add Members';
+ };
+ anthropicContext: {
+ defaultSetting: 'default context setting';
+ description: "Anthropic context is team-wide for this launch: {{mode}}. Use the lead runtime panel's Limit context checkbox to change it.";
+ limitEnabled: '200K limit enabled';
+ };
+ mcp: {
+ agentTeamsMcp: 'Agent Teams MCP';
+ buttonInherit: 'MCP inherit';
+ buttonScopes: 'MCP scopes';
+ chooseScopes: 'Choose scopes';
+ inheritLead: 'Inherit lead';
+ lockedInfo: 'Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.';
+ mode: 'MCP mode';
+ scopes: {
+ local: 'local';
+ project: 'project';
+ user: 'user';
+ };
+ serverNames: 'Server names';
+ settingInfo: 'Agent Teams MCP launches this teammate with only the Agent Teams server. Scope and allowlist modes apply only to this teammate launch.';
+ strictAllowlist: 'Strict allowlist';
+ tooltip: "{{label}}: Control this member's MCP inheritance policy";
+ };
+ model: {
+ ariaLabel: '{{provider}} provider, {{model}}';
+ currentLeadRuntime: 'Current lead runtime';
+ default: 'Default';
+ inheritedTooltip: 'Provider, model, and effort are inherited from the lead while sync is enabled.';
+ leadSuffix: '{{label}} (lead)';
+ liveDisabled: 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.';
+ lockedActionFallback: 'Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.';
+ restartWholeTeam: 'Saving those runtime changes restarts the whole team.';
+ };
+ nameAria: 'Member {{index}} name';
+ nameFallback: 'member {{index}}';
+ noRole: 'No role';
+ placeholders: {
+ mcpServers: 'github, sentry';
+ name: 'member-name';
+ };
+ removed: 'Removed';
+ workflow: {
+ addTooltip: 'Add teammate workflow';
+ editTooltip: 'Edit teammate workflow';
+ label: 'Workflow (optional)';
+ placeholder: 'How this agent should behave, interact with others...';
+ saved: 'Saved';
+ };
+ worktree: {
+ description: 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.';
+ label: 'Worktree';
+ };
+ };
+ memberLogStream: {
+ filters: {
+ all: 'All';
+ };
+ logs: {
+ emptyDescription: 'Member-scoped transcript or runtime logs will appear here when available.';
+ emptyTitle: 'No log stream entries were found for this member yet.';
+ loading: 'Loading member log stream...';
+ title: 'Logs';
+ };
+ tabs: {
+ execution: 'Execution';
+ process: 'Process';
+ };
+ };
+ memberWorkSync: {
+ details: {
+ actionableItems: 'Actionable items';
+ diagnostics: 'Diagnostics: {{diagnostics}}';
+ fingerprint: 'Fingerprint';
+ moreActionableItems: '{{count}} more actionable item(s)';
+ no: 'no';
+ none: 'none';
+ report: 'Report';
+ shadowWouldNudge: 'Shadow would nudge';
+ title: 'Member work sync';
+ yes: 'yes';
+ };
+ diagnosticsUnavailable: 'Member work sync diagnostics are unavailable.';
+ loadingDiagnostics: 'Loading member work sync diagnostics.';
+ title: 'Member work sync';
+ };
+ members: {
+ actions: {
+ assignTask: 'Assign task';
+ editRole: 'Edit role';
+ openProfile: 'Open profile';
+ sendMessage: 'Send message';
+ };
+ badges: {
+ worktree: 'worktree';
+ };
+ detail: {
+ assignTask: 'Assign Task';
+ copyDiagnostics: 'Copy diagnostics';
+ failedToRestartMember: 'Failed to restart member';
+ legacyLogsFallback: 'Legacy Logs Fallback';
+ pid: 'PID {{pid}}';
+ relaunchOpenCode: 'Relaunch OpenCode';
+ remove: 'Remove';
+ removedAt: 'Removed {{date}}';
+ restart: 'Restart';
+ sendMessage: 'Send Message';
+ };
+ editor: {
+ addMember: 'Add member';
+ agentTeamsMcpOnly: 'Agent Teams MCP only';
+ editAsJson: 'Edit as JSON';
+ memberNamesUnique: 'Member names must be unique';
+ removedCount: 'Removed ({{count}})';
+ removedModelLockReason: 'Removed members are kept for soft delete history. Restore them to edit settings.';
+ runInSeparateWorktrees: 'Run teammates in separate worktrees';
+ title: 'Members';
+ };
+ executionLog: {
+ agentInstructions: 'Agent instructions';
+ agentTurn: 'Agent turn';
+ empty: 'Nothing to display';
+ emptyUserMessage: '{{time}} - (empty)';
+ memberTurn: '{{member}} turn';
+ turn: 'turn';
+ };
+ leadModel: {
+ anthropicContextLimit: 'The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates.';
+ anthropicTeamWide: 'Anthropic team-wide';
+ defaultModel: 'Default';
+ leadShort: 'lead';
+ providerModelAria: '{{provider}} provider, {{model}}';
+ runtimeInheritance: 'Lead runtime applies to teammates unless they set their own provider or model.';
+ syncWithTeammates: 'Sync model with teammates';
+ teamLead: 'Team Lead';
+ };
+ list: {
+ loading: 'Loading team members';
+ removedCount: 'Removed ({{count}})';
+ soloLeadOnly: 'Solo team - lead only';
+ unavailable: 'Member roster unavailable';
+ unavailableDescription: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ unavailableDescription_few: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ unavailableDescription_many: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ unavailableDescription_one: '{{count}} teammate is known from team metadata, but roster details are missing.';
+ unavailableDescription_other: '{{count}} teammates are known from team metadata, but roster details are missing.';
+ };
+ logs: {
+ active: 'active';
+ empty: 'No logs found';
+ failedToLoadDetails: 'Failed to load details';
+ hideDetails: 'Hide details';
+ leadSessionTooltip: 'Full team lead session logs - useful for global orchestration context, not specific to this agent';
+ loadingDetails: 'Loading details...';
+ memberSessionTooltip: 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file';
+ noMemberActivity: 'This member has no recorded session activity yet';
+ noTaskActivity: 'No session activity for this task yet';
+ searching: 'Searching logs...';
+ showDetails: 'Show details';
+ startedAt: 'started {{time}}';
+ waitingForTaskActivity: 'Task is in progress - waiting for session activity (auto-refreshing)...';
+ };
+ messages: {
+ empty: {
+ loading: 'Loading activity...';
+ noActivity: 'No activity with this member';
+ noComments: 'No comments for this member';
+ noLoadedActivity: 'No loaded activity for this member yet';
+ noLoadedMessages: 'No loaded messages for this member yet';
+ noMessages: 'No messages with this member';
+ };
+ filters: {
+ all: 'All';
+ comments: 'Comments';
+ messages: 'Messages';
+ };
+ loadOlder: 'Load older messages';
+ };
+ recentMessages: {
+ collapse: 'Collapse';
+ expand: 'Expand';
+ latest: 'Latest messages';
+ latestForMember: 'Latest messages - {{member}}';
+ loadMore: 'Load more';
+ };
+ roleSelect: {
+ customRolePlaceholder: 'Enter custom role...';
+ };
+ runtimeLogs: {
+ autoRefresh: 'Auto-refresh';
+ empty: 'No process log file captured for this member yet.';
+ loadingTail: 'Loading process log tail...';
+ wrapLines: 'Wrap lines';
+ };
+ runtimeTelemetry: {
+ cpu: 'CPU';
+ description: 'Parent and child processes only. Remote LLM inference is not included.';
+ memory: 'Memory';
+ processTreeCapped: 'Process tree was capped for this sample.';
+ rssHint: 'RSS can include shared pages, so it is best read as a load signal, not exclusive memory.';
+ sharedHost: 'Shared OpenCode host metric. It is not exclusive to this member.';
+ summedRss: 'summed RSS';
+ title: 'Local runtime load';
+ };
+ stats: {
+ computing: 'Computing stats...';
+ empty: 'No stats available';
+ files: 'Files';
+ filesTouched: 'Files Touched ({{count}})';
+ footer: '{{count}} sessions · computed {{computedAgo}}';
+ footer_few: '{{count}} sessions · computed {{computedAgo}}';
+ footer_many: '{{count}} sessions · computed {{computedAgo}}';
+ footer_one: '{{count}} session · computed {{computedAgo}}';
+ footer_other: '{{count}} sessions · computed {{computedAgo}}';
+ lines: 'Lines';
+ linesInfo: 'Approximate. Accurate for Edit and Write tools. Bash file writes are estimated from command patterns (heredoc, echo, sed) and may be underreported.';
+ moreFiles: '+{{count}} more';
+ showLess: 'Show less';
+ tokens: 'Tokens';
+ toolCalls: 'Tool Calls';
+ toolUsage: 'Tool Usage';
+ viewAllChanges: 'View All Changes';
+ };
+ tasks: {
+ empty: 'No tasks assigned to this member';
+ };
+ };
+ messageComposer: {
+ actions: {
+ send: 'Send';
+ sendingUnavailableLaunching: 'Sending unavailable while team is launching';
+ voiceToText: 'Voice to text';
+ };
+ attachments: {
+ attachFiles: 'Attach files (paste or drag & drop)';
+ disabledHint: 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.';
+ restrictions: {
+ crossTeam: 'File attachments are not supported for cross-team messages';
+ leadOnly: 'Files can only be sent to the team lead';
+ maximumReached: 'Maximum attachments reached';
+ openCodeOffline: 'Team must be online to attach files for OpenCode teammates';
+ sending: 'Wait for current message to finish sending before adding files';
+ teamOffline: 'Team must be online to attach files';
+ unsupportedRecipient: 'Files can be sent to the team lead or OpenCode teammates';
+ };
+ unavailable: 'Attachments are unavailable';
+ };
+ crossTeam: {
+ hint: 'Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message.';
+ };
+ input: {
+ charsLeft: '{{count}} chars left';
+ charsLeft_few: '{{count}} chars left';
+ charsLeft_many: '{{count}} chars left';
+ charsLeft_one: '{{count}} char left';
+ charsLeft_other: '{{count}} chars left';
+ crossTeamPlaceholder: 'Cross-team message to {{team}}...';
+ placeholder: 'Write a message... (Enter to send, Shift+Enter for new line)';
+ slashTip: 'Tip: You can use "/" to run any Claude commands.';
+ teamFallback: 'team';
+ teamLaunchingPlaceholder: 'Team is launching... message will be queued for inbox delivery.';
+ };
+ recipient: {
+ noResults: 'No results';
+ searchPlaceholder: 'Search...';
+ select: 'Select...';
+ };
+ slash: {
+ restrictions: {
+ attachments: 'Slash commands require a live team lead and cannot be sent with attachments';
+ crossTeam: 'Slash commands can only be run on the current team lead';
+ leadOffline: 'Slash commands require the team lead to be online';
+ notLead: 'Slash commands can only be sent to the team lead';
+ };
+ };
+ status: {
+ reusedCrossTeamRequest: 'Reused recent cross-team request';
+ teamOffline: 'Team offline';
+ };
+ teamSelector: {
+ current: 'current';
+ offline: 'offline';
+ offlineTitle: 'Offline';
+ online: 'online';
+ onlineTitle: 'Online';
+ thisTeam: 'This team';
+ };
+ };
+ messages: {
+ actionMode: {
+ label: 'Action mode';
+ };
+ actions: {
+ bottomSheetActions: 'Message bottom sheet actions';
+ collapseAll: 'Collapse all messages';
+ collapseSheet: 'Collapse sheet';
+ expandAll: 'Expand all messages';
+ expandSheet: 'Expand sheet';
+ floatComposer: 'Float composer';
+ floatMessagesComposer: 'Float messages composer';
+ hideSearch: 'Hide search';
+ loadOlder: 'Load older messages';
+ markAllRead: 'Mark all as read';
+ messageActions: 'Message actions';
+ moveMessagesToBottomSheet: 'Move messages to bottom sheet';
+ moveMessagesToSidebar: 'Move messages to sidebar';
+ moveToBottomSheet: 'Move to bottom sheet';
+ moveToInline: 'Move to inline';
+ moveToSidebar: 'Move to sidebar';
+ panelActions: 'Message panel actions';
+ searchMessages: 'Search messages';
+ };
+ delivery: {
+ copied: 'Copied';
+ copyDebugDetails: 'Copy debug details';
+ details: 'Details';
+ fields: {
+ acceptanceUnknown: 'acceptanceUnknown';
+ delivered: 'delivered';
+ diagnostics: 'diagnostics';
+ ledgerStatus: 'ledgerStatus';
+ messageId: 'messageId';
+ providerId: 'providerId';
+ queuedBehindMessageId: 'queuedBehindMessageId';
+ reason: 'reason';
+ responsePending: 'responsePending';
+ responseState: 'responseState';
+ statusMessageId: 'statusMessageId';
+ userVisibleMessage: 'userVisibleMessage';
+ userVisibleNextReviewAt: 'userVisibleNextReviewAt';
+ userVisibleReasonCode: 'userVisibleReasonCode';
+ userVisibleState: 'userVisibleState';
+ visibleReplyCorrelation: 'visibleReplyCorrelation';
+ visibleReplyMessageId: 'visibleReplyMessageId';
+ };
+ };
+ filter: {
+ actions: {
+ reset: 'Reset';
+ save: 'Save';
+ };
+ ariaLabel: 'Filter messages';
+ from: 'From';
+ noData: 'No data';
+ showStatusUpdates: 'Show status updates (idle/shutdown)';
+ to: 'To';
+ tooltip: 'Filter messages';
+ };
+ panelMode: 'Message panel mode';
+ search: {
+ placeholder: 'Search...';
+ };
+ status: {
+ title: 'Status';
+ };
+ title: 'Messages';
+ unread: {
+ new: '{{count}} new';
+ new_few: '{{count}} new';
+ new_many: '{{count}} new';
+ new_one: '{{count}} new';
+ new_other: '{{count}} new';
+ unread: '{{count}} unread';
+ unread_few: '{{count}} unread';
+ unread_many: '{{count}} unread';
+ unread_one: '{{count}} unread';
+ unread_other: '{{count}} unread';
+ };
+ };
+ modelSelector: {
+ advisory: {
+ note: 'Note';
+ pingNotConfirmed: 'Ping not confirmed';
+ };
+ anthropicExtraUsage: {
+ pricingDocs: 'Read Anthropic pricing docs';
+ };
+ badges: {
+ configured: 'Configured';
+ connected: 'Connected';
+ failed: 'Failed';
+ free: 'Free';
+ issue: 'Issue';
+ local: 'Local';
+ needsTest: 'Needs test';
+ unavailable: 'Unavailable';
+ verified: 'Verified';
+ };
+ customModelId: 'Custom model id';
+ defaultModel: 'Default';
+ defaultTooltip: {
+ anthropic: 'Uses the Claude team default model.\nResolves to {{longContextModel}} with 1M context, or {{limitedContextModel}} with 200K context when Limit context is enabled.';
+ anthropicCompatible: 'Uses the Anthropic-compatible endpoint default model.';
+ anthropicCompatibleWithResolved: 'Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to {{model}}.';
+ openCode: 'Uses the OpenCode runtime default model.';
+ openCodeWithResolved: 'Uses the OpenCode default model.\nCurrently resolves to {{model}}.';
+ runtime: 'Uses the runtime default for the selected provider.';
+ };
+ empty: {
+ freeOpenCode: 'No free OpenCode models are available in the current runtime list.';
+ noModels: 'No models are available in the current runtime list.';
+ noSearchMatches: 'No models match this search.';
+ recommendedFreeOpenCode: 'No recommended free OpenCode models are available in the current runtime list.';
+ recommendedOpenCode: 'No recommended OpenCode models are available in the current runtime list.';
+ };
+ fastMode: {
+ codexLabel: 'Fast mode (2x credits)';
+ defaultFast: 'Default (Fast)';
+ defaultOff: 'Default (Off)';
+ defaultResolvesTo: 'Default currently resolves to {{mode}}.';
+ fast: 'Fast';
+ off: 'Off';
+ optionalLabel: 'Fast mode (optional)';
+ runtimeBackedHint: 'Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it.';
+ };
+ label: 'Model (optional)';
+ multimodelOff: 'Multimodel off';
+ multimodelRequired: 'Codex and Gemini require Multimodel mode.';
+ openCode: {
+ allSources: 'All OpenCode sources';
+ filterSource: 'Filter {{source}}';
+ filterSources: 'Filter OpenCode sources';
+ freeOnly: 'Free only';
+ freeTooltip: 'OpenCode marks this model as free.';
+ loadingModels: 'Loading OpenCode models...';
+ noSourcesFound: 'No sources found.';
+ recommendedOnly: 'Recommended only';
+ searchSources: 'Search sources';
+ sourcesCount: '{{count}} OpenCode sources';
+ sourcesCount_few: '{{count}} OpenCode sources';
+ sourcesCount_many: '{{count}} OpenCode sources';
+ sourcesCount_one: '{{count}} OpenCode sources';
+ sourcesCount_other: '{{count}} OpenCode sources';
+ };
+ openCodeStatus: {
+ badges: {
+ check: 'Check';
+ free: 'Free';
+ install: 'Install';
+ setup: 'Setup';
+ };
+ freeModelsAvailableTitle: 'OpenCode free models are available';
+ loadingRuntime: 'OpenCode runtime status is still loading.';
+ messages: {
+ checking: 'The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.';
+ freeAvailable: 'OpenCode is detected. You can use free OpenCode models such as Big Pickle without connecting a provider. Connect a provider only when you want provider-backed models.';
+ launchBlocked: 'OpenCode is installed and authenticated, but Agent Teams launch readiness is blocked.';
+ noFreeListed: 'OpenCode is detected, but no free OpenCode model is listed yet. Refresh provider status, or connect a provider in OpenCode for provider-backed models.';
+ ready: 'OpenCode is ready for team launch.';
+ unsupported: 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.';
+ };
+ notReadyTitle: 'OpenCode is not ready for team launch';
+ providerNotConnectedTitle: 'OpenCode provider is not connected';
+ readyMessage: 'OpenCode passed provider readiness. Select it to use OpenCode models for this team.';
+ readyTitle: 'OpenCode is ready';
+ summary: {
+ checking: 'OpenCode status: checking runtime';
+ status: 'OpenCode status: {{parts}}';
+ };
+ summaryParts: {
+ freeWithoutAuth: 'free models available without auth';
+ providerConnected: 'provider connected';
+ providerModelsNeedSetup: 'provider-backed models need setup';
+ providerNotConnected: 'provider not connected';
+ providerOptional: 'provider connection optional';
+ runtimeDetected: 'runtime detected';
+ runtimeMissing: 'runtime missing';
+ teamLaunchBlocked: 'team launch blocked';
+ teamLaunchReady: 'team launch ready';
+ };
+ useOpenCode: 'Use OpenCode';
+ };
+ placeholders: {
+ customModelId: 'openai/gpt-oss-20b';
+ };
+ pricing: {
+ cacheReadTitle: 'Cache read: {{rate}} per 1M tokens';
+ cacheWriteTitle: 'Cache write: {{rate}} per 1M tokens';
+ free: 'Free';
+ inputShort: 'in {{rate}}';
+ inputTitle: 'Input: {{rate}} per 1M tokens';
+ outputShort: 'out {{rate}}';
+ outputTitle: 'Output: {{rate}} per 1M tokens';
+ perMillionSummary: '{{summary}} / 1M';
+ };
+ reason: 'Reason: {{reason}}';
+ routeGroups: {
+ builtinFree: 'Free built-in';
+ connectedProviders: 'Connected providers';
+ openCodeConfig: 'OpenCode config';
+ otherCatalog: 'Other OpenCode catalog';
+ };
+ runtimeModelsSyncing: 'Explicit models load from the current runtime. Default remains available while the list is syncing.';
+ searchModels: 'Search models';
+ unavailableInRuntime: 'Unavailable in current runtime';
+ };
+ openCodeContextConfigHint: {
+ and: 'and';
+ compactionConfig: 'Compaction config';
+ description: 'Add matching limits to the OpenCode config for the provider and model used by this teammate. This helps OpenCode compact and prune before local models overflow their context window.';
+ promptInstructionsSuffix: 'are weaker because the request is assembled before the model reads them.';
+ providerLimits: 'Provider limits';
+ replacePrefix: 'Replace';
+ replaceSuffix: 'with the provider and model IDs from your OpenCode setup. Prompt instructions like';
+ summary: 'OpenCode local models can use an OpenCode context budget instead of prompt-only limits.';
+ };
+ permissions: {
+ autoApproveAllTools: 'Auto-approve all tools';
+ autonomousModeDescription: 'Autonomous mode: team tools execute without confirmation. Be cautious with untrusted code.';
+ manualModeDescription: "Manual mode: you'll approve or deny each tool call in real time.";
+ };
+ processes: {
+ ago: '{{time}} ago';
+ kill: 'Kill';
+ open: 'Open';
+ openInBrowser: 'Open in browser';
+ pid: 'PID{{pid}}';
+ running: 'Running';
+ stopProcess: 'Stop process (SIGTERM)';
+ stopped: 'Stopped';
+ stoppedAgo: 'stopped {{time}} ago';
+ title: 'CLI Processes';
+ };
+ projectPath: {
+ browse: 'Browse';
+ createAutomatically: 'If the directory does not exist, it will be created automatically.';
+ customWorkingDirectory: 'Custom working directory';
+ deleted: {
+ label: 'Deleted';
+ title: 'Project folder no longer exists';
+ };
+ empty: 'Nothing found';
+ label: 'Project';
+ loadingProjects: 'Loading projects...';
+ mode: {
+ customPath: 'Custom path';
+ projectList: 'From project list';
+ };
+ noProjects: 'No projects found, switch to custom path.';
+ searchPlaceholder: 'Search project by name or path';
+ selectFromList: 'Select a project from the list';
+ selectProject: 'Select a project...';
+ source: {
+ claude: 'Found by Claude';
+ codex: 'Found by Codex';
+ mixed: 'Found by Claude and Codex';
+ };
+ };
+ provisioning: {
+ cancel: 'Cancel';
+ cliLogs: 'CLI logs';
+ copied: 'Copied';
+ copyDiagnostics: 'Copy diagnostics';
+ diagnostics: 'Diagnostics';
+ diagnosticsCopied: 'Diagnostics copied';
+ liveOutput: 'Live output';
+ moreWarningsHidden: '{{count}} more warnings hidden';
+ noOutput: 'No output captured yet.';
+ pid: 'PID {{pid}}';
+ presentation: {
+ awaitingPermission: '{{count}} teammate awaiting permission approval';
+ bootstrapStalled: 'Bootstrap stalled: {{names}}';
+ bootstrapStalledWithOpenCodeWait: '{{stalled}}; Waiting for OpenCode: {{names}}';
+ countPendingDiagnostic: '{{count}} {{label}}';
+ failed: {
+ memberFailedToStart: '{{name}} failed to start';
+ teammatesFailedRatio: '{{count}}/{{total}} teammates failed to start';
+ teammatesFailedToStart: '{{count}} teammates failed to start';
+ };
+ joining: {
+ teammatesConfirmedRatio: '{{count}}/{{total}} teammates confirmed';
+ teammatesStillJoining: '{{count}} teammates still joining';
+ };
+ nameListWithMore: '{{names}}, +{{count}} more';
+ namedPendingDiagnostic: '{{label}}: {{names}}';
+ panel: {
+ coreTeamReady: 'Core team ready';
+ finishingLaunch: 'Finishing launch';
+ launchContinuedSkipped: 'Launch continued with skipped teammates';
+ launchDetails: 'Launch details';
+ launchFailed: 'Launch failed';
+ launchFinishedWithErrors: 'Launch finished with errors';
+ launchingTeam: 'Launching team';
+ teamLaunched: 'Team launched';
+ };
+ pendingLabels: {
+ awaitingPermission: 'Awaiting permission';
+ awaitingPermissionLower: 'awaiting permission';
+ bootstrapStalled: 'Bootstrap stalled';
+ bootstrapUnconfirmed: 'Bootstrap unconfirmed';
+ bootstrapUnconfirmedLower: 'bootstrap unconfirmed';
+ shellOnly: 'Shell-only';
+ shellOnlyLower: 'shell-only';
+ waitingForBootstrap: 'Waiting for bootstrap';
+ waitingForBootstrapLower: 'waiting for bootstrap';
+ waitingForRuntime: 'Waiting for runtime';
+ waitingForRuntimeLower: 'waiting for runtime';
+ };
+ ready: {
+ allTeammatesJoined: 'All {{count}} teammates joined';
+ launchContinuedSkipped: 'Launch continued - {{count}}/{{total}} teammates skipped';
+ launchFinishedWithErrors: 'Launch finished with errors - {{count}}/{{total}} teammates failed to start';
+ leadOnline: 'Lead online';
+ teamLaunchedAllJoined: 'Team launched - all {{count}} teammates joined';
+ teamLaunchedLeadOnline: 'Team launched - lead online';
+ teamProvisionedAllJoined: 'Team provisioned - all {{count}} teammates joined';
+ teamProvisionedLeadOnline: 'Team provisioned - lead online';
+ teamProvisionedStillJoining: 'Team provisioned - teammates are still joining';
+ };
+ skipped: {
+ memberSkipped: '{{name}} skipped for this launch';
+ memberSkippedCompact: '{{name}} skipped';
+ memberSkippedWithReason: '{{name}} skipped for this launch - {{reason}}';
+ teammatesSkipped: '{{count}} teammates skipped';
+ teammatesSkippedList: 'Skipped teammates: {{list}}';
+ teammatesSkippedRatio: '{{count}}/{{total}} teammates skipped for this launch';
+ };
+ waitingForOpenCode: 'Waiting for OpenCode: {{names}}';
+ };
+ providerStatus: {
+ copied: 'Copied';
+ copyDiagnostics: 'Copy diagnostics';
+ deepVerificationPending: 'Deep verification is still running. OpenCode free models may take around 20 seconds.';
+ detailSummary: {
+ authenticationRequired: 'Authentication required';
+ cliBinaryCouldNotStart: 'CLI binary could not be started';
+ cliBinaryMissing: 'CLI binary missing';
+ cliPreflightFailed: 'CLI preflight failed';
+ cliPreflightIncomplete: 'CLI preflight did not complete';
+ needsAttention: 'Needs attention';
+ openCodeMcpUnreachable: 'OpenCode app MCP unreachable';
+ openCodeNoOutput: 'OpenCode runtime check returned no output';
+ openCodeRuntimeMissing: 'OpenCode runtime missing';
+ openCodeWindowsAccessBlocked: 'OpenCode Windows access blocked';
+ readyWithNotes: 'Ready with notes';
+ runtimeProviderNotConfigured: 'Runtime provider is not configured';
+ selectedModelAvailable: 'Selected model available';
+ selectedModelCheckFailed: 'Selected model check failed';
+ selectedModelCompatibilityPending: 'Selected model compatibility pending';
+ selectedModelCompatible: 'Selected model compatible';
+ selectedModelDeferred: 'Selected model verification deferred';
+ selectedModelPingNotConfirmed: 'Selected model ping not confirmed';
+ selectedModelTimedOut: 'Selected model verification timed out';
+ selectedModelUnavailable: 'Selected model unavailable';
+ selectedModelVerified: 'Selected model verified';
+ workingDirectoryMissing: 'Working directory missing';
+ };
+ failureHints: {
+ authenticationRequired: 'Authenticate the required provider in Claude CLI, then reopen this dialog.';
+ cliBinaryMissing: 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.';
+ default: 'Resolve the issue above, then reopen this dialog.';
+ openCodeAccessDenied: 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
+ openCodeAppMcpUnreachable: 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.';
+ openCodeBridgeNoOutput: 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.';
+ openCodeRuntimeMissing: 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.';
+ runtimeProviderNotConfigured: 'Configure the selected provider runtime, then reopen this dialog.';
+ workingDirectoryMissing: 'Choose an existing working directory, then reopen this dialog.';
+ };
+ modelChecksSummary: 'Selected model checks - {{details}}';
+ modelParts: {
+ available: '{{count}} available';
+ available_few: '{{count}} available';
+ available_many: '{{count}} available';
+ available_one: '{{count}} available';
+ available_other: '{{count}} available';
+ checkFailed: '{{count}} model check failed';
+ checkFailed_few: '{{count}} models check failed';
+ checkFailed_many: '{{count}} models check failed';
+ checkFailed_one: '{{count}} model check failed';
+ checkFailed_other: '{{count}} models check failed';
+ checking: '{{count}} checking';
+ checking_few: '{{count}} checking';
+ checking_many: '{{count}} checking';
+ checking_one: '{{count}} checking';
+ checking_other: '{{count}} checking';
+ compatibilityPending: '{{count}} compatible, deep verification pending';
+ compatibilityPending_few: '{{count}} compatible, deep verification pending';
+ compatibilityPending_many: '{{count}} compatible, deep verification pending';
+ compatibilityPending_one: '{{count}} compatible, deep verification pending';
+ compatibilityPending_other: '{{count}} compatible, deep verification pending';
+ compatible: '{{count}} compatible';
+ compatible_few: '{{count}} compatible';
+ compatible_many: '{{count}} compatible';
+ compatible_one: '{{count}} compatible';
+ compatible_other: '{{count}} compatible';
+ deferred: '{{count}} verification deferred';
+ deferred_few: '{{count}} verification deferred';
+ deferred_many: '{{count}} verification deferred';
+ deferred_one: '{{count}} verification deferred';
+ deferred_other: '{{count}} verification deferred';
+ pingNotConfirmed: '{{count}} ping not confirmed';
+ pingNotConfirmed_few: '{{count}} ping not confirmed';
+ pingNotConfirmed_many: '{{count}} ping not confirmed';
+ pingNotConfirmed_one: '{{count}} ping not confirmed';
+ pingNotConfirmed_other: '{{count}} ping not confirmed';
+ timedOut: '{{count}} model timed out';
+ timedOut_few: '{{count}} models timed out';
+ timedOut_many: '{{count}} models timed out';
+ timedOut_one: '{{count}} model timed out';
+ timedOut_other: '{{count}} models timed out';
+ unavailable: '{{count}} model unavailable';
+ unavailable_few: '{{count}} models unavailable';
+ unavailable_many: '{{count}} models unavailable';
+ unavailable_one: '{{count}} model unavailable';
+ unavailable_other: '{{count}} models unavailable';
+ verified: '{{count}} verified';
+ verified_few: '{{count}} verified';
+ verified_many: '{{count}} verified';
+ verified_one: '{{count}} verified';
+ verified_other: '{{count}} verified';
+ };
+ openProviderSettings: 'Open {{provider}} settings';
+ progress: {
+ checkingProvider: 'Checking {{provider}} provider...';
+ checkingProviders: 'Checking {{providers}} providers...';
+ checkingSelectedProviders: 'Checking selected providers in parallel...';
+ };
+ status: {
+ checking: 'checking...';
+ failed: 'ERR';
+ notes: 'OK (notes)';
+ pending: 'waiting';
+ ready: 'OK';
+ };
+ };
+ steps: {
+ assembling: 'Members joining';
+ configuring: 'Team setup';
+ finalizing: 'Finalizing';
+ starting: 'Starting';
+ };
+ };
+ review: {
+ conflict: {
+ cancel: 'Cancel';
+ description: "This file has been modified since the agent's changes";
+ editManually: 'Edit Manually';
+ keepCurrent: 'Keep Current';
+ saveResolution: 'Save Resolution';
+ title: 'Conflict Detected';
+ useOriginal: 'Use Original';
+ };
+ continuousScroll: {
+ empty: 'No reviewable file changes';
+ };
+ diffControls: {
+ acceptChange: 'Accept change (⌘Y)';
+ acceptShortcut: '⌘Y';
+ keep: 'Keep';
+ nextChunk: 'Next chunk';
+ previousChunk: 'Previous chunk';
+ rejectChange: 'Reject change (⌘N)';
+ rejectShortcut: '⌘N';
+ undo: 'Undo';
+ };
+ diffError: {
+ actions: {
+ retry: 'Retry';
+ };
+ raw: {
+ charsTotal: '... ({{count}} chars total)';
+ charsTotal_few: '... ({{count}} chars total)';
+ charsTotal_many: '... ({{count}} chars total)';
+ charsTotal_one: '... ({{count}} char total)';
+ charsTotal_other: '... ({{count}} chars total)';
+ file: 'File: {{file}}';
+ modified: '+++ Modified';
+ original: '--- Original';
+ show: 'Show raw diff data';
+ };
+ title: 'Failed to render diff view';
+ unexpected: 'An unexpected error occurred while rendering the diff.';
+ };
+ empty: {
+ noFileChangesRecorded: 'No file changes recorded';
+ noFileEvents: 'The task ledger has no file events for this task.';
+ noFileEventsYet: 'The task ledger has no file events for this task yet.';
+ noSafeDiff: 'No safe diff available';
+ noSafeDiffDescription: 'The task ledger did not expose a safe file diff for this task.';
+ noSafeDiffDiagnosticsDescription: 'The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.';
+ };
+ fileHeader: {
+ actions: {
+ accept: 'Accept';
+ discard: 'Discard';
+ discardTooltip: 'Discard all edits for this file';
+ keepMyDraft: 'Keep my draft';
+ reject: 'Reject';
+ reloadFromDisk: 'Reload from disk';
+ restore: 'Restore';
+ restoreTooltip: 'Create/restore this file on disk from the preview';
+ saveFile: 'Save File';
+ saveFileTooltip: 'Save file to disk';
+ };
+ badges: {
+ deleted: 'DELETED';
+ manualReview: 'MANUAL REVIEW';
+ new: 'NEW';
+ worktree: 'WORKTREE';
+ };
+ contentSource: {
+ 'disk-current': 'Current Disk';
+ 'file-history': 'File History';
+ 'git-fallback': 'Git Fallback';
+ 'ledger-exact': 'Task Ledger';
+ 'ledger-snapshot': 'Ledger Snapshot';
+ 'snippet-reconstruction': 'Reconstructed';
+ unavailable: 'Content unavailable';
+ };
+ contentUnavailable: {
+ badge: 'Content unavailable';
+ description: 'The ledger recorded metadata for this change, but full text content is not available. This usually means binary, large, or hash-only content.';
+ safety: 'Automatic accept/reject is disabled for this file to avoid unsafe disk writes.';
+ title: 'Text content is unavailable';
+ };
+ disabled: {
+ acceptRejectContentUnavailable: 'Accept/Reject is disabled because full text content is unavailable.';
+ acceptRejectMissingOnDisk: 'Accept/Reject is disabled while the file is missing on disk.';
+ rejectBaselineUnavailable: 'Reject is disabled because the original baseline is unavailable.';
+ rejectContentUnavailable: 'Reject is disabled because full text content is unavailable.';
+ rejectManualLedgerReview: 'Reject is disabled because this ledger change has binary, large, or unavailable content.';
+ };
+ externalChange: {
+ changedOnDisk: 'Changed on disk';
+ deletedOnDisk: 'Deleted on disk';
+ recreatedOnDisk: 'Recreated on disk';
+ };
+ missingOnDisk: {
+ badge: 'Missing on disk';
+ description: 'We can still show a preview from agent logs, but your filesystem is out of sync.';
+ restorePrefix: 'Use';
+ restoreSuffix: 'to write the preview content back to disk.';
+ restoreUnavailable: 'Full file content is not available to restore automatically.';
+ title: 'File is missing on disk';
+ };
+ pathChange: {
+ from: 'From {{path}}';
+ to: 'To {{path}}';
+ };
+ worktree: {
+ isolated: 'Isolated worktree';
+ };
+ };
+ fileMissingPrefix: 'File is missing on disk. This diff may be only a preview from agent logs. Use';
+ fileMissingSuffix: 'to create the file on disk.';
+ filePlaceholder: {
+ description: 'Preparing a full editor diff for this file.';
+ loading: 'Loading';
+ };
+ fileTree: {
+ badges: {
+ deleted: 'deleted';
+ new: 'new';
+ };
+ collapseFolder: 'Collapse {{name}}';
+ empty: {
+ noChangedFiles: 'No changed files';
+ noMatchingFiles: 'No matching files';
+ };
+ expandFolder: 'Expand {{name}}';
+ filters: {
+ clear: 'Clear';
+ new: 'New';
+ rejected: 'Rejected';
+ unresolved: 'Unresolved';
+ };
+ searchPlaceholder: 'Search files…';
+ viewed: 'Viewed';
+ };
+ fullDiffLoading: {
+ editorViewLoading: 'Editor view loading';
+ filesInProgress: '{{count}} files in progress';
+ filesInProgress_few: '{{count}} files in progress';
+ filesInProgress_many: '{{count}} files in progress';
+ filesInProgress_one: '{{count}} file in progress';
+ filesInProgress_other: '{{count}} files in progress';
+ filesReady: '{{ready}}/{{total}} files ready';
+ previewsReady: '{{count}} previews ready';
+ previewsReady_few: '{{count}} previews ready';
+ previewsReady_many: '{{count}} previews ready';
+ previewsReady_one: '{{count}} preview ready';
+ previewsReady_other: '{{count}} previews ready';
+ progressDescription: '{{ready}} ready, {{loading}} still loading. Preview diffs stay visible below while the remaining baselines are resolved.';
+ singleDescription: 'Preview diffs stay visible below while the exact baseline is resolved.';
+ subtitleCurrentFile: 'Finalizing the exact editor diff for the current file.';
+ subtitleForFile: 'Finalizing the exact editor diff for {{file}}.';
+ subtitleMany: 'Resolving exact before/after baselines for the files currently loading.';
+ titleMany: 'Preparing {{count}} Full Diffs';
+ titleOne: 'Preparing Full Diff';
+ };
+ loading: {
+ diff: 'DIFF';
+ ledgerObjectsProcessed: '{{count}} ledger objects processed';
+ ledgerObjectsProcessed_few: '{{count}} ledger objects processed';
+ ledgerObjectsProcessed_many: '{{count}} ledger objects processed';
+ ledgerObjectsProcessed_one: '{{count}} ledger object processed';
+ ledgerObjectsProcessed_other: '{{count}} ledger objects processed';
+ phases: {
+ checkingWorktree: 'Checking worktree context...';
+ preparingDiffs: 'Preparing review diffs...';
+ readingLedger: 'Reading task ledger...';
+ resolvingFiles: 'Resolving file states...';
+ };
+ };
+ progress: {
+ viewed: '{{viewed}}/{{total}} viewed';
+ };
+ restore: 'Restore';
+ scope: {
+ confidence: {
+ bestEffort: 'Best effort';
+ high: 'High confidence';
+ low: 'Low confidence';
+ medium: 'Medium confidence';
+ };
+ ledger: {
+ exact: {
+ badge: 'Ledger exact';
+ detail: 'The orchestrator captured these file changes while the agent was working on this task.';
+ title: 'Changes captured by task ledger';
+ };
+ limited: {
+ detail: 'The orchestrator captured these file changes for this task, but at least one change was captured from a snapshot or metadata-only source. Review exact text diffs where available; binary or unavailable content may require manual review.';
+ mixedBadge: 'Mixed reviewability';
+ needsReviewBadge: 'Needs review';
+ title: 'Changes captured with limited reviewability';
+ };
+ };
+ readMore: 'Read more';
+ tiers: {
+ allSession: {
+ detail: 'No task markers found in the session log. Cannot isolate this task - all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.';
+ title: 'Showing all session changes';
+ };
+ endEstimated: {
+ detail: 'Only the start marker was found - the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.';
+ title: 'End boundary estimated';
+ };
+ exact: {
+ detail: 'Both start and completion markers found in the session log. The diff includes only changes made during this specific task - other tasks that modified the same files are excluded.';
+ title: 'Task scope determined precisely';
+ };
+ startEstimated: {
+ detail: 'Only the completion marker was found - the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.';
+ title: 'Start boundary estimated';
+ };
+ };
+ workInterval: {
+ badge: 'Interval scoped';
+ detail: 'The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.';
+ title: 'Scoped by persisted work interval';
+ };
+ };
+ shortcuts: {
+ actions: {
+ acceptChange: 'Accept change';
+ closeDialog: 'Close dialog';
+ nextChange: 'Next change';
+ nextFile: 'Next file';
+ previousChange: 'Previous change';
+ previousFile: 'Previous file';
+ redo: 'Redo';
+ rejectChange: 'Reject change';
+ saveFile: 'Save file';
+ toggleShortcuts: 'Toggle shortcuts';
+ undo: 'Undo';
+ };
+ title: 'Keyboard Shortcuts';
+ };
+ timeline: {
+ empty: 'No edit events';
+ titleWithCount: 'Edit Timeline ({{count}})';
+ };
+ toolbar: {
+ actions: {
+ acceptAll: 'Accept All';
+ applyRejections: 'Apply Rejections';
+ applying: 'Applying...';
+ auto: 'Auto';
+ rejectAll: 'Reject All';
+ undo: 'Undo';
+ };
+ stats: {
+ accepted: '{{count}} accepted';
+ accepted_few: '{{count}} accepted';
+ accepted_many: '{{count}} accepted';
+ accepted_one: '{{count}} accepted';
+ accepted_other: '{{count}} accepted';
+ acrossFiles: 'across {{count}} files';
+ acrossFiles_few: 'across {{count}} files';
+ acrossFiles_many: 'across {{count}} files';
+ acrossFiles_one: 'across {{count}} file';
+ acrossFiles_other: 'across {{count}} files';
+ edited: '{{count}} edited';
+ edited_few: '{{count}} edited';
+ edited_many: '{{count}} edited';
+ edited_one: '{{count}} edited';
+ edited_other: '{{count}} edited';
+ pending: '{{count}} pending';
+ pending_few: '{{count}} pending';
+ pending_many: '{{count}} pending';
+ pending_one: '{{count}} pending';
+ pending_other: '{{count}} pending';
+ rejected: '{{count}} rejected';
+ rejected_few: '{{count}} rejected';
+ rejected_many: '{{count}} rejected';
+ rejected_one: '{{count}} rejected';
+ rejected_other: '{{count}} rejected';
+ };
+ tooltips: {
+ acceptAll: 'Accept all changes across all files';
+ applyRejections: 'Apply rejected hunks to disk; accepted changes are kept as-is';
+ autoOff: 'Auto-mark files as viewed when scrolled to end (OFF)';
+ autoOn: 'Auto-mark files as viewed when scrolled to end (ON)';
+ rejectAll: 'Reject all safely rejectable changes across all files';
+ rejectAllDisabled: 'No pending files have a safe original baseline to reject.';
+ undo: 'Undo last review operation (Ctrl+Z)';
+ };
+ };
+ };
+ reviewDialog: {
+ charsLeft: '{{count}} chars left';
+ placeholder: 'Describe what needs to change... (Enter to submit)';
+ saved: 'Saved';
+ submit: 'Submit';
+ title: 'Request Changes';
+ };
+ roleSelect: {
+ customRole: 'Custom role...';
+ empty: 'No roles found.';
+ noRole: 'No role';
+ reservedRole: 'This role is reserved';
+ searchPlaceholder: 'Search roles...';
+ };
+ runningTeams: {
+ title: 'Running Teams';
+ };
+ schedule: {
+ actions: {
+ addSchedule: 'Add Schedule';
+ delete: 'Delete';
+ edit: 'Edit';
+ pause: 'Pause';
+ resume: 'Resume';
+ runNow: 'Run now';
+ };
+ count: '{{count}} schedules';
+ count_few: '{{count}} schedules';
+ count_many: '{{count}} schedules';
+ count_one: '{{count}} schedule';
+ count_other: '{{count}} schedules';
+ cron: {
+ errors: {
+ enterExpression: 'Enter a cron expression';
+ invalidExpression: 'Invalid cron expression';
+ };
+ expression: 'Cron expression';
+ highFrequencyWarning: 'High frequency schedule (less than 5 min interval)';
+ nextRuns: 'Next runs:';
+ presets: {
+ dailyAtNine: 'Daily at 9am';
+ everyHour: 'Every hour';
+ everySixHours: 'Every 6 hours';
+ everyThirtyMinutes: 'Every 30 min';
+ mondayAtNine: 'Monday at 9am';
+ weekdaysAtNine: 'Weekdays at 9am';
+ };
+ selectTimezone: 'Select timezone';
+ timezone: 'Timezone';
+ warmUpDescription: 'Prepares selected providers before scheduled execution';
+ warmUpOptions: {
+ fifteenMinutes: '15 min';
+ fiveMinutes: '5 min';
+ none: 'No warm-up';
+ tenMinutes: '10 min';
+ thirtyMinutes: '30 min';
+ };
+ warmUpTime: 'Warm-up time';
+ };
+ empty: {
+ description: 'Create a schedule to run Claude tasks automatically on a cron schedule.';
+ title: 'No schedules yet';
+ };
+ nextRun: 'Next: {{next}}';
+ runHistory: {
+ empty: 'No runs yet';
+ loading: 'Loading run history...';
+ };
+ runLog: {
+ close: 'Close';
+ errors: 'Errors';
+ exitCode: 'exit {{code}}';
+ loadingLogs: 'Loading logs...';
+ retryCount: 'retry {{count}}/{{max}}';
+ stillRunning: 'Task is still running...';
+ title: 'Run Log';
+ };
+ runStatus: {
+ cancelled: 'Cancelled';
+ completed: 'Completed';
+ failed: 'Failed';
+ interrupted: 'Interrupted';
+ pending: 'Pending';
+ running: 'Running';
+ warm: 'Warm';
+ warmingUp: 'Warming up';
+ };
+ status: {
+ active: 'Active';
+ disabled: 'Disabled';
+ paused: 'Paused';
+ };
+ title: 'Schedules';
+ };
+ sendMessage: {
+ attachments: {
+ attachFiles: 'Attach files (paste or drag & drop)';
+ disabledHint: 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.';
+ openCodeOnlineRequired: 'Team must be online to attach files for OpenCode teammates';
+ recipientUnsupported: 'Files can be sent to the team lead or OpenCode teammates';
+ teamOnlineRequired: 'Team must be online to attach files';
+ unavailable: 'Attachments are unavailable';
+ };
+ charsLeft: '{{count}} chars left';
+ description: 'Send a direct message to a team member.';
+ messageLabel: 'Message';
+ placeholder: 'Write your message... (Enter to send)';
+ quote: {
+ remove: 'Remove quote';
+ replyingTo: 'Replying to';
+ };
+ recipientLabel: 'Recipient';
+ saved: 'Saved';
+ selectMemberPlaceholder: 'Select member...';
+ send: 'Send';
+ sending: 'Sending...';
+ title: 'Send Message';
+ };
+ sessions: {
+ empty: 'No sessions found';
+ filterBySession: 'Filter by this session';
+ lead: 'lead';
+ loading: 'Loading sessions...';
+ noProjectPath: 'No project path linked';
+ openSession: 'Open session';
+ projectNotFound: 'Project not found';
+ provisioningHint: 'Sessions will appear after team provisioning';
+ removeFilter: 'Remove filter';
+ showAllSessions: 'Show for all sessions';
+ title: 'Sessions';
+ };
+ taskActivity: {
+ contextUnavailable: 'Detailed transcript context is no longer available for this activity.';
+ description: 'Key explicit runtime activity linked to this task from transcript metadata.';
+ empty: 'No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.';
+ loading: 'Loading task activity...';
+ loadingDetails: 'Loading activity details...';
+ lowSignalOnly: 'No key task activity was found yet. Low-level execution details are available below in Task Log Stream.';
+ title: 'Task Activity';
+ };
+ taskAttachments: {
+ attachImage: 'Attach image';
+ dropFilesHere: 'Drop files here';
+ dropImageHere: 'Drop image here';
+ fromOriginalMessage: 'From original message';
+ loading: 'Loading attachments...';
+ pasteOrDragDrop: 'or paste / drag-drop';
+ };
+ taskComments: {
+ attachFile: 'Attach file (or paste)';
+ awaitingReplyFrom: 'Awaiting reply from';
+ cancelReply: 'Cancel reply';
+ charsLeft: '{{count}} chars left';
+ comment: 'Comment';
+ or: 'or';
+ placeholder: 'Add a comment... (Enter to send)';
+ replyingTo: 'Replying to';
+ saved: 'Saved';
+ voiceToText: 'Voice to text';
+ };
+ taskDetail: {
+ actions: {
+ cancel: 'Cancel';
+ delete: 'Delete';
+ markResolved: 'Mark resolved';
+ save: 'Save';
+ };
+ attachments: {
+ commentAttachment: 'Comment attachment';
+ fromComments: 'From comments';
+ preview: 'Preview {{filename}}';
+ };
+ changes: {
+ badges: {
+ attention: 'attention';
+ noSafeDiff: 'no safe diff';
+ };
+ empty: {
+ noFileChangesRecorded: 'No file changes recorded';
+ noFileChangesRecordedYet: 'No file changes recorded yet';
+ noReviewableChangesRecovered: 'No reviewable file changes recovered';
+ noSafeDiffAvailable: 'No safe diff available';
+ };
+ fileCount: '{{count}} files';
+ fileCount_few: '{{count}} files';
+ fileCount_many: '{{count}} files';
+ fileCount_one: '{{count}} files';
+ fileCount_other: '{{count}} files';
+ fileRowsHidden: '{{count}} file rows hidden';
+ fileRowsHidden_few: '{{count}} file rows hidden';
+ fileRowsHidden_many: '{{count}} file rows hidden';
+ fileRowsHidden_one: '{{count}} file rows hidden';
+ fileRowsHidden_other: '{{count}} file rows hidden';
+ loadFailed: 'Failed to load task changes summary';
+ loading: 'Loading changes...';
+ moreDiagnostics: '{{count}} more diagnostics';
+ moreDiagnostics_few: '{{count}} more diagnostics';
+ moreDiagnostics_many: '{{count}} more diagnostics';
+ moreDiagnostics_one: '{{count}} more diagnostics';
+ moreDiagnostics_other: '{{count}} more diagnostics';
+ moreFiles: '{{count}} more files';
+ moreFiles_few: '{{count}} more files';
+ moreFiles_many: '{{count}} more files';
+ moreFiles_one: '{{count}} more files';
+ moreFiles_other: '{{count}} more files';
+ openInEditor: 'Open in editor';
+ openTask: 'Open task {{subject}}';
+ refresh: 'Refresh changes';
+ refreshFailed: 'Refresh failed: {{error}}';
+ refreshShort: 'Refresh';
+ refreshTeamChanges: 'Refresh team changes';
+ refreshing: 'Refreshing';
+ refreshingChanges: 'Refreshing changes...';
+ reviewDiff: 'Review diff';
+ reviewTaskDiff: 'Review task diff';
+ scannedCandidateTasks: 'Scanned {{requested}} of {{eligible}} candidate tasks';
+ tasksDeferred: '{{count}} tasks deferred this pass';
+ tasksDeferred_few: '{{count}} tasks deferred this pass';
+ tasksDeferred_many: '{{count}} tasks deferred this pass';
+ tasksDeferred_one: '{{count}} tasks deferred this pass';
+ tasksDeferred_other: '{{count}} tasks deferred this pass';
+ title: 'Changes';
+ };
+ clarification: {
+ awaitingLead: 'Awaiting clarification from team lead';
+ awaitingUser: 'Awaiting clarification from you';
+ };
+ comments: {
+ actions: {
+ cancelReply: 'Cancel reply';
+ comment: 'Comment';
+ reply: 'Reply';
+ replyToComment: 'Reply to comment';
+ showMore: 'Show more comments ({{visible}}/{{total}})';
+ };
+ attachments: {
+ downloadFailed: 'Download failed';
+ previewAlt: 'Attachment preview';
+ };
+ badges: {
+ approved: 'Approved';
+ reviewRequested: 'Review requested';
+ };
+ input: {
+ charsLeft: '{{count}} chars left';
+ charsLeft_few: '{{count}} chars left';
+ charsLeft_many: '{{count}} chars left';
+ charsLeft_one: '{{count}} char left';
+ charsLeft_other: '{{count}} chars left';
+ placeholder: 'Add a comment... (Enter to send)';
+ };
+ renderLimit: 'Showing the most recent {{formattedCount}} comments to keep the UI responsive.';
+ replyingTo: 'Replying to';
+ unknownTime: 'unknown time';
+ };
+ description: {
+ add: 'Click to add description...';
+ edit: 'Edit description';
+ placeholder: 'Task description (supports markdown)';
+ };
+ loading: {
+ fetchingTeamData: 'Fetching team data';
+ title: 'Loading task...';
+ };
+ logs: {
+ newArriving: 'New task logs arriving';
+ };
+ notFound: 'Task not found';
+ related: {
+ blockedBy: 'Blocked by';
+ blocks: 'Blocks';
+ linkedFrom: 'Linked from';
+ links: 'Links';
+ title: 'Related tasks';
+ };
+ review: {
+ reviewer: 'Reviewer: {{reviewer}}';
+ };
+ reviewStates: {
+ approved: 'Approved';
+ inReview: 'In review';
+ needsFix: 'Needs fix';
+ };
+ sections: {
+ attachments: 'Attachments';
+ changes: 'Changes';
+ comments: 'Comments';
+ description: 'Description';
+ taskLogs: 'Task Logs';
+ workflowHistory: 'Workflow History';
+ };
+ unassigned: 'Unassigned';
+ workflow: {
+ implementationTimeTitle: 'Implementation time from persisted work intervals';
+ inProgressTime: 'In progress time {{duration}}';
+ };
+ workflowTimeline: {
+ approved: 'Approved';
+ assignedTo: 'Assigned to';
+ by: 'by';
+ changesRequested: 'Changes requested';
+ createdAs: 'Created as';
+ currentImplementationInterval: 'Current implementation interval';
+ empty: 'No workflow history recorded';
+ implementationIntervalEnded: 'Implementation interval ended at this transition';
+ ownerChanged: 'Owner changed';
+ reassigned: 'Reassigned';
+ reviewRequested: 'Review requested';
+ reviewStarted: 'Review started';
+ runningPrefix: 'running ';
+ unassignedFrom: 'Unassigned from';
+ unknownEvent: 'Unknown event';
+ };
+ };
+ taskLogs: {
+ exact: {
+ description: 'Exact transcript slices rendered with the same execution-log components used in Logs.';
+ emptyDescription: 'Exact transcript bundles will appear here when explicit task-linked transcript metadata is available.';
+ emptyTitle: 'No exact task logs yet';
+ loading: 'Loading exact task logs...';
+ summaryOnly: 'summary only';
+ title: 'Exact Task Logs';
+ };
+ executionSessions: {
+ description: 'Legacy session-centric transcript browsing and previews.';
+ online: 'Online';
+ title: 'Execution Sessions';
+ updating: 'Updating...';
+ };
+ stream: {
+ title: 'Task Log Stream';
+ };
+ };
+ tasks: {
+ createTask: {
+ assignee: 'Assignee';
+ assigneeOptional: 'Assignee (optional)';
+ blockedByOptional: 'Blocked by tasks (optional)';
+ blockedBySummary: 'Task will be blocked by: {{tasks}}';
+ cancel: 'Cancel';
+ create: 'Create';
+ creating: 'Creating...';
+ description: "The task will be created in the team's tasks/ directory and appear on the Kanban board.";
+ descriptionOptional: 'Description (optional)';
+ detailsPlaceholder: 'Task details (supports markdown)';
+ hideOptionalFields: 'Hide optional fields';
+ offlineNotice: {
+ after: '- launch the team to start execution.';
+ before: 'Team is offline. The task will be added to';
+ };
+ promptOptional: 'Prompt for assignee (optional)';
+ promptPlaceholder: 'Custom instructions for the team member...';
+ relatedOptional: 'Related tasks (optional)';
+ relatedSummary: 'Related: {{tasks}}';
+ saved: 'Saved';
+ searchTasks: 'Search tasks...';
+ selectMember: 'Select a member';
+ selectMemberOptional: 'Select member...';
+ showOptionalFields: 'Show optional fields';
+ startImmediately: 'Start immediately';
+ startOfflineHint: 'Team is offline. Launch the team first to start tasks immediately.';
+ subject: 'Subject';
+ subjectPlaceholder: 'What needs to be done?';
+ title: 'Create Task';
+ todo: 'TODO';
+ };
+ deleteConfirm: {
+ cancelLabel: 'Cancel';
+ confirmLabel: 'Delete';
+ message: 'Move task #{{taskId}} to trash?';
+ title: 'Delete task';
+ };
+ list: {
+ columns: {
+ blockedBy: 'Blocked By';
+ blocks: 'Blocks';
+ id: 'ID';
+ owner: 'Owner';
+ status: 'Status';
+ subject: 'Subject';
+ };
+ empty: 'No tasks in this team';
+ filters: {
+ allOwners: 'All owners';
+ allStatuses: 'All statuses';
+ ownerAria: 'Filter tasks by owner';
+ statusAria: 'Filter tasks by status';
+ };
+ showing: 'Showing {{shown}} of {{total}}';
+ };
+ openTask: 'Open task';
+ status: {
+ completed: 'completed';
+ deleted: 'deleted';
+ inProgress: 'in_progress';
+ pending: 'pending';
+ };
+ statusSummary: {
+ completed: '{{count}} completed';
+ completed_few: '{{count}} completed';
+ completed_many: '{{count}} completed';
+ completed_one: '{{count}} completed';
+ completed_other: '{{count}} completed';
+ inProgress: '{{count}} in_progress';
+ inProgress_few: '{{count}} in_progress';
+ inProgress_many: '{{count}} in_progress';
+ inProgress_one: '{{count}} in_progress';
+ inProgress_other: '{{count}} in_progress';
+ pending: '{{count}} pending';
+ pending_few: '{{count}} pending';
+ pending_many: '{{count}} pending';
+ pending_one: '{{count}} pending';
+ pending_other: '{{count}} pending';
+ progressAria: 'Tasks {{completed}}/{{total}} completed';
+ };
+ teamPrefix: 'Team:';
+ unassigned: 'Unassigned';
+ };
+ toolApproval: {
+ after: 'after';
+ allow: 'Allow';
+ allowAll: 'Allow all';
+ autoActionIn: 'Auto-{{action}} in {{time}}';
+ autoAllowAllTools: 'Auto-allow all tools';
+ autoAllowFileEdits: 'Auto-allow file edits (Edit, Write, NotebookEdit)';
+ autoAllowSafeCommands: 'Auto-allow safe commands (git, pnpm, npm, ls...)';
+ deny: 'Deny';
+ diff: {
+ binaryFile: 'Binary file - cannot preview';
+ newFile: 'New file';
+ previewChanges: 'Preview changes';
+ readingFile: 'Reading file...';
+ truncated: 'File truncated at 2MB - diff may be incomplete';
+ };
+ onTimeout: 'On timeout:';
+ pendingCount: '{{count}} pending';
+ secondsShort: 'sec';
+ settings: 'Settings';
+ submit: 'Submit';
+ timeoutActions: {
+ allow: 'Allow';
+ deny: 'Deny';
+ wait: 'Wait forever';
+ };
+ };
+ worktreeGitReadiness: {
+ checking: 'Checking Git repository status for teammate worktrees...';
+ createInitialCommit: 'Create initial commit';
+ initialCommitMessage: 'chore: initial commit';
+ initialCommitNotice: 'The initial commit action stages and commits all current files with message';
+ initializeRepository: 'Initialize Git repository';
+ needsSetup: 'Worktree isolation needs Git setup';
+ ready: 'Git worktrees are ready.';
+ readyOnBranch: 'Git worktrees are ready on branch {{branch}}.';
+ };
+ };
+}
diff --git a/src/features/localization/renderer/ui/AppLanguageSelect.tsx b/src/features/localization/renderer/ui/AppLanguageSelect.tsx
new file mode 100644
index 00000000..e8e28a43
--- /dev/null
+++ b/src/features/localization/renderer/ui/AppLanguageSelect.tsx
@@ -0,0 +1,64 @@
+import { useCallback, useMemo } from 'react';
+
+import { Combobox } from '@renderer/components/ui/combobox';
+import { Check } from 'lucide-react';
+
+import { APP_LOCALE_PREFERENCES } from '../../contracts';
+import { resolveAppLocale } from '../../core/domain/localePolicy';
+import { getBrowserSystemLocale } from '../adapters/browserSystemLocaleAdapter';
+import { useAppTranslation } from '../hooks/useAppTranslation';
+
+import type { AppLocalePreference } from '../../contracts';
+
+interface AppLanguageSelectProps {
+ readonly value: AppLocalePreference;
+ readonly disabled?: boolean;
+ readonly onValueChange: (value: AppLocalePreference) => void;
+}
+
+export const AppLanguageSelect = ({
+ value,
+ disabled = false,
+ onValueChange,
+}: AppLanguageSelectProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+ const systemLocale = getBrowserSystemLocale();
+ const resolvedSystemLocale = resolveAppLocale({ preference: 'system', systemLocale });
+ const options = useMemo(
+ () =>
+ APP_LOCALE_PREFERENCES.map((preference) => ({
+ label:
+ preference === 'system'
+ ? t('locales.systemWithResolved', {
+ locale: t(`locales.names.${resolvedSystemLocale}`),
+ })
+ : t(`locales.names.${preference}`),
+ value: preference,
+ })),
+ [resolvedSystemLocale, t]
+ );
+
+ const renderOption = useCallback(
+ (option: { value: string; label: string }, isSelected: boolean) => (
+ <>
+
+ {option.label}
+ >
+ ),
+ []
+ );
+
+ return (
+ onValueChange(nextValue as AppLocalePreference)}
+ placeholder={t('locales.selectPlaceholder')}
+ searchPlaceholder={t('locales.searchPlaceholder')}
+ emptyMessage={t('locales.emptyMessage')}
+ disabled={disabled}
+ className="min-w-[180px]"
+ renderOption={renderOption}
+ />
+ );
+};
diff --git a/src/features/localization/renderer/ui/LocalizationProvider.tsx b/src/features/localization/renderer/ui/LocalizationProvider.tsx
new file mode 100644
index 00000000..54146451
--- /dev/null
+++ b/src/features/localization/renderer/ui/LocalizationProvider.tsx
@@ -0,0 +1,40 @@
+import { useEffect, useMemo } from 'react';
+import { I18nextProvider } from 'react-i18next';
+
+import { resolveRuntimeLocale } from '../../core/application/resolveRuntimeLocale';
+import { normalizeAppLocalePreference } from '../../core/domain/localePolicy';
+import { getBrowserSystemLocale } from '../adapters/browserSystemLocaleAdapter';
+import { appI18n } from '../composition/createI18nextInstance';
+
+import type { AppConfig } from '@shared/types';
+
+interface LocalizationProviderProps {
+ readonly appConfig: AppConfig | null;
+ readonly children: React.ReactNode;
+}
+
+export const LocalizationProvider = ({
+ appConfig,
+ children,
+}: LocalizationProviderProps): React.JSX.Element => {
+ const resolvedLocale = useMemo(
+ () =>
+ resolveRuntimeLocale({
+ preference: normalizeAppLocalePreference(appConfig?.general.appLocale),
+ systemLocale: getBrowserSystemLocale(),
+ }),
+ [appConfig?.general.appLocale]
+ );
+
+ useEffect(() => {
+ if (appI18n.language !== resolvedLocale) {
+ void appI18n.changeLanguage(resolvedLocale);
+ }
+ }, [resolvedLocale]);
+
+ useEffect(() => {
+ document.documentElement.lang = resolvedLocale;
+ }, [resolvedLocale]);
+
+ return {children} ;
+};
diff --git a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx
index 6f973e58..b38401a6 100644
--- a/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx
+++ b/src/features/member-log-stream/renderer/adapters/MemberLogStreamSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
@@ -43,6 +44,7 @@ export function MemberLogStreamSection({
enabled = true,
onInitialLoadErrorChange,
}: Readonly): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const [selectedLogView, setSelectedLogView] = useState<'execution' | 'process'>('execution');
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
const { stream, loading, error } = useMemberLogStream({ teamName, member, enabled });
@@ -79,7 +81,7 @@ export function MemberLogStreamSection({
}`}
onClick={() => setSelectedLogView('execution')}
>
- Execution
+ {t('memberLogStream.tabs.execution')}
setSelectedLogView('process')}
>
- Process
+ {t('memberLogStream.tabs.process')}
{selectedLogView === 'execution' ? (
({
buildSegmentRenderKey,
getSegmentMetaLabel,
}: Readonly>): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const [selectedParticipantKey, setSelectedParticipantKey] = useState('all');
const appliedSelectionResetKeyRef = useRef(null);
const participants = stream?.participants ?? [];
@@ -329,7 +331,7 @@ export function ExecutionLogStreamView({
}`}
onClick={() => setSelectedParticipantKey('all')}
>
- All
+ {t('memberLogStream.filters.all')}
{participants.map((participant) => (
): React.JSX.Element {
+ const { t } = useAppTranslation('team');
+ const { t: tCommon } = useAppTranslation('common');
const [kind, setKind] = useState('stdout');
const [log, setLog] = useState(null);
const [loading, setLoading] = useState(false);
@@ -222,7 +225,7 @@ export function MemberRuntimeProcessLogsPanel({
checked={autoRefresh}
onChange={(event) => setAutoRefresh(event.target.checked)}
/>
- Auto-refresh
+ {t('members.runtimeLogs.autoRefresh')}
setWrapLines(event.target.checked)}
/>
- Wrap lines
+ {t('members.runtimeLogs.wrapLines')}
{loading ? : }
- Refresh
+ {tCommon('actions.refresh')}
- Loading process log tail...
+ {t('members.runtimeLogs.loadingTail')}
) : hasContent ? (
) : (
- {statusText ?? 'No process log file captured for this member yet.'}
+ {statusText ?? t('members.runtimeLogs.empty')}
)}
diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx
index 5f21e65f..829227ba 100644
--- a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx
+++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
import { toMemberWorkSyncStatusViewModel } from '../adapters/memberWorkSyncStatusViewModel';
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
@@ -22,6 +24,7 @@ export function MemberWorkSyncDetails({
status,
showDiagnostics = false,
}: MemberWorkSyncDetailsProps): React.ReactElement {
+ const { t } = useAppTranslation('team');
const viewModel = toMemberWorkSyncStatusViewModel(status);
const agendaItems = status?.agenda.items ?? [];
@@ -29,7 +32,9 @@ export function MemberWorkSyncDetails({
-
Member work sync
+
+ {t('memberWorkSync.details.title')}
+
{viewModel.tooltip}
@@ -37,25 +42,33 @@ export function MemberWorkSyncDetails({
-
Actionable items
+
+ {t('memberWorkSync.details.actionableItems')}
+
{viewModel.actionableCount}
-
Fingerprint
+
+ {t('memberWorkSync.details.fingerprint')}
+
{shortFingerprint(viewModel.fingerprint)}
-
Report
+ {t('memberWorkSync.details.report')}
- {viewModel.reportState ?? 'none'}
+ {viewModel.reportState ?? t('memberWorkSync.details.none')}
-
Shadow would nudge
+
+ {t('memberWorkSync.details.shadowWouldNudge')}
+
- {viewModel.wouldNudge ? 'yes' : 'no'}
+ {viewModel.wouldNudge
+ ? t('memberWorkSync.details.yes')
+ : t('memberWorkSync.details.no')}
@@ -69,7 +82,7 @@ export function MemberWorkSyncDetails({
))}
{agendaItems.length > 3 ? (
- {agendaItems.length - 3} more actionable item(s)
+ {t('memberWorkSync.details.moreActionableItems', { count: agendaItems.length - 3 })}
) : null}
@@ -77,7 +90,7 @@ export function MemberWorkSyncDetails({
{showDiagnostics && status?.diagnostics.length ? (
- Diagnostics: {status.diagnostics.join(', ')}
+ {t('memberWorkSync.details.diagnostics', { diagnostics: status.diagnostics.join(', ') })}
) : null}
diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx
index 919fcfa3..b9f697c6 100644
--- a/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx
+++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncStatusPanel.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
import { useMemberWorkSyncStatus } from '../hooks/useMemberWorkSyncStatus';
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
@@ -18,6 +20,7 @@ export function MemberWorkSyncStatusPanel({
enabled = true,
showDiagnostics = false,
}: MemberWorkSyncStatusPanelProps): React.ReactElement | null {
+ const { t } = useAppTranslation('team');
const { status, viewModel, loading, error } = useMemberWorkSyncStatus({
teamName,
memberName,
@@ -36,12 +39,14 @@ export function MemberWorkSyncStatusPanel({
-
Member work sync
+
+ {t('memberWorkSync.title')}
+
{loading
- ? 'Loading member work sync diagnostics.'
+ ? t('memberWorkSync.loadingDiagnostics')
: error
- ? 'Member work sync diagnostics are unavailable.'
+ ? t('memberWorkSync.diagnosticsUnavailable')
: viewModel.tooltip}
diff --git a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
index cdc03227..e754ea49 100644
--- a/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
+++ b/src/features/recent-projects/renderer/ui/RecentProjectCard.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -20,6 +21,8 @@ export const RecentProjectCard = ({
onClick,
onOpenPath,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+ const { t: tCommon } = useAppTranslation('common');
const color = useMemo(() => projectColor(card.name), [card.name]);
const isDeleted = card.filesystemState === 'deleted';
const FolderIcon = isDeleted ? FolderX : FolderGit2;
@@ -53,10 +56,12 @@ export const RecentProjectCard = ({
- Deleted
+ {t('recentProjects.card.deleted')}
- Project folder no longer exists
+
+ {t('recentProjects.card.projectFolderMissing')}
+
)}
{card.pathSummary && (
@@ -134,7 +139,7 @@ export const RecentProjectCard = ({
- {isDeleted ? 'Project folder no longer exists' : 'Open'}
+ {isDeleted ? t('recentProjects.card.projectFolderMissing') : tCommon('actions.open')}
@@ -164,17 +169,23 @@ export const RecentProjectCard = ({
<>
{card.taskCounts.inProgress > 0 && (
- {card.taskCounts.inProgress} active
+ {t('recentProjects.card.taskCounts.active', {
+ count: card.taskCounts.inProgress,
+ })}
)}
{card.taskCounts.pending > 0 && (
- {card.taskCounts.pending} pending
+ {t('recentProjects.card.taskCounts.pending', {
+ count: card.taskCounts.pending,
+ })}
)}
{card.taskCounts.completed > 0 && (
- {card.taskCounts.completed} done
+ {t('recentProjects.card.taskCounts.done', {
+ count: card.taskCounts.completed,
+ })}
)}
·
diff --git a/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx
index b8e27d50..7131a840 100644
--- a/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx
+++ b/src/features/recent-projects/renderer/ui/RecentProjectsSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { FolderGit2, FolderOpen, Search } from 'lucide-react';
@@ -17,17 +18,18 @@ function SelectProjectFolderCard({
}: Readonly<{
onClick: () => void;
}>): React.JSX.Element {
+ const { t } = useAppTranslation('dashboard');
return (
- Select Folder
+ {t('recentProjects.selectFolder')}
);
@@ -36,6 +38,7 @@ function SelectProjectFolderCard({
export const RecentProjectsSection = ({
searchQuery,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const {
cards,
loading,
@@ -102,14 +105,14 @@ export const RecentProjectsSection = ({
-
Failed to load projects
+
{t('recentProjects.failedToLoad')}
{error}
void reload()}
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
>
- Retry
+ {t('recentProjects.retry')}
);
@@ -121,8 +124,10 @@ export const RecentProjectsSection = ({
-
No projects found
-
No matches for "{searchQuery}"
+
{t('recentProjects.noProjects')}
+
+ {t('recentProjects.noMatches', { query: searchQuery })}
+
);
}
@@ -133,10 +138,8 @@ export const RecentProjectsSection = ({
-
No recent projects found
-
- Recent Claude and Codex activity will appear here.
-
+
{t('recentProjects.noRecentProjects')}
+
{t('recentProjects.emptyDescription')}
);
}
@@ -162,7 +165,7 @@ export const RecentProjectsSection = ({
{canLoadMore && (
- Load more
+ {t('recentProjects.loadMore')}
)}
diff --git a/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx b/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx
index cecf9581..e78c04e4 100644
--- a/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx
+++ b/src/features/running-teams/renderer/ui/RunningTeamsSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { TeamTaskStatusSummary } from '@renderer/components/team/TeamTaskStatusSummary';
import { ActivePulseIndicator } from '@renderer/components/ui/ActivePulseIndicator';
import { FolderOpen, UsersRound } from 'lucide-react';
@@ -18,6 +19,7 @@ function getRowTitle(row: RunningTeamRowModel): string {
export function RunningTeamsSection({
searchQuery,
}: Readonly
): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const { rows, hidden, openRunningTeam } = useRunningTeamsSection(searchQuery);
if (hidden) {
@@ -28,7 +30,7 @@ export function RunningTeamsSection({
- Running Teams
+ {t('runningTeams.title')}
{rows.length}
diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx
index abdab89c..7c475646 100644
--- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx
+++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -95,6 +96,7 @@ interface RuntimeProviderErrorAlertProps {
}
type OpenCodeSettingsSection = 'models' | 'providers';
+type SettingsT = ReturnType['t'];
const NO_PROJECT_CONTEXT_VALUE = '__runtime-provider-no-project-context__';
@@ -149,31 +151,36 @@ function getProjectContextName(projectPath: string | null | undefined): string |
return name || normalized;
}
-function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto): string {
+function getDefaultScopeDescription(scope: RuntimeProviderDefaultScopeDto, t: SettingsT): string {
return scope === 'all_projects'
- ? 'Default for every project that does not have its own OpenCode override.'
- : 'Override only the selected project. Running teams are not changed.';
+ ? t('runtimeProvider.defaults.scopeDescriptionAllProjects')
+ : t('runtimeProvider.defaults.scopeDescriptionProject');
}
-function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto): string {
- return scope === 'all_projects' ? 'Set all-projects default' : 'Set project default';
+function getDefaultScopeButtonLabel(scope: RuntimeProviderDefaultScopeDto, t: SettingsT): string {
+ return scope === 'all_projects'
+ ? t('runtimeProvider.defaults.setAllProjectsDefault')
+ : t('runtimeProvider.defaults.setProjectDefault');
}
-function getContextControlLabel(scope: RuntimeProviderDefaultScopeDto): string {
- return scope === 'all_projects' ? 'Validation context' : 'Project override context';
+function getContextControlLabel(scope: RuntimeProviderDefaultScopeDto, t: SettingsT): string {
+ return scope === 'all_projects'
+ ? t('runtimeProvider.defaults.validationContext')
+ : t('runtimeProvider.defaults.projectOverrideContext');
}
function getContextControlHint(
scope: RuntimeProviderDefaultScopeDto,
- projectPath: string | null | undefined
+ projectPath: string | null | undefined,
+ t: SettingsT
): string {
const projectName = getProjectContextName(projectPath) ?? projectPath?.trim();
if (!projectName) {
- return 'Select a project before testing local models or saving defaults.';
+ return t('runtimeProvider.defaults.selectProjectHint');
}
return scope === 'all_projects'
- ? `Tests use ${projectName}. Default applies unless a project has an override.`
- : `Saving overrides only ${projectName}.`;
+ ? t('runtimeProvider.defaults.allProjectsHint', { project: projectName })
+ : t('runtimeProvider.defaults.projectHint', { project: projectName });
}
function getDefaultModelSourceLabel(
@@ -345,6 +352,7 @@ function ProviderSetupFormPanel({
readonly disabled: boolean;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const form = state.setupForm?.providerId === provider.providerId ? state.setupForm : null;
const loading = state.setupFormLoading && state.activeFormProviderId === provider.providerId;
const error = state.setupFormError;
@@ -364,7 +372,7 @@ function ProviderSetupFormPanel({
{loading ? (
- Loading provider setup...
+ {t('runtimeProvider.setup.loading')}
) : null}
@@ -477,7 +485,7 @@ function ProviderSetupFormPanel({
disabled={busy}
onClick={actions.cancelConnect}
>
- Cancel
+ {t('runtimeProvider.actions.cancel')}
& {
onRefresh: () => void;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const runtime = state.view?.runtime;
const loadingWithoutRuntime = state.loading && !runtime;
const defaultSourceLabel = getDefaultModelSourceLabel(state.view?.defaultModelSource);
@@ -515,7 +524,7 @@ function RuntimeSummary({
- OpenCode runtime
+ {t('runtimeProvider.summary.title')}
- OpenCode default: {state.view.defaultModel}
+ {t('runtimeProvider.summary.defaultModel', { model: state.view.defaultModel })}
) : null}
{defaultSourceLabel ? (
- Source: {defaultSourceLabel}
+
+ {t('runtimeProvider.summary.source', { source: defaultSourceLabel })}
+
) : null}
{state.loading ? (
@@ -546,9 +557,7 @@ function RuntimeSummary({
style={{ color: 'var(--color-text-secondary)' }}
>
-
- Loading managed OpenCode runtime, connected providers, and model defaults...
-
+
{t('runtimeProvider.summary.loading')}
) : null}
{state.view?.diagnostics.length ? (
@@ -582,6 +591,7 @@ function RuntimeSummary({
}
function RuntimeProviderLoadingPlaceholder(): JSX.Element {
+ const { t } = useAppTranslation('settings');
return (
- Loading OpenCode providers
+ {t('runtimeProvider.providers.loading')}
{
+ const { t } = useAppTranslation('settings');
const [copied, setCopied] = useState(false);
const [headline = message, ...detailLines] = message.trim().split(/\r?\n/);
const fallbackDetails = detailLines.join('\n').trim();
@@ -824,22 +835,34 @@ const RuntimeProviderErrorAlert = ({
'h-6 shrink-0 px-2 text-[11px]',
!copied && 'member-launch-diagnostics-pulse'
)}
- title={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
- aria-label={copied ? 'Diagnostics copied' : 'Copy diagnostics'}
+ title={
+ copied
+ ? t('runtimeProvider.diagnostics.copied')
+ : t('runtimeProvider.diagnostics.copy')
+ }
+ aria-label={
+ copied
+ ? t('runtimeProvider.diagnostics.copied')
+ : t('runtimeProvider.diagnostics.copy')
+ }
onClick={(event) => {
event.stopPropagation();
void copyDiagnostics();
}}
>
{copied ? : }
- {copied ? 'Copied' : 'Copy diagnostics'}
+ {copied
+ ? t('runtimeProvider.diagnostics.copiedShort')
+ : t('runtimeProvider.diagnostics.copy')}
{diagnostics ? (
{diagnostics.likelyCause ? (
- Likely cause:
+
+ {t('runtimeProvider.diagnostics.likelyCause')}{' '}
+
{diagnostics.likelyCause}
) : null}
@@ -855,7 +878,9 @@ const RuntimeProviderErrorAlert = ({
) : null}
{hints.length > 0 ? (
-
Hints
+
+ {t('runtimeProvider.diagnostics.hints')}
+
{hints.map((hint, index) => (
{provider.displayName}
- {provider.recommended ? Recommended : null}
+ {provider.recommended ? (
+ {t('runtimeProvider.providers.recommended')}
+ ) : null}
{provider.defaultModelId ? (
- OpenCode default: {provider.defaultModelId}
+ {t('runtimeProvider.summary.defaultModel', { model: provider.defaultModelId })}
) : null}
{provider.ownership.map((owner) => (
@@ -1171,6 +1199,7 @@ function DirectoryProviderRow({
readonly hasProjectContext: boolean;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const connect = getDirectoryAction(provider, 'connect');
const configure = getDirectoryAction(provider, 'configure');
const forget = getDirectoryAction(provider, 'forget');
@@ -1227,7 +1256,9 @@ function DirectoryProviderRow({
{provider.displayName}
- {provider.recommended ? Recommended : null}
+ {provider.recommended ? (
+ {t('runtimeProvider.providers.recommended')}
+ ) : null}
@@ -1338,6 +1369,7 @@ function ModelBadges({
readonly model: RuntimeProviderModelDto;
readonly usedForNewTeams: boolean;
}): JSX.Element | null {
+ const { t } = useAppTranslation('settings');
const modelRecommendation = getOpenCodeTeamModelRecommendation(model.modelId);
const localRoute = model.routeKind === 'configured_local';
const connectedRoute = model.routeKind === 'connected_provider';
@@ -1403,39 +1435,53 @@ function ModelBadges({
{usedForNewTeams ? (
- Used in team picker
+ {t('runtimeProvider.badges.usedInTeamPicker')}
) : null}
{freeModel ? (
- free
+
+ {t('runtimeProvider.badges.free')}
+
) : null}
{localRoute ? (
<>
- local
- configured
+
+ {t('runtimeProvider.badges.local')}
+
+
+ {t('runtimeProvider.badges.configured')}
+
>
) : null}
{connectedRoute ? (
- connected
+ {t('runtimeProvider.badges.connected')}
) : null}
{verified ? (
- verified
+ {t('runtimeProvider.badges.verified')}
) : null}
{needsTest && !verified ? (
- needs test
+
+ {t('runtimeProvider.badges.needsTest')}
+
) : null}
{failed ? (
- failed
+
+ {t('runtimeProvider.badges.failed')}
+
) : null}
{unknown ? (
- unknown
+
+ {t('runtimeProvider.badges.unknown')}
+
) : null}
{model.default ? (
- default
+
+ {t('runtimeProvider.badges.default')}
+
) : null}
);
@@ -1546,6 +1592,7 @@ function ModelRow({
readonly result: RuntimeProviderModelTestResultDto | undefined;
readonly actions: RuntimeProviderManagementActions;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const chooseModel = (): void => {
if (!disabled) {
actions.useModelForNewTeams(model.modelId);
@@ -1607,7 +1654,7 @@ function ModelRow({
className="h-8 min-w-20 justify-center"
disabled={disabled || !hasProjectContext || testing}
title={
- hasProjectContext ? undefined : 'Select a project context before testing models.'
+ hasProjectContext ? undefined : t('runtimeProvider.models.selectProjectBeforeTesting')
}
onClick={(event) => {
event.stopPropagation();
@@ -1620,7 +1667,7 @@ function ModelRow({
) : (
)}
- Test
+ {t('runtimeProvider.actions.test')}
@@ -1646,6 +1693,7 @@ function OpenCodeModelScopeControls({
readonly error: string | null;
readonly onProjectContextChange?: (projectPath: string | null) => void;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const selectedValue = projectPath?.trim() || NO_PROJECT_CONTEXT_VALUE;
const projectOptions = useMemo(() => {
const seen = new Set
();
@@ -1671,10 +1719,10 @@ function OpenCodeModelScopeControls({
return options;
}, [projectPath, projects]);
const contextPlaceholder = loading
- ? 'Loading contexts...'
+ ? t('runtimeProvider.defaults.loadingContexts')
: defaultScope === 'all_projects'
- ? 'Select validation context'
- : 'Select project context';
+ ? t('runtimeProvider.defaults.selectValidationContext')
+ : t('runtimeProvider.defaults.selectProjectContext');
return (
-
OpenCode defaults
+
+ {t('runtimeProvider.defaults.title')}
+
- {getDefaultScopeDescription(defaultScope)}
+ {getDefaultScopeDescription(defaultScope, t)}
@@ -1703,7 +1753,9 @@ function OpenCodeModelScopeControls({
}`}
onClick={() => onDefaultScopeChange(scope)}
>
- {scope === 'all_projects' ? 'All projects' : 'This project'}
+ {scope === 'all_projects'
+ ? t('runtimeProvider.defaults.allProjects')
+ : t('runtimeProvider.defaults.thisProject')}
))}
@@ -1712,7 +1764,7 @@ function OpenCodeModelScopeControls({
- {getContextControlLabel(defaultScope)}
+ {getContextControlLabel(defaultScope, t)}
- {getContextControlHint(defaultScope, projectPath)}
+ {getContextControlHint(defaultScope, projectPath, t)}
@@ -1766,6 +1818,7 @@ function ConfiguredOpenCodeModelsPanel({
readonly defaultScope: RuntimeProviderDefaultScopeDto;
readonly hasProjectContext: boolean;
}): JSX.Element | null {
+ const { t } = useAppTranslation('settings');
const models = useMemo(() => state.view?.configuredModels ?? [], [state.view?.configuredModels]);
const [query, setQuery] = useState('');
const normalizedQuery = query.trim().toLowerCase();
@@ -1791,11 +1844,10 @@ function ConfiguredOpenCodeModelsPanel({
- Launchable OpenCode models
+ {t('runtimeProvider.models.launchableTitle')}
- Routes you can test or use in the team picker: local config, free built-in models, and
- current default.
+ {t('runtimeProvider.models.launchableDescription')}
@@ -1803,7 +1855,7 @@ function ConfiguredOpenCodeModelsPanel({
setQuery(event.target.value)}
- placeholder="Search model routes"
+ placeholder={t('runtimeProvider.modelRoutes.searchPlaceholder')}
className="h-9 pl-10 pr-3 text-sm leading-5"
style={{ paddingLeft: 40 }}
/>
@@ -1813,7 +1865,7 @@ function ConfiguredOpenCodeModelsPanel({
{visibleModels.length === 0 ? (
- No OpenCode model routes match “{query.trim()}”.
+ {t('runtimeProvider.models.noRoutesMatch', { query: query.trim() })}
) : null}
{visibleModels.map((model) => {
@@ -1824,7 +1876,7 @@ function ConfiguredOpenCodeModelsPanel({
const unavailableTitle = getOpenCodeRouteUnavailableTitle(model);
const contextRequiredTitle = hasProjectContext
? undefined
- : 'Select a project context before testing or saving OpenCode defaults.';
+ : t('runtimeProvider.models.selectProjectBeforeTestingDefaults');
const alreadyDefaultForScope = isDefaultForScope(model, state, defaultScope);
const canTest =
!disabled && hasProjectContext && !testing && canTestOpenCodeModelRoute(model);
@@ -1877,7 +1929,7 @@ function ConfiguredOpenCodeModelsPanel({
) : (
)}
- Test
+ {t('runtimeProvider.actions.test')}
- Use in team picker
+ {t('runtimeProvider.models.useInTeamPicker')}
{
@@ -1913,7 +1965,7 @@ function ConfiguredOpenCodeModelsPanel({
}}
>
{savingDefault ? : null}
- {getDefaultScopeButtonLabel(defaultScope)}
+ {getDefaultScopeButtonLabel(defaultScope, t)}
@@ -1939,6 +1991,7 @@ function ProviderModelList({
readonly disabled: boolean;
readonly hasProjectContext: boolean;
}): JSX.Element {
+ const { t } = useAppTranslation('settings');
const pickerOpen = state.modelPickerProviderId === provider.providerId;
const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
@@ -1981,11 +2034,11 @@ function ProviderModelList({
);
const emptyModelListMessage = recommendedOnly
? freeOnly
- ? 'No recommended free models found.'
- : 'No recommended models found.'
+ ? t('runtimeProvider.models.emptyRecommendedFree')
+ : t('runtimeProvider.models.emptyRecommended')
: freeOnly
- ? 'No free models found.'
- : 'No models found.';
+ ? t('runtimeProvider.models.emptyFree')
+ : t('runtimeProvider.models.empty');
return (
@@ -1999,7 +2052,7 @@ function ProviderModelList({
onChange={(event) => actions.setModelQuery(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
- placeholder="Search models"
+ placeholder={t('runtimeProvider.models.searchPlaceholder')}
className="h-10 pl-10 pr-3 text-sm leading-5"
style={{ paddingLeft: 42 }}
/>
@@ -2021,7 +2074,7 @@ function ProviderModelList({
htmlFor={`runtime-provider-${provider.providerId}-recommended-only`}
className="cursor-pointer text-xs font-normal text-[var(--color-text-secondary)]"
>
- Recommended only
+ {t('runtimeProvider.models.recommendedOnly')}
) : null}
@@ -2042,7 +2095,7 @@ function ProviderModelList({
htmlFor={`runtime-provider-${provider.providerId}-free-only`}
className="cursor-pointer text-xs font-normal text-[var(--color-text-secondary)]"
>
- Free only
+ {t('runtimeProvider.models.freeOnly')}
) : null}
@@ -2095,6 +2148,7 @@ export function RuntimeProviderManagementPanelView({
projectContextError = null,
onProjectContextChange,
}: RuntimeProviderManagementPanelViewProps): JSX.Element {
+ const { t } = useAppTranslation('settings');
const [selectedSection, setSelectedSection] = useState
(null);
const [defaultScope, setDefaultScope] = useState('all_projects');
const providerQuery = state.providerQuery.trim().toLowerCase();
@@ -2123,8 +2177,8 @@ export function RuntimeProviderManagementPanelView({
state.directoryTotalCount !== null
? formatOpenCodeProviderCount(state.directoryTotalCount)
: state.directorySupported
- ? 'OpenCode provider catalog'
- : 'OpenCode providers';
+ ? t('runtimeProvider.providers.catalog')
+ : t('runtimeProvider.providers.countFallback');
const launchableModelCount = state.view?.configuredModels?.length ?? 0;
const modelsLoading = state.loading && launchableModelCount === 0;
const activeSection =
@@ -2167,7 +2221,7 @@ export function RuntimeProviderManagementPanelView({
value="models"
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
>
- Models
+ {t('runtimeProvider.tabs.models')}
{launchableModelCount > 0 ? (
{launchableModelCount}
@@ -2178,7 +2232,7 @@ export function RuntimeProviderManagementPanelView({
value="providers"
className="rounded-b-none data-[state=active]:bg-[var(--color-surface)]"
>
- Providers
+ {t('runtimeProvider.tabs.providers')}
{state.directoryTotalCount !== null ? (
{state.directoryTotalCount}
@@ -2215,15 +2269,14 @@ export function RuntimeProviderManagementPanelView({
>
- Loading OpenCode model routes...
+ {t('runtimeProvider.models.loadingRoutes')}
) : null}
{!modelsLoading && launchableModelCount === 0 ? (
- No launchable OpenCode model routes were reported yet. Configure a local route in
- OpenCode or use the Providers tab to inspect catalog providers.
+ {t('runtimeProvider.models.noneReported')}
) : null}
@@ -2231,9 +2284,11 @@ export function RuntimeProviderManagementPanelView({
-
Providers
+
+ {t('runtimeProvider.tabs.providers')}
+
- {providerCountLabel}. Connected and recommended providers are shown first.
+ {t('runtimeProvider.providers.description', { count: providerCountLabel })}
{state.directorySupported ? (
@@ -2249,7 +2304,7 @@ export function RuntimeProviderManagementPanelView({
) : (
)}
- Refresh catalog
+ {t('runtimeProvider.providers.refreshCatalog')}
) : null}
@@ -2267,7 +2322,7 @@ export function RuntimeProviderManagementPanelView({
actions.searchAllProviders(state.providerQuery.trim());
}
}}
- placeholder="Search providers"
+ placeholder={t('runtimeProvider.providers.searchPlaceholder')}
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
/>
@@ -2313,7 +2368,7 @@ export function RuntimeProviderManagementPanelView({
{state.directoryRefreshing ? (
) : null}
- Load more providers
+ {t('runtimeProvider.providers.loadMore')}
) : null}
@@ -2351,7 +2406,7 @@ export function RuntimeProviderManagementPanelView({
color: 'var(--color-text-secondary)',
}}
>
- No providers match that search.
+ {t('runtimeProvider.providers.noMatches')}
) : null}
@@ -2366,7 +2421,7 @@ export function RuntimeProviderManagementPanelView({
color: 'var(--color-text-secondary)',
}}
>
- No providers match that search.
+ {t('runtimeProvider.providers.noMatches')}
) : null}
@@ -2378,7 +2433,7 @@ export function RuntimeProviderManagementPanelView({
color: 'var(--color-text-secondary)',
}}
>
- No OpenCode providers reported by the managed runtime.
+ {t('runtimeProvider.providers.noneReported')}
) : null}
diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx
index 4a57e3ae..e2f4309d 100644
--- a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx
+++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
AlertTriangle,
ChevronDown,
@@ -12,7 +13,6 @@ import {
import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner';
-const SUMMARY_TITLE = 'tmux is not installed';
const BANNER_MIN_H = 'min-h-[4.25rem]';
const SourceLink = ({
@@ -36,6 +36,7 @@ const SourceLink = ({
);
export function TmuxInstallerBannerView(): React.JSX.Element | null {
+ const { t } = useAppTranslation('common');
const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } =
useTmuxInstallerBanner();
const [expanded, setExpanded] = React.useState(false);
@@ -78,6 +79,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
viewModel.manualHints.length > 0 && (!viewModel.manualHintsCollapsible || manualHintsExpanded);
const primaryGuideUrl = viewModel.primaryGuideUrl;
const bannerPaddingClass = expanded ? `py-3 ${BANNER_MIN_H}` : 'py-2.5';
+ const summaryTitle = t('tmuxInstaller.summaryTitle');
return (
- {SUMMARY_TITLE}
+ {summaryTitle}
{!expanded && viewModel.benefitsBody && (
- {viewModel.title !== SUMMARY_TITLE && (
+ {viewModel.title !== summaryTitle && (
- Detected OS: {viewModel.platformLabel}
+ {t('tmuxInstaller.detectedOs', { os: viewModel.platformLabel })}
)}
{viewModel.locationLabel && (
@@ -187,7 +189,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
- Runtime path: {viewModel.locationLabel}
+ {t('tmuxInstaller.runtimePath', { path: viewModel.locationLabel })}
)}
{viewModel.runtimeReadyLabel && (
@@ -220,7 +222,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
- Phase: {viewModel.phase}
+ {t('tmuxInstaller.phase', { phase: viewModel.phase })}
)}
@@ -258,7 +260,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
style={{ borderColor: 'var(--color-border)' }}
>
- Cancel
+ {t('tmuxInstaller.actions.cancel')}
)}
{primaryGuideUrl && (
@@ -269,7 +271,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
style={{ borderColor: 'var(--color-border)' }}
>
- Manual guide
+ {t('tmuxInstaller.actions.manualGuide')}
)}
{viewModel.manualHintsCollapsible && (
@@ -285,8 +287,10 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
)}
{manualHintsExpanded
- ? 'Hide setup steps'
- : `Show setup steps (${viewModel.manualHints.length})`}
+ ? t('tmuxInstaller.actions.hideSetupSteps')
+ : t('tmuxInstaller.actions.showSetupSteps', {
+ count: viewModel.manualHints.length,
+ })}
)}
{viewModel.showRefreshButton && (
@@ -297,7 +301,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
style={{ borderColor: 'var(--color-border)' }}
>
- Re-check
+ {t('tmuxInstaller.actions.recheck')}
)}
@@ -305,7 +309,9 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
{viewModel.progressPercent !== null && (
-
Installer progress
+
+ {t('tmuxInstaller.installerProgress')}
+
{viewModel.progressPercent}%
@@ -343,7 +349,7 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
type={viewModel.inputSecret ? 'password' : 'text'}
value={inputValue}
onChange={(event) => setInputValue(event.target.value)}
- placeholder={viewModel.inputPrompt ?? 'Send input to the installer'}
+ placeholder={viewModel.inputPrompt ?? t('tmuxInstaller.input.placeholder')}
className="min-w-0 flex-1 rounded-md border px-3 py-2 text-sm"
style={{
borderColor: 'var(--color-border)',
@@ -357,13 +363,12 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
className="inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60"
style={{ borderColor: 'var(--color-border)' }}
>
- Send input
+ {t('tmuxInstaller.input.send')}
{viewModel.inputSecret && (
- Password input is sent directly to the installer terminal and is not added to the
- log output.
+ {t('tmuxInstaller.input.passwordNotice')}
)}
@@ -409,7 +414,9 @@ export function TmuxInstallerBannerView(): React.JSX.Element | null {
className="text-xs underline-offset-4 hover:underline"
style={{ color: 'var(--color-text-secondary)' }}
>
- {viewModel.detailsOpen ? 'Hide details' : 'Show details'}
+ {viewModel.detailsOpen
+ ? t('tmuxInstaller.details.hide')
+ : t('tmuxInstaller.details.show')}
{viewModel.detailsOpen && (
| V
'multimodelEnabled',
'claudeRootPath',
'agentLanguage',
+ 'appLocale',
'autoExpandAIGroups',
'useNativeTitleBar',
'telemetryEnabled',
@@ -407,6 +409,12 @@ function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | V
}
result.agentLanguage = value.trim();
break;
+ case 'appLocale':
+ if (!isAppLocalePreference(value)) {
+ return { valid: false, error: 'general.appLocale must be a supported app locale' };
+ }
+ result.appLocale = value;
+ break;
case 'autoExpandAIGroups':
if (typeof value !== 'boolean') {
return { valid: false, error: `general.${key} must be a boolean` };
diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts
index a1d8972d..dba96c79 100644
--- a/src/main/services/infrastructure/ConfigManager.ts
+++ b/src/main/services/infrastructure/ConfigManager.ts
@@ -9,6 +9,7 @@
* - Handle JSON parse errors gracefully
*/
+import { normalizeAppLocalePreference } from '@features/localization';
import { getClaudeBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { validateRegexPattern } from '@main/utils/regexValidation';
import { createLogger } from '@shared/utils/logger';
@@ -258,6 +259,7 @@ export interface GeneralConfig {
multimodelEnabled: boolean;
claudeRootPath: string | null;
agentLanguage: string;
+ appLocale: string;
autoExpandAIGroups: boolean;
useNativeTitleBar: boolean;
/** Paths manually added via "Select Folder" that persist across app restarts */
@@ -373,6 +375,7 @@ const DEFAULT_CONFIG: AppConfig = {
multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
+ appLocale: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
customProjectPaths: [],
@@ -598,6 +601,7 @@ export class ConfigManager {
};
mergedGeneral.multimodelEnabled = true;
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
+ mergedGeneral.appLocale = normalizeAppLocalePreference(mergedGeneral.appLocale);
// Merge triggers: preserve existing triggers, add missing builtin ones
const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS);
diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index 52bd7695..a3089b46 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
+import { LocalizationProvider } from '@features/localization/renderer';
import { TooltipProvider } from '@renderer/components/ui/tooltip';
import { ConfirmDialog } from './components/common/ConfirmDialog';
@@ -33,6 +34,7 @@ const SPLASH_REDUCED_AVATAR_READY_MAX_WAIT_MS = 160;
export const App = (): React.JSX.Element => {
// Initialize theme on app load
useTheme();
+ const appConfig = useStore((s) => s.appConfig);
// Upgrade the static preload splash, then dismiss it after the scene is visible.
useEffect(() => {
@@ -104,13 +106,15 @@ export const App = (): React.JSX.Element => {
}, []);
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
};
diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx
index 25209ee3..1bd51996 100644
--- a/src/renderer/components/chat/AIChatGroup.tsx
+++ b/src/renderer/components/chat/AIChatGroup.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { useTabUI } from '@renderer/hooks/useTabUI';
import { useStore } from '@renderer/store';
@@ -125,6 +126,7 @@ const AIChatGroupInner = ({
highlightColor,
registerToolRef,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
// Per-tab UI state for expansion (completely isolated per tab)
const {
tabId,
@@ -396,7 +398,7 @@ const AIChatGroupInner = ({
className="shrink-0 text-xs font-semibold"
style={{ color: COLOR_TEXT_SECONDARY }}
>
- Claude
+ {t('brand.claude')}
{/* Main agent model */}
diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx
index ea7d0e5e..71e6b531 100644
--- a/src/renderer/components/chat/ChatHistory.tsx
+++ b/src/renderer/components/chat/ChatHistory.tsx
@@ -1,5 +1,6 @@
import { type JSX, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isNearBottom, useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom';
import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController';
import { useTabUI } from '@renderer/hooks/useTabUI';
@@ -39,6 +40,7 @@ interface ChatHistoryProps {
}
export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
+ const { t } = useAppTranslation('common');
const VIRTUALIZATION_THRESHOLD = 120;
const ESTIMATED_CHAT_ITEM_HEIGHT = 260;
@@ -914,12 +916,14 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
}}
>
{' '}
- ({remainingContext.remainingPct.toFixed(0)}% left)
+ {t('chat.context.remainingPercent', {
+ percent: remainingContext.remainingPct.toFixed(0),
+ })}
)}
>
) : (
- `Context (${allContextInjections.length})`
+ t('chat.context.count', { count: allContextInjections.length })
)}
@@ -1031,10 +1035,10 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border-emphasis)',
}}
- title="Scroll to bottom"
+ title={t('chat.scrollToBottom')}
>
- Bottom
+ {t('chat.bottom')}
)}
diff --git a/src/renderer/components/chat/ChatHistoryEmptyState.tsx b/src/renderer/components/chat/ChatHistoryEmptyState.tsx
index 82e959f0..3a3c4810 100644
--- a/src/renderer/components/chat/ChatHistoryEmptyState.tsx
+++ b/src/renderer/components/chat/ChatHistoryEmptyState.tsx
@@ -1,14 +1,19 @@
import type { JSX } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
/**
* Empty state for ChatHistory when no conversation exists.
*/
export const ChatHistoryEmptyState = (): JSX.Element => {
+ const { t } = useAppTranslation('common');
return (
-
💬
-
No conversation history
-
This session does not contain any messages yet.
+
+ {t('chat.empty.icon')}
+
+
{t('chat.empty.title')}
+
{t('chat.empty.description')}
);
diff --git a/src/renderer/components/chat/CompactBoundary.tsx b/src/renderer/components/chat/CompactBoundary.tsx
index 88f44216..0a195ff2 100644
--- a/src/renderer/components/chat/CompactBoundary.tsx
+++ b/src/renderer/components/chat/CompactBoundary.tsx
@@ -1,6 +1,7 @@
import React, { memo, useState } from 'react';
import ReactMarkdown from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CODE_BG,
CODE_BORDER,
@@ -31,6 +32,7 @@ interface CompactBoundaryProps {
export const CompactBoundary = memo(function CompactBoundary({
compactGroup,
}: Readonly): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const { timestamp, message } = compactGroup;
const [isExpanded, setIsExpanded] = useState(false);
@@ -62,7 +64,7 @@ export const CompactBoundary = memo(function CompactBoundary({
onClick={() => setIsExpanded(!isExpanded)}
className="group flex w-full cursor-pointer items-center transition-opacity hover:opacity-90"
aria-expanded={isExpanded}
- aria-label="Toggle compacted content"
+ aria-label={t('chat.compact.toggle')}
>
{/* Left line */}
@@ -82,7 +84,7 @@ export const CompactBoundary = memo(function CompactBoundary({
className="whitespace-nowrap text-[11px] font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
- Context compacted
+ {t('chat.compact.contextCompacted')}
{/* Token delta */}
@@ -95,7 +97,9 @@ export const CompactBoundary = memo(function CompactBoundary({
{formatTokens(compactGroup.tokenDelta.postCompactionTokens)}
{' '}
- ({formatTokens(Math.abs(compactGroup.tokenDelta.delta))} freed)
+ {t('chat.compact.freedTokens', {
+ tokens: formatTokens(Math.abs(compactGroup.tokenDelta.delta)),
+ })}
)}
@@ -109,7 +113,7 @@ export const CompactBoundary = memo(function CompactBoundary({
color: 'var(--compact-phase-text)',
}}
>
- Phase {compactGroup.startingPhaseNumber}
+ {t('chat.compact.phase', { phase: compactGroup.startingPhaseNumber })}
)}
@@ -152,12 +156,9 @@ export const CompactBoundary = memo(function CompactBoundary({
- Conversation Compacted
-
-
- Previous messages were summarized to save context. The full conversation history
- is preserved in the session file.
+ {t('chat.compact.conversationCompacted')}
+
{t('chat.compact.summary')}
)}
diff --git a/src/renderer/components/chat/ContextBadge.tsx b/src/renderer/components/chat/ContextBadge.tsx
index 2b4d5991..66e36c49 100644
--- a/src/renderer/components/chat/ContextBadge.tsx
+++ b/src/renderer/components/chat/ContextBadge.tsx
@@ -7,6 +7,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import {
COLOR_BORDER,
COLOR_BORDER_SUBTLE,
@@ -95,6 +96,7 @@ const PopoverSection = ({
children: React.ReactNode;
defaultExpanded?: boolean;
}>): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(defaultExpanded);
return (
@@ -121,7 +123,11 @@ const PopoverSection = ({
className={`size-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
- {title} ({count}) ~{formatTokens(tokenCount)} tokens
+ {t('contextBadge.sectionSummary', {
+ title,
+ count,
+ tokens: formatTokens(tokenCount),
+ })}
{/* Section content */}
@@ -134,6 +140,7 @@ export const ContextBadge = ({
stats,
projectRoot,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
const [showPopover, setShowPopover] = useState(false);
const [popoverStyle, setPopoverStyle] = useState({});
const [arrowStyle, setArrowStyle] = useState({});
@@ -361,7 +368,7 @@ export const ContextBadge = ({
className="inline-flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={badgeStyle}
>
- Context
+ {t('contextBadge.badge')}
+{totalNew}
@@ -373,7 +380,7 @@ export const ContextBadge = ({
ref={popoverRef}
role="dialog"
aria-modal="false"
- aria-label="Context injection details"
+ aria-label={t('contextBadge.detailsAria')}
className="rounded-lg p-3 shadow-xl"
style={{
...popoverStyle,
@@ -395,7 +402,7 @@ export const ContextBadge = ({
borderBottom: `1px solid ${COLOR_BORDER_SUBTLE}`,
}}
>
- New Context Injected In This Turn
+ {t('contextBadge.title')}
{/* Sections */}
@@ -403,7 +410,7 @@ export const ContextBadge = ({
{/* User Messages section */}
{newUserMessageInjections.length > 0 && (
@@ -411,10 +418,12 @@ export const ContextBadge = ({
- Turn {injection.turnIndex + 1}
+ {t('contextBadge.turn', { turn: injection.turnIndex + 1 })}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
{injection.textPreview && (
@@ -433,7 +442,7 @@ export const ContextBadge = ({
{/* CLAUDE.md Files section */}
{newClaudeMdInjections.length > 0 && (
@@ -450,7 +459,9 @@ export const ContextBadge = ({
style={{ color: COLOR_TEXT_SECONDARY }}
/>
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
);
@@ -461,7 +472,7 @@ export const ContextBadge = ({
{/* Mentioned Files section */}
{newMentionedFileInjections.length > 0 && (
@@ -477,7 +488,9 @@ export const ContextBadge = ({
style={{ color: COLOR_TEXT_SECONDARY }}
/>
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
);
@@ -488,7 +501,7 @@ export const ContextBadge = ({
{/* Tool Outputs section */}
{newToolOutputInjections.length > 0 && (
@@ -500,7 +513,9 @@ export const ContextBadge = ({
>
{tool.toolName}
- ~{formatTokens(tool.tokenCount)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(tool.tokenCount),
+ })}
))
@@ -511,7 +526,7 @@ export const ContextBadge = ({
{/* Task Coordination section */}
{newTaskCoordinationInjections.length > 0 && (
@@ -523,7 +538,9 @@ export const ContextBadge = ({
>
{item.label}
- ~{formatTokens(item.tokenCount)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(item.tokenCount),
+ })}
))
@@ -534,14 +551,14 @@ export const ContextBadge = ({
{/* Thinking + Text section */}
{newThinkingTextInjections.length > 0 && (
{newThinkingTextInjections.map((injection) => (
- Turn {injection.turnIndex + 1}
+ {t('contextBadge.turn', { turn: injection.turnIndex + 1 })}
{injection.breakdown.map((item, idx) => (
@@ -550,10 +567,14 @@ export const ContextBadge = ({
className="flex items-center justify-between text-xs"
>
- {item.type === 'thinking' ? 'Thinking' : 'Text'}
+ {item.type === 'thinking'
+ ? t('contextBadge.breakdown.thinking')
+ : t('contextBadge.breakdown.text')}
- ~{formatTokens(item.tokenCount)} tokens
+ {t('contextBadge.tokenCount', {
+ tokens: formatTokens(item.tokenCount),
+ })}
))}
@@ -569,9 +590,9 @@ export const ContextBadge = ({
className="mt-2 flex items-center justify-between pt-2 text-xs"
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
-
Total new tokens
+
{t('contextBadge.totalNewTokens')}
- ~{formatTokens(totalNewTokens)} tokens
+ {t('contextBadge.tokenCount', { tokens: formatTokens(totalNewTokens) })}
,
diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx
index 8ae4e8c9..dc230401 100644
--- a/src/renderer/components/chat/DisplayItemList.tsx
+++ b/src/renderer/components/chat/DisplayItemList.tsx
@@ -1,5 +1,6 @@
import React, { memo, useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CODE_BG,
CODE_BORDER,
@@ -138,6 +139,7 @@ const DisplayItemRow = memo(function DisplayItemRow({
timestampFormat,
showItemMetaTooltip = false,
}: DisplayItemRowProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('common');
const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]);
let element: React.ReactNode = null;
@@ -343,7 +345,7 @@ const DisplayItemRow = memo(function DisplayItemRow({
- Compacted
+ {t('chat.compact.compacted')}
{item.tokenDelta && (
{' '}
- ({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
+ {t('chat.compact.freedTokens', {
+ tokens: formatTokensCompact(Math.abs(item.tokenDelta.delta)),
+ })}
)}
@@ -365,7 +369,7 @@ const DisplayItemRow = memo(function DisplayItemRow({
color: '#818cf8',
}}
>
- Phase {item.phaseNumber}
+ {t('chat.compact.phase', { phase: item.phaseNumber })}
{format(new Date(item.timestamp), 'h:mm:ss a')}
@@ -438,6 +442,7 @@ export const DisplayItemList = React.memo(function DisplayItemList({
timestampFormat,
showItemMetaTooltip = false,
}: Readonly): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const [replyLinkToolId, setReplyLinkToolId] = useState(null);
const handleReplyHover = useCallback((toolId: string | null) => {
@@ -447,7 +452,7 @@ export const DisplayItemList = React.memo(function DisplayItemList({
if (!items || items.length === 0) {
return (
- No items to display
+ {t('chat.items.empty')}
);
}
diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx
index 3df2c96b..5074cfe9 100644
--- a/src/renderer/components/chat/LastOutputDisplay.tsx
+++ b/src/renderer/components/chat/LastOutputDisplay.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react';
@@ -41,6 +42,7 @@ export const LastOutputDisplay = ({
isLastGroup = false,
isSessionOngoing = false,
}: Readonly): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
// Only re-render if THIS AI group has search matches
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
useShallow((s) => {
@@ -152,7 +154,7 @@ export const LastOutputDisplay = ({
className="text-xs font-medium"
style={{ color: 'var(--tool-result-error-text)' }}
>
- Error
+ {t('states.error')}
)}
@@ -185,7 +187,7 @@ export const LastOutputDisplay = ({
style={{ color: 'var(--warning-text, #f59e0b)' }}
/>
- Request interrupted by user
+ {t('chat.lastOutput.requestInterrupted')}
);
@@ -234,7 +236,7 @@ export const LastOutputDisplay = ({
- Plan Ready for Approval
+ {t('chat.lastOutput.planReadyForApproval')}
diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx
index 28f06a05..5f94f08a 100644
--- a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CopyablePath } from '@renderer/components/common/CopyablePath';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { ChevronRight } from 'lucide-react';
@@ -24,6 +25,7 @@ export const DirectoryTreeNode = ({
depth = 0,
onNavigateToTurn,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(true);
const indent = depth * 12;
@@ -48,7 +50,9 @@ export const DirectoryTreeNode = ({
className="text-xs"
style={{ color: COLOR_TEXT_SECONDARY }}
/>
- (~{formatTokens(node.tokens ?? 0)})
+
+ {t('tokens.approxTokensParenthesized', { tokens: formatTokens(node.tokens ?? 0) })}
+
{node.firstSeenInGroup &&
(isClickable ? (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
// Group CLAUDE.md injections by category
const claudeMdGroups = useMemo(() => {
const groups = new Map();
@@ -65,7 +68,7 @@ export const ClaudeMdFilesSection = ({
return (
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(true);
const sectionTokens = injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0);
@@ -59,7 +61,9 @@ export const ClaudeMdSubSection = ({
>
{injections.length}
- (~{formatTokens(sectionTokens)})
+
+ {t('tokens.approxTokensParenthesized', { tokens: formatTokens(sectionTokens) })}
+
{expanded && (
diff --git a/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx
index bbceba0c..c79b891a 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { formatTokens } from '../utils/formatting';
@@ -25,6 +26,8 @@ export const CollapsibleSection = ({
onToggle,
children,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
+
return (
- ~{formatTokens(tokenCount)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(tokenCount) })}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx
index 107c9c3b..9a849435 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx
@@ -6,6 +6,7 @@
import React, { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
@@ -169,6 +170,7 @@ export const FlatInjectionList = ({
onNavigateToTool,
onNavigateToUserGroup,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const rows = useMemo(() => flattenInjections(injections), [injections]);
return (
@@ -223,7 +225,7 @@ export const FlatInjectionList = ({
fontSize: '10px',
}}
>
- error
+ {t('states.error')}
)}
{/* Token count */}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx
index 75dd31a1..b497c71c 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MentionedFileItem } from '../items/MentionedFileItem';
import { CollapsibleSection } from './CollapsibleSection';
@@ -27,11 +28,13 @@ export const MentionedFilesSection = ({
projectRoot,
onNavigateToTurn,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
void;
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
}>): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const hasBreakdown = injection.toolBreakdown.length > 0;
const categoryInfo = CATEGORY_COLORS['tool-output'];
@@ -183,7 +185,7 @@ const ToolOutputRankedItem = ({
fontSize: '10px',
}}
>
- error
+ {t('states.error')}
)}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx
index b23b085e..a002cf1d 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
COLOR_BORDER,
COLOR_BORDER_SUBTLE,
@@ -53,6 +54,8 @@ export const SessionContextHeader = ({
viewMode,
onViewModeChange,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
+
const formatPercentLabel = (percent: number | null, suffix: string): string | null => {
if (percent === null) {
return null;
@@ -77,7 +80,7 @@ export const SessionContextHeader = ({
{tokens === null
- ? (options?.unavailableLabel ?? 'Unavailable')
+ ? (options?.unavailableLabel ?? t('sessionContext.metrics.unavailable'))
: `${options?.approximate ? '~' : ''}${formatTokens(tokens)}`}
{percentLabel && (
@@ -99,7 +102,7 @@ export const SessionContextHeader = ({
- Context
+ {t('sessionContext.header.title')}
@@ -132,27 +135,27 @@ export const SessionContextHeader = ({
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
{renderMetricValue(
- 'Context Used',
+ t('sessionContext.metrics.contextUsed'),
contextMetrics?.contextUsedTokens ?? null,
formatPercentLabel(
contextMetrics?.contextUsedPercentOfContextWindow ?? null,
- 'of context'
+ t('sessionContext.metrics.ofContext')
)
)}
{renderMetricValue(
- 'Prompt Input',
+ t('sessionContext.metrics.promptInput'),
contextMetrics?.promptInputTokens ?? null,
formatPercentLabel(
contextMetrics?.promptInputPercentOfContextWindow ?? null,
- 'of context'
+ t('sessionContext.metrics.ofContext')
)
)}
{renderMetricValue(
- 'Visible Context',
+ t('sessionContext.metrics.visibleContext'),
totalTokens,
formatPercentLabel(
contextMetrics?.visibleContextPercentOfPromptInput ?? null,
- 'of prompt'
+ t('sessionContext.metrics.ofPrompt')
),
{ approximate: true }
)}
@@ -166,8 +169,7 @@ export const SessionContextHeader = ({
color: COLOR_TEXT_MUTED,
}}
>
- Codex prompt-side usage is not exposed by the current runtime telemetry yet, so Prompt
- Input and Context Used stay unavailable instead of showing a fake zero.
+ {t('sessionContext.metrics.codexTelemetryUnavailable')}
)}
@@ -180,7 +182,9 @@ export const SessionContextHeader = ({
{/* Cost */}
{sessionMetrics.costUsd !== undefined && sessionMetrics.costUsd > 0 && (
- Session Cost:
+
+ {t('sessionContext.metrics.sessionCost')}{' '}
+
{formatCostUsd(sessionMetrics.costUsd + (subagentCostUsd ?? 0))}
@@ -188,9 +192,9 @@ export const SessionContextHeader = ({
{' ('}
{formatCostUsd(sessionMetrics.costUsd)}
- {' parent + '}
+ {` ${t('sessionContext.metrics.parentPlus')} `}
{formatCostUsd(subagentCostUsd)}
- {' subagents'}
+ {` ${t('sessionContext.metrics.subagents')}`}
{onViewReport && (
<>
{' · '}
@@ -199,7 +203,7 @@ export const SessionContextHeader = ({
className="underline"
style={{ color: COLOR_TEXT_SECONDARY }}
>
- details
+ {t('sessionContext.metrics.details')}
>
)}
@@ -218,7 +222,7 @@ export const SessionContextHeader = ({
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
- Phase:
+ {t('sessionContext.header.phase')}
{phaseInfo.phases.map((phase) => (
- Current
+ {t('sessionContext.header.current')}
)}
@@ -258,7 +262,7 @@ export const SessionContextHeader = ({
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
>
- View:
+ {t('sessionContext.header.view')}
onViewModeChange('category')}
@@ -270,7 +274,7 @@ export const SessionContextHeader = ({
}}
>
- Category
+ {t('sessionContext.header.category')}
onViewModeChange('ranked')}
@@ -282,7 +286,7 @@ export const SessionContextHeader = ({
}}
>
- By Size
+ {t('sessionContext.header.bySize')}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx
index 666a3f23..511c5e25 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx
@@ -5,9 +5,11 @@
import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { HelpCircle } from 'lucide-react';
export const SessionContextHelpTooltip = (): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipStyle, setTooltipStyle] = useState({});
const [arrowStyle, setArrowStyle] = useState({});
@@ -119,41 +121,37 @@ export const SessionContextHelpTooltip = (): React.ReactElement => {
{/* Metric definitions */}
- Context Used
+ {t('sessionContext.help.contextUsed.title')}
- Prompt input plus output tokens currently occupying the model's context
- window.
+ {t('sessionContext.help.contextUsed.description')}
- Prompt Input
+ {t('sessionContext.help.promptInput.title')}
- Tokens sent to the model before generation. For Claude this includes `input_tokens
- + cache_creation_input_tokens + cache_read_input_tokens`.
+ {t('sessionContext.help.promptInput.description')}
- Visible Context
+ {t('sessionContext.help.visibleContext.title')}
- The inspectable subset of prompt input: files, CLAUDE.md, tool outputs, user
- messages, and similar injections that you can optimize directly.
+ {t('sessionContext.help.visibleContext.description')}
- Availability
+ {t('sessionContext.help.availability.title')}
- If a provider runtime does not expose prompt-side usage yet, the panel shows
- metrics as unavailable instead of pretending they are zero.
+ {t('sessionContext.help.availability.description')}
diff --git a/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx
index 508eccdb..b71928f1 100644
--- a/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { TaskCoordinationItem } from '../items/TaskCoordinationItem';
import { CollapsibleSection } from './CollapsibleSection';
@@ -25,11 +26,13 @@ export const TaskCoordinationSection = ({
onToggle,
onNavigateToTurn,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
+
if (injections.length === 0) return null;
return (
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
// View mode: category sections or ranked list
const [viewMode, setViewMode] = useState('category');
// Flat sub-toggle within "By Size" view
@@ -212,7 +214,7 @@ export const SessionContextPanel = ({
className="flex h-full items-center justify-center text-sm"
style={{ color: COLOR_TEXT_MUTED }}
>
- No context injections detected in this session
+ {t('sessionContext.empty')}
) : viewMode === 'category' ? (
<>
@@ -278,7 +280,7 @@ export const SessionContextPanel = ({
color: !flatMode ? '#818cf8' : COLOR_TEXT_MUTED,
}}
>
- Grouped
+ {t('sessionContext.view.grouped')}
setFlatMode(true)}
@@ -288,7 +290,7 @@ export const SessionContextPanel = ({
color: flatMode ? '#818cf8' : COLOR_TEXT_MUTED,
}}
>
- Flat
+ {t('sessionContext.view.flat')}
{flatMode ? (
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx
index 6b25072b..b36aa765 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CopyablePath } from '@renderer/components/common/CopyablePath';
import { resolveAbsolutePath, shortenDisplayPath } from '@renderer/utils/pathDisplay';
@@ -23,6 +24,7 @@ export const ClaudeMdItem = ({
projectRoot,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const turnIndex = parseTurnIndex(injection.firstSeenInGroup);
const isClickable = onNavigateToTurn && turnIndex >= 0;
const displayPath = shortenDisplayPath(injection.path, projectRoot);
@@ -38,7 +40,7 @@ export const ClaudeMdItem = ({
/>
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(injection.estimatedTokens) })}
{isClickable ? (
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const turnIndex = injection.firstSeenTurnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
const displayPath = shortenDisplayPath(injection.path, projectRoot);
@@ -46,13 +48,15 @@ export const MentionedFileItem = ({
color: 'var(--color-error)',
}}
>
- missing
+ {t('sessionContext.items.missing')}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
{isClickable ? (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
diff --git a/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx
index 62832646..51fd720d 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { ChevronRight, Users } from 'lucide-react';
@@ -20,6 +21,7 @@ export const TaskCoordinationItem = ({
injection,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -56,15 +58,17 @@ export const TaskCoordinationItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
- {injection.breakdown.length} item{injection.breakdown.length !== 1 ? 's' : ''}
+ {t('sessionContext.items.itemsCount', { count: injection.breakdown.length })}
>
);
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx
index 92be27b0..491cd322 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { Brain, ChevronRight } from 'lucide-react';
@@ -20,6 +21,7 @@ export const ThinkingTextItem = ({
injection,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -65,15 +67,17 @@ export const ThinkingTextItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
@@ -82,7 +86,9 @@ export const ThinkingTextItem = ({
{injection.breakdown.map((item, idx) => (
- {item.type === 'thinking' ? 'Thinking' : 'Text'}
+ {item.type === 'thinking'
+ ? t('sessionContext.items.thinking')
+ : t('sessionContext.items.text')}
~{formatTokens(item.tokenCount)}
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx
index dec23d2c..beb7594f 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatTokens } from '../utils/formatting';
import type { ToolTokenBreakdown } from '@renderer/types/contextInjection';
@@ -15,6 +16,8 @@ interface ToolBreakdownItemProps {
export const ToolBreakdownItem = ({
tool,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
+
return (
{tool.toolName}
@@ -30,7 +33,7 @@ export const ToolBreakdownItem = ({
fontSize: '10px',
}}
>
- error
+ {t('states.error')}
)}
diff --git a/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx
index fe46a522..e5b2ddf8 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx
@@ -4,6 +4,7 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { ChevronRight, Wrench } from 'lucide-react';
@@ -22,6 +23,7 @@ export const ToolOutputItem = ({
injection,
onNavigateToTurn,
}: Readonly): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -58,15 +60,17 @@ export const ToolOutputItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
- {injection.toolCount} tool{injection.toolCount !== 1 ? 's' : ''}
+ {t('sessionContext.items.toolsCount', { count: injection.toolCount })}
>
);
diff --git a/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx
index ce3219fa..111f95a5 100644
--- a/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx
+++ b/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
import { MessageSquare } from 'lucide-react';
@@ -20,6 +21,7 @@ export const UserMessageItem = ({
injection,
onNavigateToTurn,
}: Readonly
): React.ReactElement => {
+ const { t } = useAppTranslation('common');
const turnIndex = injection.turnIndex;
const isClickable = onNavigateToTurn && turnIndex >= 0;
@@ -45,15 +47,17 @@ export const UserMessageItem = ({
}
}}
>
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
) : (
- @Turn {turnIndex + 1}
+ {t('sessionContext.items.turn', { turn: turnIndex + 1 })}
)}
- ~{formatTokens(injection.estimatedTokens)} tokens
+ {t('sessionContext.items.tokensApprox', {
+ tokens: formatTokens(injection.estimatedTokens),
+ })}
{injection.textPreview && (
diff --git a/src/renderer/components/chat/SystemChatGroup.tsx b/src/renderer/components/chat/SystemChatGroup.tsx
index 92f03e01..8dd337b6 100644
--- a/src/renderer/components/chat/SystemChatGroup.tsx
+++ b/src/renderer/components/chat/SystemChatGroup.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { format } from 'date-fns';
import { Terminal } from 'lucide-react';
@@ -19,6 +20,7 @@ interface SystemChatGroupProps {
const SystemChatGroupInner = ({
systemGroup,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { commandOutput, timestamp } = systemGroup;
// Clean ANSI escape codes from output
@@ -34,7 +36,7 @@ const SystemChatGroupInner = ({
>
- System
+ {t('chat.system.label')}
·
{format(timestamp, 'h:mm:ss a')}
diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx
index ef7fa9a5..7d6db1a7 100644
--- a/src/renderer/components/chat/UserChatGroup.tsx
+++ b/src/renderer/components/chat/UserChatGroup.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
@@ -380,6 +381,7 @@ function createUserMarkdownComponents(
* - Shows image count indicator
*/
const UserChatGroupInner = ({ userGroup }: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { content, timestamp, id: groupId } = userGroup;
const [isManuallyExpanded, setIsManuallyExpanded] = useState(false);
const [validatedPaths, setValidatedPaths] = useState>({});
@@ -544,7 +546,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
{format(timestamp, 'h:mm:ss a')}
- You
+ {t('chat.user.you')}
@@ -578,7 +580,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
style={{ color: 'var(--color-text-muted)' }}
>
- Show more
+ {t('chat.user.showMore')}
)}
@@ -596,7 +598,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
}}
>
- Show less
+ {t('chat.user.showLess')}
) : null}
@@ -613,7 +615,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
: 'var(--color-text-muted)';
const commandMatch = /"([^"]+)"/.exec(notification.summary);
const commandName =
- commandMatch?.[1] ?? notification.summary.trim() ?? 'Background task';
+ commandMatch?.[1] ?? notification.summary.trim() ?? t('chat.user.backgroundTask');
const exitCodeMatch = /\(exit code (\d+)\)/.exec(notification.summary);
const outputFileName = notification.outputFile
? (notification.outputFile.split(/[\\/]/).pop() ?? notification.outputFile)
@@ -634,14 +636,16 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
className="text-xs font-medium leading-snug"
style={{ color: 'var(--color-text-secondary)' }}
>
- {commandName || 'Background task'}
+ {commandName || t('chat.user.backgroundTask')}
{notification.status || 'unknown'}
- {exitCodeMatch?.[1] ?
exit {exitCodeMatch[1]} : null}
+ {exitCodeMatch?.[1] ? (
+
{t('chat.user.exitCode', { code: exitCodeMatch[1] })}
+ ) : null}
{outputFileName ? (
@@ -657,7 +661,7 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
{/* Images indicator */}
{hasImages && (
- {content.images.length} image{content.images.length > 1 ? 's' : ''} attached
+ {t('chat.user.imagesAttached', { count: content.images.length })}
)}
diff --git a/src/renderer/components/chat/items/ExecutionTrace.tsx b/src/renderer/components/chat/items/ExecutionTrace.tsx
index aaf39889..872f385f 100644
--- a/src/renderer/components/chat/items/ExecutionTrace.tsx
+++ b/src/renderer/components/chat/items/ExecutionTrace.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CARD_ICON_MUTED,
CODE_BG,
@@ -56,6 +57,7 @@ export const ExecutionTrace: React.FC = React.memo(
searchExpandedItemId,
registerToolRef,
}): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [manualExpandedItemId, setManualExpandedItemId] = useState(null);
// Use searchExpandedItemId if set, otherwise use manually expanded item
@@ -68,7 +70,7 @@ export const ExecutionTrace: React.FC = React.memo(
if (!items || items.length === 0) {
return (
- No execution items
+ {t('chat.executionTrace.empty')}
);
}
@@ -157,7 +159,9 @@ export const ExecutionTrace: React.FC = React.memo(
className="px-2 py-1 text-xs"
style={{ color: CARD_ICON_MUTED }}
>
- Nested: {item.subagent.description ?? item.subagent.id}
+ {t('chat.executionTrace.nested', {
+ name: item.subagent.description ?? item.subagent.id,
+ })}
);
@@ -168,7 +172,7 @@ export const ExecutionTrace: React.FC = React.memo(
}
- label="Input"
+ label={t('chat.executionTrace.input')}
summary={truncateText(item.content, 80)}
tokenCount={item.tokenCount}
timestamp={item.timestamp}
@@ -222,7 +226,7 @@ export const ExecutionTrace: React.FC = React.memo(
className="shrink-0 text-xs font-medium"
style={{ color: TOOL_CALL_TEXT }}
>
- Compacted
+ {t('chat.compact.compacted')}
{item.tokenDelta && (
= React.memo(
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
{' '}
- ({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
+ {t('chat.compact.freedTokens', {
+ tokens: formatTokensCompact(Math.abs(item.tokenDelta.delta)),
+ })}
)}
@@ -244,7 +250,7 @@ export const ExecutionTrace: React.FC = React.memo(
color: '#818cf8',
}}
>
- Phase {item.phaseNumber}
+ {t('chat.compact.phase', { phase: item.phaseNumber })}
{
+ const { t } = useAppTranslation('common');
const status = getToolStatus(linkedTool);
const { isLight } = useTheme();
const summary = getToolSummary(linkedTool.name, linkedTool.input);
@@ -107,7 +109,7 @@ export const LinkedToolItem = memo(
const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned';
if (isTeammateSpawned) {
const teamResult = linkedTool.result!.toolUseResult!;
- const name = (teamResult.name as string) || 'teammate';
+ const name = (teamResult.name as string) || t('members.teammateFallback');
const color = (teamResult.color as string) || '';
const colors = getTeamColorSet(color);
return (
@@ -120,7 +122,7 @@ export const LinkedToolItem = memo(
{name}
- Teammate spawned
+ {t('chat.tools.teammateSpawned')}
);
@@ -130,12 +132,12 @@ export const LinkedToolItem = memo(
const isShutdownRequest =
linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request';
if (isShutdownRequest) {
- const target = (linkedTool.input?.recipient as string) || 'teammate';
+ const target = (linkedTool.input?.recipient as string) || t('members.teammateFallback');
return (
- Shutdown requested →{' '}
+ {t('chat.tools.shutdownRequested')}{' '}
{target}
@@ -223,13 +225,13 @@ export const LinkedToolItem = memo(
style={{ color: 'var(--tool-item-muted)' }}
>
- No result received
+ {t('chat.tools.noResultReceived')}
)}
{/* Timing */}
- Duration: {formatDuration(linkedTool.durationMs)}
+ {t('chat.tools.duration', { duration: formatDuration(linkedTool.durationMs) })}
diff --git a/src/renderer/components/chat/items/MetricsPill.tsx b/src/renderer/components/chat/items/MetricsPill.tsx
index e6a31c61..b4e3652f 100644
--- a/src/renderer/components/chat/items/MetricsPill.tsx
+++ b/src/renderer/components/chat/items/MetricsPill.tsx
@@ -1,6 +1,7 @@
import React, { memo, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CARD_ICON_MUTED,
CARD_SEPARATOR,
@@ -49,6 +50,7 @@ export const MetricsPill = memo(
isolatedOverride,
phaseBreakdown,
}: Readonly): React.ReactElement | null => {
+ const { t } = useAppTranslation('common');
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipStyle, setTooltipStyle] = useState({});
const containerRef = useRef(null);
@@ -160,7 +162,9 @@ export const MetricsPill = memo(
{hasMainImpact && (
- Main Context
+
+ {t('chat.subagent.metrics.mainContext')}
+
{mainSessionImpact.totalTokens.toLocaleString()}
@@ -181,7 +185,7 @@ export const MetricsPill = memo(
className="flex items-center justify-between gap-3 pl-2"
>
- Phase {phase.phaseNumber}
+ {t('chat.subagent.metrics.phase', { phase: phase.phaseNumber })}
= React.memo(
notificationColorMap,
registerToolRef,
}) => {
- const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent';
+ const { t } = useAppTranslation('common');
+ const description =
+ subagent.description ?? step.content.subagentDescription ?? t('chat.subagent.fallbackName');
const subagentType = subagent.subagentType ?? 'Task';
const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description;
@@ -142,10 +145,10 @@ export const SubagentItem: React.FC = React.memo(
Array.isArray(m.content) &&
m.content.some((b) => b.type === 'tool_use')
).length ?? 0;
- return toolCount > 0 ? `${toolCount} tools` : '';
+ return toolCount > 0 ? t('chat.subagent.summary.tools', { count: toolCount }) : '';
}
return buildSummary(displayItems);
- }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]);
+ }, [isExpanded, containsHighlightedError, displayItems, subagent.messages, t]);
// Model info
const modelInfo = useMemo(() => {
@@ -250,7 +253,7 @@ export const SubagentItem: React.FC = React.memo(
{subagent.team.memberName}
- Shutdown confirmed
+ {t('chat.subagent.shutdownConfirmed')}
= React.memo(
0 ? phaseData.totalConsumption : undefined
}
@@ -391,14 +394,14 @@ export const SubagentItem: React.FC = React.memo(
style={{ color: COLOR_TEXT_MUTED }}
>
- Type {' '}
+ {t('chat.subagent.meta.type')} {' '}
{subagentType}
•
- Duration {' '}
+ {t('chat.subagent.meta.duration')} {' '}
{formatDuration(subagent.durationMs)}
@@ -407,7 +410,7 @@ export const SubagentItem: React.FC = React.memo(
<>
•
- Model {' '}
+ {t('chat.subagent.meta.model')} {' '}
{modelInfo.name}
@@ -416,7 +419,7 @@ export const SubagentItem: React.FC = React.memo(
)}
•
- ID {' '}
+ {t('chat.subagent.meta.id')} {' '}
= React.memo(
className="mb-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: CARD_ICON_MUTED }}
>
- Context Usage
+ {t('chat.subagent.metrics.contextUsage')}
{/* Token rows - floating alignment */}
@@ -448,7 +451,7 @@ export const SubagentItem: React.FC
= React.memo(
style={{ color: 'rgba(251, 191, 36, 0.7)' }}
/>
- Main Context
+ {t('chat.subagent.metrics.mainContext')}
= React.memo(
- Total Output
+ {t('chat.subagent.metrics.totalOutput')}
= React.memo(
{cumulativeMetrics.outputTokens.toLocaleString()}
{' '}
- ({cumulativeMetrics.turnCount} turns)
+ {t('chat.subagent.metrics.turns', {
+ count: cumulativeMetrics.turnCount,
+ })}
@@ -489,7 +494,9 @@ export const SubagentItem: React.FC = React.memo(
style={{ color: 'rgba(56, 189, 248, 0.7)' }}
/>
- {subagent.team ? 'Context Window' : 'Subagent Context'}
+ {subagent.team
+ ? t('chat.subagent.metrics.contextWindow')
+ : t('chat.subagent.metrics.subagentContext')}
= React.memo(
className="flex items-center justify-between pl-5"
>
- Phase {phase.phaseNumber}
+ {t('chat.subagent.metrics.phase', { phase: phase.phaseNumber })}
= React.memo(
/>
- Execution Trace
+ {t('chat.subagent.trace.title')}
· {itemsSummary}
diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx
index 4bc8d309..a8c7b236 100644
--- a/src/renderer/components/chat/items/TeammateMessageItem.tsx
+++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx
@@ -1,5 +1,6 @@
import React, { memo, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CARD_BG,
CARD_BORDER_STYLE,
@@ -84,6 +85,7 @@ export const TeammateMessageItem = memo(
highlightClasses = '',
highlightStyle,
}: TeammateMessageItemProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const colors = getTeamColorSet(teammateMessage.color);
const { isLight } = useTheme();
@@ -200,7 +202,7 @@ export const TeammateMessageItem = memo(
{/* "Message" type label — parallels SubagentItem's model info */}
- Message
+ {t('chat.teammateMessage.message')}
{/* Reply indicator — shows which SendMessage triggered this response */}
@@ -226,13 +228,13 @@ export const TeammateMessageItem = memo(
style={{ color: CARD_ICON_MUTED }}
>
- Resent
+ {t('chat.teammateMessage.resent')}
)}
{/* Summary */}
- {truncatedSummary || 'Teammate message'}
+ {truncatedSummary || t('chat.teammateMessage.fallback')}
{/* Context impact — tokens injected into main session */}
@@ -241,7 +243,9 @@ export const TeammateMessageItem = memo(
className="shrink-0 font-mono text-[11px] tabular-nums"
style={{ color: CARD_ICON_MUTED }}
>
- ~{formatTokensCompact(teammateMessage.tokenCount)} tokens
+ {t('tokens.approxTokens', {
+ tokens: formatTokensCompact(teammateMessage.tokenCount),
+ })}
)}
diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
index 8c4ba316..60918d14 100644
--- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
+++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
@@ -6,6 +6,8 @@
import React, { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
import { type ItemStatus } from '../BaseItem';
import { CollapsibleOutputSection } from './CollapsibleOutputSection';
@@ -27,6 +29,7 @@ export const DefaultToolViewer = memo(function DefaultToolViewer({
linkedTool,
status,
}: DefaultToolViewerProps) {
+ const { t } = useAppTranslation('common');
const displayOutputContent = linkedTool.result
? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content)
: null;
@@ -42,7 +45,7 @@ export const DefaultToolViewer = memo(function DefaultToolViewer({
{/* Input Section */}
- Input
+ {t('toolViewer.input')}
- {renderInput(linkedTool.name, linkedTool.input)}
+ {renderInput(linkedTool.name, linkedTool.input, {
+ replaceAll: t('toolViewer.replaceAll'),
+ agentAction: t('toolViewer.agent.action'),
+ agentTeammate: t('toolViewer.agent.teammate'),
+ agentTeam: t('toolViewer.agent.team'),
+ agentRuntime: t('toolViewer.agent.runtime'),
+ agentType: t('toolViewer.agent.type'),
+ startupInstructionsHidden: t('toolViewer.agent.startupInstructionsHidden'),
+ noInputRecorded: t('toolViewer.noInputRecorded'),
+ })}
diff --git a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx
index c537eb04..b2059cdb 100644
--- a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx
+++ b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx
@@ -6,6 +6,7 @@
import React, { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { DiffViewer } from '@renderer/components/chat/viewers';
import { type ItemStatus, StatusDot } from '../BaseItem';
@@ -24,6 +25,7 @@ export const EditToolViewer = memo(function EditToolViewer({
linkedTool,
status,
}: EditToolViewerProps) {
+ const { t } = useAppTranslation('common');
const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined;
const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string);
@@ -49,11 +51,11 @@ export const EditToolViewer = memo(function EditToolViewer({
className="mb-1 flex items-center gap-2 text-xs"
style={{ color: 'var(--tool-item-muted)' }}
>
- Result
+ {t('chat.tools.result')}
{linkedTool.result?.tokenCount !== undefined && linkedTool.result.tokenCount > 0 && (
- ~{formatTokens(linkedTool.result.tokenCount)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(linkedTool.result.tokenCount) })}
)}
diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx
index c2d14b6b..e1fd4124 100644
--- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx
+++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx
@@ -6,6 +6,7 @@
import React, { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
import type { LinkedToolItem } from '@renderer/types/groups';
@@ -15,6 +16,7 @@ interface ReadToolViewerProps {
}
export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadToolViewerProps) {
+ const { t } = useAppTranslation('common');
const filePath = linkedTool.input.file_path as string;
// Prefer enriched toolUseResult data
@@ -73,7 +75,7 @@ export const ReadToolViewer = memo(function ReadToolViewer({ linkedTool }: ReadT
border: '1px solid var(--tag-border)',
}}
>
- Code
+ {t('code.code')}
- Preview
+ {t('code.preview')}
)}
{isMarkdownFile && viewMode === 'preview' ? (
-
+
) : (
- Result
+ {t('chat.tools.result')}
- Skill Instructions
+ {t('chat.tools.skill.instructions')}
- Error
+ {t('states.error')}
| undefined;
const filePath =
@@ -37,7 +39,7 @@ export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: Wri
return (
- {isCreate ? 'Created file' : 'Wrote to file'}
+ {isCreate ? t('chat.tools.write.createdFile') : t('chat.tools.write.wroteToFile')}
{isMarkdownFile && (
@@ -51,7 +53,7 @@ export const WriteToolViewer = memo(function WriteToolViewer({ linkedTool }: Wri
border: '1px solid var(--tag-border)',
}}
>
- Code
+ {t('code.code')}
- Preview
+ {t('code.preview')}
)}
{isMarkdownFile && viewMode === 'preview' ? (
-
+
) : (
)}
diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx
index 924e5725..47c5b1d9 100644
--- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx
+++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx
@@ -15,10 +15,25 @@ import {
import { highlightLines } from '@renderer/utils/syntaxHighlighter';
import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary';
+export interface RenderInputLabels {
+ replaceAll: string;
+ agentAction: string;
+ agentTeammate: string;
+ agentTeam: string;
+ agentRuntime: string;
+ agentType: string;
+ startupInstructionsHidden: string;
+ noInputRecorded: string;
+}
+
/**
* Renders the input section based on tool type with theme-aware styling.
*/
-export function renderInput(toolName: string, input: Record
): React.ReactElement {
+export function renderInput(
+ toolName: string,
+ input: Record,
+ labels: RenderInputLabels
+): React.ReactElement {
const normalizedToolName = toolName.toLowerCase();
// Special rendering for Edit tool - show diff-like format
if (normalizedToolName === 'edit') {
@@ -34,7 +49,7 @@ export function renderInput(toolName: string, input: Record): R
{filePath}
{replaceAll && (
- (replace all)
+ {labels.replaceAll}
)}
@@ -110,7 +125,7 @@ export function renderInput(toolName: string, input: Record
): R
- action
+ {labels.agentAction}
{details.action}
@@ -118,7 +133,7 @@ export function renderInput(toolName: string, input: Record
): R
{details.teammateName && (
- teammate
+ {labels.agentTeammate}
{details.teammateName}
@@ -127,7 +142,7 @@ export function renderInput(toolName: string, input: Record): R
{details.teamName && (
- team
+ {labels.agentTeam}
{details.teamName}
@@ -136,7 +151,7 @@ export function renderInput(toolName: string, input: Record): R
{details.runtime && (
- runtime
+ {labels.agentRuntime}
{details.runtime}
@@ -145,7 +160,7 @@ export function renderInput(toolName: string, input: Record): R
{details.subagentType && (
- type
+ {labels.agentType}
{details.subagentType}
@@ -160,7 +175,7 @@ export function renderInput(toolName: string, input: Record): R
color: COLOR_TEXT_MUTED,
}}
>
- Startup instructions are hidden in the UI.
+ {labels.startupInstructionsHidden}
);
@@ -180,7 +195,7 @@ export function renderInput(toolName: string, input: Record): R
))
) : (
- No input recorded for this tool call.
+ {labels.noInputRecorded}
)}
diff --git a/src/renderer/components/chat/session-panel.ts b/src/renderer/components/chat/session-panel.ts
new file mode 100644
index 00000000..5e636ab6
--- /dev/null
+++ b/src/renderer/components/chat/session-panel.ts
@@ -0,0 +1 @@
+export { SessionContextPanel as SessionPanel } from './SessionContextPanel/index';
diff --git a/src/renderer/components/chat/viewers/CodeBlockViewer.tsx b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx
index 9edb7a64..997dcc41 100644
--- a/src/renderer/components/chat/viewers/CodeBlockViewer.tsx
+++ b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx
@@ -1,5 +1,6 @@
import React, { memo, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getBaseName } from '@renderer/utils/pathUtils';
import { createLogger } from '@shared/utils/logger';
import { Check, Copy, FileCode } from 'lucide-react';
@@ -125,6 +126,7 @@ export const CodeBlockViewer = memo(function CodeBlockViewer({
endLine,
maxHeight = 'max-h-96',
}: CodeBlockViewerProps): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const [isCopied, setIsCopied] = useState(false);
// Infer language from file extension if not provided
@@ -178,7 +180,7 @@ export const CodeBlockViewer = memo(function CodeBlockViewer({
{(startLine > 1 || endLine) && (
- (lines {startLine}-{actualEndLine})
+ {t('code.linesParenthesized', { from: startLine, to: actualEndLine })}
)}
{isCopied ? (
diff --git a/src/renderer/components/chat/viewers/DiffViewer.tsx b/src/renderer/components/chat/viewers/DiffViewer.tsx
index efb74cd6..63611209 100644
--- a/src/renderer/components/chat/viewers/DiffViewer.tsx
+++ b/src/renderer/components/chat/viewers/DiffViewer.tsx
@@ -1,5 +1,6 @@
import React, { memo, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CODE_BG,
CODE_BORDER,
@@ -357,6 +358,7 @@ export const DiffViewer = memo(function DiffViewer({
tokenCount,
syntaxHighlight = false,
}: DiffViewerProps): React.JSX.Element {
+ const { t } = useAppTranslation('common');
// Compute diff
const oldLines = oldString.split(/\r?\n/);
const newLines = newString.split(/\r?\n/);
@@ -431,12 +433,12 @@ export const DiffViewer = memo(function DiffViewer({
)}
{stats.removed > 0 && -{stats.removed} }
{stats.added === 0 && stats.removed === 0 && (
- Changed
+ {t('diff.changed')}
)}
{tokenCount !== undefined && tokenCount > 0 && (
- ~{formatTokens(tokenCount)} tokens
+ {t('tokens.approxTokens', { tokens: formatTokens(tokenCount) })}
)}
@@ -449,7 +451,7 @@ export const DiffViewer = memo(function DiffViewer({
))}
{diffLines.length === 0 && (
- No changes detected
+ {t('diff.noChangesDetected')}
)}
diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
index cca878d4..61d3a7f2 100644
--- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx
+++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { MemberHoverCard } from '@renderer/components/team/members/MemberHoverCard';
@@ -338,6 +339,7 @@ const LocalImage = React.memo(function LocalImage({
alt,
baseDir,
}: LocalImageProps): React.ReactElement {
+ const { t } = useAppTranslation('common');
const [dataUrl, setDataUrl] = React.useState(null);
const [error, setError] = React.useState(false);
@@ -366,7 +368,7 @@ const LocalImage = React.memo(function LocalImage({
if (error) {
return (
- [Image: {alt || src}]
+ {t('markdown.imageFallback', { label: alt || src })}
);
}
@@ -959,6 +961,7 @@ export const MarkdownViewer: React.FC = React.memo(function
teamColorByName: providedTeamColorByName,
onTeamClick: providedOnTeamClick,
}) {
+ const { t } = useAppTranslation('common');
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
const { isLight } = useTheme();
@@ -1016,7 +1019,7 @@ export const MarkdownViewer: React.FC = React.memo(function
{label}
- Raw
+ {t('markdown.raw')}
= React.memo(function
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(false)}
disabled={isTooLarge}
- title={
- isTooLarge
- ? 'Large content is shown as raw to prevent UI freeze'
- : 'Render markdown'
- }
+ title={isTooLarge ? t('markdown.largeContentTitle') : t('markdown.renderMarkdown')}
>
- Render markdown
+ {t('markdown.renderMarkdown')}
{copyable && }
@@ -1042,28 +1041,23 @@ export const MarkdownViewer: React.FC = React.memo(function
className="flex items-center justify-between px-3 py-2 text-xs"
style={{ color: COLOR_TEXT_MUTED }}
>
- Raw preview
+ {t('markdown.rawPreview')}
setShowRaw(false)}
disabled={isTooLarge}
- title={
- isTooLarge
- ? 'Large content is shown as raw to prevent UI freeze'
- : 'Render markdown'
- }
+ title={isTooLarge ? t('markdown.largeContentTitle') : t('markdown.renderMarkdown')}
>
- Render markdown
+ {t('markdown.renderMarkdown')}
)}
{isTooLarge && (
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to
- keep the UI responsive.
+ {t('markdown.largeContentNotice', { count: content.length.toLocaleString() })}
)}
@@ -1077,7 +1071,10 @@ export const MarkdownViewer: React.FC = React.memo(function
{isTruncated && (
- Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars
+ {t('markdown.showingChars', {
+ shown: shown.length.toLocaleString(),
+ total: content.length.toLocaleString(),
+ })}
= React.memo(function
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit((v) => Math.min(content.length, v * 2))}
>
- Show more
+ {t('markdown.showMore')}
= React.memo(function
style={{ borderColor: CODE_BORDER, color: PROSE_LINK }}
onClick={() => setRawLimit(content.length)}
>
- Show all
+ {t('markdown.showAll')}
@@ -1175,9 +1172,9 @@ export const MarkdownViewer: React.FC = React.memo(function
className="text-xs underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(true)}
- title="Show raw"
+ title={t('markdown.showRaw')}
>
- Show raw
+ {t('markdown.showRaw')}
{copyable && }
@@ -1195,9 +1192,9 @@ export const MarkdownViewer: React.FC = React.memo(function
className="underline"
style={{ color: PROSE_LINK }}
onClick={() => setShowRaw(true)}
- title="Show raw"
+ title={t('markdown.showRaw')}
>
- Show raw
+ {t('markdown.showRaw')}
)}
diff --git a/src/renderer/components/chat/viewers/MermaidDiagram.tsx b/src/renderer/components/chat/viewers/MermaidDiagram.tsx
index 41833bdc..c69b6557 100644
--- a/src/renderer/components/chat/viewers/MermaidDiagram.tsx
+++ b/src/renderer/components/chat/viewers/MermaidDiagram.tsx
@@ -9,6 +9,7 @@
import React, { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { PROSE_PRE_BG, PROSE_PRE_BORDER } from '@renderer/constants/cssVariables';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
@@ -52,6 +53,7 @@ interface MermaidDiagramProps {
export const MermaidDiagram = React.memo(function MermaidDiagram({
code,
}: MermaidDiagramProps): React.ReactElement {
+ const { t } = useAppTranslation('common');
const containerRef = useRef(null);
const [error, setError] = useState(null);
@@ -97,7 +99,7 @@ export const MermaidDiagram = React.memo(function MermaidDiagram({
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
- Mermaid syntax error
+ {t('code.mermaidSyntaxError')}
{code}
);
diff --git a/src/renderer/components/common/CliInstallWarningBanner.tsx b/src/renderer/components/common/CliInstallWarningBanner.tsx
index 3a9cb1f1..eca46c3e 100644
--- a/src/renderer/components/common/CliInstallWarningBanner.tsx
+++ b/src/renderer/components/common/CliInstallWarningBanner.tsx
@@ -6,12 +6,14 @@
* Only rendered in Electron mode.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { useStore } from '@renderer/store';
import { AlertTriangle } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
export const CliInstallWarningBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const cliStatus = useStore(useShallow((s) => s.cliStatus));
const cliStatusLoading = useStore((s) => s.cliStatusLoading);
const openDashboard = useStore((s) => s.openDashboard);
@@ -58,7 +60,7 @@ export const CliInstallWarningBanner = (): React.JSX.Element | null => {
color: 'var(--warning-text)',
}}
>
- Go to Dashboard
+ {t('actions.goToDashboard')}
);
diff --git a/src/renderer/components/common/ConfirmDialog.tsx b/src/renderer/components/common/ConfirmDialog.tsx
index 0633e5f1..1f2d3aec 100644
--- a/src/renderer/components/common/ConfirmDialog.tsx
+++ b/src/renderer/components/common/ConfirmDialog.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle } from 'lucide-react';
interface ConfirmDialogState {
@@ -67,6 +68,7 @@ export async function confirm(opts: {
* ConfirmDialog component. Mount once at the app root (e.g. in App.tsx).
*/
export const ConfirmDialog = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const [state, setState] = useState(initialState);
const dialogRef = useRef(null);
@@ -115,7 +117,7 @@ export const ConfirmDialog = (): React.JSX.Element | null => {
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={() => close(false)}
- aria-label="Close dialog"
+ aria-label={t('actions.closeDialog')}
tabIndex={-1}
/>
{
+ const { t } = useAppTranslation('common');
const isContextSwitching = useStore((state) => state.isContextSwitching);
const targetContextId = useStore((state) => state.targetContextId);
@@ -19,7 +21,9 @@ export const ContextSwitchOverlay: React.FC = () => {
// Format context label for display
const contextLabel =
- targetContextId === 'local' ? 'Local' : (targetContextId?.replace(/^ssh-/, '') ?? 'Unknown');
+ targetContextId === 'local'
+ ? t('context.local')
+ : (targetContextId?.replace(/^ssh-/, '') ?? t('states.unknown'));
return (
@@ -29,8 +33,8 @@ export const ContextSwitchOverlay: React.FC = () => {
{/* Text */}
-
Switching to {contextLabel}...
-
Loading workspace
+
{t('context.switchingTo', { workspace: contextLabel })}
+
{t('context.loadingWorkspace')}
diff --git a/src/renderer/components/common/CopyButton.tsx b/src/renderer/components/common/CopyButton.tsx
index 005a95ca..cee3ccea 100644
--- a/src/renderer/components/common/CopyButton.tsx
+++ b/src/renderer/components/common/CopyButton.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Check, Copy } from 'lucide-react';
interface CopyButtonProps {
@@ -26,6 +27,7 @@ export const CopyButton: React.FC = ({
bgColor = 'var(--code-bg)',
inline = false,
}) => {
+ const { t } = useAppTranslation('common');
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async (): Promise => {
@@ -49,7 +51,7 @@ export const CopyButton: React.FC = ({
{icon}
@@ -75,7 +77,7 @@ export const CopyButton: React.FC = ({
{icon}
diff --git a/src/renderer/components/common/ErrorBoundary.tsx b/src/renderer/components/common/ErrorBoundary.tsx
index 9c4b69cc..93f99b48 100644
--- a/src/renderer/components/common/ErrorBoundary.tsx
+++ b/src/renderer/components/common/ErrorBoundary.tsx
@@ -1,5 +1,6 @@
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { captureRendererException, isSentryRendererActive } from '@renderer/sentry';
import { useStore } from '@renderer/store';
import {
@@ -15,6 +16,19 @@ const logger = createLogger('Component:ErrorBoundary');
interface Props {
children: ReactNode;
fallback?: ReactNode;
+ labels?: ErrorBoundaryLabels;
+}
+
+interface ErrorBoundaryLabels {
+ title: string;
+ description: string;
+ componentStack: string;
+ tryAgain: string;
+ copied: string;
+ copyErrorDetails: string;
+ reportBugOnGitHub: string;
+ reloadApp: string;
+ diagnosticsNotice: string;
}
interface State {
@@ -24,7 +38,7 @@ interface State {
errorInfo: ErrorInfo | null;
}
-export class ErrorBoundary extends Component {
+class ErrorBoundaryInner extends Component {
private copyResetTimeout: ReturnType | null = null;
constructor(props: Props) {
@@ -136,7 +150,7 @@ export class ErrorBoundary extends Component {
// eslint-disable-next-line sonarjs/function-return-type -- Error boundaries inherently return different content based on error state
render(): ReactNode {
const { hasError, copiedReport, error, errorInfo } = this.state;
- const { children, fallback } = this.props;
+ const { children, fallback, labels } = this.props;
if (hasError) {
if (fallback) {
@@ -147,12 +161,11 @@ export class ErrorBoundary extends Component {
-
Something went wrong
+
{labels?.title}
- An unexpected error occurred in the application. You can try reloading the page or
- resetting the error state.
+ {labels?.description}
{error && (
@@ -161,7 +174,7 @@ export class ErrorBoundary extends Component
{
{errorInfo?.componentStack && (
- Component Stack
+ {labels?.componentStack}
{errorInfo.componentStack}
@@ -176,7 +189,7 @@ export class ErrorBoundary extends Component {
onClick={this.handleReset}
className="flex items-center gap-2 rounded-lg border border-claude-dark-border bg-claude-dark-surface px-4 py-2 transition-colors hover:bg-claude-dark-border"
>
- Try Again
+ {labels?.tryAgain}
void this.handleCopyErrorDetails()}
@@ -187,26 +200,25 @@ export class ErrorBoundary extends Component {
) : (
)}
- {copiedReport ? 'Copied' : 'Copy Error Details'}
+ {copiedReport ? labels?.copied : labels?.copyErrorDetails}
- Report Bug on GitHub
+ {labels?.reportBugOnGitHub}
- Reload App
+ {labels?.reloadApp}
- GitHub bug reports and copied diagnostics include the error message, stack traces, app
- version, active tab, selected team, task context, and environment details.
+ {labels?.diagnosticsNotice}
);
@@ -215,3 +227,24 @@ export class ErrorBoundary extends Component {
return children;
}
}
+
+export function ErrorBoundary(props: Omit): React.JSX.Element {
+ const { t } = useAppTranslation('common');
+
+ return (
+
+ );
+}
diff --git a/src/renderer/components/common/ExportDropdown.tsx b/src/renderer/components/common/ExportDropdown.tsx
index c35f71d9..def86290 100644
--- a/src/renderer/components/common/ExportDropdown.tsx
+++ b/src/renderer/components/common/ExportDropdown.tsx
@@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { triggerDownload } from '@renderer/utils/sessionExporter';
import { Braces, Download, FileText, Type } from 'lucide-react';
@@ -33,6 +34,7 @@ const FORMAT_OPTIONS: FormatOption[] = [
export const ExportDropdown = ({
sessionDetail,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isOpen, setIsOpen] = useState(false);
const [buttonHover, setButtonHover] = useState(false);
const [hoveredFormat, setHoveredFormat] = useState(null);
@@ -86,7 +88,7 @@ export const ExportDropdown = ({
color: buttonHover || isOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
- title="Export session"
+ title={t('export.session')}
>
@@ -108,7 +110,7 @@ export const ExportDropdown = ({
borderBottom: '1px solid var(--color-border)',
}}
>
- Export Session
+ {t('export.sessionTitle')}
{/* Format options */}
diff --git a/src/renderer/components/common/OngoingIndicator.tsx b/src/renderer/components/common/OngoingIndicator.tsx
index 86008604..a00112cd 100644
--- a/src/renderer/components/common/OngoingIndicator.tsx
+++ b/src/renderer/components/common/OngoingIndicator.tsx
@@ -5,6 +5,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Loader2 } from 'lucide-react';
interface OngoingIndicatorProps {
@@ -50,6 +51,8 @@ export const OngoingIndicator = ({
* Shows animated spinner and text.
*/
export const OngoingBanner = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+
return (
{
>
- Session is in progress...
+ {t('sessions.inProgress')}
);
diff --git a/src/renderer/components/common/ProviderActivityStatusStrip.tsx b/src/renderer/components/common/ProviderActivityStatusStrip.tsx
index 1c576dd7..c73ac632 100644
--- a/src/renderer/components/common/ProviderActivityStatusStrip.tsx
+++ b/src/renderer/components/common/ProviderActivityStatusStrip.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
@@ -236,8 +237,10 @@ export const ProviderActivityStatusStrip = ({
codexSnapshotPending = false,
providerIds,
className = '',
- label = 'Provider Activity',
+ label,
}: ProviderActivityStatusStripProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
+ const effectiveLabel = label ?? t('providerRuntime.connectionUi.status.providerActivity');
const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({
cliStatus,
sourceCliStatus,
@@ -254,12 +257,12 @@ export const ProviderActivityStatusStrip = ({
return (
- {label ? (
+ {effectiveLabel ? (
- {label}
+ {effectiveLabel}
) : null}
@@ -277,10 +280,10 @@ export const ProviderActivityStatusStrip = ({
const styles = getActivityToneStyles(tone);
const statusText =
tone === 'loading'
- ? 'Checking...'
+ ? t('providerRuntime.connectionUi.status.checking')
: tone === 'error'
- ? formatProviderStatusText(providerState.provider)
- : 'Checked';
+ ? formatProviderStatusText(providerState.provider, t)
+ : t('providerRuntime.connectionUi.status.checked');
return (
): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);
@@ -115,7 +117,7 @@ export const RepositoryDropdown = ({
>
- {isEmpty ? 'No repositories available' : placeholder}
+ {isEmpty ? t('repositories.noneAvailable') : placeholder}
void;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
return (
)}
- {item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}
+ {t('sessions.count', { count: item.totalSessions })}
{item.path}
@@ -190,6 +193,7 @@ const SelectedRepositoryItemInner = ({
onRemove: () => void;
disabled?: boolean;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
return (
@@ -212,7 +216,7 @@ const SelectedRepositoryItemInner = ({
onClick={onRemove}
disabled={disabled}
className={`shrink-0 rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
- aria-label="Remove repository"
+ aria-label={t('repositories.remove')}
>
): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const { tokensByCategory } = contextStats;
@@ -139,13 +141,14 @@ const SessionContextSection = ({
- Visible Context
+ {t('tokens.visibleContext')}
- {formatTokens(contextStats.totalEstimatedTokens)} ({contextPercent}% of prompt input)
+ {formatTokens(contextStats.totalEstimatedTokens)} (
+ {t('tokens.promptInputShare', { percent: contextPercent })})
@@ -156,11 +159,13 @@ const SessionContextSection = ({
{tokensByCategory.claudeMd > 0 && (
- CLAUDE.md ×{claudeMdCount}
+ {t('tokens.claudeMd')} ×{claudeMdCount}
{formatTokens(tokensByCategory.claudeMd)}{' '}
- ({claudeMdPercent}%)
+
+ {t('tokens.percentValue', { percent: claudeMdPercent })}
+
)}
@@ -169,11 +174,14 @@ const SessionContextSection = ({
{tokensByCategory.mentionedFiles > 0 && (
- @files ×{mentionedFilesCount}
+ {t('tokens.mentionedFiles')}{' '}
+ ×{mentionedFilesCount}
{formatTokens(tokensByCategory.mentionedFiles)}{' '}
- ({mentionedFilesPercent}%)
+
+ {t('tokens.percentValue', { percent: mentionedFilesPercent })}
+
)}
@@ -182,11 +190,13 @@ const SessionContextSection = ({
{tokensByCategory.toolOutputs > 0 && (
- Tool Outputs ×{toolOutputsCount}
+ {t('tokens.toolOutputs')} ×{toolOutputsCount}
{formatTokens(tokensByCategory.toolOutputs)}{' '}
- ({toolOutputsPercent}%)
+
+ {t('tokens.percentValue', { percent: toolOutputsPercent })}
+
)}
@@ -195,11 +205,14 @@ const SessionContextSection = ({
{tokensByCategory.taskCoordination > 0 && (
- Task Coordination ×{taskCoordinationCount}
+ {t('tokens.taskCoordination')}{' '}
+ ×{taskCoordinationCount}
{formatTokens(tokensByCategory.taskCoordination)}{' '}
- ({taskCoordinationPercent}%)
+
+ {t('tokens.percentValue', { percent: taskCoordinationPercent })}
+
)}
@@ -208,11 +221,13 @@ const SessionContextSection = ({
{tokensByCategory.userMessages > 0 && (
- User Messages ×{userMessagesCount}
+ {t('tokens.userMessages')} ×{userMessagesCount}
{formatTokens(tokensByCategory.userMessages)}{' '}
- ({userMessagesPercent}%)
+
+ {t('tokens.percentValue', { percent: userMessagesPercent })}
+
)}
@@ -220,10 +235,12 @@ const SessionContextSection = ({
{/* Thinking + Text */}
{tokensByCategory.thinkingText > 0 && (
- Thinking + Text
+ {t('tokens.thinkingText')}
{formatTokens(tokensByCategory.thinkingText)}{' '}
- ({thinkingTextPercent}%)
+
+ {t('tokens.percentValue', { percent: thinkingTextPercent })}
+
)}
@@ -233,7 +250,7 @@ const SessionContextSection = ({
className="pt-0.5 text-[9px] italic"
style={{ color: COLOR_TEXT_MUTED, opacity: 0.7 }}
>
- Accumulated across entire session without duplication
+ {t('tokens.accumulatedWithoutDuplication')}
)}
@@ -255,6 +272,7 @@ export const TokenUsageDisplay = ({
totalPhases,
costUsd,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens;
// Total prompt-side tokens only (without output) - used as denominator for visible context %
const totalInputTokens = inputTokens + cacheReadTokens + cacheCreationTokens;
@@ -391,7 +409,7 @@ export const TokenUsageDisplay = ({
className="rounded px-1 py-0.5 text-[10px]"
style={{ backgroundColor: 'rgba(99, 102, 241, 0.15)', color: '#818cf8' }}
>
- Phase {phaseNumber}/{totalPhases}
+ {t('tokens.phase', { phase: phaseNumber, total: totalPhases })}
)}
{/* Input Tokens */}
-
Input Tokens
+
{t('tokens.inputTokens')}
- Cache Read
+ {t('tokens.cacheRead')}
- Cache Write
+ {t('tokens.cacheWrite')}
- Output Tokens
+ {t('tokens.outputTokens')}
- Total
+ {t('tokens.total')}
0 && (
-
Cost (USD)
+
{t('tokens.costUsd')}
- incl. CLAUDE.md ×{claudeMdStats.accumulatedCount}
+ {t('tokens.includesClaudeMd', { count: claudeMdStats.accumulatedCount })}
{totalInputTokens > 0
@@ -561,7 +579,7 @@ export const TokenUsageDisplay = ({
style={{ borderTop: '1px solid var(--color-border-subtle)' }}
/>
-
Model
+
{t('tokens.model')}
{
+ const { t } = useAppTranslation('common');
const {
showUpdateBanner,
updateStatus,
@@ -57,7 +59,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Updating app
+ {t('updates.updatingApp')}
{clampedPercent}%
@@ -76,7 +78,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
- Update ready
+ {t('updates.updateReady')}
{availableVersion ? (
v{availableVersion}
@@ -94,7 +96,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
} as React.CSSProperties
}
>
- Restart now
+ {t('updates.restartNow')}
)}
diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx
index 72fa533d..82e5c846 100644
--- a/src/renderer/components/common/UpdateDialog.tsx
+++ b/src/renderer/components/common/UpdateDialog.tsx
@@ -9,6 +9,7 @@
import { useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
import { useStore } from '@renderer/store';
@@ -19,6 +20,7 @@ import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
export const UpdateDialog = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const {
showUpdateDialog,
updateStatus,
@@ -117,7 +119,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={dismissUpdateDialog}
- aria-label="Close dialog"
+ aria-label={t('updateDialog.closeDialog')}
tabIndex={-1}
/>
{
className="relative mx-4 w-full max-w-2xl rounded-md border p-5 shadow-lg"
role="dialog"
aria-modal="true"
- aria-label="Update available"
+ aria-label={t('updateDialog.updateAvailable')}
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
@@ -142,7 +144,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
- {isDownloaded ? 'Update Ready' : 'Update Available'}
+ {isDownloaded ? t('updateDialog.updateReady') : t('updateDialog.updateAvailable')}
{availableVersion && (
{
) : (
- No release notes available.
+ {t('updateDialog.noReleaseNotes')}
)}
@@ -192,7 +194,7 @@ export const UpdateDialog = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }}
>
- View on GitHub
+ {t('updateDialog.viewOnGitHub')}
)}
@@ -204,21 +206,21 @@ export const UpdateDialog = (): React.JSX.Element | null => {
color: 'var(--color-text-secondary)',
}}
>
- Later
+ {t('updateDialog.later')}
{isDownloaded ? (
- Restart now
+ {t('updateDialog.restartNow')}
) : (
- Download
+ {t('updateDialog.download')}
)}
diff --git a/src/renderer/components/common/WorkspaceIndicator.tsx b/src/renderer/components/common/WorkspaceIndicator.tsx
index fbd70624..b5355eb8 100644
--- a/src/renderer/components/common/WorkspaceIndicator.tsx
+++ b/src/renderer/components/common/WorkspaceIndicator.tsx
@@ -8,6 +8,7 @@
import { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { Check, ChevronDown } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -15,6 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
import { ConnectionStatusBadge } from './ConnectionStatusBadge';
export const WorkspaceIndicator = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const { activeContextId, isContextSwitching, availableContexts, switchContext } = useStore(
useShallow((s) => ({
activeContextId: s.activeContextId,
@@ -109,7 +111,7 @@ export const WorkspaceIndicator = (): React.JSX.Element | null => {
className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
- Switch Workspace
+ {t('context.switchWorkspace')}
{/* Context list */}
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx
index 4f3a4134..ed06f95b 100644
--- a/src/renderer/components/dashboard/CliStatusBanner.tsx
+++ b/src/renderer/components/dashboard/CliStatusBanner.tsx
@@ -15,6 +15,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import atlasCloudLogo from '@renderer/assets/atlascloud-logo.svg';
import { confirm } from '@renderer/components/common/ConfirmDialog';
@@ -110,9 +111,6 @@ const ANTHROPIC_LIMIT_REFRESH_INTERVAL_MS = 60 * 1000;
const SHOW_ATLAS_CLOUD_OPENCODE_BANNER = false;
const ATLAS_CLOUD_OPENCODE_PROVIDER_ID = 'atlascloud';
const ATLAS_CLOUD_CODING_PLAN_URL = 'https://www.atlascloud.ai/console/coding-plan';
-const ATLAS_CLOUD_DESCRIPTION =
- "Atlas Cloud is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out Atlas Cloud's new coding plan promotion for more budget-friendly API access.";
-
const ProviderRuntimeSettingsDialog = lazy(() =>
import('@renderer/components/runtime/ProviderRuntimeSettingsDialog').then((module) => ({
default: module.ProviderRuntimeSettingsDialog,
@@ -135,78 +133,92 @@ const DashboardRateLimitChips = ({
}: {
providerId: CliProviderId;
items: DashboardRateLimitItem[];
-}): React.JSX.Element => (
-
- {items.map((item) => (
-
-
-
- {item.label}
-
-
- {item.remaining}
-
-
- • resets {item.resetsAt}
-
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+
+ return (
+
+ {items.map((item) => (
+
+
+
+ {item.label}
+
+
+ {item.remaining}
+
+
+ • {t('cliStatus.labels.resets', { time: item.resetsAt })}
+
+
-
- ))}
-
-);
+ ))}
+
+ );
+};
const RATE_LIMIT_SKELETON_LABELS = ['5h left', 'Weekly left'] as const;
-const DashboardRateLimitSkeletonChips = (): React.JSX.Element => (
-
- {RATE_LIMIT_SKELETON_LABELS.map((label, index) => (
-
- ))}
-
-);
+const DashboardRateLimitSkeletonChips = (): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
-function getCodexDashboardHint(provider: CliProviderStatus): string | null {
+ return (
+
+ {RATE_LIMIT_SKELETON_LABELS.map((label, index) => (
+
+ ))}
+
+ );
+};
+
+function getCodexDashboardHint(
+ provider: CliProviderStatus,
+ t: ReturnType
['t']
+): string | null {
if (provider.providerId !== 'codex') {
return null;
}
@@ -217,25 +229,23 @@ function getCodexDashboardHint(provider: CliProviderStatus): string | null {
}
if (codex.login.status === 'starting' || codex.login.status === 'pending') {
- return codex.login.authUrl
- ? 'Finish ChatGPT login in the browser. Enter the shown code if prompted.'
- : null;
+ return codex.login.authUrl ? t('cliStatus.hints.codexFinishLogin') : null;
}
const usageHint = codex.localActiveChatgptAccountPresent
- ? 'Usage limits appear only after Codex refreshes the currently selected ChatGPT session. Right now the local session needs reconnect.'
+ ? t('cliStatus.hints.codexReconnectNeeded')
: codex.localAccountArtifactsPresent
- ? 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Local Codex account data exists, but no active managed session is selected right now.'
- : 'Usage limits appear only after Codex CLI sees an active ChatGPT account. Right now it reports no active ChatGPT login.';
+ ? t('cliStatus.hints.codexNoActiveManagedSession')
+ : t('cliStatus.hints.codexNoActiveLogin');
if (
provider.connection?.configuredAuthMode === 'chatgpt' &&
provider.connection.apiKeyConfigured
) {
- return `${usageHint} API key fallback is available if you switch auth mode.`;
+ return t('cliStatus.hints.codexApiKeyFallback', { hint: usageHint });
}
if (provider.connection?.configuredAuthMode === 'auto' && provider.connection.apiKeyConfigured) {
- return `${usageHint} Auto will keep using the API key until ChatGPT is connected.`;
+ return t('cliStatus.hints.codexAutoApiKey', { hint: usageHint });
}
return provider.connection?.configuredAuthMode === 'chatgpt' ? usageHint : null;
@@ -261,20 +271,27 @@ const InstallCompletedNotice = ({
}: {
version: string | null;
runtimeDisplayName: string;
-}): React.JSX.Element => (
-
-
-
- Successfully installed {runtimeDisplayName} v{version ?? 'latest'}
-
-
-);
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+
+ return (
+
+
+
+ {t('cliStatus.installer.success', {
+ runtime: runtimeDisplayName,
+ version: version ?? 'latest',
+ })}
+
+
+ );
+};
/** Error display with multi-line support */
const ErrorDisplay = ({
@@ -284,6 +301,7 @@ const ErrorDisplay = ({
error: string;
onRetry: () => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const lines = error.split('\n');
const title = lines[0];
const details = lines.slice(1).filter(Boolean);
@@ -321,7 +339,7 @@ const ErrorDisplay = ({
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Retry
+ {t('cliStatus.actions.retry')}
@@ -341,6 +359,7 @@ const CliCheckingSpinner = ({
styles: { border: string; bg: string };
label: string;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const [showHint, setShowHint] = useState(false);
useEffect(() => {
@@ -363,7 +382,7 @@ const CliCheckingSpinner = ({
{showHint && (
- First check may take up to 30 seconds
+ {t('cliStatus.hints.firstCheckSlow')}
)}
@@ -506,7 +525,8 @@ function isPendingMultimodelProviderStatus(provider: CliProviderStatus): boolean
function formatRuntimeAuthSummary(
cliStatus: NonNullable['cliStatus']>,
- visibleProviders: readonly CliProviderStatus[]
+ visibleProviders: readonly CliProviderStatus[],
+ t: ReturnType['t']
): string | null {
if (isMultimodelRuntimeStatus(cliStatus)) {
if (visibleProviders.length === 0) {
@@ -514,20 +534,20 @@ function formatRuntimeAuthSummary(
}
if (visibleProviders.every(isPendingMultimodelProviderStatus)) {
- return 'Checking providers...';
+ return t('cliStatus.provider.checkingProviders');
}
const denominator = visibleProviders.length;
const connected = visibleProviders.filter((provider) => provider.authenticated).length;
- return `Providers: ${connected}/${denominator} connected`;
+ return t('cliStatus.provider.connectedCount', { connected, denominator });
}
if (cliStatus.authStatusChecking) {
- return 'Checking authentication...';
+ return t('cliStatus.provider.checkingAuthentication');
}
if (cliStatus.authLoggedIn) {
- return 'Authenticated';
+ return t('cliStatus.provider.authenticated');
}
return null;
@@ -625,32 +645,35 @@ function isRuntimeInstalling(
);
}
-function getRuntimeInstallLabel(status: OpenCodeRuntimeStatus | CodexRuntimeStatus | null): string {
+function getRuntimeInstallLabel(
+ status: OpenCodeRuntimeStatus | CodexRuntimeStatus | null,
+ t: ReturnType['t']
+): string {
if (status?.state === 'downloading') {
const percent = status.progress?.percent;
- return typeof percent === 'number' ? `Downloading ${percent}%` : 'Downloading';
+ return typeof percent === 'number'
+ ? t('cliStatus.runtimeInstall.downloadingPercent', { percent })
+ : t('cliStatus.runtimeInstall.downloading');
}
if (status?.state === 'installing') {
- return 'Installing';
+ return t('cliStatus.runtimeInstall.installing');
}
if (status?.state === 'checking') {
- return 'Checking';
+ return t('cliStatus.runtimeInstall.checking');
}
if (status?.state === 'failed') {
- return 'Retry install';
+ return t('cliStatus.runtimeInstall.retryInstall');
}
- return 'Install';
+ return t('cliStatus.runtimeInstall.install');
}
-const OPENCODE_PROVIDER_FREE_BADGE_TITLE =
- 'OpenCode includes free model options such as Big Pickle when available in your setup. OpenRouter through OpenCode can also expose free models, but not every OpenCode/OpenRouter model is free. Availability and limits may change.';
-
function shouldShowOpenCodeProviderFreeBadge(provider: CliProviderStatus): boolean {
return provider.providerId === 'opencode';
}
function getOpenCodeDashboardChips(
- provider: CliProviderStatus
+ provider: CliProviderStatus,
+ t: ReturnType['t']
): { label: string; title?: string }[] {
if (!shouldShowOpenCodeProviderFreeBadge(provider)) {
return [];
@@ -670,22 +693,24 @@ function getOpenCodeDashboardChips(
return [
{
- label: 'Free models',
- title: OPENCODE_PROVIDER_FREE_BADGE_TITLE,
+ label: t('cliStatus.provider.freeModels'),
+ title: t('cliStatus.provider.freeModelsTitle'),
},
...(configuredLocalCount > 0
? [
{
- label: `${configuredLocalCount} configured local`,
- title: 'Local OpenCode routes imported from your OpenCode config.',
+ label: t('cliStatus.provider.configuredLocalCount', {
+ count: configuredLocalCount,
+ }),
+ title: t('cliStatus.provider.configuredLocalTitle'),
},
]
: []),
...(verifiedCount > 0
? [
{
- label: `${verifiedCount} verified`,
- title: 'OpenCode routes with a successful execution proof.',
+ label: t('cliStatus.provider.verifiedCount', { count: verifiedCount }),
+ title: t('cliStatus.provider.verifiedTitle'),
},
]
: []),
@@ -698,93 +723,97 @@ const OpenCodeAtlasCloudBanner = ({
}: {
disabled: boolean;
onConnect: () => void;
-}): React.JSX.Element => (
-
-
-
-
-
- Atlas Cloud coding plan
-
-
- Sponsor
-
-
- OpenCode provider
-
-
-
-
-
- Connect
-
-
void api.openExternal(ATLAS_CLOUD_CODING_PLAN_URL)}
- className="flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
- style={{
- borderColor: 'var(--color-border)',
- color: 'var(--color-text-muted)',
- }}
- >
-
- Plan
-
-
-
- Become a sponsor
-
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+
+ return (
+
+
+
+
+
+ {t('cliStatus.atlas.plan')}
+
+
+ {t('cliStatus.atlas.sponsor')}
+
+
+ {t('cliStatus.atlas.openCodeProvider')}
+
+
+
+
+
+ {t('cliStatus.actions.connect')}
+
+ void api.openExternal(ATLAS_CLOUD_CODING_PLAN_URL)}
+ className="flex items-center gap-1 rounded-md border px-2 py-1 text-[10px] font-medium transition-colors hover:bg-white/5"
+ style={{
+ borderColor: 'var(--color-border)',
+ color: 'var(--color-text-muted)',
+ }}
+ >
+
+ {t('cliStatus.actions.plan')}
+
+
+
+ {t('cliStatus.actions.becomeSponsor')}
+
+
+
+ {t('cliStatus.atlas.description')}
+
-
- {ATLAS_CLOUD_DESCRIPTION}
-
-
-);
+ );
+};
const InstalledBanner = ({
cliStatus,
@@ -817,6 +846,8 @@ const InstalledBanner = ({
codexReconnectBusy,
variant,
}: InstalledBannerProps): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
+ const { t: settingsT } = useAppTranslation('settings');
const openExtensionsTab = useStore((s) => s.openExtensionsTab);
const styles = VARIANT_STYLES[variant];
const visibleProviders = useMemo(
@@ -825,7 +856,7 @@ const InstalledBanner = ({
);
const canOpenExtensions = cliStatus.installed;
const runtimeLabel = formatRuntimeLabel(cliStatus);
- const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders);
+ const runtimeAuthSummary = formatRuntimeAuthSummary(cliStatus, visibleProviders, t);
const showCollapseControl = visibleProviders.length > 0;
const showExpandedContent = !providersCollapsed;
@@ -845,10 +876,16 @@ const InstalledBanner = ({
className="flex items-center justify-center rounded-md p-1 transition-colors hover:bg-white/5"
style={{ color: 'var(--color-text-muted)' }}
aria-label={
- providersCollapsed ? 'Expand provider details' : 'Collapse provider details'
+ providersCollapsed
+ ? t('cliStatus.labels.expandProviderDetails')
+ : t('cliStatus.labels.collapseProviderDetails')
}
aria-expanded={!providersCollapsed}
- title={providersCollapsed ? 'Expand provider details' : 'Collapse provider details'}
+ title={
+ providersCollapsed
+ ? t('cliStatus.labels.expandProviderDetails')
+ : t('cliStatus.labels.collapseProviderDetails')
+ }
>
{providersCollapsed ? (
@@ -875,7 +912,7 @@ const InstalledBanner = ({
style={{ backgroundColor: '#3b82f6' }}
>
- Update to v{cliStatus.latestVersion}
+ {t('cliStatus.actions.updateTo', { version: cliStatus.latestVersion })}
) : cliStatus.supportsSelfUpdate ? (
- {cliStatusLoading ? 'Checking...' : 'Check for Updates'}
+ {cliStatusLoading
+ ? t('cliStatus.actions.checking')
+ : t('cliStatus.actions.checkUpdates')}
) : null}
@@ -917,14 +956,14 @@ const InstalledBanner = ({
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Extensions
+ {t('cliStatus.actions.extensions')}
)}
{showExpandedContent && cliStatusError && !cliStatusLoading && (
- Failed to check for updates. Check your network connection and try again.
+ {t('cliStatus.errors.refreshFailed')}
)}
{showExpandedContent && visibleProviders.length > 0 && (
@@ -935,10 +974,10 @@ const InstalledBanner = ({
{visibleProviders.map((provider) => {
const actionDisabled = isBusy || !cliStatus.binaryPath;
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
- ? getProviderCurrentRuntimeSummary(provider)
+ ? getProviderCurrentRuntimeSummary(provider, settingsT)
: getProviderRuntimeBackendSummary(provider);
- const connectionModeSummary = getProviderConnectionModeSummary(provider);
- const credentialSummary = getProviderCredentialSummary(provider);
+ const connectionModeSummary = getProviderConnectionModeSummary(provider, settingsT);
+ const credentialSummary = getProviderCredentialSummary(provider, settingsT);
const dashboardRateLimits = getDashboardRateLimitsForProvider(provider);
const hasDashboardRateLimits = Boolean(dashboardRateLimits?.length);
const isSubscriptionRateLimitMode = isDashboardRateLimitSubscriptionMode({
@@ -946,7 +985,7 @@ const InstalledBanner = ({
sourceProvider: sourceProviderMap.get(provider.providerId) ?? null,
configuredAuthModes: providerConnectionAuthModes,
});
- const codexDashboardHint = getCodexDashboardHint(provider);
+ const codexDashboardHint = getCodexDashboardHint(provider, t);
const codexNeedsReconnect =
provider.providerId === 'codex' &&
Boolean(provider.connection?.codex?.localActiveChatgptAccountPresent) &&
@@ -956,7 +995,7 @@ const InstalledBanner = ({
const codexLoginAuthUrl = provider.connection?.codex?.login.authUrl ?? null;
const codexLoginUserCode = provider.connection?.codex?.login.userCode ?? null;
const showCodexLoginActions = codexNeedsReconnect || Boolean(codexLoginAuthUrl);
- const disconnectAction = getProviderDisconnectAction(provider);
+ const disconnectAction = getProviderDisconnectAction(provider, settingsT);
const providerLoading = cliProviderStatusLoading[provider.providerId] === true;
const sourceProvider = sourceProviderMap.get(provider.providerId) ?? null;
const maskNegativeBootstrapState = shouldMaskCodexNegativeBootstrapState(
@@ -982,7 +1021,9 @@ const InstalledBanner = ({
hasRateLimits: hasDashboardRateLimits,
loading: rateLimitsLoading,
});
- const statusText = showSkeleton ? 'Checking...' : formatProviderStatusText(provider);
+ const statusText = showSkeleton
+ ? t('cliStatus.actions.checking')
+ : formatProviderStatusText(provider, settingsT);
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading' ||
isOpenCodeCatalogHydrating(provider);
@@ -991,7 +1032,7 @@ const InstalledBanner = ({
? getVisibleTeamProviderModels(provider.providerId, provider.models, provider)
.length > 0
: provider.models.length > 0;
- const openCodeDashboardChips = getOpenCodeDashboardChips(provider);
+ const openCodeDashboardChips = getOpenCodeDashboardChips(provider, t);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
@@ -1050,20 +1091,24 @@ const InstalledBanner = ({
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && !runtimeSummary && (
- Backend: {provider.backend.label}
+
+ {t('cliStatus.provider.backend', { backend: provider.backend.label })}
+
)}
{runtimeSummary ? (
{isConnectionManagedRuntimeProvider(provider)
? runtimeSummary
- : `Runtime: ${runtimeSummary}`}
+ : t('cliStatus.provider.runtime', { runtime: runtimeSummary })}
) : null}
{connectionModeSummary ? {connectionModeSummary} : null}
{credentialSummary ? {credentialSummary} : null}
- {modelCatalogLoading ? Loading models... : null}
+ {modelCatalogLoading ? (
+ {t('cliStatus.provider.loadingModels')}
+ ) : null}
{!hasProviderModels && !modelCatalogLoading && (
- Models unavailable for this runtime build
+ {t('cliStatus.provider.modelsUnavailable')}
)}
) : null}
@@ -1099,7 +1144,7 @@ const InstalledBanner = ({
color: '#fbbf24',
}}
>
- Use code
+ {t('cliStatus.actions.useCode')}
) : null}
- {codexLoginAuthUrl ? 'Open login' : 'Generate link'}
+ {codexLoginAuthUrl
+ ? t('cliStatus.labels.openLogin')
+ : t('cliStatus.labels.generateLink')}
>
) : null}
@@ -1144,7 +1191,7 @@ const InstalledBanner = ({
title={
codexRuntimeStatus?.error ??
codexRuntimeStatus?.progress?.detail ??
- 'Install Codex CLI into app data'
+ t('cliStatus.runtimeInstall.codexTitle')
}
>
{isRuntimeInstalling(codexRuntimeStatus, codexRuntimeStatusLoading) ? (
@@ -1152,7 +1199,7 @@ const InstalledBanner = ({
) : (
)}
- {getRuntimeInstallLabel(codexRuntimeStatus)}
+ {getRuntimeInstallLabel(codexRuntimeStatus, t)}
) : null}
{shouldShowOpenCodeInstallAction(
@@ -1175,7 +1222,7 @@ const InstalledBanner = ({
title={
openCodeRuntimeStatus?.error ??
openCodeRuntimeStatus?.progress?.detail ??
- 'Install OpenCode runtime into app data'
+ t('cliStatus.runtimeInstall.openCodeTitle')
}
>
{isRuntimeInstalling(
@@ -1186,7 +1233,7 @@ const InstalledBanner = ({
) : (
)}
- {getRuntimeInstallLabel(openCodeRuntimeStatus)}
+ {getRuntimeInstallLabel(openCodeRuntimeStatus, t)}
) : null}
- Manage
+ {t('cliStatus.actions.manage')}
{disconnectAction ? (
- {getProviderConnectLabel(provider)}
+ {getProviderConnectLabel(provider, settingsT)}
) : null}
{
+ const { t } = useAppTranslation('dashboard');
+ const { t: settingsT } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const selectedProjectId = useStore((s) => s.selectedProjectId);
@@ -1543,7 +1594,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
void (async () => {
const provider =
effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
- const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
+ const disconnectAction = provider ? getProviderDisconnectAction(provider, settingsT) : null;
if (!disconnectAction) {
return;
}
@@ -1552,7 +1603,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
title: disconnectAction.title,
message: disconnectAction.message,
confirmLabel: disconnectAction.confirmLabel,
- cancelLabel: 'Cancel',
+ cancelLabel: t('cliStatus.actions.cancel'),
variant: 'danger',
});
@@ -1563,7 +1614,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
setProviderTerminal({ providerId, action: 'logout' });
})();
},
- [effectiveCliStatus?.providers]
+ [effectiveCliStatus?.providers, settingsT, t]
);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
@@ -1616,10 +1667,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
try {
await fetchCliProviderStatus(providerId);
} catch {
- throw new Error('Runtime updated, but failed to refresh provider status.');
+ throw new Error(t('cliStatus.errors.runtimeUpdatedRefreshFailed'));
}
},
- [appConfig?.runtime?.providerBackends, fetchCliProviderStatus, updateConfig]
+ [appConfig?.runtime?.providerBackends, fetchCliProviderStatus, t, updateConfig]
);
if (!isElectron) return null;
@@ -1693,7 +1744,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{
autoCloseOnSuccessMs={3000}
successMessage={
providerTerminal.action === 'login'
- ? 'Authentication updated'
- : 'Provider logged out'
+ ? t('cliStatus.labels.loginAuthUpdated')
+ : t('cliStatus.labels.loggedOut')
}
failureMessage={
- providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ providerTerminal.action === 'login'
+ ? t('cliStatus.labels.loginAuthFailed')
+ : t('cliStatus.labels.logoutFailed')
}
/>
@@ -1736,7 +1791,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
- Failed to check CLI status
+ {t('cliStatus.errors.checkStatusFailed')}
{
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Retry
+ {t('cliStatus.actions.retry')}
@@ -1761,7 +1816,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
- {runtimeDisplayName} status will be checked in the background.
+ {t('cliStatus.hints.backgroundStatus', { runtime: runtimeDisplayName })}
{
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Check now
+ {t('cliStatus.actions.checkNow')}
);
@@ -1816,7 +1871,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
return (
);
}
@@ -1832,7 +1889,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
- Downloading {runtimeDisplayName}...
+ {t('cliStatus.installer.downloading', { runtime: runtimeDisplayName })}
@@ -1864,7 +1921,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
// ── Checking / Verifying ───────────────────────────────────────────────
if (installerState === 'checking' || installerState === 'verifying') {
const label =
- installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...';
+ installerState === 'checking'
+ ? t('cliStatus.installer.checkingLatest')
+ : t('cliStatus.installer.verifying');
return (
{
- Installing {runtimeDisplayName}...
+ {t('cliStatus.installer.installing', { runtime: runtimeDisplayName })}
@@ -1919,7 +1978,10 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
-
+
);
}
@@ -1943,13 +2005,17 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{cliLaunchIssue
- ? `${runtimeDisplayName} was found but failed to start`
- : `${runtimeDisplayName} is required`}
+ ? t('cliStatus.runtime.foundButFailed', { runtime: runtimeDisplayName })
+ : t('cliStatus.runtime.isRequired', { runtime: runtimeDisplayName })}
{cliLaunchIssue
- ? `The app found the configured ${runtimeDisplayName}, but its startup health check failed. Repair or reinstall it, then retry.`
- : `${runtimeDisplayName} is required for team provisioning and session management. Install it to get started.`}
+ ? t('cliStatus.runtime.healthCheckFailedDescription', {
+ runtime: runtimeDisplayName,
+ })
+ : t('cliStatus.runtime.installRequiredDescription', {
+ runtime: runtimeDisplayName,
+ })}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
{
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
- Re-check
+ {t('cliStatus.actions.recheck')}
{renderCliStatus.supportsSelfUpdate ? (
{
>
{cliLaunchIssue
- ? `Reinstall ${runtimeDisplayName}`
- : `Install ${runtimeDisplayName}`}
+ ? t('cliStatus.runtime.reinstall', { runtime: runtimeDisplayName })
+ : t('cliStatus.runtime.install', { runtime: runtimeDisplayName })}
) : (
{cliLaunchIssue
- ? `The configured ${runtimeDisplayName} failed its startup health check.`
- : `The configured ${runtimeDisplayName} was not found.`}
+ ? t('cliStatus.runtime.configuredHealthCheckFailed', {
+ runtime: runtimeDisplayName,
+ })
+ : t('cliStatus.runtime.configuredNotFound', { runtime: runtimeDisplayName })}
)}
@@ -2071,18 +2139,22 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
hasApiKeyModeIssue && apiKeyMissingProviders.length === apiKeyActionRequiredProviders.length;
const warningTitle = hasApiKeyModeIssue
? allApiKeyIssuesAreMissingKeys
- ? 'API key required'
- : 'Provider action required'
- : 'Not logged in';
+ ? t('cliStatus.labels.apiKeyRequired')
+ : t('cliStatus.labels.providerActionRequired')
+ : t('cliStatus.labels.notLoggedIn');
const warningMessage = hasApiKeyModeIssue
? allApiKeyIssuesAreMissingKeys
? apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider
- ? `${primaryApiKeyProvider.displayName} is set to API key mode, but no API key is configured. Open Manage Providers to add a key or switch the connection mode.`
- : 'One or more providers are set to API key mode, but no API key is configured. Open Manage Providers to add keys or switch the connection mode.'
+ ? t('cliStatus.warnings.singleApiKeyMissing', {
+ provider: primaryApiKeyProvider.displayName,
+ })
+ : t('cliStatus.warnings.multipleApiKeysMissing')
: apiKeyActionRequiredProviders.length === 1 && primaryApiKeyProvider
- ? `${primaryApiKeyProvider.displayName} is set to API key mode, but it is not connected. Open Manage Providers to review the saved key or switch the connection mode.`
- : 'One or more providers are set to API key mode and need attention. Open Manage Providers to review saved keys or switch the connection mode.'
- : `${runtimeDisplayName} is installed but you are not authenticated. Login is required for team provisioning and AI features.`;
+ ? t('cliStatus.warnings.singleApiKeyNeedsAttention', {
+ provider: primaryApiKeyProvider.displayName,
+ })
+ : t('cliStatus.warnings.multipleApiKeysNeedAttention')
+ : t('cliStatus.warnings.notAuthenticated', { runtime: runtimeDisplayName });
return (
<>
@@ -2146,7 +2218,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ backgroundColor: '#f59e0b' }}
>
- Manage Providers
+ {t('cliStatus.actions.manageProviders')}
) : (
<>
@@ -2159,7 +2231,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}}
>
- Already logged in?
+ {t('cliStatus.actions.alreadyLoggedIn')}
{showTroubleshoot ? (
) : (
@@ -2172,7 +2244,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
style={{ backgroundColor: '#f59e0b' }}
>
- Login
+ {t('cliStatus.actions.login')}
>
)}
@@ -2191,14 +2263,14 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
className="mb-2 text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- If you're sure you're logged in, try these steps:
+ {t('cliStatus.hints.troubleshootTitle')}
- Click{' '}
+ {t('cliStatus.troubleshoot.click')}{' '}
{
setIsVerifyingAuth(true);
@@ -2220,36 +2292,36 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
}}
>
- Re-check
+ {t('cliStatus.actions.recheck')}
{' '}
- — sometimes the status is cached for a few seconds
+ {t('cliStatus.troubleshoot.statusCacheHint')}
- Open your terminal and run:{' '}
+ {t('cliStatus.troubleshoot.openTerminal')}{' '}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${renderCliStatus.binaryPath}" auth status`
- : 'your configured CLI auth status command'}
+ : t('cliStatus.troubleshoot.authStatusCommand')}
{' '}
- — check if it shows "Logged in"
+ {t('cliStatus.troubleshoot.checkLoggedIn')}
- If it says logged in but the app doesn't see it, try:{' '}
+ {t('cliStatus.troubleshoot.reloginPrefix')}{' '}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${renderCliStatus.binaryPath}" auth logout`
- : 'the runtime logout command'}
+ : t('cliStatus.troubleshoot.logoutCommand')}
{' '}
- then{' '}
+ {t('cliStatus.troubleshoot.then')}{' '}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath
? `"${renderCliStatus.binaryPath}" auth login`
- : 'the runtime login command'}
+ : t('cliStatus.troubleshoot.loginCommand')}
{' '}
- again
+ {t('cliStatus.troubleshoot.again')}
- Make sure the CLI in your terminal is the same runtime the app uses
+ {t('cliStatus.troubleshoot.sameRuntime')}
{renderCliStatus.showBinaryPath && renderCliStatus.binaryPath && (
:{' '}
@@ -2261,8 +2333,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
- Browsing sessions and projects works without login. Login is only needed to run
- agent teams.
+ {t('cliStatus.hints.loginRequiredForTeams')}
)}
@@ -2271,7 +2342,9 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
{showLoginTerminal && renderCliStatus.binaryPath && (
{
@@ -2306,8 +2379,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
})();
}}
autoCloseOnSuccessMs={4000}
- successMessage="Login complete"
- failureMessage="Login failed"
+ successMessage={t('cliStatus.labels.loginComplete')}
+ failureMessage={t('cliStatus.labels.loginFailed')}
/>
)}
diff --git a/src/renderer/components/dashboard/DashboardUpdateBanner.tsx b/src/renderer/components/dashboard/DashboardUpdateBanner.tsx
index 598049fd..7479f2b8 100644
--- a/src/renderer/components/dashboard/DashboardUpdateBanner.tsx
+++ b/src/renderer/components/dashboard/DashboardUpdateBanner.tsx
@@ -8,6 +8,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { ArrowUpCircle, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -15,6 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
const DISMISSED_KEY = 'update:dashboard-dismissed-version';
export const DashboardUpdateBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('dashboard');
const { updateStatus, availableVersion, openUpdateDialog, installUpdate } = useStore(
useShallow((s) => ({
updateStatus: s.updateStatus,
@@ -57,7 +59,7 @@ export const DashboardUpdateBanner = (): React.JSX.Element | null => {
>
- New version available{' '}
+ {t('updateBanner.newVersionAvailable')}{' '}
{availableVersion && (
v{availableVersion}
)}
@@ -70,7 +72,7 @@ export const DashboardUpdateBanner = (): React.JSX.Element | null => {
color: '#4ade80',
}}
>
- {isDownloaded ? 'Restart now' : 'View details'}
+ {isDownloaded ? t('updateBanner.restartNow') : t('updateBanner.viewDetails')}
): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef(null);
const { openCommandPalette, selectedProjectId } = useStore(
@@ -76,7 +78,7 @@ const CommandSearch = ({ value, onChange }: Readonly): React
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
- placeholder="Search projects..."
+ placeholder={t('recentProjects.searchPlaceholder')}
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
@@ -103,6 +105,7 @@ const CommandSearch = ({ value, onChange }: Readonly): React
};
export const DashboardView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('dashboard');
const [searchQuery, setSearchQuery] = useState('');
const openTeamsTab = useStore((state) => state.openTeamsTab);
@@ -126,9 +129,9 @@ export const DashboardView = (): React.JSX.Element => {
className="flex shrink-0 items-center gap-2 rounded-sm border border-border bg-surface-raised px-4 py-3 text-sm text-text-secondary transition-all duration-200 hover:border-zinc-500 hover:text-text"
>
- Select Team
+ {t('actions.selectTeam')}
- or
+ {t('actions.or')}
@@ -138,14 +141,14 @@ export const DashboardView = (): React.JSX.Element => {
- {searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
+ {searchQuery.trim() ? t('recentProjects.searchResults') : t('recentProjects.title')}
{searchQuery.trim() && (
setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
- Clear search
+ {t('actions.clearSearch')}
)}
diff --git a/src/renderer/components/dashboard/WebPreviewBanner.tsx b/src/renderer/components/dashboard/WebPreviewBanner.tsx
index 1c7a7cdd..1d63ffc5 100644
--- a/src/renderer/components/dashboard/WebPreviewBanner.tsx
+++ b/src/renderer/components/dashboard/WebPreviewBanner.tsx
@@ -1,7 +1,9 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { FlaskConical } from 'lucide-react';
export const WebPreviewBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('dashboard');
if (isElectronMode()) {
return null;
}
@@ -16,13 +18,8 @@ export const WebPreviewBanner = (): React.JSX.Element | null => {
>
-
- Open the desktop app for full functionality
-
-
- The browser version is still in development. Project actions, integrations, and live
- status updates may be limited here. Use the desktop app to access all features reliably.
-
+
{t('webPreview.title')}
+
{t('webPreview.description')}
);
diff --git a/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx b/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx
index 633b708a..eda3b776 100644
--- a/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx
+++ b/src/renderer/components/dashboard/WindowsAdministratorBanner.tsx
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { AlertTriangle } from 'lucide-react';
import type { WindowsElevationStatus } from '@shared/types/api';
export const WindowsAdministratorBanner = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('dashboard');
const [status, setStatus] = useState(null);
useEffect(() => {
@@ -51,12 +53,9 @@ export const WindowsAdministratorBanner = (): React.JSX.Element | null => {
>
-
- Windows Administrator mode recommended
-
+
{t('windowsAdmin.title')}
- OpenCode runtime checks can time out when Agent Teams AI is not elevated. Restart the app
- with Run as administrator before launching OpenCode teams.
+ {t('windowsAdmin.description')}
diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx
index d635f6bf..df7e310c 100644
--- a/src/renderer/components/extensions/ExtensionStoreView.tsx
+++ b/src/renderer/components/extensions/ExtensionStoreView.tsx
@@ -10,6 +10,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Badge } from '@renderer/components/ui/badge';
@@ -63,33 +64,36 @@ const ProviderCapabilityCardSkeleton = ({
}: {
providerId: 'anthropic' | 'codex' | 'gemini' | 'opencode';
displayName: string;
-}): React.JSX.Element => (
-
-
-
-
-
- {displayName}
-
-
-
-
Checking provider status...
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
+ return (
+
+
+
+
+
+ {displayName}
+
+
+
+ {t('store.provider.checkingStatus')}
+
+
+ {t('store.provider.loading')}
+
+
+
+ {Array.from({ length: 3 }, (_, index) => (
+
+ ))}
-
- Loading...
-
-
- {Array.from({ length: 3 }, (_, index) => (
-
- ))}
-
-
-);
+ );
+};
function isProviderCapabilityCardLoading(
provider: CliProviderStatus,
@@ -112,6 +116,7 @@ function isCodexSnapshotPending(
}
export const ExtensionStoreView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const isElectron = useMemo(() => isElectronMode(), []);
const tabId = useTabIdOptional();
const {
@@ -222,34 +227,30 @@ export const ExtensionStoreView = (): React.JSX.Element => {
() => [
{
value: 'plugins' as const,
- label: 'Plugins',
+ label: t('store.tabs.plugins.label'),
icon: Puzzle,
- description:
- 'Small add-ons for the runtime. In multimodel mode they currently apply to Anthropic sessions when supported. Broader provider support is in development.',
+ description: t('store.tabs.plugins.description'),
},
{
value: 'mcp-servers' as const,
- label: 'MCP Servers',
+ label: t('store.tabs.mcpServers.label'),
icon: Server,
- description:
- 'Connections to outside tools and apps. They let the runtime read data or do actions beyond this app.',
+ description: t('store.tabs.mcpServers.description'),
},
{
value: 'skills' as const,
- label: 'Skills',
+ label: t('store.tabs.skills.label'),
icon: BookOpen,
- description:
- 'Ready-made instructions for common jobs. They help the runtime handle repeatable tasks more consistently.',
+ description: t('store.tabs.skills.description'),
},
{
value: 'api-keys' as const,
- label: 'API Keys',
+ label: t('store.tabs.apiKeys.label'),
icon: Key,
- description:
- 'Secret keys for online services. Add them here so plugins, servers, and integrations can connect and work.',
+ description: t('store.tabs.apiKeys.description'),
},
],
- []
+ [t]
);
// Fetch plugin catalog on mount
@@ -343,11 +344,10 @@ export const ExtensionStoreView = (): React.JSX.Element => {
- Checking extensions runtime availability
+ {t('store.runtime.checkingAvailabilityTitle')}
- Extensions need the configured runtime to manage plugins, MCP servers, skills, and
- provider connections.
+ {t('store.runtime.checkingAvailabilityDescription')}
@@ -364,13 +364,13 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{cliLaunchIssue
- ? 'The configured runtime was found but failed to start'
- : 'The configured runtime is not available'}
+ ? t('store.runtime.failedToStartTitle')
+ : t('store.runtime.notAvailableTitle')}
{cliLaunchIssue
- ? 'Extensions are disabled until the runtime passes its startup health check. Open the Dashboard to repair or reinstall it.'
- : 'Extensions are disabled until the runtime is installed. Open the Dashboard to install it and retry.'}
+ ? t('store.runtime.failedToStartDescription')
+ : t('store.runtime.notAvailableDescription')}
{cliLaunchIssue && effectiveCliStatus.launchError && (
@@ -379,7 +379,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
)}
- Open Dashboard
+ {t('store.actions.openDashboard')}
);
@@ -390,17 +390,20 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
{runtimeDisplayName} needs sign-in
+
+ {t('store.runtime.needsSignInTitle', { runtime: runtimeDisplayName })}
+
- {runtimeDisplayName} was found
- {effectiveCliStatus.installedVersion
- ? ` (${effectiveCliStatus.installedVersion})`
- : ''}
- , but plugin installs are disabled until you sign in from the Dashboard.
+ {t('store.runtime.needsSignInDescription', {
+ runtime: runtimeDisplayName,
+ version: effectiveCliStatus.installedVersion
+ ? ` (${effectiveCliStatus.installedVersion})`
+ : '',
+ })}
- Open Dashboard
+ {t('store.actions.openDashboard')}
);
@@ -412,10 +415,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
Multimodel runtime capabilities
+
+ {t('store.runtime.multimodelCapabilitiesTitle')}
+
- Provider support can differ by section. Plugins are shown only where the runtime
- explicitly declares support.
+ {t('store.runtime.multimodelCapabilitiesDescription')}
@@ -444,8 +448,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
const statusLabel = provider.authenticated
? 'Connected'
: provider.supported
- ? 'Needs setup'
- : 'Unsupported';
+ ? t('store.provider.needsSetup')
+ : t('store.provider.unsupported');
+ const finalStatusLabel = provider.authenticated
+ ? t('store.provider.connected')
+ : statusLabel;
const extensionCapabilities = getCliProviderExtensionCapabilities(provider);
const pluginStatus = extensionCapabilities.plugins.status;
@@ -466,11 +473,11 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{provider.statusMessage ??
provider.backend?.label ??
- 'Ready to configure'}
+ t('store.provider.readyToConfigure')}
- {statusLabel}
+ {finalStatusLabel}
@@ -482,13 +489,21 @@ export const ExtensionStoreView = (): React.JSX.Element => {
: undefined
}
>
- Plugins: {formatCliExtensionCapabilityStatus(pluginStatus)}
+ {t('store.capabilities.plugins', {
+ status: formatCliExtensionCapabilityStatus(pluginStatus),
+ })}
- MCP: {formatCliExtensionCapabilityStatus(extensionCapabilities.mcp.status)}
+ {t('store.capabilities.mcp', {
+ status: formatCliExtensionCapabilityStatus(
+ extensionCapabilities.mcp.status
+ ),
+ })}
- Skills: {extensionCapabilities.skills.ownership}
+ {t('store.capabilities.skills', {
+ status: extensionCapabilities.skills.ownership,
+ })}
@@ -504,13 +519,16 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
{runtimeDisplayName} is ready
+
+ {t('store.runtime.readyTitle', { runtime: runtimeDisplayName })}
+
- Plugins can be installed from this page
- {effectiveCliStatus.installedVersion
- ? ` using ${runtimeDisplayName} ${effectiveCliStatus.installedVersion}`
- : ''}
- .
+ {t('store.runtime.readyDescription', {
+ runtime: runtimeDisplayName,
+ versionSuffix: effectiveCliStatus.installedVersion
+ ? ` using ${runtimeDisplayName} ${effectiveCliStatus.installedVersion}`
+ : '',
+ })}
@@ -522,6 +540,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
effectiveCliStatusLoading,
openDashboard,
runtimeDisplayName,
+ t,
]);
// Browser mode guard
@@ -530,8 +549,8 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
Extensions
-
Available in the desktop app only.
+
{t('store.title')}
+
{t('store.desktopOnly')}
);
@@ -546,7 +565,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
-
Extensions
+
{t('store.title')}
@@ -554,7 +573,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
- Refresh catalog
+ {t('store.actions.refreshCatalog')}
@@ -564,15 +583,14 @@ export const ExtensionStoreView = (): React.JSX.Element => {
{!cliInstalled && (
- The configured runtime is required to install or uninstall extensions. Install or
- repair it from the Dashboard.
+ {t('store.runtime.requiredForMutations')}
)}
{/* Active sessions warning */}
{hasOngoingSessions && (
- Running sessions won't pick up extension changes until restarted.
+ {t('store.sessionsRestartWarning')}
)}
{
disabled={Boolean(mcpMutationDisableReason)}
>
- Add Custom
+ {t('store.actions.addCustom')}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
index a946adec..80940929 100644
--- a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
+++ b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx
@@ -4,6 +4,7 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -23,6 +24,7 @@ interface ApiKeyCardProps {
}
export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const deleteApiKey = useStore((s) => s.deleteApiKey);
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -117,7 +119,7 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
- Edit
+ {t('apiKeys.actions.edit')}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
index f2b53396..45035a46 100644
--- a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
+++ b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx
@@ -5,6 +5,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -39,11 +40,6 @@ interface ApiKeyFormDialogProps {
type Scope = 'user' | 'project';
-const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
- { value: 'user', label: 'User (global)' },
- { value: 'project', label: 'Project' },
-];
-
export const ApiKeyFormDialog = ({
open,
editingKey,
@@ -51,6 +47,7 @@ export const ApiKeyFormDialog = ({
currentProjectLabel,
onClose,
}: ApiKeyFormDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const saveApiKey = useStore((s) => s.saveApiKey);
const apiKeySaving = useStore((s) => s.apiKeySaving);
const storageStatus = useStore((s) => s.apiKeyStorageStatus);
@@ -101,7 +98,7 @@ export const ApiKeyFormDialog = ({
return;
}
if (!ENV_KEY_RE.test(v)) {
- setEnvVarError('Use letters, digits, underscores. Must start with a letter or underscore.');
+ setEnvVarError(t('apiKeys.form.errors.invalidEnvVarFormat'));
} else {
setEnvVarError(null);
}
@@ -112,23 +109,23 @@ export const ApiKeyFormDialog = ({
setError(null);
if (!name.trim()) {
- setError('Name is required');
+ setError(t('apiKeys.form.errors.nameRequired'));
return;
}
if (!envVarName.trim()) {
- setError('Environment variable name is required');
+ setError(t('apiKeys.form.errors.envVarRequired'));
return;
}
if (!ENV_KEY_RE.test(envVarName)) {
- setError('Invalid environment variable name');
+ setError(t('apiKeys.form.errors.invalidEnvVar'));
return;
}
if (!value) {
- setError('Key value is required');
+ setError(t('apiKeys.form.errors.valueRequired'));
return;
}
if (scope === 'project' && !effectiveProjectPath) {
- setError('Project-scoped API keys require an active project');
+ setError(t('apiKeys.form.errors.projectScopeRequiresProject'));
return;
}
@@ -143,7 +140,7 @@ export const ApiKeyFormDialog = ({
});
onClose();
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to save');
+ setError(err instanceof Error ? err.message : t('apiKeys.form.errors.saveFailed'));
}
};
@@ -165,11 +162,11 @@ export const ApiKeyFormDialog = ({
- {isEdit ? 'Edit API Key' : 'Add API Key'}
+
+ {isEdit ? t('apiKeys.form.editTitle') : t('apiKeys.form.addTitle')}
+
- {isEdit
- ? 'Update the key details. You must re-enter the value.'
- : 'Store an API key for auto-filling in MCP server installations.'}
+ {isEdit ? t('apiKeys.form.editDescription') : t('apiKeys.form.addDescription')}
@@ -178,8 +175,7 @@ export const ApiKeyFormDialog = ({
{storageStatus && storageStatus.encryptionMethod !== 'os-keychain' && (
- OS keychain unavailable — keys encrypted with AES-256 locally. Install gnome-keyring for
- OS-level protection.
+ {t('apiKeys.form.keychainUnavailable')}
)}
@@ -187,13 +183,13 @@ export const ApiKeyFormDialog = ({
{/* Name */}
- Name
+ {t('apiKeys.form.name')}
setName(e.target.value)}
- placeholder="e.g. OpenAI Production"
+ placeholder={t('apiKeys.form.namePlaceholder')}
className="h-8 text-sm"
autoFocus
/>
@@ -202,7 +198,7 @@ export const ApiKeyFormDialog = ({
{/* Env var name */}
- Environment Variable Name
+ {t('apiKeys.form.environmentVariableName')}
{envVarError &&
{envVarError}
}
@@ -220,43 +216,47 @@ export const ApiKeyFormDialog = ({
{/* Value */}
- Value
+ {t('apiKeys.form.value')}
setValue(e.target.value)}
- placeholder={isEdit ? 'Re-enter key value' : 'sk-...'}
+ placeholder={
+ isEdit ? t('apiKeys.form.reenterValue') : t('apiKeys.form.valuePlaceholder')
+ }
className="h-8 text-sm"
/>
{/* Scope */}
-
Scope
+
{t('apiKeys.form.scope')}
setScope(v as Scope)}>
- {SCOPE_OPTIONS.map((opt) => (
+ {(['user', 'project'] as const).map((scopeOption) => (
- {opt.value === 'project'
+ {scopeOption === 'project'
? effectiveProjectPath
- ? `Project: ${effectiveProjectLabel}`
- : 'Project unavailable'
- : opt.label}
+ ? t('apiKeys.form.projectScopeLabel', { project: effectiveProjectLabel })
+ : t('apiKeys.form.projectUnavailable')
+ : t('apiKeys.form.userScopeLabel')}
))}
{scope === 'project' && effectiveProjectPath && (
-
Bound to {effectiveProjectPath}
+
+ {t('apiKeys.form.boundTo', { path: effectiveProjectPath })}
+
)}
@@ -270,10 +270,14 @@ export const ApiKeyFormDialog = ({
{/* Actions */}
- Cancel
+ {t('apiKeys.form.cancel')}
- {apiKeySaving ? 'Saving...' : isEdit ? 'Update' : 'Save'}
+ {apiKeySaving
+ ? t('apiKeys.form.saving')
+ : isEdit
+ ? t('apiKeys.form.update')
+ : t('apiKeys.form.save')}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
index 4d70cc9d..8b663758 100644
--- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
+++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx
@@ -8,6 +8,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -30,6 +31,7 @@ export const ApiKeysPanel = ({
projectPath,
projectLabel,
}: ApiKeysPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const isElectron = useMemo(() => isElectronMode(), []);
const {
apiKeys,
@@ -188,7 +190,7 @@ export const ApiKeysPanel = ({
{/* Header row */}
- Securely store API keys for auto-filling when installing MCP servers.
+ {t('apiKeys.description')}
{storageStatus && (
@@ -200,15 +202,9 @@ export const ApiKeysPanel = ({
{isOsKeychain ? (
-
- Keys are encrypted via {storageStatus.backend} and stored with restricted file
- permissions (owner-only).
-
+ {t('apiKeys.storage.osKeychain', { backend: storageStatus.backend })}
) : (
-
- OS keychain unavailable — keys are encrypted locally with AES-256. For stronger
- protection, install a keyring service (gnome-keyring, kwallet).
-
+ {t('apiKeys.storage.localEncryption')}
)}
@@ -216,7 +212,7 @@ export const ApiKeysPanel = ({
- Add API Key
+ {t('apiKeys.actions.add')}
@@ -250,13 +246,11 @@ export const ApiKeysPanel = ({
-
No API keys saved
-
- Add keys to auto-fill environment variables when installing MCP servers.
-
+
{t('apiKeys.empty.title')}
+
{t('apiKeys.empty.description')}
- Add your first key
+ {t('apiKeys.actions.addFirst')}
)}
diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx
index 653b9ef2..e509c439 100644
--- a/src/renderer/components/extensions/common/InstallButton.tsx
+++ b/src/renderer/components/extensions/common/InstallButton.tsx
@@ -5,6 +5,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Tooltip,
@@ -48,6 +49,7 @@ export const InstallButton = ({
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: InstallButtonProps) => {
+ const { t } = useAppTranslation('extensions');
const { cliStatus: storedCliStatus, cliStatusLoading: storedCliStatusLoading } = useStore(
useShallow((s) => ({
cliStatus: s.cliStatus,
@@ -77,7 +79,9 @@ export const InstallButton = ({
- {pendingAction === 'uninstall' ? 'Removing...' : 'Installing...'}
+ {pendingAction === 'uninstall'
+ ? t('installButton.removing')
+ : t('installButton.installing')}
);
@@ -87,7 +91,7 @@ export const InstallButton = ({
return (
- Done
+ {t('installButton.done')}
);
}
@@ -111,7 +115,7 @@ export const InstallButton = ({
}}
disabled={isDisabled}
>
-
Retry
+
{t('installButton.retry')}
);
@@ -152,7 +156,7 @@ export const InstallButton = ({
disabled={isDisabled}
>
-
Uninstall
+
{t('installButton.uninstall')}
) : (
- Install
+ {t('installButton.install')}
);
diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
index 114f9cb8..1f029337 100644
--- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
+++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx
@@ -5,6 +5,7 @@
import { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
@@ -75,6 +76,7 @@ export const CustomMcpServerDialog = ({
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: CustomMcpServerDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const installCustomMcpServer = useStore((s) => s.installCustomMcpServer);
const storedCliStatus = useStore((s) => s.cliStatus);
const storedCliStatusLoading = useStore((s) => s.cliStatusLoading);
@@ -231,11 +233,11 @@ export const CustomMcpServerDialog = ({
}
if (!serverName.trim()) {
- setError('Server name is required');
+ setError(t('customMcp.errors.serverNameRequired'));
return;
}
if (!SERVER_NAME_RE.test(serverName)) {
- setError('Invalid server name. Use alphanumeric characters, dashes, underscores, dots.');
+ setError(t('customMcp.errors.invalidServerName'));
return;
}
@@ -243,7 +245,7 @@ export const CustomMcpServerDialog = ({
if (transportMode === 'stdio') {
if (!npmPackage.trim()) {
- setError('npm package name is required');
+ setError(t('customMcp.errors.npmPackageRequired'));
return;
}
installSpec = {
@@ -253,7 +255,7 @@ export const CustomMcpServerDialog = ({
};
} else {
if (!httpUrl.trim()) {
- setError('Server URL is required');
+ setError(t('customMcp.errors.serverUrlRequired'));
return;
}
installSpec = {
@@ -284,7 +286,7 @@ export const CustomMcpServerDialog = ({
await installCustomMcpServer(request);
onClose();
} catch (err) {
- setError(err instanceof Error ? err.message : 'Install failed');
+ setError(err instanceof Error ? err.message : t('customMcp.errors.installFailed'));
} finally {
setInstalling(false);
}
@@ -316,8 +318,8 @@ export const CustomMcpServerDialog = ({
- Add Custom MCP Server
- Add a server manually without the catalog.
+ {t('customMcp.title')}
+ {t('customMcp.description')}
@@ -326,13 +328,13 @@ export const CustomMcpServerDialog = ({
{/* Server name */}
- Server Name
+ {t('customMcp.fields.serverName')}
setServerName(e.target.value)}
- placeholder="my-server"
+ placeholder={t('customMcp.placeholders.serverName')}
className="h-8 text-sm"
autoFocus
/>
@@ -340,7 +342,7 @@ export const CustomMcpServerDialog = ({
{/* Transport toggle */}
-
Transport
+
{t('customMcp.fields.transport')}
setTransportMode('stdio')}
>
- Stdio (npm)
+ {t('customMcp.transport.stdio')}
setTransportMode('http')}
>
- HTTP / SSE
+ {t('customMcp.transport.httpSse')}
@@ -366,7 +368,7 @@ export const CustomMcpServerDialog = ({
- npm Package
+ {t('customMcp.fields.npmPackage')}
- Version (optional)
+ {t('customMcp.fields.versionOptional')}
setNpmVersion(e.target.value)}
- placeholder="latest"
+ placeholder={t('customMcp.placeholders.latest')}
className="h-8 text-sm"
/>
@@ -396,18 +398,18 @@ export const CustomMcpServerDialog = ({
- Server URL
+ {t('customMcp.fields.serverUrl')}
setHttpUrl(e.target.value)}
- placeholder="https://api.example.com/mcp"
+ placeholder={t('customMcp.placeholders.serverUrl')}
className="h-8 text-sm"
/>
-
Transport Type
+
{t('customMcp.fields.transportType')}
setHttpTransport(v as HttpTransport)}
@@ -428,7 +430,7 @@ export const CustomMcpServerDialog = ({
{/* Headers */}
-
Headers
+
{t('customMcp.fields.headers')}
- Add
+ {t('customMcp.actions.add')}
{headers.length > 0 && (
@@ -447,13 +449,13 @@ export const CustomMcpServerDialog = ({
value={header.key}
onChange={(e) => updateHeader(i, 'key', e.target.value)}
className="h-7 w-32 text-xs"
- placeholder="Header-Name"
+ placeholder={t('customMcp.placeholders.headerName')}
/>
updateHeader(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
- placeholder="value"
+ placeholder={t('customMcp.placeholders.value')}
/>
- Scope
+ {t('customMcp.fields.scope')}
setScope(v as Scope)}>
@@ -495,10 +497,10 @@ export const CustomMcpServerDialog = ({
{/* Environment variables */}
-
Environment Variables
+
{t('customMcp.fields.environmentVariables')}
- Add
+ {t('customMcp.actions.add')}
{envVars.length > 0 && (
@@ -509,14 +511,14 @@ export const CustomMcpServerDialog = ({
value={entry.key}
onChange={(e) => updateEnvVar(i, 'key', e.target.value)}
className="h-7 w-40 font-mono text-xs"
- placeholder="ENV_VAR_NAME"
+ placeholder={t('customMcp.placeholders.envVarName')}
/>
updateEnvVar(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
- placeholder="value"
+ placeholder={t('customMcp.placeholders.value')}
/>
- Cancel
+ {t('customMcp.actions.cancel')}
void handleInstall()}>
- {installing ? 'Installing...' : 'Install'}
+ {installing ? t('customMcp.actions.installing') : t('customMcp.actions.install')}
diff --git a/src/renderer/components/extensions/mcp/McpServerCard.tsx b/src/renderer/components/extensions/mcp/McpServerCard.tsx
index 3e6d0f6e..0a60fec0 100644
--- a/src/renderer/components/extensions/mcp/McpServerCard.tsx
+++ b/src/renderer/components/extensions/mcp/McpServerCard.tsx
@@ -5,6 +5,7 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -56,6 +57,7 @@ export const McpServerCard = ({
cliStatus: cliStatusOverride,
cliStatusLoading,
}: McpServerCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const storedCliStatus = useStore((s) => s.cliStatus);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const sharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
@@ -179,19 +181,19 @@ export const McpServerCard = ({
{server.tools.length > 0 && (
- {server.tools.length} {server.tools.length === 1 ? 'tool' : 'tools'}
+ {t('mcpCard.toolsCount', { count: server.tools.length })}
)}
{server.envVars.length > 0 && (
- {server.envVars.length} {server.envVars.length === 1 ? 'env' : 'envs'}
+ {t('mcpCard.envCount', { count: server.envVars.length })}
)}
{server.requiresAuth && (
- Auth
+ {t('mcpCard.auth')}
)}
{server.version && (
@@ -206,23 +208,25 @@ export const McpServerCard = ({
{formatRelativeTime(server.updatedAt)}
)}
- {server.author && by {server.author} }
+ {server.author && (
+ {t('mcpCard.byAuthor', { author: server.author })}
+ )}
{server.hostingType === 'remote' && (
- Remote
+ {t('mcpCard.hosting.remote')}
)}
{server.hostingType === 'local' && (
- Local
+ {t('mcpCard.hosting.local')}
)}
{server.hostingType === 'both' && (
- Both
+ {t('mcpCard.hosting.both')}
)}
{/* External links + stars */}
@@ -245,7 +249,7 @@ export const McpServerCard = ({
)}
- Repository
+ {t('mcpCard.repository')}
)}
{server.websiteUrl && (
@@ -261,7 +265,7 @@ export const McpServerCard = ({
- Website
+ {t('mcpCard.website')}
)}
diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx
index 0cd87512..59bfeb3e 100644
--- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx
+++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx
@@ -5,6 +5,7 @@
import { useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -82,6 +83,7 @@ export const McpServerDetailDialog = ({
cliStatus: cliStatusOverride,
cliStatusLoading,
}: McpServerDetailDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const storedCliStatus = useStore((s) => s.cliStatus);
const cliStatus = cliStatusOverride ?? storedCliStatus;
const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor);
@@ -115,8 +117,8 @@ export const McpServerDetailDialog = ({
normalizedInstalledEntries.some((entry) => entry.scope === 'user')
? [{ value: 'user' as const, label: getMcpScopeLabel('user', cliStatus?.flavor) }]
: []),
- { value: 'project', label: 'Project' },
- { value: 'local', label: 'Local' },
+ { value: 'project', label: t('mcpDetail.scope.project') },
+ { value: 'local', label: t('mcpDetail.scope.local') },
];
const preferredInstalledEntry = getPreferredMcpInstallationEntry(normalizedInstalledEntries);
const selectedInstalledEntry =
@@ -336,12 +338,12 @@ export const McpServerDetailDialog = ({
{/* Metadata grid */}
-
Source
+
{t('mcpDetail.metadata.source')}
{server.source}
{stars != null && (
-
GitHub Stars
+
{t('mcpDetail.metadata.githubStars')}
{stars.toLocaleString()}
@@ -350,55 +352,57 @@ export const McpServerDetailDialog = ({
)}
{server.version && (
-
Version
+
{t('mcpDetail.metadata.version')}
{server.version}
)}
{server.license && (
-
License
+
{t('mcpDetail.metadata.license')}
{server.license}
)}
-
Install Type
+
{t('mcpDetail.metadata.installType')}
{server.installSpec?.type === 'stdio' ? (
void api.openExternal(npmPackageUrl!)}
>
- npm: {server.installSpec.npmPackage}
+ {t('mcpDetail.install.npmPackage', { package: server.installSpec.npmPackage })}
) : (
{server.installSpec
- ? `HTTP: ${server.installSpec.transportType}`
- : 'Manual setup required'}
+ ? t('mcpDetail.install.httpTransport', {
+ transport: server.installSpec.transportType,
+ })
+ : t('mcpDetail.install.manualSetupRequired')}
)}
{server.author && (
-
Author
+
{t('mcpDetail.metadata.author')}
{server.author}
)}
{server.hostingType && (
-
Hosting
+
{t('mcpDetail.metadata.hosting')}
{server.hostingType}
)}
{server.publishedAt && (
-
Published
+
{t('mcpDetail.metadata.published')}
{new Date(server.publishedAt).toLocaleDateString()}
)}
{server.updatedAt && (
-
Updated
+
{t('mcpDetail.metadata.updated')}
{new Date(server.updatedAt).toLocaleDateString()}
)}
@@ -408,13 +412,12 @@ export const McpServerDetailDialog = ({
{server.requiresAuth && (
- This server requires authentication
+ {t('mcpDetail.auth.required')}
)}
{isHttp && !server.requiresAuth && (server.authHeaders?.length ?? 0) === 0 && (
- Remote MCP servers may still require custom headers or API keys even when the registry
- does not describe them. If connection fails after install, check the provider docs.
+ {t('mcpDetail.auth.remoteMayNeedHeaders')}
)}
{isInstalledForScope && (
@@ -443,7 +446,9 @@ export const McpServerDetailDialog = ({
{diagnostic?.target && (
-
Launch Target
+
+ {t('mcpDetail.diagnostics.launchTarget')}
+
{diagnostic.target}
@@ -456,19 +461,19 @@ export const McpServerDetailDialog = ({
{canAutoInstall && (
- {isInstalledForScope ? 'Manage Installation' : 'Install Server'}
+ {isInstalledForScope ? t('mcpDetail.install.manage') : t('mcpDetail.install.install')}
{/* Server name */}
- Server Name
+ {t('mcpDetail.form.serverName')}
setServerName(e.target.value)}
- placeholder="my-server"
+ placeholder={t('mcpDetail.placeholders.serverName')}
className="h-8 text-sm"
disabled={isInstalledForScope}
/>
@@ -476,7 +481,7 @@ export const McpServerDetailDialog = ({
{/* Scope */}
-
Scope
+
{t('mcpDetail.form.scope')}
setScope(v as Scope)}>
@@ -498,7 +503,7 @@ export const McpServerDetailDialog = ({
{/* Environment variables */}
{server.envVars.length > 0 && (
-
Environment Variables
+
{t('mcpDetail.form.environmentVariables')}
{server.envVars.map((env) => (
@@ -515,7 +520,9 @@ export const McpServerDetailDialog = ({
placeholder={env.description ?? env.name}
/>
{autoFilledFields.has(env.name) && envValues[env.name] && (
- Auto-filled
+
+ {t('mcpDetail.form.autoFilled')}
+
)}
))}
@@ -527,7 +534,7 @@ export const McpServerDetailDialog = ({
{isHttp && (
- Headers
+ {t('mcpDetail.form.headers')}
updateHeader(index, 'key', e.target.value)}
className="h-7 w-32 text-xs"
- placeholder="Header-Name"
+ placeholder={t('customMcp.placeholders.headerName')}
/>
)}
- This server requires manual setup. Check the repository for installation instructions.
+ {t('mcpDetail.install.manualSetupDescription')}
)}
@@ -619,7 +626,7 @@ export const McpServerDetailDialog = ({
- Tools ({server.tools.length})
+ {t('mcpDetail.tools.title', { count: server.tools.length })}
{server.tools.map((tool) => (
@@ -641,7 +648,7 @@ export const McpServerDetailDialog = ({
onClick={() => void api.openExternal(server.repositoryUrl!)}
>
- Repository
+ {t('mcpDetail.links.repository')}
)}
{server.glamaUrl && (
@@ -651,7 +658,7 @@ export const McpServerDetailDialog = ({
onClick={() => void api.openExternal(server.glamaUrl!)}
>
- Glama
+ {t('mcpDetail.links.glama')}
)}
{server.websiteUrl && (
@@ -661,7 +668,7 @@ export const McpServerDetailDialog = ({
onClick={() => void api.openExternal(server.websiteUrl!)}
>
- Website
+ {t('mcpDetail.links.website')}
)}
diff --git a/src/renderer/components/extensions/mcp/McpServersPanel.tsx b/src/renderer/components/extensions/mcp/McpServersPanel.tsx
index 39eb99ee..6146109a 100644
--- a/src/renderer/components/extensions/mcp/McpServersPanel.tsx
+++ b/src/renderer/components/extensions/mcp/McpServersPanel.tsx
@@ -4,6 +4,7 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -46,6 +47,20 @@ const MCP_SORT_OPTIONS: { value: McpSortValue; label: string }[] = [
{ value: 'tools-desc', label: 'Most tools' },
];
+function getMcpSortLabel(
+ value: McpSortValue,
+ t: ReturnType
['t']
+): string {
+ switch (value) {
+ case 'name-asc':
+ return t('mcpPanel.sort.nameAsc');
+ case 'name-desc':
+ return t('mcpPanel.sort.nameDesc');
+ case 'tools-desc':
+ return t('mcpPanel.sort.toolsDesc');
+ }
+}
+
function sortMcpServers(servers: McpCatalogItem[], sort: McpSortValue): McpCatalogItem[] {
return [...servers].sort((a, b) => {
switch (sort) {
@@ -95,6 +110,7 @@ export const McpServersPanel = ({
cliStatus: cliStatusOverride,
cliStatusLoading: cliStatusLoadingOverride,
}: McpServersPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const projectStateKey = getMcpProjectStateKey(projectPath);
const {
browseCatalog,
@@ -163,18 +179,20 @@ export const McpServersPanel = ({
const diagnosticsDisableReason = useMemo(() => {
if (cliStatus === null || typeof cliStatus === 'undefined') {
- return cliStatusLoading ? 'Checking runtime status...' : 'Checking runtime availability...';
+ return cliStatusLoading
+ ? t('mcpPanel.diagnostics.disableReasons.checkingRuntimeStatus')
+ : t('mcpPanel.diagnostics.disableReasons.checkingRuntimeAvailability');
}
if (cliStatus?.installed === false) {
if (cliStatus.binaryPath && cliStatus.launchError) {
- return 'The configured runtime was found but failed to start. Open the Dashboard to repair or reinstall it.';
+ return t('mcpPanel.diagnostics.disableReasons.runtimeFailedToStart');
}
- return 'The configured runtime is required. Install or repair it from the Dashboard.';
+ return t('mcpPanel.diagnostics.disableReasons.runtimeRequired');
}
return null;
- }, [cliStatus, cliStatusLoading]);
+ }, [cliStatus, cliStatusLoading, t]);
useEffect(() => {
if (diagnosticsDisableReason) {
@@ -270,17 +288,19 @@ export const McpServersPanel = ({
-
MCP Health Status
+
{t('mcpPanel.health.title')}
- {mcpDiagnosticsLoading ? (
- <>Checking installed MCP servers via {runtimeLabel} ...>
- ) : diagnosticsDisableReason ? (
- diagnosticsDisableReason
- ) : mcpDiagnosticsLastCheckedAt ? (
- `Last checked ${formatRelativeTime(new Date(mcpDiagnosticsLastCheckedAt).toISOString())}`
- ) : (
- <>Run diagnostics from this page to verify installed MCP connectivity.>
- )}
+ {mcpDiagnosticsLoading
+ ? t('mcpPanel.health.checkingViaRuntime', { runtime: runtimeLabel })
+ : diagnosticsDisableReason
+ ? diagnosticsDisableReason
+ : mcpDiagnosticsLastCheckedAt
+ ? t('mcpPanel.health.lastChecked', {
+ time: formatRelativeTime(
+ new Date(mcpDiagnosticsLastCheckedAt).toISOString()
+ ),
+ })
+ : t('mcpPanel.health.description')}
- {mcpDiagnosticsLoading ? 'Checking...' : 'Check Status'}
+ {mcpDiagnosticsLoading
+ ? t('mcpPanel.health.checking')
+ : t('mcpPanel.health.checkStatus')}
{(mcpDiagnosticsLoading || allDiagnostics.length > 0) && (
-
Runtime MCP Diagnostics
+
{t('mcpPanel.diagnostics.title')}
{allDiagnostics.length > 0 && (
-
{allDiagnostics.length} servers
+
+ {t('mcpPanel.diagnostics.serversCount', { count: allDiagnostics.length })}
+
)}
{allDiagnostics.length > 0 ? (
@@ -335,7 +359,7 @@ export const McpServersPanel = ({
))}
) : (
-
Waiting for diagnostics results...
+
{t('mcpPanel.diagnostics.waiting')}
)}
)}
@@ -347,7 +371,7 @@ export const McpServersPanel = ({
setMcpSort(v as McpSortValue)}>
@@ -357,7 +381,7 @@ export const McpServersPanel = ({
{MCP_SORT_OPTIONS.map((opt) => (
- {opt.label}
+ {getMcpSortLabel(opt.value, t)}
))}
@@ -421,12 +445,11 @@ export const McpServersPanel = ({
{cliStatus?.flavor === 'agent_teams_orchestrator'
- ? `${runtimeLabel} not available`
- : `${runtimeLabel} not installed`}
+ ? t('mcpPanel.runtime.notAvailable', { runtime: runtimeLabel })
+ : t('mcpPanel.runtime.notInstalled', { runtime: runtimeLabel })}
- MCP health checks require {runtimeLabel}. Go to the Dashboard to install or repair
- it.
+ {t('mcpPanel.runtime.requiredDescription', { runtime: runtimeLabel })}
@@ -447,10 +470,10 @@ export const McpServersPanel = ({
)}
- {isSearching ? 'No servers found' : 'No MCP servers available'}
+ {isSearching ? t('mcpPanel.empty.searchTitle') : t('mcpPanel.empty.title')}
- {isSearching ? 'Try a different search term' : 'Check back later for new servers'}
+ {isSearching ? t('mcpPanel.empty.searchDescription') : t('mcpPanel.empty.description')}
)}
@@ -483,7 +506,7 @@ export const McpServersPanel = ({
disabled={browseLoading}
onClick={() => void mcpBrowse(browseNextCursor)}
>
- Load more
+ {t('mcpPanel.loadMore')}
)}
diff --git a/src/renderer/components/extensions/plugins/PluginCard.tsx b/src/renderer/components/extensions/plugins/PluginCard.tsx
index fe1344b2..0f07965f 100644
--- a/src/renderer/components/extensions/plugins/PluginCard.tsx
+++ b/src/renderer/components/extensions/plugins/PluginCard.tsx
@@ -3,6 +3,7 @@
*/
import { Badge } from '@renderer/components/ui/badge';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import {
getCapabilityLabel,
@@ -38,6 +39,7 @@ export const PluginCard = ({
cliStatus,
cliStatusLoading,
}: PluginCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const capabilities = inferCapabilities(plugin);
const category = normalizeCategory(plugin.category);
const operationKey = getPluginOperationKey(plugin.pluginId, 'user');
@@ -73,7 +75,7 @@ export const PluginCard = ({
{plugin.source === 'official' && (
- Official
+ {t('pluginCard.official')}
)}
diff --git a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
index a4ef3fa2..bae3f364 100644
--- a/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
+++ b/src/renderer/components/extensions/plugins/PluginDetailDialog.tsx
@@ -4,6 +4,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Badge } from '@renderer/components/ui/badge';
@@ -54,11 +55,23 @@ interface PluginDetailDialogProps {
cliStatusLoading?: boolean;
}
-const SCOPE_OPTIONS: { value: InstallScope; label: string }[] = [
- { value: 'user', label: 'User (global)' },
- { value: 'project', label: 'Project (shared)' },
- { value: 'local', label: 'Local (gitignored)' },
-];
+const SCOPE_OPTIONS: InstallScope[] = ['user', 'project', 'local'];
+
+function getScopeOptionLabel(
+ scope: InstallScope,
+ t: ReturnType
['t']
+): string {
+ switch (scope) {
+ case 'user':
+ return t('pluginDetail.scope.options.user');
+ case 'project':
+ return t('pluginDetail.scope.options.project');
+ case 'local':
+ return t('pluginDetail.scope.options.local');
+ default:
+ return String(scope);
+ }
+}
export const PluginDetailDialog = ({
plugin,
@@ -68,6 +81,7 @@ export const PluginDetailDialog = ({
cliStatus,
cliStatusLoading,
}: PluginDetailDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const { fetchPluginReadme, readmes, readmeLoading, installPlugin, uninstallPlugin } = useStore(
useShallow((s) => ({
fetchPluginReadme: s.fetchPluginReadme,
@@ -142,25 +156,25 @@ export const PluginDetailDialog = ({
{/* Metadata grid */}
-
Author
-
{plugin.author?.name ?? 'Unknown'}
+
{t('pluginDetail.metadata.author')}
+
{plugin.author?.name ?? t('pluginDetail.unknown')}
-
Category
+
{t('pluginDetail.metadata.category')}
{category}
-
Source
+
{t('pluginDetail.metadata.source')}
{plugin.source}
{plugin.version && (
-
Version
+
{t('pluginDetail.metadata.version')}
{plugin.version}
)}
-
Capabilities
+
{t('pluginDetail.metadata.capabilities')}
{capabilities.map((cap) => (
-
Installs
+
{t('pluginDetail.metadata.installs')}
@@ -184,19 +198,19 @@ export const PluginDetailDialog = ({
{/* Install controls */}
- Scope:
+ {t('pluginDetail.scope.label')}
setScope(v as InstallScope)}>
- {SCOPE_OPTIONS.map((opt) => (
+ {SCOPE_OPTIONS.map((scopeOption) => (
- {opt.label}
+ {getScopeOptionLabel(scopeOption, t)}
))}
@@ -236,7 +250,7 @@ export const PluginDetailDialog = ({
onClick={() => void api.openExternal(plugin.homepage!)}
>
- Homepage
+ {t('pluginDetail.links.homepage')}
)}
{plugin.author?.email && (
@@ -246,7 +260,7 @@ export const PluginDetailDialog = ({
onClick={() => void api.openExternal(`mailto:${plugin.author!.email}`)}
>
- Contact
+ {t('pluginDetail.links.contact')}
)}
@@ -256,14 +270,14 @@ export const PluginDetailDialog = ({
{isReadmeLoading && (
- Loading README...
+ {t('pluginDetail.readme.loading')}
)}
{!isReadmeLoading && readme && (
)}
{!isReadmeLoading && !readme && (
-
No README available.
+
{t('pluginDetail.readme.empty')}
)}
diff --git a/src/renderer/components/extensions/plugins/PluginsPanel.tsx b/src/renderer/components/extensions/plugins/PluginsPanel.tsx
index bb5bbf1f..69633e9e 100644
--- a/src/renderer/components/extensions/plugins/PluginsPanel.tsx
+++ b/src/renderer/components/extensions/plugins/PluginsPanel.tsx
@@ -4,6 +4,7 @@
import { useEffect, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -56,12 +57,7 @@ interface PluginsPanelProps {
cliStatusLoading?: boolean;
}
-const SORT_OPTIONS: { value: string; label: string }[] = [
- { value: 'popularity:desc', label: 'Popular' },
- { value: 'name:asc', label: 'Name A-Z' },
- { value: 'name:desc', label: 'Name Z-A' },
- { value: 'category:asc', label: 'Category' },
-];
+const SORT_OPTIONS = ['popularity:desc', 'name:asc', 'name:desc', 'category:asc'] as const;
/** Pure function: filter + sort the catalog */
function selectFilteredPlugins(
@@ -134,6 +130,7 @@ export const PluginsPanel = ({
cliStatus: cliStatusOverride,
cliStatusLoading,
}: PluginsPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const {
catalog,
loading,
@@ -191,6 +188,20 @@ export const PluginsPanel = ({
}
return counts.size;
}, [catalog]);
+
+ const getSortLabel = (value: (typeof SORT_OPTIONS)[number]): string => {
+ switch (value) {
+ case 'popularity:desc':
+ return t('pluginsPanel.sort.popular');
+ case 'name:asc':
+ return t('pluginsPanel.sort.nameAsc');
+ case 'name:desc':
+ return t('pluginsPanel.sort.nameDesc');
+ case 'category:asc':
+ return t('pluginsPanel.sort.category');
+ }
+ };
+
return (
{cliStatus?.flavor === 'agent_teams_orchestrator' &&
@@ -206,8 +217,7 @@ export const PluginsPanel = ({
return (
- Plugin support is currently guaranteed for Anthropic (Claude) sessions only.
- We're working to support plugins across all agents.
+ {t('pluginsPanel.providerSupportNotice')}
);
})()}
@@ -217,7 +227,7 @@ export const PluginsPanel = ({
@@ -233,9 +243,9 @@ export const PluginsPanel = ({
- {SORT_OPTIONS.map((opt) => (
-
- {opt.label}
+ {SORT_OPTIONS.map((value) => (
+
+ {getSortLabel(value)}
))}
@@ -249,7 +259,7 @@ export const PluginsPanel = ({
checked={pluginFilters.installedOnly}
onCheckedChange={toggleInstalledOnly}
/>
- Installed only
+ {t('pluginsPanel.installedOnly')}
@@ -265,25 +275,25 @@ export const PluginsPanel = ({
-
Browse by fit
+
+ {t('pluginsPanel.browseByFit')}
+
- {activeFilterCount} active
+ {t('pluginsPanel.activeFilters', { count: activeFilterCount })}
-
- Narrow the catalog by category, capability, or installed state.
-
+
{t('pluginsPanel.filterDescription')}
- {catalog.length} plugins
+ {t('pluginsPanel.counts.plugins', { count: catalog.length })}
- {totalCategoryCount} categories
+ {t('pluginsPanel.counts.categories', { count: totalCategoryCount })}
- {totalCapabilityCount} capabilities
+ {t('pluginsPanel.counts.capabilities', { count: totalCapabilityCount })}
@@ -294,7 +304,7 @@ export const PluginsPanel = ({
onClick={clearFilters}
className="justify-start rounded-lg border border-border px-3 text-xs text-text-secondary hover:text-text lg:justify-center"
>
- Clear all filters
+ {t('pluginsPanel.clearAllFilters')}
)}
@@ -304,10 +314,12 @@ export const PluginsPanel = ({
- Categories
+ {t('pluginsPanel.categories')}
- {pluginFilters.categories.length} selected
+ {t('pluginsPanel.selectedCount', {
+ count: pluginFilters.categories.length,
+ })}
- Capabilities
+ {t('pluginsPanel.capabilities')}
- {pluginFilters.capabilities.length} selected
+ {t('pluginsPanel.selectedCount', {
+ count: pluginFilters.capabilities.length,
+ })}
0 && (
- Showing {filtered.length} of {catalog.length} plugin{catalog.length !== 1 ? 's' : ''}
+ {t('pluginsPanel.showing', { shown: filtered.length, total: catalog.length })}
{hasActiveFilters && (
-
- Results update instantly as you refine filters.
-
+
{t('pluginsPanel.resultsUpdateInstantly')}
)}
)}
@@ -397,16 +409,18 @@ export const PluginsPanel = ({
)}
- {hasActiveFilters ? 'No plugins match your filters' : 'No plugins available'}
+ {hasActiveFilters
+ ? t('pluginsPanel.empty.filteredTitle')
+ : t('pluginsPanel.empty.title')}
{hasActiveFilters
- ? 'Try adjusting your search or filter criteria'
- : 'Check back later for new plugins'}
+ ? t('pluginsPanel.empty.filteredDescription')
+ : t('pluginsPanel.empty.description')}
{hasActiveFilters && (
- Clear filters
+ {t('pluginsPanel.clearFilters')}
)}
diff --git a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx
index 40df90fd..751ad77f 100644
--- a/src/renderer/components/extensions/skills/SkillDetailDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { CodeBlockViewer } from '@renderer/components/chat/viewers/CodeBlockViewer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
@@ -48,6 +49,7 @@ export const SkillDetailDialog = ({
onEdit,
onDeleted,
}: SkillDetailDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
const deleteSkill = useStore((s) => s.deleteSkill);
const detail = useStore(useShallow((s) => (skillId ? s.skillsDetailsById[skillId] : undefined)));
@@ -86,13 +88,15 @@ export const SkillDetailDialog = ({
const issuesTone = item?.issues.length ? getIssuesTone(item.issues) : null;
function formatScopeLabel(scope: 'user' | 'project'): string {
- return scope === 'project' ? 'This project only' : 'Your personal skills';
+ return scope === 'project'
+ ? t('skillDetail.scope.projectOnly')
+ : t('skillDetail.scope.personal');
}
function formatInvocationLabel(invocationMode: 'auto' | 'manual-only'): string {
return invocationMode === 'manual-only'
- ? 'Only runs when you explicitly ask for it.'
- : 'Runs automatically when it matches the task.';
+ ? t('skillDetail.invocation.manualOnly')
+ : t('skillDetail.invocation.auto');
}
function getIssuesTone(issues: SkillValidationIssue[]): {
@@ -104,14 +108,14 @@ export const SkillDetailDialog = ({
if (informationalOnly) {
return {
className: 'border-blue-500/30 bg-blue-500/5',
- title: 'This skill includes bundled scripts',
+ title: t('skillDetail.issues.bundledScripts'),
Icon: Info,
};
}
return {
className: 'border-amber-500/30 bg-amber-500/5',
- title: 'Review this skill carefully before using it',
+ title: t('skillDetail.issues.reviewCarefully'),
Icon: AlertTriangle,
};
}
@@ -128,7 +132,7 @@ export const SkillDetailDialog = ({
setDeleteConfirmOpen(false);
onDeleted();
} catch (error) {
- setDeleteError(error instanceof Error ? error.message : 'Failed to delete skill');
+ setDeleteError(error instanceof Error ? error.message : t('skillDetail.errors.deleteFailed'));
} finally {
setDeleteLoading(false);
}
@@ -138,14 +142,14 @@ export const SkillDetailDialog = ({
!next && onClose()}>
- {item?.name ?? 'Skill details'}
+ {item?.name ?? t('skillDetail.titleFallback')}
- {item?.description ?? 'Inspect discovered skill metadata and raw instructions.'}
+ {item?.description ?? t('skillDetail.descriptionFallback')}
{(loading || (open && skillId && detail === undefined)) && (
- Loading skill details...
+ {t('skillDetail.loading')}
)}
{!loading && detailError && (
@@ -159,7 +163,7 @@ export const SkillDetailDialog = ({
void fetchSkillDetail(skillId, effectiveProjectPath).catch(() => undefined);
}}
>
- Retry
+ {t('skillDetail.actions.retry')}
)}
@@ -167,7 +171,7 @@ export const SkillDetailDialog = ({
{!loading && !detailError && detail === null && (
- Unable to load this skill.
+ {t('skillDetail.errors.loadFailed')}
)}
@@ -180,14 +184,24 @@ export const SkillDetailDialog = ({
)}
{formatScopeLabel(item.scope)}
- Stored in {formatSkillRootKind(item.rootKind)}
+
+ {t('skillDetail.badges.storedIn', { root: formatSkillRootKind(item.rootKind) })}
+
{getSkillAudienceLabel(item.rootKind)}
- {item.invocationMode === 'manual-only' ? 'Manual use' : 'Auto use'}
+ {item.invocationMode === 'manual-only'
+ ? t('skillDetail.badges.manualUse')
+ : t('skillDetail.badges.autoUse')}
- {item.flags.hasScripts && Has scripts }
- {item.flags.hasReferences && References }
- {item.flags.hasAssets && Assets }
+ {item.flags.hasScripts && (
+ {t('skillDetail.badges.hasScripts')}
+ )}
+ {item.flags.hasReferences && (
+ {t('skillDetail.badges.references')}
+ )}
+ {item.flags.hasAssets && (
+ {t('skillDetail.badges.assets')}
+ )}
{item.issues.length > 0 && (
@@ -224,28 +238,28 @@ export const SkillDetailDialog = ({
- Who can use it
+ {t('skillDetail.summary.whoCanUse')}
{formatScopeLabel(item.scope)}
- How it is used
+ {t('skillDetail.summary.howUsed')}
{formatInvocationLabel(item.invocationMode)}
- What comes with it
+ {t('skillDetail.summary.included')}
{[
- item.flags.hasReferences ? 'references' : null,
- item.flags.hasScripts ? 'scripts' : null,
- item.flags.hasAssets ? 'assets' : null,
+ item.flags.hasReferences ? t('skillDetail.includes.references') : null,
+ item.flags.hasScripts ? t('skillDetail.includes.scripts') : null,
+ item.flags.hasAssets ? t('skillDetail.includes.assets') : null,
]
.filter(Boolean)
- .join(', ') || 'Just the skill instructions'}
+ .join(', ') || t('skillDetail.includes.instructionsOnly')}
@@ -253,7 +267,7 @@ export const SkillDetailDialog = ({
- Edit Skill
+ {t('skillDetail.actions.editSkill')}
- {deleteLoading ? 'Deleting...' : 'Delete'}
+ {deleteLoading
+ ? t('skillDetail.actions.deleting')
+ : t('skillDetail.actions.delete')}
@@ -279,13 +295,13 @@ export const SkillDetailDialog = ({
-
Stored at
+
{t('skillDetail.files.storedAt')}
{item.skillDir}
{detail.scriptFiles.length > 0 && (
-
Scripts
+
{t('skillDetail.files.scripts')}
{detail.scriptFiles.map((file) => (
{file}
@@ -296,7 +312,7 @@ export const SkillDetailDialog = ({
{detail.referencesFiles.length > 0 && (
-
References
+
{t('skillDetail.files.references')}
{detail.referencesFiles.map((file) => (
{file}
@@ -307,7 +323,7 @@ export const SkillDetailDialog = ({
{detail.assetFiles.length > 0 && (
-
Assets
+
{t('skillDetail.files.assets')}
{detail.assetFiles.map((file) => (
{file}
@@ -319,7 +335,7 @@ export const SkillDetailDialog = ({
- Advanced file details
+ {t('skillDetail.files.advancedDetails')}
@@ -329,7 +345,7 @@ export const SkillDetailDialog = ({
onClick={() => void api.showInFolder(item.skillFile)}
>
- Open Folder
+ {t('skillDetail.actions.openFolder')}
void api.openPath(item.skillFile, effectiveProjectPath)}
>
- Open SKILL.md
+ {t('skillDetail.actions.openSkillFile')}
- Delete skill?
+ {t('skillDetail.deleteDialog.title')}
{item
- ? `Delete "${item.name}" and move it to Trash? You can restore it later from Trash if needed.`
- : 'Delete this skill and move it to Trash?'}
+ ? t('skillDetail.deleteDialog.descriptionWithName', { name: item.name })
+ : t('skillDetail.deleteDialog.description')}
- Cancel
+
+ {t('skillDetail.actions.cancel')}
+
void handleDelete()} disabled={deleteLoading}>
- {deleteLoading ? 'Deleting...' : 'Delete Skill'}
+ {deleteLoading
+ ? t('skillDetail.actions.deleting')
+ : t('skillDetail.actions.deleteSkill')}
diff --git a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx
index 65885733..fd59eb80 100644
--- a/src/renderer/components/extensions/skills/SkillEditorDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillEditorDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownPreviewPane } from '@renderer/components/team/editor/MarkdownPreviewPane';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -45,6 +46,8 @@ import type {
SkillRootKind,
} from '@shared/types/extensions';
+const SKILL_MARKDOWN_FILENAME = ['SKILL', 'md'].join('.');
+
type EditorMode = 'create' | 'edit';
interface SkillEditorDialogProps {
@@ -76,6 +79,7 @@ export const SkillEditorDialog = ({
onClose,
onSaved,
}: SkillEditorDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const containerRef = useRef(null);
const editorScrollRef = useRef(null);
const rawContentRef = useRef('');
@@ -294,7 +298,7 @@ export const SkillEditorDialog = ({
[request.files]
);
const auxiliaryDraftFilePaths = useMemo(
- () => draftFilePaths.filter((filePath) => filePath !== 'SKILL.md'),
+ () => draftFilePaths.filter((filePath) => filePath !== SKILL_MARKDOWN_FILENAME),
[draftFilePaths]
);
@@ -308,11 +312,9 @@ export const SkillEditorDialog = ({
[allowCodexRootKind, detail?.item.rootKind]
);
const instructionsLocked = manualRawEdit || customMarkdownDetected;
- const title = mode === 'create' ? 'Create skill' : 'Edit skill';
+ const title = mode === 'create' ? t('skillEditor.title.create') : t('skillEditor.title.edit');
const descriptionText =
- mode === 'create'
- ? 'Describe the workflow in plain language, review the files that will be created, then save it.'
- : 'Update this skill, review the resulting file changes, then save it.';
+ mode === 'create' ? t('skillEditor.description.create') : t('skillEditor.description.edit');
function validateBeforeReview(): string | null {
if (!name.trim()) {
@@ -412,16 +414,15 @@ export const SkillEditorDialog = ({
- 1. Basics
-
- Give this skill a clear name, choose who can use it, and decide where it should
- live.
-
+
+ {t('skillEditor.basics.title')}
+
+ {t('skillEditor.basics.description')}
- Who can use it
+ {t('skillEditor.fields.scope')}
setScope(value as 'user' | 'project')}
@@ -431,18 +432,20 @@ export const SkillEditorDialog = ({
- User
+ {t('skillEditor.scope.user')}
{canUseProjectScope
- ? `Project: ${projectLabel ?? projectPath}`
- : 'Project unavailable'}
+ ? t('skillEditor.scope.project', {
+ project: projectLabel ?? projectPath,
+ })
+ : t('skillEditor.scope.projectUnavailable')}
- Where to store it
+ {t('skillEditor.fields.root')}
setRootKind(value as SkillRootKind)}
@@ -455,7 +458,9 @@ export const SkillEditorDialog = ({
{visibleRootDefinitions.map((definition) => (
{definition.directoryName}
- {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
+ {definition.audience === 'codex'
+ ? t('skillEditor.root.codexOnly')
+ : t('skillEditor.root.shared')}
))}
@@ -463,7 +468,7 @@ export const SkillEditorDialog = ({
-
Folder name
+
{t('skillEditor.fields.folderName')}
{mode === 'create' && (
- We suggest this automatically from the skill name so review works right
- away.
+ {t('skillEditor.fields.folderNameHint')}
)}
- How it should be used
+ {t('skillEditor.fields.invocation')}
{
@@ -495,8 +499,10 @@ export const SkillEditorDialog = ({
- Can be used automatically
- Only when you ask for it
+ {t('skillEditor.invocation.auto')}
+
+ {t('skillEditor.invocation.manualOnly')}
+
@@ -504,7 +510,7 @@ export const SkillEditorDialog = ({
@@ -566,16 +574,19 @@ export const SkillEditorDialog = ({
{!customMarkdownDetected && (
<>
- 2. Instructions
+
+ {t('skillEditor.instructions.title')}
+
- These sections generate the skill file for you, so you do not need to edit
- markdown unless you want to.
+ {t('skillEditor.instructions.description')}
- When to reach for this
+
+ {t('skillEditor.fields.whenToUse')}
+
- Main steps to follow
+ {t('skillEditor.fields.steps')}
-
Extra notes or guardrails
+
{t('skillEditor.fields.notes')}
{instructionsLocked && (
- Structured fields are locked because you switched to manual `SKILL.md`
- editing below.
+ {t('skillEditor.instructions.locked')}
)}
@@ -634,24 +642,27 @@ export const SkillEditorDialog = ({
)}
- 3. Extra files
+
+ {t('skillEditor.extraFiles.title')}
+
- Add supporting docs, scripts, or assets only if this skill really needs them.
+ {t('skillEditor.extraFiles.description')}
-
Optional files
+
+ {t('skillEditor.extraFiles.optionalTitle')}
+
- Add starter files that will be included in the review and written together
- with `SKILL.md`.
+ {t('skillEditor.extraFiles.optionalDescription')}
{mode === 'edit' && (
- Root and folder are locked for edits
+ {t('skillEditor.extraFiles.lockedForEdits')}
)}
@@ -664,9 +675,11 @@ export const SkillEditorDialog = ({
className="mt-0.5"
/>
-
References
+
+ {t('skillEditor.extraFiles.references')}
+
- Add supporting docs, links, or examples the runtime can look at.
+ {t('skillEditor.extraFiles.referencesDescription')}
@@ -678,10 +691,11 @@ export const SkillEditorDialog = ({
className="mt-0.5"
/>
-
Scripts
+
+ {t('skillEditor.extraFiles.scripts')}
+
- Add helper commands or setup notes. Review carefully before sharing this
- skill.
+ {t('skillEditor.extraFiles.scriptsDescription')}
@@ -693,9 +707,11 @@ export const SkillEditorDialog = ({
className="mt-0.5"
/>
-
Assets
+
+ {t('skillEditor.extraFiles.assets')}
+
- Add screenshots or bundled media only if they help explain the workflow.
+ {t('skillEditor.extraFiles.assetsDescription')}
@@ -704,7 +720,7 @@ export const SkillEditorDialog = ({
{auxiliaryDraftFilePaths.length > 0 && (
- Added files:
+ {t('skillEditor.extraFiles.addedFiles')}
{auxiliaryDraftFilePaths.map((filePath) => (
@@ -728,13 +744,13 @@ export const SkillEditorDialog = ({
{customMarkdownDetected
- ? '2. SKILL.md editor'
- : '4. Advanced SKILL.md editor'}
+ ? t('skillEditor.advanced.customTitle')
+ : t('skillEditor.advanced.title')}
{customMarkdownDetected
- ? 'This skill uses a custom markdown format, so edit it directly here.'
- : 'Most people can skip this. Open it only if you want direct control over the raw markdown file.'}
+ ? t('skillEditor.advanced.customDescription')
+ : t('skillEditor.advanced.description')}
{!customMarkdownDetected && (
@@ -743,7 +759,9 @@ export const SkillEditorDialog = ({
size="sm"
onClick={() => setShowAdvancedEditor((prev) => !prev)}
>
- {showAdvancedEditor ? 'Hide Advanced Editor' : 'Show Advanced Editor'}
+ {showAdvancedEditor
+ ? t('skillEditor.advanced.hide')
+ : t('skillEditor.advanced.show')}
)}
@@ -751,7 +769,7 @@ export const SkillEditorDialog = ({
{showAdvancedEditor && (
- SKILL.md
+ {SKILL_MARKDOWN_FILENAME}
- Reset From Structured Fields
+ {t('skillEditor.advanced.resetFromStructuredFields')}
@@ -835,21 +853,19 @@ export const SkillEditorDialog = ({
- Cancel
+ {t('skillEditor.actions.cancel')}
-
- Review the file changes first, then confirm save in the next step.
-
+
{t('skillEditor.review.hint')}
{mutationError &&
{mutationError}
}
void handleReview()} disabled={reviewLoading || saveLoading}>
{reviewLoading
- ? 'Preparing...'
+ ? t('skillEditor.actions.preparing')
: mode === 'create'
- ? 'Review And Create'
- : 'Review And Save'}
+ ? t('skillEditor.actions.reviewAndCreate')
+ : t('skillEditor.actions.reviewAndSave')}
@@ -863,8 +879,14 @@ export const SkillEditorDialog = ({
error={mutationError}
onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmSave()}
- confirmLabel={mode === 'create' ? 'Create Skill' : 'Save Skill'}
- reviewLabel={mode === 'create' ? 'Creating a skill' : 'Saving this skill'}
+ confirmLabel={
+ mode === 'create'
+ ? t('skillEditor.actions.createSkill')
+ : t('skillEditor.actions.saveSkill')
+ }
+ reviewLabel={
+ mode === 'create' ? t('skillEditor.review.creating') : t('skillEditor.review.saving')
+ }
/>
>
);
diff --git a/src/renderer/components/extensions/skills/SkillImportDialog.tsx b/src/renderer/components/extensions/skills/SkillImportDialog.tsx
index 5f1ed04e..a49ab650 100644
--- a/src/renderer/components/extensions/skills/SkillImportDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillImportDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
@@ -30,24 +31,26 @@ import { validateSkillFolderName, validateSkillImportSourceDir } from './skillVa
import type { SkillReviewPreview, SkillRootKind } from '@shared/types/extensions';
-function getFriendlyImportError(message: string): string {
+type ExtensionsT = ReturnType
['t'];
+
+function getFriendlyImportError(message: string, t: ExtensionsT): string {
if (message.includes('valid skill file')) {
- return 'This folder does not look like a skill yet. It needs a SKILL.md, Skill.md, or skill.md file.';
+ return t('skillImport.errors.missingSkillFile');
}
if (message.includes('symbolic links')) {
- return 'This folder contains symbolic links. Import the real files instead of links.';
+ return t('skillImport.errors.symbolicLinks');
}
if (message.includes('too many files')) {
- return 'This skill folder is too large to import at once. Remove extra files and try again.';
+ return t('skillImport.errors.tooManyFiles');
}
if (message.includes('too large')) {
- return 'This skill folder is too large to import safely. Trim large assets and try again.';
+ return t('skillImport.errors.tooLarge');
}
if (message.includes('Invalid folder name')) {
- return 'Pick a simpler destination folder name using letters, numbers, dots, dashes, or underscores.';
+ return t('skillImport.errors.invalidFolderName');
}
if (message.includes('must be a directory')) {
- return 'Choose a folder to import, not a single file.';
+ return t('skillImport.errors.mustBeDirectory');
}
return message;
}
@@ -69,6 +72,7 @@ export const SkillImportDialog = ({
onClose,
onImported,
}: SkillImportDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const previewSkillImport = useStore((s) => s.previewSkillImport);
const applySkillImport = useStore((s) => s.applySkillImport);
@@ -170,7 +174,8 @@ export const SkillImportDialog = ({
} catch (error) {
setMutationError(
getFriendlyImportError(
- error instanceof Error ? error.message : 'Failed to review import changes'
+ error instanceof Error ? error.message : t('skillImport.errors.reviewFailed'),
+ t
)
);
} finally {
@@ -198,7 +203,10 @@ export const SkillImportDialog = ({
onClose();
} catch (error) {
setMutationError(
- getFriendlyImportError(error instanceof Error ? error.message : 'Failed to import skill')
+ getFriendlyImportError(
+ error instanceof Error ? error.message : t('skillImport.errors.importFailed'),
+ t
+ )
);
} finally {
setImportLoading(false);
@@ -211,24 +219,24 @@ export const SkillImportDialog = ({
- Import skill
-
- Pick an existing skill folder, review what will be copied, then import it into one
- of your supported skill locations.
-
+ {t('skillImport.title')}
+ {t('skillImport.description')}
- 1. Choose a skill folder
+
+ {t('skillImport.steps.chooseFolder.title')}
+
- This should be a folder that already contains a `SKILL.md`, `Skill.md`, or
- `skill.md` file.
+ {t('skillImport.steps.chooseFolder.description')}
-
Source folder
+
+ {t('skillImport.fields.sourceFolder')}
+
void handleChooseFolder()}>
- Browse
+ {t('skillImport.actions.browse')}
- Destination folder name
+
+ {t('skillImport.fields.destinationFolderName')}
+
- 2. Decide where it belongs
+
+ {t('skillImport.steps.location.title')}
+
- Personal skills work everywhere. Project skills only show up for one codebase.
+ {t('skillImport.steps.location.description')}
- Who can use it
+ {t('skillImport.fields.audience')}
setScope(value as 'user' | 'project')}
@@ -272,18 +284,20 @@ export const SkillImportDialog = ({
- User
+ {t('skillImport.scope.user')}
{projectPath
- ? `Project: ${projectLabel ?? projectPath}`
- : 'Project unavailable'}
+ ? t('skillImport.scope.project', {
+ project: projectLabel ?? projectPath,
+ })
+ : t('skillImport.scope.projectUnavailable')}
-
Where to store it
+
{t('skillImport.fields.storage')}
setRootKind(value as SkillRootKind)}
@@ -295,7 +309,9 @@ export const SkillImportDialog = ({
{visibleRootDefinitions.map((definition) => (
{definition.directoryName}
- {definition.audience === 'codex' ? ' - Codex only' : ' - Shared'}
+ {definition.audience === 'codex'
+ ? t('skillImport.rootSuffix.codexOnly')
+ : t('skillImport.rootSuffix.shared')}
))}
@@ -314,17 +330,19 @@ export const SkillImportDialog = ({
- Cancel
+ {t('skillImport.actions.cancel')}
- Review the copied files first, then confirm the import in the next step.
+ {t('skillImport.reviewHint')}
void handleReview()}
disabled={!sourceDir.trim() || reviewLoading || importLoading}
>
- {reviewLoading ? 'Preparing...' : 'Review And Import'}
+ {reviewLoading
+ ? t('skillImport.actions.preparing')
+ : t('skillImport.actions.reviewAndImport')}
@@ -338,9 +356,9 @@ export const SkillImportDialog = ({
error={mutationError}
onClose={() => setReviewOpen(false)}
onConfirm={() => void handleConfirmImport()}
- confirmLabel="Import Skill"
- reviewLabel="Importing this skill"
- backLabel="Back To Import"
+ confirmLabel={t('skillImport.actions.importSkill')}
+ reviewLabel={t('skillImport.reviewLabel')}
+ backLabel={t('skillImport.actions.backToImport')}
/>
>
);
diff --git a/src/renderer/components/extensions/skills/SkillReviewDialog.tsx b/src/renderer/components/extensions/skills/SkillReviewDialog.tsx
index 9120bbfb..53ca7d4c 100644
--- a/src/renderer/components/extensions/skills/SkillReviewDialog.tsx
+++ b/src/renderer/components/extensions/skills/SkillReviewDialog.tsx
@@ -1,4 +1,5 @@
import { DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -36,6 +37,7 @@ export const SkillReviewDialog = ({
reviewLabel,
backLabel = 'Back To Editor',
}: SkillReviewDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const hasChanges = Boolean(preview && preview.changes.length > 0);
return (
@@ -43,41 +45,48 @@ export const SkillReviewDialog = ({
- Review skill changes
-
- {reviewLabel} previews the filesystem changes first. Nothing is written until you
- confirm below.
-
+ {t('skillReview.title')}
+ {t('skillReview.description', { reviewLabel })}
- {!preview &&
No preview available.
}
+ {!preview &&
{t('skillReview.noPreview')}
}
{preview && (
- {preview.changes.length} file changes
+
+ {t('skillReview.summary.fileChanges', { count: preview.changes.length })}
+
{preview.summary.created > 0 && (
- {preview.summary.created} new
+
+ {t('skillReview.summary.new', { count: preview.summary.created })}
+
)}
{preview.summary.updated > 0 && (
- {preview.summary.updated} updated
+
+ {t('skillReview.summary.updated', { count: preview.summary.updated })}
+
)}
{preview.summary.deleted > 0 && (
- {preview.summary.deleted} removed
+
+ {t('skillReview.summary.removed', { count: preview.summary.deleted })}
+
)}
{preview.summary.binary > 0 && (
- {preview.summary.binary} binary
+
+ {t('skillReview.summary.binary', { count: preview.summary.binary })}
+
)}
{preview.targetSkillDir}
- Review the diff below, then use{' '}
- {confirmLabel} to apply these
- changes.
+ {t('skillReview.confirmPromptPrefix')}{' '}
+ {confirmLabel} {' '}
+ {t('skillReview.confirmPromptSuffix')}
@@ -97,7 +106,7 @@ export const SkillReviewDialog = ({
{!hasChanges && (
- No file changes detected yet.
+ {t('skillReview.noChanges')}
)}
@@ -112,12 +121,14 @@ export const SkillReviewDialog = ({
{change.action}
{change.relativePath}
- {change.isBinary &&
binary }
+ {change.isBinary && (
+
{t('skillReview.binaryBadge')}
+ )}
{change.isBinary ? (
- Binary file preview is not shown. The file will be copied as-is.
+ {t('skillReview.binaryPreviewHidden')}
) : (
diff --git a/src/renderer/components/extensions/skills/SkillsPanel.tsx b/src/renderer/components/extensions/skills/SkillsPanel.tsx
index 0ee032da..875f7486 100644
--- a/src/renderer/components/extensions/skills/SkillsPanel.tsx
+++ b/src/renderer/components/extensions/skills/SkillsPanel.tsx
@@ -4,6 +4,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@@ -81,26 +82,6 @@ function sortSkills(skills: SkillCatalogItem[], sort: SkillsSortState): SkillCat
return next;
}
-function getScopeLabel(skill: SkillCatalogItem): string {
- return skill.scope === 'project' ? 'This project' : 'Personal';
-}
-
-function getInvocationLabel(skill: SkillCatalogItem): string {
- return skill.invocationMode === 'manual-only'
- ? 'Only runs when you explicitly ask for it'
- : 'Runs automatically when it fits';
-}
-
-function getSkillStatus(skill: SkillCatalogItem): string {
- if (!skill.isValid) {
- return 'Needs attention before you rely on it';
- }
- if (skill.flags.hasScripts) {
- return 'Includes scripts, so review it carefully';
- }
- return 'Ready to use';
-}
-
function getPrimarySkillIssue(skill: SkillCatalogItem): SkillValidationIssue | null {
return (
skill.issues.find((issue) => issue.severity === 'error') ??
@@ -150,6 +131,7 @@ export const SkillsPanel = ({
selectedSkillId,
setSelectedSkillId,
}: SkillsPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('extensions');
const catalogKey = projectPath ?? USER_SKILLS_CATALOG_KEY;
const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog);
const fetchSkillDetail = useStore((s) => s.fetchSkillDetail);
@@ -344,14 +326,29 @@ export const SkillsPanel = ({
[visibleSkills]
);
const isRefreshing = skillsLoading && mergedSkills.length > 0;
+ const getScopeLabel = (skill: SkillCatalogItem): string =>
+ skill.scope === 'project' ? t('skillsPanel.scope.project') : t('skillsPanel.scope.user');
+ const getInvocationLabel = (skill: SkillCatalogItem): string =>
+ skill.invocationMode === 'manual-only'
+ ? t('skillsPanel.invocation.manualOnly')
+ : t('skillsPanel.invocation.auto');
+ const getSkillStatus = (skill: SkillCatalogItem): string => {
+ if (!skill.isValid) {
+ return t('skillsPanel.status.needsAttention');
+ }
+ if (skill.flags.hasScripts) {
+ return t('skillsPanel.status.hasScripts');
+ }
+ return t('skillsPanel.status.ready');
+ };
return (
{effectiveCliStatus?.flavor === 'agent_teams_orchestrator' && (
- Shared skills in `.claude`, `.cursor`, and `.agents` are available to{' '}
- {skillsAudienceLabel ?? 'the configured runtime'}. Skills stored in `.codex` stay
- Codex-only when Codex support is available.
+ {t('skillsPanel.runtimeAudience', {
+ audience: skillsAudienceLabel ?? t('skillsPanel.configuredRuntime'),
+ })}
)}
@@ -359,21 +356,19 @@ export const SkillsPanel = ({
-
Teach repeatable work
+ {t('skillsPanel.hero.title')}
- Skills are reusable instructions that help the runtime handle the same kind of task
- more consistently.{' '}
+ {t('skillsPanel.hero.description')}{' '}
{projectPath
- ? `You are seeing skills for ${projectLabel ?? projectPath} plus your personal skills.`
- : 'You are seeing only your personal skills right now.'}
+ ? t('skillsPanel.hero.projectContext', { project: projectLabel ?? projectPath })
+ : t('skillsPanel.hero.personalContext')}
- Use personal skills for habits you want everywhere. Use project skills for workflows
- that only make sense inside one codebase.
+ {t('skillsPanel.hero.guidance')}
{codexSkillOverlayAvailable
- ? ' Use `.codex` when a skill should stay Codex-only.'
- : ' Existing `.codex` skills stay editable here, but new Codex-only skills need the Codex runtime enabled.'}
+ ? ` ${t('skillsPanel.hero.codexAvailable')}`
+ : ` ${t('skillsPanel.hero.codexUnavailable')}`}
@@ -383,17 +378,17 @@ export const SkillsPanel = ({
setCreateOpen(true)}>
- Create Skill
+ {t('skillsPanel.actions.createSkill')}
setImportOpen(true)}>
- Import
+ {t('skillsPanel.actions.import')}
@@ -403,13 +398,13 @@ export const SkillsPanel = ({
variant="outline"
size="icon"
className="size-9 shrink-0"
- aria-label="Sort skills"
+ aria-label={t('skillsPanel.sort.label')}
>
- Sort skills
+ {t('skillsPanel.sort.label')}
- Name
+ {t('skillsPanel.sort.name')}
{skillsSort === 'name-asc' && }
- Recent
+ {t('skillsPanel.sort.recent')}
{skillsSort === 'recent-desc' && }
@@ -443,20 +438,20 @@ export const SkillsPanel = ({
- {mergedSkills.length} total
+ {t('skillsPanel.counts.total', { count: mergedSkills.length })}
- {projectSkills.length} project
+ {t('skillsPanel.counts.project', { count: projectSkills.length })}
- {userSkills.length} personal
+ {t('skillsPanel.counts.personal', { count: userSkills.length })}
- {sharedSkillsCount} shared
+ {t('skillsPanel.counts.shared', { count: sharedSkillsCount })}
{showCodexOnlyUi && (
- {codexOnlySkillsCount} Codex only
+ {t('skillsPanel.counts.codexOnly', { count: codexOnlySkillsCount })}
)}
@@ -467,15 +462,18 @@ export const SkillsPanel = ({
{(
[
- ['all', 'All skills'],
- ['project', 'Project'],
- ['personal', 'Personal'],
- ['shared', 'Shared'],
+ ['all', t('skillsPanel.filters.all')],
+ ['project', t('skillsPanel.filters.project')],
+ ['personal', t('skillsPanel.filters.personal')],
+ ['shared', t('skillsPanel.filters.shared')],
...(showCodexOnlyUi
- ? ([['codex-only', 'Codex only']] as [SkillsQuickFilter, string][])
+ ? ([['codex-only', t('skillsPanel.filters.codexOnly')]] as [
+ SkillsQuickFilter,
+ string,
+ ][])
: []),
- ['needs-attention', 'Needs attention'],
- ['has-scripts', 'Has scripts'],
+ ['needs-attention', t('skillsPanel.filters.needsAttention')],
+ ['has-scripts', t('skillsPanel.filters.hasScripts')],
] as [SkillsQuickFilter, string][]
).map(([value, label]) => (
- Refreshing skills...
+ {t('skillsPanel.loading.refreshing')}
)}
{skillsLoading && visibleSkills.length === 0 && (
- Loading skills...
+ {t('skillsPanel.loading.loading')}
)}
@@ -521,12 +519,12 @@ export const SkillsPanel = ({
- {skillsSearchQuery ? 'No skills match your search' : 'No skills yet'}
+ {skillsSearchQuery ? t('skillsPanel.empty.noMatches') : t('skillsPanel.empty.noSkills')}
{skillsSearchQuery
- ? 'Try a different search term or switch filters.'
- : 'Create your first skill to teach a repeatable workflow, or import one you already use.'}
+ ? t('skillsPanel.empty.noMatchesDescription')
+ : t('skillsPanel.empty.noSkillsDescription')}
)}
@@ -537,9 +535,11 @@ export const SkillsPanel = ({
-
Project skills
+
+ {t('skillsPanel.sections.project.title')}
+
- Workflows that only make sense for this codebase.
+ {t('skillsPanel.sections.project.description')}
@@ -573,7 +573,7 @@ export const SkillsPanel = ({
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
- Needs attention
+ {t('skillsPanel.badges.needsAttention')}
)}
@@ -591,24 +591,26 @@ export const SkillsPanel = ({
- Stored in {formatSkillRootKind(skill.rootKind)}
+ {t('skillsPanel.badges.storedIn', {
+ root: formatSkillRootKind(skill.rootKind),
+ })}
{getSkillAudienceLabel(skill.rootKind)}
{skill.flags.hasScripts && (
- Has scripts
+ {t('skillsPanel.badges.hasScripts')}
)}
{skill.flags.hasReferences && (
- References
+ {t('skillsPanel.badges.references')}
)}
{skill.flags.hasAssets && (
- Assets
+ {t('skillsPanel.badges.assets')}
)}
@@ -632,9 +634,11 @@ export const SkillsPanel = ({
-
Personal skills
+
+ {t('skillsPanel.sections.personal.title')}
+
- Habits and instructions you want available everywhere.
+ {t('skillsPanel.sections.personal.description')}
@@ -668,7 +672,7 @@ export const SkillsPanel = ({
variant="outline"
className="border-amber-500/40 text-amber-700 dark:text-amber-300"
>
- Needs attention
+ {t('skillsPanel.badges.needsAttention')}
)}
@@ -686,24 +690,26 @@ export const SkillsPanel = ({
- Stored in {formatSkillRootKind(skill.rootKind)}
+ {t('skillsPanel.badges.storedIn', {
+ root: formatSkillRootKind(skill.rootKind),
+ })}
{getSkillAudienceLabel(skill.rootKind)}
{skill.flags.hasScripts && (
- Has scripts
+ {t('skillsPanel.badges.hasScripts')}
)}
{skill.flags.hasReferences && (
- References
+ {t('skillsPanel.badges.references')}
)}
{skill.flags.hasAssets && (
- Assets
+ {t('skillsPanel.badges.assets')}
)}
@@ -749,7 +755,7 @@ export const SkillsPanel = ({
onClose={() => setCreateOpen(false)}
onSaved={(skillId) => {
setCreateOpen(false);
- setSuccessMessage('Skill created successfully.');
+ setSuccessMessage(t('skillsPanel.success.created'));
setHighlightedSkillId(skillId);
setSelectedSkillId(null);
}}
@@ -769,7 +775,7 @@ export const SkillsPanel = ({
onSaved={(skillId) => {
setEditOpen(false);
setEditingDetail(null);
- setSuccessMessage('Skill saved successfully.');
+ setSuccessMessage(t('skillsPanel.success.saved'));
setSelectedSkillId(skillId);
}}
/>
@@ -782,7 +788,7 @@ export const SkillsPanel = ({
onClose={() => setImportOpen(false)}
onImported={(skillId) => {
setImportOpen(false);
- setSuccessMessage('Skill imported successfully.');
+ setSuccessMessage(t('skillsPanel.success.imported'));
setSelectedSkillId(skillId);
}}
/>
diff --git a/src/renderer/components/layout/CustomTitleBar.tsx b/src/renderer/components/layout/CustomTitleBar.tsx
index 7a1593dc..4fa2f303 100644
--- a/src/renderer/components/layout/CustomTitleBar.tsx
+++ b/src/renderer/components/layout/CustomTitleBar.tsx
@@ -7,6 +7,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import faviconUrl from '@renderer/favicon.png';
@@ -30,6 +31,7 @@ function needsCustomTitleBar(): boolean {
}
export const CustomTitleBar = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const [isMaximized, setIsMaximized] = useState(false);
const useNativeTitleBar = useStore((s) => s.appConfig?.general?.useNativeTitleBar ?? false);
const showTitleBar = needsCustomTitleBar() && !useNativeTitleBar;
@@ -76,12 +78,12 @@ export const CustomTitleBar = (): React.JSX.Element | null => {
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void minimize()}
- aria-label="Minimize"
+ aria-label={t('window.minimize')}
>
- Minimize
+ {t('window.minimize')}
@@ -90,12 +92,14 @@ export const CustomTitleBar = (): React.JSX.Element | null => {
className={`${buttonBase} ${buttonHover}`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void handleMaximize()}
- aria-label={isMaximized ? 'Restore' : 'Maximize'}
+ aria-label={isMaximized ? t('window.restore') : t('window.maximize')}
>
- {isMaximized ? 'Restore' : 'Maximize'}
+
+ {isMaximized ? t('window.restore') : t('window.maximize')}
+
@@ -104,12 +108,12 @@ export const CustomTitleBar = (): React.JSX.Element | null => {
className={`${buttonBase} hover:bg-red-500/90 hover:text-white`}
style={{ color: 'var(--color-text-secondary)' }}
onClick={() => void close()}
- aria-label="Close"
+ aria-label={t('actions.close')}
>
- Close
+ {t('actions.close')}
diff --git a/src/renderer/components/layout/MoreMenu.tsx b/src/renderer/components/layout/MoreMenu.tsx
index c1bbb2c7..5069b98c 100644
--- a/src/renderer/components/layout/MoreMenu.tsx
+++ b/src/renderer/components/layout/MoreMenu.tsx
@@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -52,6 +53,7 @@ export const MoreMenu = ({
activeTabSessionDetail,
activeTabId,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isOpen, setIsOpen] = useState(false);
const [buttonHover, setButtonHover] = useState(false);
const [hoveredId, setHoveredId] = useState(null);
@@ -128,7 +130,7 @@ export const MoreMenu = ({
const topItems: MenuItem[] = [
{
id: 'teams',
- label: 'Teams',
+ label: t('layout.menu.teams'),
icon: Users,
onClick: () => {
openTeamsTab();
@@ -137,7 +139,7 @@ export const MoreMenu = ({
},
{
id: 'settings',
- label: 'Settings',
+ label: t('layout.menu.settings'),
icon: Settings,
onClick: () => {
openSettingsTab();
@@ -146,7 +148,7 @@ export const MoreMenu = ({
},
{
id: 'extensions',
- label: 'Extensions',
+ label: t('layout.menu.extensions'),
icon: Puzzle,
onClick: () => {
openExtensionsTab();
@@ -155,7 +157,7 @@ export const MoreMenu = ({
},
{
id: 'search',
- label: 'Search',
+ label: t('layout.menu.search'),
icon: Search,
shortcut: formatShortcut('K'),
onClick: () => {
@@ -165,7 +167,7 @@ export const MoreMenu = ({
},
{
id: 'schedules',
- label: 'Schedules',
+ label: t('layout.menu.schedules'),
icon: Calendar,
onClick: () => {
openSchedulesTab();
@@ -174,7 +176,7 @@ export const MoreMenu = ({
},
{
id: 'docs',
- label: 'Docs',
+ label: t('layout.menu.docs'),
icon: BookOpen,
onClick: () => {
void handleOpenDocs();
@@ -186,28 +188,28 @@ export const MoreMenu = ({
? [
{
id: 'export-md',
- label: 'Export as Markdown',
+ label: t('layout.menu.exportMarkdown'),
icon: FileText,
shortcut: '.md',
onClick: () => handleExport('markdown'),
},
{
id: 'export-json',
- label: 'Export as JSON',
+ label: t('layout.menu.exportJson'),
icon: Braces,
shortcut: '.json',
onClick: () => handleExport('json'),
},
{
id: 'export-txt',
- label: 'Export as Plain Text',
+ label: t('layout.menu.exportPlainText'),
icon: Type,
shortcut: '.txt',
onClick: () => handleExport('plaintext'),
},
{
id: 'analyze',
- label: 'Analyze Session',
+ label: t('layout.menu.analyzeSession'),
icon: Activity,
onClick: () => {
if (activeTabId) openSessionReport(activeTabId);
@@ -258,12 +260,12 @@ export const MoreMenu = ({
backgroundColor:
buttonHover || isOpen ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="More actions"
+ aria-label={t('actions.moreActions')}
>
- More actions
+ {t('actions.moreActions')}
{/* Dropdown menu */}
diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx
index d7d71e43..4ab940d5 100644
--- a/src/renderer/components/layout/PaneContent.tsx
+++ b/src/renderer/components/layout/PaneContent.tsx
@@ -5,6 +5,7 @@
import { lazy, Suspense, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { TabUIProvider } from '@renderer/contexts/TabUIContext';
import { DashboardView } from '../dashboard/DashboardView';
@@ -69,15 +70,19 @@ interface PaneTabSlotProps {
isPaneFocused: boolean;
}
-const PaneLazyFallback = (): React.JSX.Element => (
-
-);
+const PaneLazyFallback = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+
+ return (
+
+ );
+};
const PaneTabSlot = ({ tab, isActive, isPaneFocused }: PaneTabSlotProps): React.JSX.Element => {
const [hasActivated, setHasActivated] = useState(isActive);
diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx
index 51039bf3..0e7f217a 100644
--- a/src/renderer/components/layout/PaneView.tsx
+++ b/src/renderer/components/layout/PaneView.tsx
@@ -5,6 +5,7 @@
*/
import { useDndContext } from '@dnd-kit/core';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { MAX_PANES } from '@renderer/types/panes';
import { useShallow } from 'zustand/react/shallow';
@@ -17,6 +18,7 @@ interface PaneViewProps {
}
export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { pane, isFocused, paneCount, focusPane } = useStore(
useShallow((s) => ({
pane: s.paneLayout.panes.find((p) => p.id === paneId),
@@ -66,7 +68,7 @@ export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => {
color: 'var(--color-text-muted)',
}}
>
- Maximum {MAX_PANES} panes reached
+ {t('layout.maxPanesReached', { count: MAX_PANES })}
)}
diff --git a/src/renderer/components/layout/SessionTabContent.tsx b/src/renderer/components/layout/SessionTabContent.tsx
index 3bf89e98..581de5f4 100644
--- a/src/renderer/components/layout/SessionTabContent.tsx
+++ b/src/renderer/components/layout/SessionTabContent.tsx
@@ -5,6 +5,7 @@
import { useEffect } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { AlertCircle, RefreshCw } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -17,6 +18,7 @@ export const SessionTabContent = ({
tab,
isActive,
}: Readonly<{ tab: Tab; isActive: boolean }>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { fetchSessionDetail, closeTab, initTabUIState } = useStore(
useShallow((s) => ({
fetchSessionDetail: s.fetchSessionDetail,
@@ -55,7 +57,9 @@ export const SessionTabContent = ({
-
Failed to load session
+
+ {t('sessions.failedToLoad')}
+
{sessionDetailError}
@@ -69,13 +73,13 @@ export const SessionTabContent = ({
className="flex items-center gap-2 rounded-md border border-claude-dark-border bg-claude-dark-surface px-4 py-2 text-sm transition-colors hover:bg-claude-dark-border"
>
- Retry
+ {t('actions.retry')}
closeTab(tab.id)}
className="px-4 py-2 text-sm text-claude-dark-text-secondary transition-colors hover:text-claude-dark-text"
>
- Close tab
+ {t('layout.closeTab')}
@@ -88,7 +92,7 @@ export const SessionTabContent = ({
-
Loading session...
+
{t('sessions.loading')}
);
diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx
index 989a8e27..fbc41594 100644
--- a/src/renderer/components/layout/Sidebar.tsx
+++ b/src/renderer/components/layout/Sidebar.tsx
@@ -10,6 +10,7 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { PanelLeft } from 'lucide-react';
@@ -33,6 +34,7 @@ const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 280;
export const Sidebar = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { sidebarCollapsed, toggleSidebar } = useStore(
useShallow((s) => ({
sidebarCollapsed: s.sidebarCollapsed,
@@ -129,13 +131,13 @@ export const Sidebar = (): React.JSX.Element => {
color: isCollapseHovered ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
backgroundColor: isCollapseHovered ? 'var(--color-surface-raised)' : 'transparent',
}}
- title={`Collapse sidebar (${formatShortcut('B')})`}
+ title={t('layout.collapseSidebarShortcut', { shortcut: formatShortcut('B') })}
>
-
+
{
}
onClick={() => setSidebarTab('tasks')}
>
- Tasks
+ {t('tasksPanel.title')}
{
}
onClick={() => setSidebarTab('sessions')}
>
- Sessions
+ {t('sessions.title')}
@@ -219,7 +221,7 @@ export const Sidebar = (): React.JSX.Element => {
{!sidebarCollapsed && (
{
+ const { t } = useAppTranslation('common');
const [isHovered, setIsHovered] = useState(false);
const { isLight } = useTheme();
@@ -185,12 +187,12 @@ export const SortableTab = ({
>
{tab.fromSearch && (
-
+
)}
{isPinned && (
-
+
)}
@@ -222,12 +224,12 @@ export const SortableTab = ({
onClose(tab.id);
}}
onPointerDown={(e) => e.stopPropagation()}
- aria-label="Close tab"
+ aria-label={t('layout.closeTab')}
>
-
Close tab
+
{t('layout.closeTab')}
);
diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx
index 2b7586be..7848f04c 100644
--- a/src/renderer/components/layout/TabBar.tsx
+++ b/src/renderer/components/layout/TabBar.tsx
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -26,6 +27,7 @@ interface TabBarProps {
}
export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
pane,
isFocused,
@@ -305,12 +307,14 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
onMouseEnter={() => setRefreshHover(true)}
onMouseLeave={() => setRefreshHover(false)}
onClick={handleRefresh}
- aria-label="Refresh session"
+ aria-label={t('layout.refreshSession')}
>
-
{`Refresh Session (${formatShortcut('R')})`}
+
+ {t('layout.refreshSessionWithShortcut', { shortcut: formatShortcut('R') })}
+
)}
diff --git a/src/renderer/components/layout/TabBarActions.tsx b/src/renderer/components/layout/TabBarActions.tsx
index 7a017892..41bf7205 100644
--- a/src/renderer/components/layout/TabBarActions.tsx
+++ b/src/renderer/components/layout/TabBarActions.tsx
@@ -6,6 +6,7 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -15,6 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
import { MoreMenu } from './MoreMenu';
export const TabBarActions = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
unreadCount,
openNotificationsTab,
@@ -74,13 +76,15 @@ export const TabBarActions = (): React.JSX.Element => {
backgroundColor: updateHover ? 'rgba(34, 197, 94, 0.1)' : 'transparent',
}}
>
- {updateStatus === 'downloaded' ? 'Restart to update' : 'Update app'}
+ {updateStatus === 'downloaded'
+ ? t('updates.restartToUpdate')
+ : t('updates.updateApp')}
{updateStatus === 'downloaded'
- ? 'Update downloaded, restart to apply'
- : 'New version available'}
+ ? t('updates.downloadedRestartTooltip')
+ : t('updates.newVersionAvailable')}
)}
@@ -97,7 +101,7 @@ export const TabBarActions = (): React.JSX.Element => {
color: notificationsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: notificationsHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="Notifications"
+ aria-label={t('notifications.title')}
>
{unreadCount > 0 && (
@@ -107,7 +111,7 @@ export const TabBarActions = (): React.JSX.Element => {
)}
-
Notifications
+
{t('notifications.title')}
{/* GitHub link */}
@@ -135,14 +139,14 @@ export const TabBarActions = (): React.JSX.Element => {
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="GitHub"
+ aria-label={t('layout.github')}
>
-
GitHub
+
{t('layout.github')}
{/* Discord link */}
@@ -164,14 +168,14 @@ export const TabBarActions = (): React.JSX.Element => {
color: discordHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: discordHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="Discord"
+ aria-label={t('layout.discord')}
>
-
Discord
+
{t('layout.discord')}
{/* More menu (Teams, Settings, Extensions, Search, Schedules, Docs, Export, Analyze) */}
@@ -194,12 +198,12 @@ export const TabBarActions = (): React.JSX.Element => {
color: expandHover ? 'var(--color-text)' : 'var(--color-text-muted)',
backgroundColor: expandHover ? 'var(--color-surface-raised)' : 'transparent',
}}
- aria-label="Expand sidebar"
+ aria-label={t('layout.expandSidebar')}
>
-
Expand sidebar
+
{t('layout.expandSidebar')}
)}
diff --git a/src/renderer/components/layout/TabBarRow.tsx b/src/renderer/components/layout/TabBarRow.tsx
index 7f1ef8a8..4816e3a6 100644
--- a/src/renderer/components/layout/TabBarRow.tsx
+++ b/src/renderer/components/layout/TabBarRow.tsx
@@ -6,6 +6,7 @@
import { Fragment, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
@@ -17,6 +18,7 @@ import { TabBar } from './TabBar';
import { TabBarActions } from './TabBarActions';
export const TabBarRow = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const { panes, focusedPaneId, openDashboard } = useStore(
useShallow((s) => ({
panes: s.paneLayout.panes,
@@ -85,12 +87,12 @@ export const TabBarRow = (): React.JSX.Element => {
backgroundColor: newTabHover ? 'var(--color-surface-raised)' : 'transparent',
} as React.CSSProperties
}
- aria-label="New tab"
+ aria-label={t('layout.newTab')}
>
-
New tab (Dashboard)
+
{t('layout.newTabDashboard')}
diff --git a/src/renderer/components/layout/TabContextMenu.tsx b/src/renderer/components/layout/TabContextMenu.tsx
index a58e0de3..6a2e6059 100644
--- a/src/renderer/components/layout/TabContextMenu.tsx
+++ b/src/renderer/components/layout/TabContextMenu.tsx
@@ -7,6 +7,7 @@
import { useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatShortcut } from '@renderer/utils/stringUtils';
interface TabContextMenuProps {
@@ -53,6 +54,7 @@ export const TabContextMenu = ({
isHidden,
onToggleHide,
}: TabContextMenuProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const menuRef = useRef(null);
// Close on click-outside and Escape
@@ -98,43 +100,54 @@ export const TabContextMenu = ({
>
{selectedCount > 1 && onCloseSelectedTabs ? (
) : (
)}
-
+
-
+
{isSessionTab && onTogglePin && (
<>
>
)}
{isSessionTab && onToggleHide && (
)}
diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx
index 4cd48f64..871f708f 100644
--- a/src/renderer/components/layout/TeamTabSectionNav.tsx
+++ b/src/renderer/components/layout/TeamTabSectionNav.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { ChevronDown, Columns3, History, MessageSquare, Terminal, Users } from 'lucide-react';
@@ -11,18 +12,21 @@ interface TeamTabSectionNavProps {
onActivate?: () => void;
}
-const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
- { id: 'team', label: 'Team', icon: Users },
- { id: 'sessions', label: 'Sessions', icon: History },
- { id: 'kanban', label: 'Kanban', icon: Columns3 },
- { id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
- { id: 'messages', label: 'Messages', icon: MessageSquare },
+const SECTIONS: readonly { id: string; labelKey: TeamSectionLabelKey; icon: LucideIcon }[] = [
+ { id: 'team', labelKey: 'team', icon: Users },
+ { id: 'sessions', labelKey: 'sessions', icon: History },
+ { id: 'kanban', labelKey: 'kanban', icon: Columns3 },
+ { id: 'claude-logs', labelKey: 'claudeLogs', icon: Terminal },
+ { id: 'messages', labelKey: 'messages', icon: MessageSquare },
];
+type TeamSectionLabelKey = 'team' | 'sessions' | 'kanban' | 'claudeLogs' | 'messages';
+
export const TeamTabSectionNav = ({
teamName,
onActivate,
}: TeamTabSectionNavProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const messagesPanelMode = useStore((s) => s.messagesPanelMode);
const [open, setOpen] = useState(false);
const [hoveredId, setHoveredId] = useState(null);
@@ -91,7 +95,7 @@ export const TeamTabSectionNav = ({
e.stopPropagation();
setOpen((prev) => !prev);
}}
- title="Jump to section"
+ title={t('layout.jumpToSection')}
>
@@ -134,7 +138,7 @@ export const TeamTabSectionNav = ({
}}
>
- {section.label}
+ {t(`layout.sections.${section.labelKey}`)}
);
})}
diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx
index c5e8285e..af12876a 100644
--- a/src/renderer/components/notifications/NotificationRow.tsx
+++ b/src/renderer/components/notifications/NotificationRow.tsx
@@ -5,6 +5,7 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { formatDistanceToNow } from 'date-fns';
import { ArrowRight, Bot, Check, Trash2, Users } from 'lucide-react';
@@ -32,6 +33,7 @@ export const NotificationRow = ({
onArchive,
onDelete,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [isHovered, setIsHovered] = useState(false);
const isUnread = !error.isRead;
const projectName = error.context?.projectName || 'Unknown Project';
@@ -113,7 +115,7 @@ export const NotificationRow = ({
}}
>
- team
+ {t('notifications.row.team')}
)}
{error.subagentId && (
@@ -126,7 +128,7 @@ export const NotificationRow = ({
}}
>
- subagent
+ {t('notifications.row.subagent')}
)}
@@ -174,6 +176,7 @@ const HoverActions = ({
onDeleteClick,
onNavigateClick,
}: HoverActionsProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [hoveredButton, setHoveredButton] = useState
(null);
const getButtonStyle = (buttonId: string, isDelete = false): React.CSSProperties => ({
@@ -196,7 +199,7 @@ const HoverActions = ({
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('archive')}
- title="Mark as read"
+ title={t('notifications.row.markAsRead')}
>
@@ -208,7 +211,7 @@ const HoverActions = ({
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('delete', true)}
- title="Delete"
+ title={t('notifications.row.delete')}
>
@@ -219,7 +222,7 @@ const HoverActions = ({
onMouseLeave={() => setHoveredButton(null)}
className="rounded p-1.5 transition-colors"
style={getButtonStyle('navigate')}
- title="View in session"
+ title={t('notifications.row.viewInSession')}
>
diff --git a/src/renderer/components/notifications/NotificationsView.tsx b/src/renderer/components/notifications/NotificationsView.tsx
index 839cd45b..6cc42b6c 100644
--- a/src/renderer/components/notifications/NotificationsView.tsx
+++ b/src/renderer/components/notifications/NotificationsView.tsx
@@ -6,6 +6,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { useVirtualizer } from '@tanstack/react-virtual';
@@ -21,7 +22,7 @@ const ROW_HEIGHT = 56;
const OVERSCAN = 5;
/** Label used for notifications without a triggerName */
-const OTHER_LABEL = 'Other';
+const OTHER_LABEL = '__other__';
interface FilterChip {
label: string;
@@ -30,6 +31,7 @@ interface FilterChip {
}
export const NotificationsView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
notifications,
unreadCount,
@@ -188,7 +190,7 @@ export const NotificationsView = (): React.JSX.Element => {
style={{ color: 'var(--color-text-muted)' }}
/>
- Loading notifications...
+ {t('notifications.loading')}
@@ -207,17 +209,17 @@ export const NotificationsView = (): React.JSX.Element => {
- Notifications
+ {t('notifications.title')}
{notifications.length > 0 && (
{activeFilter !== null
? filteredUnreadCount > 0
- ? `${filteredUnreadCount} unread in filter`
- : `${filteredNotifications.length} in filter`
+ ? t('notifications.counts.unreadInFilter', { count: filteredUnreadCount })
+ : t('notifications.counts.inFilter', { count: filteredNotifications.length })
: unreadCount > 0
- ? `${unreadCount} unread`
- : `${notifications.length} total`}
+ ? t('notifications.counts.unread', { count: unreadCount })
+ : t('notifications.counts.total', { count: notifications.length })}
)}
@@ -231,11 +233,17 @@ export const NotificationsView = (): React.JSX.Element => {
onClick={handleMarkAllRead}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors hover:opacity-80"
style={{ color: 'var(--color-text-muted)' }}
- title={activeFilter !== null ? 'Mark filtered as read' : 'Mark all as read'}
+ title={
+ activeFilter !== null
+ ? t('notifications.actions.markFilteredAsRead')
+ : t('notifications.actions.markAllAsRead')
+ }
>
- {activeFilter !== null ? 'Mark filtered read' : 'Mark all read'}
+ {activeFilter !== null
+ ? t('notifications.actions.markFilteredRead')
+ : t('notifications.actions.markAllRead')}
)}
@@ -249,16 +257,18 @@ export const NotificationsView = (): React.JSX.Element => {
}`}
style={showClearConfirm ? undefined : { color: 'var(--color-text-muted)' }}
title={
- activeFilter !== null ? 'Clear filtered notifications' : 'Clear all notifications'
+ activeFilter !== null
+ ? t('notifications.actions.clearFilteredNotifications')
+ : t('notifications.actions.clearAllNotifications')
}
>
{showClearConfirm
- ? 'Click to confirm'
+ ? t('notifications.actions.clickToConfirm')
: activeFilter !== null
- ? 'Clear filtered'
- : 'Clear all'}
+ ? t('notifications.actions.clearFiltered')
+ : t('notifications.actions.clearAll')}
@@ -286,7 +296,7 @@ export const NotificationsView = (): React.JSX.Element => {
: '1px solid var(--color-border)',
}}
>
- All
+ {t('list.all')}
({sortedNotifications.length})
{/* Trigger chips */}
@@ -307,7 +317,7 @@ export const NotificationsView = (): React.JSX.Element => {
}}
>
- {chip.label}
+ {chip.label === OTHER_LABEL ? t('notifications.filters.other') : chip.label}
({chip.count})
))}
@@ -324,10 +334,14 @@ export const NotificationsView = (): React.JSX.Element => {
>
- {activeFilter !== null ? 'No matching notifications' : 'No notifications'}
+ {activeFilter !== null
+ ? t('notifications.empty.noMatching')
+ : t('notifications.empty.noNotifications')}
- {activeFilter !== null ? 'Try a different filter' : "You're all caught up!"}
+ {activeFilter !== null
+ ? t('notifications.empty.tryDifferentFilter')
+ : t('notifications.empty.allCaughtUp')}
) : (
diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx
index df2e9aa0..997b6d77 100644
--- a/src/renderer/components/report/SessionReportTab.tsx
+++ b/src/renderer/components/report/SessionReportTab.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { computeTakeaways } from '@renderer/utils/reportAssessments';
import { analyzeSession } from '@renderer/utils/sessionAnalyzer';
@@ -25,6 +26,7 @@ interface SessionReportTabProps {
}
export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
+ const { t } = useAppTranslation('report');
// Find session data from any session tab with matching sessionId
const sessionDetail = useStore(
useShallow((s) => {
@@ -44,14 +46,14 @@ export const SessionReportTab = ({ tab }: SessionReportTabProps) => {
if (!report) {
return (
- No session data available. Open the session tab first.
+ {t('sessionReport.noSessionData')}
);
}
return (
-
Session Analysis Report
+
{t('sessionReport.title')}
{takeaways.length > 0 &&
}
diff --git a/src/renderer/components/report/sections/CostSection.tsx b/src/renderer/components/report/sections/CostSection.tsx
index c1fdd2e0..97f97f0c 100644
--- a/src/renderer/components/report/sections/CostSection.tsx
+++ b/src/renderer/components/report/sections/CostSection.tsx
@@ -1,5 +1,6 @@
import { Fragment, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getPricing } from '@renderer/utils/sessionAnalyzer';
import { DollarSign } from 'lucide-react';
@@ -34,22 +35,31 @@ interface BreakdownLine {
const CostBreakdownCard = ({
stats,
pricing,
+ labels,
}: {
stats: ModelTokenStats;
pricing: ModelPricing;
+ labels: {
+ input: string;
+ output: string;
+ cacheRead: string;
+ cacheWrite: string;
+ breakdownTitle: string;
+ total: string;
+ };
}) => {
const lines: BreakdownLine[] = [
- { label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input },
- { label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output },
- { label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read },
- { label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
+ { label: labels.input, tokens: stats.inputTokens, ratePerM: pricing.input },
+ { label: labels.output, tokens: stats.outputTokens, ratePerM: pricing.output },
+ { label: labels.cacheRead, tokens: stats.cacheRead, ratePerM: pricing.cache_read },
+ { label: labels.cacheWrite, tokens: stats.cacheCreation, ratePerM: pricing.cache_creation },
];
const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0);
return (
- Cost Breakdown (per 1M tokens)
+ {labels.breakdownTitle}
{lines.map((l) => {
@@ -64,7 +74,7 @@ const CostBreakdownCard = ({
);
})}
- Total
+ {labels.total}
{fmt(total)}
@@ -79,6 +89,7 @@ export const CostSection = ({
linesChanged,
defaultCollapsed,
}: CostSectionProps) => {
+ const { t } = useAppTranslation('report');
const [expandedModel, setExpandedModel] = useState
(null);
const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]);
const showStackedBar = data.subagentCostUsd > 0;
@@ -88,7 +99,7 @@ export const CostSection = ({
: 100;
return (
-
+
{fmt(data.totalSessionCostUsd)}
{/* Parent/Subagent stacked bar */}
@@ -110,14 +121,18 @@ export const CostSection = ({
className="inline-block size-2 rounded-full"
style={{ backgroundColor: '#60a5fa' }}
/>
- Parent: {fmt(data.parentCostUsd)}
+
+ {t('cost.parent', { cost: fmt(data.parentCostUsd) })}
+
- Subagent: {fmt(data.subagentCostUsd)}
+
+ {t('cost.subagent', { cost: fmt(data.subagentCostUsd) })}
+
@@ -127,24 +142,22 @@ export const CostSection = ({
{!showStackedBar && (
<>
-
Parent Cost
+
{t('cost.parentCost')}
{fmt(data.parentCostUsd)}
-
Subagent Cost
+
{t('cost.subagentCost')}
{fmt(data.subagentCostUsd)}
>
)}
-
Per Commit
+
{t('cost.perCommit')}
{commitCount > 0 ? (
- <>
- total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''}
- >
+ <>{t('cost.perCommitFormula', { count: commitCount })}>
) : (
- 'no commits'
+ t('cost.noCommits')
)}
@@ -160,15 +173,12 @@ export const CostSection = ({
-
Per Line Changed
+
{t('cost.perLineChanged')}
{linesChanged > 0 ? (
- <>
- total cost {'\u00F7'} {linesChanged.toLocaleString()} line
- {linesChanged !== 1 ? 's' : ''}
- >
+ <>{t('cost.perLineFormula', { count: linesChanged })}>
) : (
- 'no lines changed'
+ t('cost.noLinesChanged')
)}
@@ -186,12 +196,12 @@ export const CostSection = ({
- Model
- Input
- Output
- Cache Read
- Cache Write
- Cost
+ {t('tokens.model')}
+ {t('cost.input')}
+ {t('cost.output')}
+ {t('cost.cacheRead')}
+ {t('cost.cacheWrite')}
+ {t('cost.cost')}
@@ -245,7 +255,18 @@ export const CostSection = ({
{isExpanded && stats && pricing && (
-
+
)}
diff --git a/src/renderer/components/report/sections/ErrorSection.tsx b/src/renderer/components/report/sections/ErrorSection.tsx
index 88832434..c14846f0 100644
--- a/src/renderer/components/report/sections/ErrorSection.tsx
+++ b/src/renderer/components/report/sections/ErrorSection.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
import { ReportSection } from '../ReportSection';
@@ -11,6 +12,7 @@ interface ErrorItemProps {
}
const ErrorItem = ({ error }: ErrorItemProps) => {
+ const { t } = useAppTranslation('report');
const [expanded, setExpanded] = useState(false);
return (
@@ -33,17 +35,19 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
color: 'var(--assess-danger)',
}}
>
- Permission Denied
+ {t('errors.permissionDenied')}
)}
- msg #{error.messageIndex}
+
+ {t('errors.messageIndex', { index: error.messageIndex })}
+
{expanded && (
{error.inputPreview && (
- Input
+ {t('errors.input')}
{error.inputPreview}
@@ -52,7 +56,7 @@ const ErrorItem = ({ error }: ErrorItemProps) => {
)}
- Error
+ {t('errors.error')}
{
+ const { t } = useAppTranslation('report');
+
return (
-
+
{
color: 'var(--assess-danger)',
}}
>
- {data.errors.length} error{data.errors.length !== 1 ? 's' : ''}
+ {t('errors.count', { count: data.errors.length })}
{data.permissionDenials.count > 0 && (
- {data.permissionDenials.count} permission denial
- {data.permissionDenials.count !== 1 ? 's' : ''}
+ {t('errors.permissionDenialCount', { count: data.permissionDenials.count })}
)}
diff --git a/src/renderer/components/report/sections/FrictionSection.tsx b/src/renderer/components/report/sections/FrictionSection.tsx
index 147b74ed..69a61dbe 100644
--- a/src/renderer/components/report/sections/FrictionSection.tsx
+++ b/src/renderer/components/report/sections/FrictionSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { MessageSquareWarning } from 'lucide-react';
@@ -13,13 +14,14 @@ interface FrictionSectionProps {
}
export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => {
+ const { t } = useAppTranslation('report');
const frictionSeverity =
data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger';
const frictionColor = severityColor(frictionSeverity);
return (
@@ -31,16 +33,18 @@ export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionS
color: frictionColor,
}}
>
- Friction Rate: {(data.frictionRate * 100).toFixed(1)}%
+ {t('friction.rate', { rate: (data.frictionRate * 100).toFixed(1) })}
- {data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''}
+ {t('friction.correctionsCount', { count: data.correctionCount })}
{data.corrections.length > 0 && (
-
Corrections
+
+ {t('friction.corrections')}
+
{data.corrections.map((corr, idx) => (
@@ -63,13 +67,17 @@ export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionS
{(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && (
-
Thrashing Signals
+
+ {t('friction.thrashingSignals')}
+
{thrashing.bashNearDuplicates.length > 0 && (
-
Repeated Bash Commands
+
+ {t('friction.repeatedBashCommands')}
+
{thrashing.bashNearDuplicates.map((dup, idx) => (
{dup.count}x
@@ -81,7 +89,7 @@ export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionS
{thrashing.editReworkFiles.length > 0 && (
-
Reworked Files (3+ edits)
+
{t('friction.reworkedFiles')}
{thrashing.editReworkFiles.map((file, idx) => (
{file.editIndices.length}x
diff --git a/src/renderer/components/report/sections/GitSection.tsx b/src/renderer/components/report/sections/GitSection.tsx
index 5481d835..13d59122 100644
--- a/src/renderer/components/report/sections/GitSection.tsx
+++ b/src/renderer/components/report/sections/GitSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { GitBranch } from 'lucide-react';
import { ReportSection } from '../ReportSection';
@@ -10,25 +11,27 @@ interface GitSectionProps {
}
export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
+ const { t } = useAppTranslation('report');
+
return (
-
+
-
Commits
+
{t('git.commits')}
{data.commitCount}
-
Pushes
+
{t('git.pushes')}
{data.pushCount}
-
Lines Added
+
{t('git.linesAdded')}
+{data.linesAdded.toLocaleString()}
-
Lines Removed
+
{t('git.linesRemoved')}
-{data.linesRemoved.toLocaleString()}
@@ -37,7 +40,7 @@ export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => {
{data.commits.length > 0 && (
-
Commits
+
{t('git.commits')}
{data.commits.map((commit, idx) => (
{
{data.branchCreations.length > 0 && (
-
Branches Created
+
{t('git.branchesCreated')}
{data.branchCreations.map((branch, idx) => (
{
+ const { t } = useAppTranslation('report');
+ const agentUnit = t('insights.agent', { count: agentTree.agentCount });
+
return (
-
+
{/* Skills invoked */}
{skills.length > 0 && (
- Skills Invoked ({skills.length})
+ {t('insights.skillsInvoked', { count: skills.length })}
{skills.map((s, idx) => (
@@ -53,18 +57,18 @@ export const InsightsSection = ({
{/* Bash commands */}
-
Bash Commands
+
{t('insights.bashCommands')}
-
Total
+
{t('insights.total')}
{bash.total}
-
Unique
+
{t('insights.unique')}
{bash.unique}
-
Repeated
+
{t('insights.repeated')}
{Object.keys(bash.repeated).length}
@@ -86,7 +90,7 @@ export const InsightsSection = ({
{subagentsList.length > 0 && (
- Task Dispatches ({subagentsList.length})
+ {t('insights.taskDispatches', { count: subagentsList.length })}
{subagentsList.map((s, idx) => (
@@ -95,7 +99,9 @@ export const InsightsSection = ({
{s.subagentType}
{s.description}
- {s.runInBackground && (background) }
+ {s.runInBackground && (
+ {t('insights.background')}
+ )}
))}
@@ -106,7 +112,7 @@ export const InsightsSection = ({
{lifecycleTasks.length > 0 && (
- Tasks Created ({lifecycleTasks.length})
+ {t('insights.tasksCreated', { count: lifecycleTasks.length })}
{lifecycleTasks.map((task, idx) => (
@@ -122,7 +128,7 @@ export const InsightsSection = ({
{userQuestions.length > 0 && (
- Questions Asked ({userQuestions.length})
+ {t('insights.questionsAsked', { count: userQuestions.length })}
{userQuestions.map((q, idx) => (
@@ -151,16 +157,16 @@ export const InsightsSection = ({
{agentTree.agentCount > 0 && (
- Agent Tree ({agentTree.agentCount} agent{agentTree.agentCount !== 1 ? 's' : ''})
+ {t('insights.agentTree', { count: agentTree.agentCount, unit: agentUnit })}
{agentTree.hasTeamMode && (
- Team Mode
+ {t('insights.teamMode')}
)}
{agentTree.teamNames.length > 0 && (
- Teams: {agentTree.teamNames.join(', ')}
+ {t('insights.teams', { teams: agentTree.teamNames.join(', ') })}
)}
@@ -182,7 +188,7 @@ export const InsightsSection = ({
{outOfScope.length > 0 && (
- Out-of-Scope Findings ({outOfScope.length})
+ {t('insights.outOfScopeFindings', { count: outOfScope.length })}
{outOfScope.map((f, idx) => (
diff --git a/src/renderer/components/report/sections/KeyTakeawaysSection.tsx b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx
index 9eee50b0..17d15959 100644
--- a/src/renderer/components/report/sections/KeyTakeawaysSection.tsx
+++ b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react';
@@ -23,9 +24,11 @@ interface KeyTakeawaysSectionProps {
}
export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => {
+ const { t } = useAppTranslation('report');
+
return (
-
Key Takeaways
+
{t('insights.keyTakeaways')}
{takeaways.map((t, idx) => {
const Icon = SEVERITY_ICONS[t.severity];
diff --git a/src/renderer/components/report/sections/OverviewSection.tsx b/src/renderer/components/report/sections/OverviewSection.tsx
index d871b02d..da39b9cb 100644
--- a/src/renderer/components/report/sections/OverviewSection.tsx
+++ b/src/renderer/components/report/sections/OverviewSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Activity } from 'lucide-react';
@@ -10,20 +11,21 @@ interface OverviewSectionProps {
}
export const OverviewSection = ({ data }: OverviewSectionProps) => {
+ const { t } = useAppTranslation('report');
return (
-
+
{data.firstMessage}
-
Duration
+
{t('overview.metrics.duration')}
{data.durationHuman}
-
Messages
+
{t('overview.metrics.messages')}
{data.totalMessages.toLocaleString()}
-
Context Usage
+
{t('overview.metrics.contextUsage')}
{
-
Compactions
+
{t('overview.metrics.compactions')}
{data.compactionCount}
-
Branch
+
{t('overview.metrics.branch')}
{data.gitBranch}
-
Subagents
-
{data.hasSubagents ? 'Yes' : 'No'}
+
{t('overview.metrics.subagents')}
+
+ {data.hasSubagents ? t('overview.yes') : t('overview.no')}
+
-
Project
+
{t('overview.metrics.project')}
{data.projectPath}
-
Session ID
+
{t('overview.metrics.sessionId')}
{data.sessionId.slice(0, 12)}...
diff --git a/src/renderer/components/report/sections/QualitySection.tsx b/src/renderer/components/report/sections/QualitySection.tsx
index fa1794cb..d9b83458 100644
--- a/src/renderer/components/report/sections/QualitySection.tsx
+++ b/src/renderer/components/report/sections/QualitySection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { BarChart3 } from 'lucide-react';
@@ -26,32 +27,35 @@ export const QualitySection = ({
fileReadRedundancy,
defaultCollapsed,
}: QualitySectionProps) => {
+ const { t } = useAppTranslation('report');
+ const snapshotUnit = t('quality.snapshot', { count: testProgression.snapshotCount });
+
return (
-
+
{/* Prompt quality */}
-
Prompt Quality
+
{t('quality.promptQuality')}
{prompt.note}
-
First Message
+
{t('quality.firstMessage')}
- {prompt.firstMessageLengthChars.toLocaleString()} chars
+ {prompt.firstMessageLengthChars.toLocaleString()} {t('quality.chars')}
-
User Messages
+
{t('quality.userMessages')}
{prompt.userMessageCount}
-
Corrections
+
{t('quality.corrections')}
{prompt.correctionCount}
-
Friction Rate
+
{t('quality.frictionRate')}
{(prompt.frictionRate * 100).toFixed(1)}%
@@ -62,22 +66,24 @@ export const QualitySection = ({
{/* Startup overhead */}
-
Startup Overhead
+
+ {t('quality.startupOverhead')}
+
-
Messages Before Work
+
{t('quality.messagesBeforeWork')}
{startup.messagesBeforeFirstWork}
-
Tokens Before Work
+
{t('quality.tokensBeforeWork')}
{startup.tokensBeforeFirstWork.toLocaleString()}
-
% of Total
+
{t('quality.percentOfTotal')}
{startup.pctOfTotal}%
@@ -86,7 +92,9 @@ export const QualitySection = ({
{/* File read redundancy */}
-
File Read Redundancy
+
+ {t('quality.fileReadRedundancy')}
+
-
Total Reads
+
{t('quality.totalReads')}
{fileReadRedundancy.totalReads}
-
Unique Files
+
{t('quality.uniqueFiles')}
{fileReadRedundancy.uniqueFiles}
-
Reads/Unique File
+
{t('quality.readsPerUniqueFile')}
{fileReadRedundancy.readsPerUniqueFile}x
@@ -112,36 +120,38 @@ export const QualitySection = ({
{/* Test progression */}
-
Test Progression
+
+ {t('quality.testProgression')}
+
- {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''}
+ {testProgression.snapshotCount} {snapshotUnit}
{testProgression.firstSnapshot && testProgression.lastSnapshot && (
-
First Run
+
{t('quality.firstRun')}
- {testProgression.firstSnapshot.passed} passed
+ {testProgression.firstSnapshot.passed} {t('quality.passed')}
{' / '}
- {testProgression.firstSnapshot.failed} failed
+ {testProgression.firstSnapshot.failed} {t('quality.failed')}
-
Last Run
+
{t('quality.lastRun')}
- {testProgression.lastSnapshot.passed} passed
+ {testProgression.lastSnapshot.passed} {t('quality.passed')}
{' / '}
- {testProgression.lastSnapshot.failed} failed
+ {testProgression.lastSnapshot.failed} {t('quality.failed')}
diff --git a/src/renderer/components/report/sections/SubagentSection.tsx b/src/renderer/components/report/sections/SubagentSection.tsx
index 7ce3dd60..bf311f78 100644
--- a/src/renderer/components/report/sections/SubagentSection.tsx
+++ b/src/renderer/components/report/sections/SubagentSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { severityColor } from '@renderer/utils/reportAssessments';
import { Users } from 'lucide-react';
@@ -19,23 +20,24 @@ interface SubagentSectionProps {
}
export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => {
+ const { t } = useAppTranslation('report');
return (
-
+
-
Count
+
{t('subagents.metrics.count')}
{data.count}
-
Total Tokens
+
{t('subagents.metrics.totalTokens')}
{data.totalTokens.toLocaleString()}
-
Total Duration
+
{t('subagents.metrics.totalDuration')}
{fmtDuration(data.totalDurationMs)}
-
Total Cost
+
{t('subagents.metrics.totalCost')}
{fmtCost(data.totalCostUsd)}
@@ -45,11 +47,11 @@ export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps
- Description
- Type
- Tokens
- Duration
- Cost
+ {t('subagents.table.description')}
+ {t('subagents.table.type')}
+ {t('subagents.table.tokens')}
+ {t('subagents.table.duration')}
+ {t('subagents.table.cost')}
diff --git a/src/renderer/components/report/sections/TimelineSection.tsx b/src/renderer/components/report/sections/TimelineSection.tsx
index 395974dd..58141cf2 100644
--- a/src/renderer/components/report/sections/TimelineSection.tsx
+++ b/src/renderer/components/report/sections/TimelineSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments';
import { Clock } from 'lucide-react';
@@ -23,31 +24,32 @@ export const TimelineSection = ({
keyEvents,
defaultCollapsed,
}: TimelineSectionProps) => {
+ const { t } = useAppTranslation('report');
const idleColor = assessmentColor(idle.idleAssessment);
return (
-
+
{/* Idle stats */}
-
Idle Analysis
+
{t('timeline.idleAnalysis')}
-
Idle Gaps
+
{t('timeline.metrics.idleGaps')}
{idle.idleGapCount}
-
Total Idle
+
{t('timeline.metrics.totalIdle')}
{idle.totalIdleHuman}
-
Active Time
+
{t('timeline.metrics.activeTime')}
{idle.activeWorkingHuman}
-
Idle %
+
{t('timeline.metrics.idlePercent')}
{idle.idlePct}%
@@ -60,7 +62,7 @@ export const TimelineSection = ({
- Model Switches ({modelSwitches.count})
+ {t('timeline.modelSwitches', { count: modelSwitches.count })}
{modelSwitches.switchPattern && (
{sw.from}
→
{sw.to}
- msg #{sw.messageIndex}
+
+ {t('timeline.messageNumber', { number: sw.messageIndex })}
+
))}
@@ -90,7 +94,7 @@ export const TimelineSection = ({
{/* Key events */}
{keyEvents.length > 0 && (
-
Key Events
+
{t('timeline.keyEvents')}
{keyEvents.map((event, idx) => (
diff --git a/src/renderer/components/report/sections/TokenSection.tsx b/src/renderer/components/report/sections/TokenSection.tsx
index f3aeaa27..5b3724c2 100644
--- a/src/renderer/components/report/sections/TokenSection.tsx
+++ b/src/renderer/components/report/sections/TokenSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Coins } from 'lucide-react';
import { AssessmentBadge } from '../AssessmentBadge';
@@ -15,22 +16,23 @@ interface TokenSectionProps {
}
export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => {
+ const { t } = useAppTranslation('report');
const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd);
return (
-
+
{/* By-model table */}
- Model
- API Calls
- Input
- Output
- Cache Read
- Cache Create
- Cost
+ {t('tokens.model')}
+ {t('tokens.apiCalls')}
+ {t('tokens.input')}
+ {t('tokens.output')}
+ {t('tokens.cacheRead')}
+ {t('tokens.cacheCreate')}
+ {t('tokens.cost')}
@@ -47,7 +49,7 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
))}
{/* Totals row */}
- Total
+ {t('tokens.total')}
{fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))}
@@ -66,7 +68,7 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
{/* Cache economics */}
-
Cache Efficiency
+
{t('tokens.cacheEfficiency')}
{cacheEconomics.cacheEfficiencyPct}%
@@ -80,7 +82,7 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
-
R/W Ratio
+
{t('tokens.readWriteRatio')}
{cacheEconomics.cacheReadToWriteRatio}x
@@ -94,11 +96,11 @@ export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSe
-
Cache Read %
+
{t('tokens.cacheReadPct')}
{data.totals.cacheReadPct}%
-
Cold Start
+
{t('tokens.coldStart')}
- {cacheEconomics.coldStartDetected ? 'Yes' : 'No'}
+ {cacheEconomics.coldStartDetected ? t('tokens.yes') : t('tokens.no')}
diff --git a/src/renderer/components/report/sections/ToolSection.tsx b/src/renderer/components/report/sections/ToolSection.tsx
index fd15c852..d3aaa474 100644
--- a/src/renderer/components/report/sections/ToolSection.tsx
+++ b/src/renderer/components/report/sections/ToolSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { assessmentColor } from '@renderer/utils/reportAssessments';
import { Wrench } from 'lucide-react';
@@ -12,15 +13,20 @@ interface ToolSectionProps {
}
export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
+ const { t } = useAppTranslation('report');
const toolEntries = Object.entries(data.successRates).sort(
(a, b) => b[1].totalCalls - a[1].totalCalls
);
return (
-
+
- {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools
+ {t('tools.summary', {
+ count: data.totalCalls,
+ formattedCount: data.totalCalls.toLocaleString(),
+ toolCount: toolEntries.length,
+ })}
@@ -28,11 +34,11 @@ export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => {
- Tool
- Calls
- Errors
- Success %
- Health
+ {t('tools.columns.tool')}
+ {t('tools.columns.calls')}
+ {t('tools.columns.errors')}
+ {t('tools.columns.successPercent')}
+ {t('tools.columns.health')}
diff --git a/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
index 8836e0c8..b84d8513 100644
--- a/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
+++ b/src/renderer/components/runtime/CodexLoginLinkCopyButton.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Check, Copy } from 'lucide-react';
interface CodexLoginLinkCopyButtonProps {
@@ -15,6 +16,7 @@ export const CodexLoginLinkCopyButton = ({
disabled = false,
size = 'sm',
}: CodexLoginLinkCopyButtonProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
useEffect(() => {
@@ -31,7 +33,7 @@ export const CodexLoginLinkCopyButton = ({
return;
}
- const text = userCode ? `${authUrl}\nCode: ${userCode}` : authUrl;
+ const text = userCode ? `${authUrl}\n${t('code.code')}: ${userCode}` : authUrl;
void navigator.clipboard.writeText(text).then(
() => setCopyState('copied'),
() => setCopyState('failed')
@@ -50,16 +52,16 @@ export const CodexLoginLinkCopyButton = ({
borderColor: 'rgba(245, 158, 11, 0.28)',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
}}
- title={userCode ? 'Copy ChatGPT login link and code' : 'Copy ChatGPT login link'}
+ title={userCode ? t('codexLogin.copyLoginLinkAndCode') : t('codexLogin.copyLoginLink')}
>
{copyState === 'copied' ? : }
{copyState === 'copied'
- ? 'Copied'
+ ? t('actions.copied')
: copyState === 'failed'
- ? 'Copy failed'
+ ? t('codexLogin.copyFailed')
: userCode
- ? 'Copy link + code'
- : 'Copy link'}
+ ? t('codexLogin.copyLinkAndCode')
+ : t('codexLogin.copyLink')}
);
};
@@ -69,6 +71,7 @@ export const CodexLoginUserCodeBadge = ({
}: {
userCode?: string | null;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
if (!userCode) {
return null;
}
@@ -81,9 +84,9 @@ export const CodexLoginUserCodeBadge = ({
backgroundColor: 'rgba(245, 158, 11, 0.06)',
color: '#fbbf24',
}}
- title="Enter this code on the ChatGPT login page"
+ title={t('codexLogin.enterCodeOnLoginPage')}
>
- Code {userCode}
+ {t('code.code')} {userCode}
);
};
diff --git a/src/renderer/components/runtime/ProviderModelBadges.tsx b/src/renderer/components/runtime/ProviderModelBadges.tsx
index 08f1a8bc..7e8845bc 100644
--- a/src/renderer/components/runtime/ProviderModelBadges.tsx
+++ b/src/renderer/components/runtime/ProviderModelBadges.tsx
@@ -1,5 +1,6 @@
import { useLayoutEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import {
getTeamModelBadgeLabel,
@@ -32,14 +33,17 @@ function getAvailabilityReason(
return modelAvailability?.find((item) => item.modelId === model)?.reason ?? null;
}
-function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null): string | null {
+function getAvailabilityChip(
+ status: CliProviderModelAvailabilityStatus | null,
+ t: ReturnType['t']
+): string | null {
switch (status) {
case 'checking':
- return 'Checking';
+ return t('providerModelBadges.checking');
case 'unavailable':
- return 'Unavailable';
+ return t('providerModelBadges.unavailable');
case 'unknown':
- return 'Check failed';
+ return t('providerModelBadges.checkFailed');
case 'available':
default:
return null;
@@ -108,6 +112,7 @@ export const ProviderModelBadges = ({
readonly collapseAfter?: number;
readonly maxCollapsedRows?: number;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const [collapsedModelLimit, setCollapsedModelLimit] = useState(null);
const [measureTick, setMeasureTick] = useState(0);
@@ -188,15 +193,17 @@ export const ProviderModelBadges = ({
const renderModelBadge = (model: string, index: number): React.JSX.Element => {
const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability);
const availabilityReason = getAvailabilityReason(model, displayModelAvailability);
- const availabilityChip = getAvailabilityChip(availabilityStatus);
+ const availabilityChip = getAvailabilityChip(availabilityStatus, t);
const modelLabel = formatModelBadgeLabel(providerId, model);
const catalogBadgeLabel = getCatalogBadgeLabel(model, providerStatus);
+ const catalogBadgeIsFree = catalogBadgeLabel === 'Free';
+ const localizedCatalogBadgeLabel = catalogBadgeIsFree
+ ? t('providerModelBadges.free')
+ : catalogBadgeLabel;
const showCatalogBadge = shouldRenderCatalogBadge(modelLabel, catalogBadgeLabel);
const title = [
availabilityReason ?? availabilityChip,
- showCatalogBadge && catalogBadgeLabel === 'Free'
- ? 'Reported by OpenCode metadata. Availability and limits may change.'
- : null,
+ showCatalogBadge && catalogBadgeIsFree ? t('providerModelBadges.freeTooltip') : null,
]
.filter(Boolean)
.join(' - ');
@@ -211,7 +218,7 @@ export const ProviderModelBadges = ({
{modelLabel}
{showCatalogBadge ? (
- {catalogBadgeLabel}
+ {localizedCatalogBadgeLabel}
) : null}
{availabilityChip ? (
@@ -243,14 +250,14 @@ export const ProviderModelBadges = ({
{shouldCollapse && !expanded ? (
setExpanded(true)}>
- +{hiddenCount} more
+ {t('list.moreCount', { count: hiddenCount })}
) : null}
{shouldCollapse && expanded ? (
setExpanded(false)}>
- Hide
+ {t('actions.hide')}
) : null}
diff --git a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx
index ffd4b966..3da2b320 100644
--- a/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx
+++ b/src/renderer/components/runtime/ProviderRuntimeBackendSelector.tsx
@@ -1,10 +1,5 @@
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@renderer/components/ui/select';
+import { useAppTranslation } from '@features/localization/renderer';
+import { Select, SelectContent, SelectItem, SelectTrigger } from '@renderer/components/ui/select';
import {
Tooltip,
TooltipContent,
@@ -111,6 +106,7 @@ export const ProviderRuntimeBackendSelector = ({
disabled = false,
onSelect,
}: Props): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const options = getVisibleProviderRuntimeBackendOptions(provider);
if (options.length === 0) {
return null;
@@ -123,15 +119,52 @@ export const ProviderRuntimeBackendSelector = ({
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(provider, selectedOption, resolvedOption);
- const selectedStateLabel = getProviderRuntimeBackendStateLabel(selectedOption);
- const selectedAudienceLabel = getProviderRuntimeBackendAudienceLabel(selectedOption);
+ const localizeStateLabel = (
+ option: NonNullable[number]
+ ): string | null => {
+ switch (getProviderRuntimeBackendStateLabel(option)) {
+ case 'Locked':
+ return t('runtimeBackendSelector.states.locked');
+ case 'Disabled':
+ return t('runtimeBackendSelector.states.disabled');
+ case 'Auth required':
+ return t('runtimeBackendSelector.states.authRequired');
+ case 'Runtime missing':
+ return t('runtimeBackendSelector.states.runtimeMissing');
+ case 'Degraded':
+ return t('runtimeBackendSelector.states.degraded');
+ case 'Unavailable':
+ return t('runtimeBackendSelector.states.unavailable');
+ default:
+ return null;
+ }
+ };
+ const localizeAudienceLabel = (
+ option: NonNullable[number]
+ ): string | null =>
+ getProviderRuntimeBackendAudienceLabel(option)
+ ? t('runtimeBackendSelector.audience.internal')
+ : null;
+ const localizeOptionDisplayLabel = (
+ option: NonNullable[number]
+ ): string => {
+ if (option.id === 'auto') {
+ if (resolvedOption?.label) {
+ return t('runtimeBackendSelector.autoCurrently', { backend: resolvedOption.label });
+ }
+ return t('runtimeBackendSelector.auto');
+ }
+ return getOptionDisplayLabel(provider, option, resolvedOption);
+ };
+ const selectedLabel = localizeOptionDisplayLabel(selectedOption);
+ const selectedStateLabel = localizeStateLabel(selectedOption);
+ const selectedAudienceLabel = localizeAudienceLabel(selectedOption);
return (
- Runtime backend
+ {t('runtimeBackendSelector.label')}
{provider.resolvedBackendId &&
provider.resolvedBackendId !== provider.selectedBackendId && (
@@ -142,7 +175,9 @@ export const ProviderRuntimeBackendSelector = ({
backgroundColor: 'rgba(255, 255, 255, 0.04)',
}}
>
- Resolved: {resolvedOption?.label ?? provider.resolvedBackendId}
+ {t('runtimeBackendSelector.resolved', {
+ backend: resolvedOption?.label ?? provider.resolvedBackendId,
+ })}
)}
@@ -154,7 +189,7 @@ export const ProviderRuntimeBackendSelector = ({
- Current
+ {t('runtimeBackendSelector.current')}
{selectedLabel}
@@ -172,9 +207,7 @@ export const ProviderRuntimeBackendSelector = ({
>
-
- {getOptionDisplayLabel(provider, option, resolvedOption)}
-
+ {localizeOptionDisplayLabel(option)}
{option.recommended ? (
- Recommended
+ {t('runtimeBackendSelector.recommended')}
) : null}
- {getProviderRuntimeBackendAudienceLabel(option) ? (
+ {localizeAudienceLabel(option) ? (
- {getProviderRuntimeBackendAudienceLabel(option)}
+ {localizeAudienceLabel(option)}
) : null}
- {getProviderRuntimeBackendStateLabel(option) ? (
+ {localizeStateLabel(option) ? (
- {getProviderRuntimeBackendStateLabel(option)}
+ {localizeStateLabel(option)}
) : null}
@@ -251,7 +284,7 @@ export const ProviderRuntimeBackendSelector = ({
backgroundColor: 'rgba(74, 222, 128, 0.14)',
}}
>
- Recommended
+ {t('runtimeBackendSelector.recommended')}
) : null}
{selectedAudienceLabel ? (
@@ -276,11 +309,13 @@ export const ProviderRuntimeBackendSelector = ({
backgroundColor: 'rgba(248, 113, 113, 0.14)',
}}
>
- Unavailable
+ {t('runtimeBackendSelector.unavailable')}
- {selectedOption.detailMessage ?? selectedOption.statusMessage ?? 'Unavailable'}
+ {selectedOption.detailMessage ??
+ selectedOption.statusMessage ??
+ t('runtimeBackendSelector.unavailable')}
@@ -307,7 +342,7 @@ export const ProviderRuntimeBackendSelector = ({
{selectedOption.detailMessage ??
selectedOption.statusMessage ??
- 'This backend cannot be selected yet.'}
+ t('runtimeBackendSelector.cannotSelectYet')}
diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx
index 22f145de..63048b5c 100644
--- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx
+++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx
@@ -4,7 +4,6 @@ import {
formatCodexCreditsValue,
formatCodexRemainingPercent,
formatCodexResetWindowLabel,
- formatCodexUsageExplanation,
formatCodexUsagePercent,
formatCodexUsageWindowLabel,
formatCodexWindowDurationLong,
@@ -19,6 +18,7 @@ import {
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { RuntimeProviderManagementPanel } from '@features/runtime-provider-management/renderer';
import { api } from '@renderer/api';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
@@ -127,6 +127,35 @@ const API_KEY_PROVIDER_CONFIG: Record<
},
};
+const API_KEY_PROVIDER_TRANSLATION_KEYS = {
+ anthropic: {
+ name: 'providerRuntime.apiKey.providers.anthropic.name',
+ title: 'providerRuntime.apiKey.providers.anthropic.title',
+ description: 'providerRuntime.apiKey.providers.anthropic.description',
+ placeholder: 'providerRuntime.apiKey.providers.anthropic.placeholder',
+ },
+ codex: {
+ name: 'providerRuntime.apiKey.providers.codex.name',
+ title: 'providerRuntime.apiKey.providers.codex.title',
+ description: 'providerRuntime.apiKey.providers.codex.description',
+ placeholder: 'providerRuntime.apiKey.providers.codex.placeholder',
+ },
+ gemini: {
+ name: 'providerRuntime.apiKey.providers.gemini.name',
+ title: 'providerRuntime.apiKey.providers.gemini.title',
+ description: 'providerRuntime.apiKey.providers.gemini.description',
+ placeholder: 'providerRuntime.apiKey.providers.gemini.placeholder',
+ },
+} as const satisfies Record<
+ ApiKeyProviderId,
+ {
+ name: string;
+ title: string;
+ description: string;
+ placeholder: string;
+ }
+>;
+
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_ENV_VAR = 'ANTHROPIC_AUTH_TOKEN';
const ANTHROPIC_COMPATIBLE_AUTH_TOKEN_NAME = 'Anthropic-compatible Auth Token';
const FIRST_PARTY_ANTHROPIC_HOSTS = new Set(['api.anthropic.com', 'api-staging.anthropic.com']);
@@ -147,18 +176,21 @@ function isCodexRuntimeInstalling(
);
}
-function getCodexRuntimeInstallLabel(status: CodexRuntimeStatus | null | undefined): string {
+function getCodexRuntimeInstallLabel(
+ status: CodexRuntimeStatus | null | undefined,
+ t: ReturnType
['t']
+): string {
switch (status?.state) {
case 'checking':
- return 'Checking';
+ return t('providerRuntime.codex.install.checking');
case 'downloading':
- return 'Downloading';
+ return t('providerRuntime.codex.install.downloading');
case 'installing':
- return 'Installing';
+ return t('providerRuntime.codex.install.installing');
case 'failed':
- return 'Retry install';
+ return t('providerRuntime.codex.install.retryInstall');
default:
- return 'Install Codex CLI';
+ return t('providerRuntime.codex.install.installCli');
}
}
@@ -167,76 +199,89 @@ function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): A
return matches.find((entry) => entry.scope === 'user') ?? null;
}
-function validateAnthropicCompatibleBaseUrl(value: string): string | null {
+function validateAnthropicCompatibleBaseUrl(
+ value: string,
+ t: ReturnType['t']
+): string | null {
const trimmed = value.trim();
if (!trimmed) {
- return 'Base URL is required';
+ return t('providerRuntime.compatibleEndpoint.validation.baseUrlRequired');
}
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
- return 'Base URL must use http:// or https://';
+ return t('providerRuntime.compatibleEndpoint.validation.httpRequired');
}
if (url.username || url.password) {
- return 'Base URL must not include credentials';
+ return t('providerRuntime.compatibleEndpoint.validation.noCredentials');
}
if (FIRST_PARTY_ANTHROPIC_HOSTS.has(url.hostname)) {
- return 'Use Auto, Subscription, or API key for first-party Anthropic';
+ return t('providerRuntime.compatibleEndpoint.validation.firstPartyAnthropic');
}
} catch {
- return 'Invalid URL';
+ return t('providerRuntime.compatibleEndpoint.validation.invalidUrl');
}
return null;
}
-function getConnectionDescription(provider: CliProviderStatus): string {
+function getConnectionDescription(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string {
switch (provider.providerId) {
case 'anthropic':
- return 'Choose how app-launched Anthropic sessions authenticate.';
+ return t('providerRuntime.connection.descriptions.anthropic');
case 'codex':
- return 'Choose whether Codex should prefer your ChatGPT subscription or an API key when the native runtime launches.';
+ return t('providerRuntime.connection.descriptions.codex');
case 'gemini':
- return 'Configure optional API access. CLI SDK and ADC are still discovered automatically.';
+ return t('providerRuntime.connection.descriptions.gemini');
case 'opencode':
- return 'OpenCode authentication and provider inventory are managed by the OpenCode runtime.';
+ return t('providerRuntime.connection.descriptions.opencode');
}
}
-function getRuntimeDescription(provider: CliProviderStatus): string {
+function getRuntimeDescription(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string {
switch (provider.providerId) {
case 'anthropic':
- return 'Anthropic currently has no separate runtime backend selector.';
+ return t('providerRuntime.runtime.descriptions.anthropic');
case 'codex':
- return 'Codex now runs only through the native runtime path.';
+ return t('providerRuntime.runtime.descriptions.codex');
case 'gemini':
- return 'Choose which Gemini runtime backend multimodel should use.';
+ return t('providerRuntime.runtime.descriptions.gemini');
case 'opencode':
- return 'OpenCode uses its own managed runtime host. Desktop currently exposes status only.';
+ return t('providerRuntime.runtime.descriptions.opencode');
}
}
-function getAuthModeDescription(providerId: CliProviderId, authMode: CliProviderAuthMode): string {
+function getAuthModeDescription(
+ providerId: CliProviderId,
+ authMode: CliProviderAuthMode,
+ t: ReturnType['t']
+): string {
if (providerId === 'anthropic') {
switch (authMode) {
case 'auto':
- return 'Use the runtime default behavior. Saved API keys in this app are only used after you switch to API key mode.';
+ return t('providerRuntime.authModeDescriptions.anthropic.auto');
case 'oauth':
- return 'Force app-launched Anthropic sessions to use the local Anthropic subscription session.';
+ return t('providerRuntime.authModeDescriptions.anthropic.oauth');
case 'api_key':
- return 'Force app-launched Anthropic sessions to use an API key credential.';
+ return t('providerRuntime.authModeDescriptions.anthropic.apiKey');
}
}
if (providerId === 'codex') {
switch (authMode) {
case 'auto':
- return 'Prefer your ChatGPT account when it is available. Fall back to API key mode only when needed.';
+ return t('providerRuntime.authModeDescriptions.codex.auto');
case 'chatgpt':
- return 'Force native Codex launches to use your connected ChatGPT account and subscription.';
+ return t('providerRuntime.authModeDescriptions.codex.chatgpt');
case 'api_key':
- return 'Force native Codex launches to use OPENAI_API_KEY / CODEX_API_KEY billing.';
+ return t('providerRuntime.authModeDescriptions.codex.apiKey');
default:
return '';
}
@@ -245,7 +290,10 @@ function getAuthModeDescription(providerId: CliProviderId, authMode: CliProvider
return '';
}
-function getConnectionAlert(provider: CliProviderStatus): string | null {
+function getConnectionAlert(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string | null {
const authMode = provider.connection?.configuredAuthMode;
const hasAnthropicSubscriptionSession =
provider.authMethod === 'oauth_token' || provider.authMethod === 'claude.ai';
@@ -253,7 +301,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
return provider.connection.compatibleEndpoint.tokenConfigured
? null
- : 'Auth token is not configured. Many local Anthropic-compatible endpoints require a non-empty token.';
+ : t('providerRuntime.alerts.authTokenMissing');
}
if (
@@ -261,7 +309,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
authMode === 'api_key' &&
!provider.connection?.apiKeyConfigured
) {
- return 'API key mode is selected, but no Anthropic API credential is available yet.';
+ return t('providerRuntime.alerts.anthropicApiKeyMissing');
}
if (
@@ -269,7 +317,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
authMode === 'oauth' &&
!hasAnthropicSubscriptionSession
) {
- return 'Anthropic subscription mode is selected. Sign in with Anthropic to use this provider.';
+ return t('providerRuntime.alerts.anthropicSubscriptionMissing');
}
if (
@@ -277,17 +325,17 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
authMode === 'auto' &&
provider.connection?.apiKeySource === 'stored'
) {
- return 'A saved API key is available, but app-launched Anthropic sessions use it only after you switch to API key mode.';
+ return t('providerRuntime.alerts.anthropicStoredKeyAvailable');
}
if (provider.providerId === 'codex') {
const codex = provider.connection?.codex;
if (codex?.login.status === 'starting') {
- return 'Starting ChatGPT login...';
+ return t('providerRuntime.alerts.chatgptLoginStarting');
}
if (codex?.login.status === 'pending') {
- return 'Waiting for ChatGPT account login to finish...';
+ return t('providerRuntime.alerts.chatgptLoginPending');
}
if (codex?.login.status === 'failed' && codex.login.error) {
@@ -296,19 +344,19 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
if (provider.connection?.configuredAuthMode === 'api_key') {
if (!provider.connection?.apiKeyConfigured) {
- return 'API key mode is selected, but no OPENAI_API_KEY or CODEX_API_KEY credential is available yet.';
+ return t('providerRuntime.alerts.codexApiKeyMissing');
}
return null;
}
if (provider.connection?.configuredAuthMode === 'chatgpt' && !codex?.managedAccount) {
const missingChatgptMessage = codex?.localActiveChatgptAccountPresent
- ? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'
+ ? t('providerRuntime.alerts.codexNeedsReconnect')
: codex?.localAccountArtifactsPresent
- ? 'Codex CLI currently has no active ChatGPT account. Local Codex account data exists, but no active managed session is selected.'
- : 'Codex CLI currently has no active ChatGPT account. Connect ChatGPT to use your subscription.';
+ ? t('providerRuntime.alerts.codexLocalArtifactsNoSession')
+ : t('providerRuntime.alerts.codexNoChatgptAccount');
return provider.connection.apiKeyConfigured
- ? `${missingChatgptMessage} Switch to API key mode to use the detected API key.`
+ ? t('providerRuntime.alerts.withApiKeyFallback', { message: missingChatgptMessage })
: missingChatgptMessage;
}
@@ -321,7 +369,7 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
}
if (!provider.connection?.apiKeyConfigured && !codex?.managedAccount) {
- return 'No ChatGPT account or API key is available yet.';
+ return t('providerRuntime.alerts.codexNoCredential');
}
return null;
@@ -331,27 +379,38 @@ function getConnectionAlert(provider: CliProviderStatus): string | null {
provider.providerId === 'gemini' &&
provider.availableBackends?.some((option) => option.id === 'api' && !option.available)
) {
- return 'Gemini API is currently unavailable. Configure `GEMINI_API_KEY` here or use valid Google ADC credentials.';
+ return t('providerRuntime.alerts.geminiApiUnavailable');
}
return null;
}
-function getProviderUsageLabel(provider: CliProviderStatus): string {
+function getProviderUsageLabel(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string {
if (provider.providerId === 'anthropic' && provider.connection?.compatibleEndpoint?.enabled) {
- return 'Using compatible endpoint';
+ return t('providerRuntime.usage.compatibleEndpoint');
}
if (
provider.providerId === 'anthropic' &&
provider.connection?.configuredAuthMode === 'api_key'
) {
- return provider.connection.apiKeyConfigured ? 'Using API key' : 'API key required';
+ return provider.connection.apiKeyConfigured
+ ? t('providerRuntime.usage.apiKey')
+ : t('providerRuntime.usage.apiKeyRequired');
}
return provider.authenticated
- ? `Using ${formatProviderAuthMethodLabelForProvider(provider.providerId, provider.authMethod)}`
- : provider.statusMessage || 'Not connected';
+ ? t('providerRuntime.usage.usingMethod', {
+ method: formatProviderAuthMethodLabelForProvider(
+ provider.providerId,
+ provider.authMethod,
+ t
+ ),
+ })
+ : provider.statusMessage || t('providerRuntime.usage.notConnected');
}
function getCompactOpenCodeProviderDetailMessage(detailMessage?: string | null): string | null {
@@ -374,7 +433,8 @@ function getCompactOpenCodeProviderDetailMessage(detailMessage?: string | null):
function getCodexAccountPanelHint(
provider: CliProviderStatus | null,
- configuredAuthMode: CliProviderAuthMode | undefined
+ configuredAuthMode: CliProviderAuthMode | undefined,
+ t: ReturnType['t']
): string | null {
if (provider?.providerId !== 'codex') {
return null;
@@ -390,23 +450,27 @@ function getCodexAccountPanelHint(
if (hasActiveChatgptSession) {
if (!codex.rateLimits) {
- return 'Usage limits appear here after Codex reports them for the connected ChatGPT account.';
+ return t('providerRuntime.codex.account.hints.usageLimitsAfterReport');
}
return null;
}
const usageSentence = codex.localActiveChatgptAccountPresent
- ? 'Codex has a locally selected ChatGPT account, but the current session needs reconnect before usage limits can load here.'
+ ? t('providerRuntime.codex.account.hints.reconnectBeforeUsage')
: codex.localAccountArtifactsPresent
- ? 'Codex CLI currently reports no active ChatGPT account. Local Codex account data exists, but no active managed session is selected. Usage limits appear here only after Codex CLI sees one.'
- : 'Codex CLI currently reports no active ChatGPT account. Usage limits appear here only after Codex CLI sees one.';
+ ? t('providerRuntime.codex.account.hints.localArtifactsNoSession')
+ : t('providerRuntime.codex.account.hints.noActiveAccount');
if (configuredAuthMode === 'chatgpt' && provider.connection?.apiKeyConfigured) {
- return `${usageSentence} The detected API key is only used after you switch Codex to API key mode.`;
+ return t('providerRuntime.codex.account.hints.detectedApiKeyNeedsApiMode', {
+ message: usageSentence,
+ });
}
if (configuredAuthMode === 'auto' && provider.connection?.apiKeyConfigured) {
- return `${usageSentence} Auto will keep using the detected API key until ChatGPT is connected.`;
+ return t('providerRuntime.codex.account.hints.autoUsesApiKeyUntilChatgpt', {
+ message: usageSentence,
+ });
}
return usageSentence;
@@ -424,9 +488,63 @@ function getProviderStatusColor(statusText: string | null, authenticated: boolea
return authenticated ? '#4ade80' : 'var(--color-text-muted)';
}
-function formatCodexResetDateTime(timestampSeconds: number | null | undefined): string {
+function formatCodexResetDateTime(
+ timestampSeconds: number | null | undefined,
+ t: ReturnType['t']
+): string {
const normalized = normalizeCodexResetTimestamp(timestampSeconds);
- return normalized ? new Date(normalized).toLocaleString() : 'Unknown';
+ return normalized ? new Date(normalized).toLocaleString() : t('providerRuntime.status.unknown');
+}
+
+function formatLocalizedCodexUsageWindowLabel(
+ title: 'Primary used' | 'Secondary used' | 'Weekly used',
+ windowDurationMins: number | null | undefined,
+ t: ReturnType['t']
+): string {
+ const titleByKey = {
+ 'Primary used': t('providerRuntime.codex.rateLimits.primaryUsed'),
+ 'Secondary used': t('providerRuntime.codex.rateLimits.secondaryUsed'),
+ 'Weekly used': t('providerRuntime.codex.rateLimits.weeklyUsed'),
+ };
+ return formatCodexUsageWindowLabel(title, windowDurationMins).replace(title, titleByKey[title]);
+}
+
+function formatLocalizedCodexResetWindowLabel(
+ title: 'Primary reset' | 'Secondary reset' | 'Weekly reset',
+ windowDurationMins: number | null | undefined,
+ t: ReturnType['t']
+): string {
+ const titleByKey = {
+ 'Primary reset': t('providerRuntime.codex.rateLimits.primaryReset'),
+ 'Secondary reset': t('providerRuntime.codex.rateLimits.secondaryReset'),
+ 'Weekly reset': t('providerRuntime.codex.rateLimits.weeklyReset'),
+ };
+ return formatCodexResetWindowLabel(title, windowDurationMins).replace(title, titleByKey[title]);
+}
+
+function formatLocalizedCodexUsageExplanation(
+ usedPercent: number | null | undefined,
+ windowDurationMins: number | null | undefined,
+ t: ReturnType['t']
+): string {
+ const windowLabel = formatCodexWindowDurationLong(windowDurationMins);
+ const remaining = formatCodexRemainingPercent(usedPercent);
+
+ if (windowLabel && remaining) {
+ return t('providerRuntime.codex.rateLimits.usageExplanationWithRemaining', {
+ used: formatCodexUsagePercent(usedPercent),
+ remaining,
+ window: windowLabel,
+ });
+ }
+
+ if (windowLabel) {
+ return t('providerRuntime.codex.rateLimits.usageExplanationWindowOnly', {
+ window: windowLabel,
+ });
+ }
+
+ return t('providerRuntime.codex.rateLimits.usageExplanationGeneric');
}
const CodexRateLimitWindowCard = ({
@@ -446,6 +564,7 @@ const CodexRateLimitWindowCard = ({
resetValue: string;
accent: 'primary' | 'secondary';
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const accentStyles =
accent === 'primary'
? {
@@ -496,7 +615,7 @@ const CodexRateLimitWindowCard = ({
{usedValue}
- {remainingValue} left
+ {t('providerRuntime.codex.rateLimits.remainingLeft', { value: remainingValue })}
@@ -517,44 +636,44 @@ const CodexRateLimitWindowCard = ({
};
function getConnectionMethodCardOptions(
- provider: CliProviderStatus
+ provider: CliProviderStatus,
+ t: ReturnType['t']
): ConnectionMethodCardOption[] | null {
switch (provider.providerId) {
case 'anthropic':
return [
{
authMode: 'auto',
- title: 'Auto',
- description: 'Use Anthropic runtime defaults and the best local credential available.',
+ title: t('providerRuntime.connectionCards.auto.title'),
+ description: t('providerRuntime.connectionCards.anthropic.autoDescription'),
},
{
authMode: 'oauth',
- title: 'Anthropic subscription',
- description: 'Use your local Anthropic sign-in session and subscription access.',
+ title: t('providerRuntime.connectionCards.anthropic.subscriptionTitle'),
+ description: t('providerRuntime.connectionCards.anthropic.subscriptionDescription'),
},
{
authMode: 'api_key',
- title: 'API key',
- description: 'Use ANTHROPIC_API_KEY and Anthropic API billing.',
+ title: t('providerRuntime.connectionCards.apiKey.title'),
+ description: t('providerRuntime.connectionCards.anthropic.apiKeyDescription'),
},
];
case 'codex':
return [
{
authMode: 'auto',
- title: 'Auto',
- description:
- 'Prefer your ChatGPT account and subscription. Use API key mode only if needed.',
+ title: t('providerRuntime.connectionCards.auto.title'),
+ description: t('providerRuntime.connectionCards.codex.autoDescription'),
},
{
authMode: 'chatgpt',
- title: 'ChatGPT account',
- description: 'Use your connected ChatGPT account and Codex subscription.',
+ title: t('providerRuntime.connectionCards.codex.chatgptTitle'),
+ description: t('providerRuntime.connectionCards.codex.chatgptDescription'),
},
{
authMode: 'api_key',
- title: 'API key',
- description: 'Use OPENAI_API_KEY and CODEX_API_KEY billing for native Codex launches.',
+ title: t('providerRuntime.connectionCards.apiKey.title'),
+ description: t('providerRuntime.connectionCards.codex.apiKeyDescription'),
},
];
default:
@@ -562,13 +681,16 @@ function getConnectionMethodCardOptions(
}
}
-function getConnectionMethodCardsHint(provider: CliProviderStatus): string | null {
+function getConnectionMethodCardsHint(
+ provider: CliProviderStatus,
+ t: ReturnType['t']
+): string | null {
if (provider.providerId === 'codex') {
- return 'Codex always runs through the native runtime. Auto prefers your ChatGPT account before falling back to API-key credentials.';
+ return t('providerRuntime.connectionCards.codex.hint');
}
if (provider.providerId === 'anthropic') {
- return 'Auto keeps Anthropic on its default local credential resolution.';
+ return t('providerRuntime.connectionCards.anthropic.hint');
}
return null;
@@ -589,6 +711,7 @@ const ConnectionMethodCards = ({
pendingConnectionAction: PendingConnectionAction;
onSelect: (authMode: CliProviderAuthMode) => void;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const gridClassName =
options.length === 3 ? 'grid gap-2 md:grid-cols-3' : 'grid gap-2 sm:grid-cols-2';
@@ -622,7 +745,7 @@ const ConnectionMethodCards = ({
}}
>
- Switching...
+ {t('providerRuntime.connection.switching')}
) : selected ? (
- Selected
+ {t('providerRuntime.connection.selected')}
) : null}
@@ -663,6 +786,7 @@ export const ProviderRuntimeSettingsDialog = ({
onRefreshProvider,
onRequestLogin,
}: Props): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const [selectedProviderId, setSelectedProviderId] = useState(initialProviderId);
const [activeApiKeyFormProviderId, setActiveApiKeyFormProviderId] =
useState(null);
@@ -800,7 +924,9 @@ export const ProviderRuntimeSettingsDialog = ({
if (selectedCompatibleToken) {
nextConnection.compatibleEndpoint.tokenConfigured = true;
nextConnection.compatibleEndpoint.tokenSource = 'stored';
- nextConnection.compatibleEndpoint.tokenSourceLabel = 'Stored in app';
+ nextConnection.compatibleEndpoint.tokenSourceLabel = t(
+ 'providerRuntime.apiKey.storedInApp'
+ );
}
}
@@ -814,11 +940,13 @@ export const ProviderRuntimeSettingsDialog = ({
if (nextConnection.apiKeySource === 'stored') {
nextConnection.apiKeyConfigured = Boolean(selectedApiKey);
nextConnection.apiKeySource = selectedApiKey ? 'stored' : null;
- nextConnection.apiKeySourceLabel = selectedApiKey ? 'Stored in app' : null;
+ nextConnection.apiKeySourceLabel = selectedApiKey
+ ? t('providerRuntime.apiKey.storedInApp')
+ : null;
} else if (!nextConnection.apiKeyConfigured && selectedApiKey) {
nextConnection.apiKeyConfigured = true;
nextConnection.apiKeySource = 'stored';
- nextConnection.apiKeySourceLabel = 'Stored in app';
+ nextConnection.apiKeySourceLabel = t('providerRuntime.apiKey.storedInApp');
}
}
@@ -836,6 +964,7 @@ export const ProviderRuntimeSettingsDialog = ({
selectedApiKey,
statusApiKeyConfig,
statusSelectedProvider,
+ t,
]);
useEffect(() => {
@@ -869,12 +998,12 @@ export const ProviderRuntimeSettingsDialog = ({
const configuredAuthMode: CliProviderAuthMode | undefined =
selectedProvider?.connection?.configuredAuthMode ?? configurableAuthModes[0] ?? undefined;
const connectionMethodCardOptions = selectedProvider
- ? getConnectionMethodCardOptions(selectedProvider)
+ ? getConnectionMethodCardOptions(selectedProvider, t)
: null;
const showConnectionMethodCards =
connectionMethodCardOptions !== null && typeof configuredAuthMode !== 'undefined';
const managedRuntimeSummary = selectedProvider
- ? getProviderCurrentRuntimeSummary(selectedProvider)
+ ? getProviderCurrentRuntimeSummary(selectedProvider, t)
: null;
const connectionManagedRuntime = selectedProvider
? isConnectionManagedRuntimeProvider(selectedProvider)
@@ -888,10 +1017,22 @@ export const ProviderRuntimeSettingsDialog = ({
? getVisibleProviderRuntimeBackendOptions(selectedProvider).length > 1
: false);
- const apiKeyConfig =
+ const apiKeyProviderId =
selectedProvider && isApiKeyProviderId(selectedProvider.providerId)
- ? API_KEY_PROVIDER_CONFIG[selectedProvider.providerId]
+ ? selectedProvider.providerId
: null;
+ const apiKeyConfig = apiKeyProviderId ? API_KEY_PROVIDER_CONFIG[apiKeyProviderId] : null;
+ const apiKeyTranslationKeys = apiKeyProviderId
+ ? API_KEY_PROVIDER_TRANSLATION_KEYS[apiKeyProviderId]
+ : null;
+ const apiKeyDisplayConfig = apiKeyTranslationKeys
+ ? {
+ title: t(apiKeyTranslationKeys.title),
+ description: t(apiKeyTranslationKeys.description),
+ name: t(apiKeyTranslationKeys.name),
+ placeholder: t(apiKeyTranslationKeys.placeholder),
+ }
+ : null;
const showApiKeyForm =
selectedProvider &&
isApiKeyProviderId(selectedProvider.providerId) &&
@@ -900,7 +1041,7 @@ export const ProviderRuntimeSettingsDialog = ({
apiKeyConfig &&
(selectedProvider?.providerId !== 'codex' || !selectedProvider.connection?.supportsOAuth)
);
- const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider) : null;
+ const connectionAlert = selectedProvider ? getConnectionAlert(selectedProvider, t) : null;
const connectionLoading =
selectedProviderLoading ||
connectionSaving ||
@@ -929,14 +1070,15 @@ export const ProviderRuntimeSettingsDialog = ({
const anthropicFastModeDisabledReason =
anthropicFastModeCapability?.reason ??
(anthropicFastModeSupported
- ? 'Fast mode is currently unavailable for this Anthropic runtime.'
- : 'This Anthropic runtime does not expose Fast mode.');
+ ? t('providerRuntime.fastMode.unavailableForRuntime')
+ : t('providerRuntime.fastMode.notExposed'));
const connectionMethodCardsHint = selectedProvider
- ? getConnectionMethodCardsHint(selectedProvider)
+ ? getConnectionMethodCardsHint(selectedProvider, t)
: null;
const codexAccountPanelHint = getCodexAccountPanelHint(
selectedProvider ?? null,
- configuredAuthMode
+ configuredAuthMode,
+ t
);
const codexFastCapability = useMemo(() => {
if (selectedProvider?.providerId !== 'codex') {
@@ -986,7 +1128,7 @@ export const ProviderRuntimeSettingsDialog = ({
const anthropicCompatibleTokenStatus =
selectedCompatibleToken?.maskedValue ??
anthropicCompatibleEndpoint?.tokenSourceLabel ??
- (anthropicCompatibleTokenConfigured ? 'Configured' : null);
+ (anthropicCompatibleTokenConfigured ? t('providerRuntime.status.configured') : null);
const anthropicCompatibleMissingToken =
anthropicCompatibleEndpointEnabled && !anthropicCompatibleTokenConfigured;
@@ -1005,9 +1147,9 @@ export const ProviderRuntimeSettingsDialog = ({
let connectionStatusLabel: string | null = null;
if (selectedProvider) {
if (!hideConnectionMethodMeta && selectedProvider.authenticated) {
- connectionStatusLabel = getProviderUsageLabel(selectedProvider);
+ connectionStatusLabel = getProviderUsageLabel(selectedProvider, t);
} else if (!hideConnectionMethodMeta) {
- connectionStatusLabel = 'Not connected';
+ connectionStatusLabel = t('providerRuntime.usage.notConnected');
}
}
const showSelectedProviderSummary = Boolean(selectedProvider) && !connectionManagedRuntime;
@@ -1029,36 +1171,36 @@ export const ProviderRuntimeSettingsDialog = ({
if (selectedProvider.providerId === 'anthropic') {
switch (pendingConnectionAction) {
case 'api_key':
- return 'Switching to API key...';
+ return t('providerRuntime.progress.switchingApiKey');
case 'oauth':
- return 'Switching to Anthropic subscription...';
+ return t('providerRuntime.progress.switchingAnthropicSubscription');
case 'auto':
- return 'Switching to Auto...';
+ return t('providerRuntime.progress.switchingAuto');
case 'compatible':
- return 'Saving compatible endpoint...';
+ return t('providerRuntime.progress.savingCompatibleEndpoint');
default:
- return 'Applying connection changes...';
+ return t('providerRuntime.progress.applyingConnectionChanges');
}
}
if (selectedProvider.providerId === 'codex') {
switch (pendingConnectionAction) {
case 'chatgpt':
- return 'Switching to ChatGPT account mode...';
+ return t('providerRuntime.progress.switchingChatgpt');
case 'api_key':
- return 'Switching to API key mode...';
+ return t('providerRuntime.progress.switchingApiKeyMode');
case 'auto':
- return 'Switching to Auto...';
+ return t('providerRuntime.progress.switchingAuto');
default:
- return 'Applying connection changes...';
+ return t('providerRuntime.progress.applyingConnectionChanges');
}
}
- return 'Applying connection changes...';
+ return t('providerRuntime.progress.applyingConnectionChanges');
}
- return 'Refreshing provider status...';
- }, [connectionLoading, connectionSaving, pendingConnectionAction, selectedProvider]);
+ return t('providerRuntime.progress.refreshingProviderStatus');
+ }, [connectionLoading, connectionSaving, pendingConnectionAction, selectedProvider, t]);
const handleStartApiKeyEdit = (): void => {
if (!selectedProvider || !isApiKeyProviderId(selectedProvider.providerId) || !apiKeyConfig) {
@@ -1084,7 +1226,7 @@ export const ProviderRuntimeSettingsDialog = ({
}
if (!apiKeyValue.trim()) {
- setApiKeyError('API key is required');
+ setApiKeyError(t('providerRuntime.errors.apiKeyRequired'));
return;
}
@@ -1099,7 +1241,9 @@ export const ProviderRuntimeSettingsDialog = ({
scope: apiKeyScope,
});
} catch (error) {
- setApiKeyError(error instanceof Error ? error.message : 'Failed to save API key');
+ setApiKeyError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.saveApiKey')
+ );
return;
}
@@ -1109,7 +1253,7 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await onRefreshProvider?.(selectedProvider.providerId);
} catch {
- setConnectionError('API key saved, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.apiKeySavedRefreshFailed'));
}
};
@@ -1123,7 +1267,9 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await deleteApiKey(selectedApiKey.id);
} catch (error) {
- setApiKeyError(error instanceof Error ? error.message : 'Failed to delete API key');
+ setApiKeyError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.deleteApiKey')
+ );
return;
}
@@ -1133,7 +1279,7 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await onRefreshProvider?.(selectedProvider.providerId);
} catch {
- setConnectionError('API key deleted, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.apiKeyDeletedRefreshFailed'));
}
};
@@ -1169,13 +1315,15 @@ export const ProviderRuntimeSettingsDialog = ({
updateSucceeded = true;
} catch (error) {
- setConnectionError(error instanceof Error ? error.message : 'Failed to update connection');
+ setConnectionError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.updateConnection')
+ );
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.(selectedProvider.providerId);
} catch {
- setConnectionError('Connection updated, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.connectionUpdatedRefreshFailed'));
}
}
@@ -1190,7 +1338,7 @@ export const ProviderRuntimeSettingsDialog = ({
}
const baseUrl = compatibleBaseUrl.trim();
- const validationError = validateAnthropicCompatibleBaseUrl(baseUrl);
+ const validationError = validateAnthropicCompatibleBaseUrl(baseUrl, t);
if (validationError) {
setCompatibleEndpointError(validationError);
setCompatibleEndpointStatus(null);
@@ -1227,19 +1375,19 @@ export const ProviderRuntimeSettingsDialog = ({
setCompatibleTokenValue('');
setCompatibleEndpointStatus(
compatibleTokenValue.trim() || anthropicCompatibleTokenConfigured
- ? 'Endpoint saved'
- : 'Endpoint saved. Auth token is not configured.'
+ ? t('providerRuntime.compatibleEndpoint.status.endpointSaved')
+ : t('providerRuntime.compatibleEndpoint.status.endpointSavedTokenMissing')
);
} catch (error) {
setCompatibleEndpointError(
- error instanceof Error ? error.message : 'Failed to save endpoint'
+ error instanceof Error ? error.message : t('providerRuntime.errors.saveEndpoint')
);
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.('anthropic');
} catch {
- setConnectionError('Endpoint saved, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.endpointSavedRefreshFailed'));
}
}
@@ -1271,17 +1419,19 @@ export const ProviderRuntimeSettingsDialog = ({
});
updateSucceeded = true;
setCompatibleTokenValue('');
- setCompatibleEndpointStatus('Endpoint disabled. Saved token was kept.');
+ setCompatibleEndpointStatus(
+ t('providerRuntime.compatibleEndpoint.status.endpointDisabledTokenKept')
+ );
} catch (error) {
setCompatibleEndpointError(
- error instanceof Error ? error.message : 'Failed to disable endpoint'
+ error instanceof Error ? error.message : t('providerRuntime.errors.disableEndpoint')
);
} finally {
if (updateSucceeded) {
try {
await onRefreshProvider?.('anthropic');
} catch {
- setConnectionError('Endpoint disabled, but failed to refresh provider status.');
+ setConnectionError(t('providerRuntime.errors.endpointDisabledRefreshFailed'));
}
}
@@ -1297,7 +1447,7 @@ export const ProviderRuntimeSettingsDialog = ({
await onRefreshProvider?.('codex');
} catch (error) {
setConnectionError(
- error instanceof Error ? error.message : 'Failed to refresh Codex account'
+ error instanceof Error ? error.message : t('providerRuntime.errors.refreshCodexAccount')
);
}
};
@@ -1341,7 +1491,9 @@ export const ProviderRuntimeSettingsDialog = ({
try {
await onSelectBackend(providerId, backendId);
} catch (error) {
- setRuntimeError(error instanceof Error ? error.message : 'Failed to update runtime backend');
+ setRuntimeError(
+ error instanceof Error ? error.message : t('providerRuntime.errors.updateRuntimeBackend')
+ );
} finally {
setRuntimeSaving(false);
}
@@ -1363,7 +1515,7 @@ export const ProviderRuntimeSettingsDialog = ({
await onRefreshProvider?.('anthropic');
} catch (error) {
setConnectionError(
- error instanceof Error ? error.message : 'Failed to update Anthropic Fast mode'
+ error instanceof Error ? error.message : t('providerRuntime.errors.updateAnthropicFastMode')
);
} finally {
setConnectionSaving(false);
@@ -1374,17 +1526,14 @@ export const ProviderRuntimeSettingsDialog = ({
- Provider Settings
-
- Manage how each provider connects and, when supported, which backend the multimodel
- runtime should use.
-
+ {t('providerRuntime.title')}
+ {t('providerRuntime.description')}
- Provider
+ {t('providerRuntime.provider')}
- {getProviderUsageLabel(selectedProvider)}
+ {getProviderUsageLabel(selectedProvider, t)}
{managedRuntimeSummary && !hideConnectionMethodMeta ? (
@@ -1444,7 +1593,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : runtimeSummary ? (
- Runtime: {runtimeSummary}
+ {t('providerRuntime.runtimeSummary', { runtime: runtimeSummary })}
) : null}
@@ -1492,10 +1641,10 @@ export const ProviderRuntimeSettingsDialog = ({
- Connection
+ {t('providerRuntime.connection.title')}
- {getConnectionDescription(selectedProvider)}
+ {getConnectionDescription(selectedProvider, t)}
{connectionProgressMessage ? (
) : null}
{showConnectionMethodCards ? (
-
Connection method
+
{t('providerRuntime.connection.method')}
{selectedProvider.providerId === 'codex'
- ? 'Connection method'
- : 'Authentication method'}
+ ? t('providerRuntime.connection.method')
+ : t('providerRuntime.connection.authenticationMethod')}
{formatProviderAuthModeLabelForProvider(
selectedProvider.providerId,
- authMode
+ authMode,
+ t
)}
))}
- {getAuthModeDescription(selectedProvider.providerId, configuredAuthMode)}
+ {getAuthModeDescription(selectedProvider.providerId, configuredAuthMode, t)}
) : null}
@@ -1581,10 +1731,10 @@ export const ProviderRuntimeSettingsDialog = ({
- Local / compatible endpoint
+ {t('providerRuntime.compatibleEndpoint.title')}
- Use an Anthropic-compatible local runtime endpoint.
+ {t('providerRuntime.compatibleEndpoint.description')}
- {anthropicCompatibleEndpointEnabled ? 'Enabled' : 'Off'}
+ {anthropicCompatibleEndpointEnabled
+ ? t('providerRuntime.status.enabled')
+ : t('providerRuntime.status.off')}
- Base URL
+ {t('providerRuntime.compatibleEndpoint.baseUrl')}
@@ -1623,7 +1775,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : null}
@@ -1718,7 +1874,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={connectionBusy}
onClick={() => void handleDisableAnthropicCompatibleEndpoint()}
>
- Disable
+ {t('providerRuntime.actions.disable')}
) : null}
)}
- Save endpoint
+ {t('providerRuntime.actions.saveEndpoint')}
@@ -1747,11 +1903,13 @@ export const ProviderRuntimeSettingsDialog = ({
backgroundColor: 'rgba(255, 255, 255, 0.05)',
}}
>
- Mode:{' '}
- {formatProviderAuthModeLabelForProvider(
- selectedProvider.providerId,
- configuredAuthMode
- )}
+ {t('providerRuntime.connection.mode', {
+ mode: formatProviderAuthModeLabelForProvider(
+ selectedProvider.providerId,
+ configuredAuthMode,
+ t
+ ),
+ })}
) : null}
{connectionStatusLabel ? (
@@ -1782,17 +1940,16 @@ export const ProviderRuntimeSettingsDialog = ({
style={{ borderColor: 'var(--color-border-subtle)' }}
>
- Fast mode default
+ {t('providerRuntime.fastMode.title')}
- Apply Claude Code Fast mode by default for new Anthropic team launches when
- the resolved model and runtime allow it.
+ {t('providerRuntime.fastMode.description')}
{anthropicFastModeSupported ? (
{[
- { enabled: false, label: 'Default Off' },
- { enabled: true, label: 'Prefer Fast' },
+ { enabled: false, label: t('providerRuntime.fastMode.defaultOff') },
+ { enabled: true, label: t('providerRuntime.fastMode.preferFast') },
].map((option) => (
{anthropicFastModeSupported && anthropicFastModeAvailable
? anthropicFastModeEnabled
- ? 'New Anthropic launches will request Fast mode by default when the resolved model supports it.'
- : 'New Anthropic launches stay on normal speed unless a team explicitly enables Fast mode.'
+ ? t('providerRuntime.fastMode.enabledHint')
+ : t('providerRuntime.fastMode.disabledHint')
: anthropicFastModeDisabledReason}
@@ -1830,11 +1987,10 @@ export const ProviderRuntimeSettingsDialog = ({
- ChatGPT account
+ {t('providerRuntime.codex.account.title')}
- Manage the local Codex app-server account session that powers
- subscription-backed native launches.
+ {t('providerRuntime.codex.account.description')}
@@ -1844,7 +2000,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={codexActionBusy}
onClick={() => void handleCodexAccountRefresh()}
>
- Refresh
+ {t('providerRuntime.actions.refresh')}
{showCodexRuntimeInstallAction ? (
void onInstallCodexRuntime?.()}
>
@@ -1863,7 +2019,7 @@ export const ProviderRuntimeSettingsDialog = ({
) : (
)}
- {getCodexRuntimeInstallLabel(codexRuntimeStatus)}
+ {getCodexRuntimeInstallLabel(codexRuntimeStatus, t)}
) : null}
{codexLoginPending ? (
@@ -1882,7 +2038,7 @@ export const ProviderRuntimeSettingsDialog = ({
onClick={() => void api.openExternal(codexLoginAuthUrl)}
>
- Open login
+ {t('providerRuntime.actions.openLogin')}
) : null}
void handleCodexCancelLogin()}
>
- Cancel login
+ {t('providerRuntime.actions.cancelLogin')}
>
) : codexHasActiveChatgptSession ? (
@@ -1901,7 +2057,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={codexActionBusy}
onClick={() => void handleCodexLogout()}
>
- Disconnect account
+ {t('providerRuntime.actions.disconnectAccount')}
) : (
<>
@@ -1918,7 +2074,7 @@ export const ProviderRuntimeSettingsDialog = ({
onClick={() => void handleCodexStartLogin('device_code')}
>
- Use code
+ {t('providerRuntime.actions.useCode')}
void handleCodexStartLogin('browser')}
>
- {codexNeedsReconnect ? 'Generate link' : 'Connect ChatGPT'}
+ {codexNeedsReconnect
+ ? t('providerRuntime.actions.generateLink')
+ : t('providerRuntime.actions.connectChatGpt')}
>
)}
@@ -1951,12 +2109,12 @@ export const ProviderRuntimeSettingsDialog = ({
}}
>
{codexHasActiveChatgptSession
- ? 'Connected'
+ ? t('providerRuntime.codex.account.connected')
: codexNeedsReconnect
- ? 'Reconnect required'
+ ? t('providerRuntime.codex.account.reconnectRequired')
: codexLoginPending
- ? 'Login in progress'
- : 'Not connected'}
+ ? t('providerRuntime.codex.account.loginInProgress')
+ : t('providerRuntime.usage.notConnected')}
{codexConnection ? (
- App-server: {codexConnection.appServerState}
+ {t('providerRuntime.codex.account.appServer', {
+ state: codexConnection.appServerState,
+ })}
) : null}
{codexConnection?.managedAccount?.planType ? (
- Plan: {codexConnection.managedAccount.planType}
+ {t('providerRuntime.codex.account.plan', {
+ plan: codexConnection.managedAccount.planType,
+ })}
) : null}
{codexConnection?.managedAccount?.email ? (
@@ -2031,27 +2193,30 @@ export const ProviderRuntimeSettingsDialog = ({
color: 'var(--color-text-secondary)',
}}
>
- These percentages show used quota, not remaining quota.{' '}
- {formatCodexUsageExplanation(
+ {t('providerRuntime.codex.rateLimits.usedQuotaNote')}{' '}
+ {formatLocalizedCodexUsageExplanation(
codexConnection.rateLimits.primary?.usedPercent,
- codexConnection.rateLimits.primary?.windowDurationMins
+ codexConnection.rateLimits.primary?.windowDurationMins,
+ t
)}
{codexConnection.rateLimits.secondary
- ? ` Weekly limits are shown separately in the ${
- formatCodexWindowDurationLong(
- codexConnection.rateLimits.secondary.windowDurationMins
- ) ?? 'secondary'
- } window.`
+ ? t('providerRuntime.codex.rateLimits.secondaryWindowNote', {
+ window:
+ formatCodexWindowDurationLong(
+ codexConnection.rateLimits.secondary.windowDurationMins
+ ) ?? t('providerRuntime.codex.rateLimits.secondaryFallback'),
+ })
: ''}
@@ -2075,14 +2242,15 @@ export const ProviderRuntimeSettingsDialog = ({
@@ -2115,25 +2285,25 @@ export const ProviderRuntimeSettingsDialog = ({
className="text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
- Weekly window
+ {t('providerRuntime.codex.rateLimits.weeklyWindow')}
- Weekly used (1w)
+ {t('providerRuntime.codex.rateLimits.weeklyUsedOneWeek')}
- Not reported
+ {t('providerRuntime.codex.rateLimits.notReported')}
- Codex did not return a secondary window for this account snapshot.
+ {t('providerRuntime.codex.rateLimits.noSecondaryWindow')}
)}
@@ -2152,7 +2322,7 @@ export const ProviderRuntimeSettingsDialog = ({
className="text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
- Credits
+ {t('providerRuntime.codex.rateLimits.credits')}
- Credits are shown separately from window-based subscription usage
- and may be unavailable for plan-backed ChatGPT sessions.
+ {t('providerRuntime.codex.rateLimits.creditsDescription')}
@@ -2202,17 +2371,19 @@ export const ProviderRuntimeSettingsDialog = ({
className="text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
- {apiKeyConfig.title}
+ {apiKeyDisplayConfig?.title ?? apiKeyConfig.title}
- {apiKeyConfig.description}
+ {apiKeyDisplayConfig?.description ?? apiKeyConfig.description}
{!showApiKeyForm ? (
- {selectedApiKey ? 'Replace key' : 'Set API key'}
+ {selectedApiKey
+ ? t('providerRuntime.actions.replaceKey')
+ : t('providerRuntime.actions.setApiKey')}
) : null}
@@ -2232,8 +2403,8 @@ export const ProviderRuntimeSettingsDialog = ({
}}
>
{selectedProvider.connection?.apiKeyConfigured || selectedApiKey
- ? 'Configured'
- : 'Not configured'}
+ ? t('providerRuntime.status.configured')
+ : t('providerRuntime.status.notConfigured')}
{selectedApiKey ? (
@@ -2246,7 +2417,9 @@ export const ProviderRuntimeSettingsDialog = ({
) : null}
{apiKeyStorageStatus && selectedApiKey ? (
- Stored in {apiKeyStorageStatus.backend}
+ {t('providerRuntime.apiKey.storedIn', {
+ backend: apiKeyStorageStatus.backend,
+ })}
) : null}
@@ -2261,7 +2434,7 @@ export const ProviderRuntimeSettingsDialog = ({
htmlFor={`${selectedProvider.providerId}-api-key`}
className="text-xs"
>
- {apiKeyConfig.name}
+ {apiKeyDisplayConfig?.name ?? apiKeyConfig.name}
setApiKeyValue(e.target.value)}
- placeholder={apiKeyConfig.placeholder}
+ placeholder={
+ apiKeyDisplayConfig?.placeholder ?? apiKeyConfig.placeholder
+ }
className="h-9 text-sm"
autoFocus
/>
- Scope
+ {t('providerRuntime.apiKey.scope')}
setApiKeyScope(value as 'user' | 'project')}
@@ -2285,8 +2460,12 @@ export const ProviderRuntimeSettingsDialog = ({
- User
- Project
+
+ {t('providerRuntime.apiKey.userScope')}
+
+
+ {t('providerRuntime.apiKey.projectScope')}
+
@@ -2314,7 +2493,7 @@ export const ProviderRuntimeSettingsDialog = ({
disabled={apiKeySaving}
>
- Delete
+ {t('providerRuntime.actions.delete')}
) : (
@@ -2326,7 +2505,7 @@ export const ProviderRuntimeSettingsDialog = ({
size="sm"
onClick={handleCancelApiKeyEdit}
>
- Cancel
+ {t('providerRuntime.actions.cancel')}
{apiKeySaving
- ? 'Saving...'
+ ? t('providerRuntime.actions.saving')
: selectedApiKey
- ? 'Update key'
- : 'Save key'}
+ ? t('providerRuntime.actions.updateKey')
+ : t('providerRuntime.actions.saveKey')}
@@ -2377,7 +2556,7 @@ export const ProviderRuntimeSettingsDialog = ({
{apiKeysLoading && !selectedApiKey ? (
- Loading stored credentials...
+ {t('providerRuntime.apiKey.loadingStoredCredentials')}
) : null}
@@ -2394,10 +2573,10 @@ export const ProviderRuntimeSettingsDialog = ({
>
- Runtime
+ {t('providerRuntime.runtime.title')}
- {getRuntimeDescription(selectedProvider)}
+ {getRuntimeDescription(selectedProvider, t)}
@@ -2415,7 +2594,7 @@ export const ProviderRuntimeSettingsDialog = ({
style={{ color: 'var(--color-text-secondary)' }}
>
- Updating runtime...
+ {t('providerRuntime.runtime.updating')}
) : null}
diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts
index da56e2db..d1b7c22d 100644
--- a/src/renderer/components/runtime/providerConnectionUi.ts
+++ b/src/renderer/components/runtime/providerConnectionUi.ts
@@ -2,6 +2,24 @@ import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
+type ProviderConnectionTranslator = unknown;
+
+function translateProviderConnection(
+ t: ProviderConnectionTranslator | undefined,
+ key: string,
+ fallback: string,
+ options?: Record
+): string {
+ if (!t) {
+ return fallback;
+ }
+
+ return (t as (translationKey: string, options?: Record) => string)(key, {
+ defaultValue: fallback,
+ ...options,
+ });
+}
+
const CODEX_NATIVE_LABEL = 'Codex native';
const ANTHROPIC_SUBSCRIPTION_LABEL = 'Anthropic subscription';
@@ -12,55 +30,114 @@ const AUTH_MODE_LABELS: Record = {
api_key: 'API key',
};
-export function formatProviderAuthModeLabel(authMode: CliProviderAuthMode | null): string | null {
- return authMode ? AUTH_MODE_LABELS[authMode] : null;
+const AUTH_MODE_LABEL_KEYS: Record = {
+ auto: 'providerRuntime.connectionUi.authMode.auto',
+ oauth: 'providerRuntime.connectionUi.authMode.oauth',
+ chatgpt: 'providerRuntime.connectionUi.authMode.chatgpt',
+ api_key: 'providerRuntime.connectionUi.authMode.apiKey',
+};
+
+export function formatProviderAuthModeLabel(
+ authMode: CliProviderAuthMode | null,
+ t?: ProviderConnectionTranslator
+): string | null {
+ return authMode
+ ? translateProviderConnection(t, AUTH_MODE_LABEL_KEYS[authMode], AUTH_MODE_LABELS[authMode])
+ : null;
}
export function formatProviderAuthModeLabelForProvider(
providerId: CliProviderStatus['providerId'],
- authMode: CliProviderAuthMode | null
+ authMode: CliProviderAuthMode | null,
+ t?: ProviderConnectionTranslator
): string | null {
if (!authMode) {
return null;
}
if (providerId === 'anthropic' && authMode === 'oauth') {
- return ANTHROPIC_SUBSCRIPTION_LABEL;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMode.anthropicSubscription',
+ ANTHROPIC_SUBSCRIPTION_LABEL
+ );
}
- return formatProviderAuthModeLabel(authMode);
+ return formatProviderAuthModeLabel(authMode, t);
}
-export function formatProviderAuthMethodLabel(authMethod: string | null): string {
+export function formatProviderAuthMethodLabel(
+ authMethod: string | null,
+ t?: ProviderConnectionTranslator
+): string {
switch (authMethod) {
case 'api_key':
- return 'API key';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.apiKey',
+ 'API key'
+ );
case 'api_key_helper':
- return 'API key helper';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.apiKeyHelper',
+ 'API key helper'
+ );
case 'oauth_token':
- return 'OAuth';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.oauth',
+ 'OAuth'
+ );
case 'claude.ai':
- return 'Claude subscription';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.claudeSubscription',
+ 'Claude subscription'
+ );
case 'cli_oauth_personal':
- return 'Gemini CLI';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.geminiCli',
+ 'Gemini CLI'
+ );
case 'gemini_adc_authorized_user':
- return 'Google account';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.googleAccount',
+ 'Google account'
+ );
case 'gemini_adc_service_account':
- return 'service account';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMethod.serviceAccount',
+ 'service account'
+ );
default:
- return authMethod ? authMethod.replaceAll('_', ' ') : 'Not connected';
+ return authMethod
+ ? authMethod.replaceAll('_', ' ')
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.notConnected',
+ 'Not connected'
+ );
}
}
export function formatProviderAuthMethodLabelForProvider(
providerId: CliProviderStatus['providerId'],
- authMethod: string | null
+ authMethod: string | null,
+ t?: ProviderConnectionTranslator
): string {
if (providerId === 'anthropic' && (authMethod === 'oauth_token' || authMethod === 'claude.ai')) {
- return ANTHROPIC_SUBSCRIPTION_LABEL;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.authMode.anthropicSubscription',
+ ANTHROPIC_SUBSCRIPTION_LABEL
+ );
}
- return formatProviderAuthMethodLabel(authMethod);
+ return formatProviderAuthMethodLabel(authMethod, t);
}
function isCodexNativeLane(provider: CliProviderStatus): boolean {
@@ -178,20 +255,38 @@ export function isConnectionManagedRuntimeProvider(provider: CliProviderStatus):
return provider.providerId === 'codex';
}
-function getCodexCurrentRuntimeLabel(): string {
- return CODEX_NATIVE_LABEL;
+function getCodexCurrentRuntimeLabel(t?: ProviderConnectionTranslator): string {
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.codexNative',
+ CODEX_NATIVE_LABEL
+ );
}
-function getCodexApiKeyAvailabilitySummary(provider: CliProviderStatus): string | null {
+function getCodexApiKeyAvailabilitySummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'codex' || !provider.connection?.apiKeyConfigured) {
return null;
}
if (provider.connection.apiKeySource === 'stored') {
- return 'Saved API key available in Manage';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.savedApiKeyAvailable',
+ 'Saved API key available in Manage'
+ );
}
- return provider.connection.apiKeySourceLabel ?? 'API key is configured';
+ return (
+ provider.connection.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ )
+ );
}
function isAnthropicApiKeyModeReady(provider: CliProviderStatus): boolean {
@@ -213,7 +308,10 @@ function isAnthropicApiKeyModeMissingCredential(provider: CliProviderStatus): bo
);
}
-function getCodexMissingManagedAccountStatus(provider: CliProviderStatus): string | null {
+function getCodexMissingManagedAccountStatus(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'codex') {
return null;
}
@@ -229,43 +327,95 @@ function getCodexMissingManagedAccountStatus(provider: CliProviderStatus): strin
if (codexConnection.requiresOpenaiAuth) {
if (codexConnection.localActiveChatgptAccountPresent) {
- return 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexLocalAccountNeedsReconnect',
+ 'Codex has a locally selected ChatGPT account, but the current session needs reconnect.'
+ );
}
return codexConnection.localAccountArtifactsPresent
- ? 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.'
- : 'Codex CLI reports no active ChatGPT login';
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNoActiveManagedSession',
+ 'Codex CLI reports no active ChatGPT login. Local Codex account data exists, but no active managed session is selected.'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNoActiveChatGptLogin',
+ 'Codex CLI reports no active ChatGPT login'
+ );
}
return (
codexConnection.launchIssueMessage ??
- 'Connect a ChatGPT account to use your Codex subscription.'
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.connectChatGptForSubscription',
+ 'Connect a ChatGPT account to use your Codex subscription.'
+ )
);
}
-export function getProviderCurrentRuntimeSummary(provider: CliProviderStatus): string | null {
+export function getProviderCurrentRuntimeSummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'codex' || !isConnectionManagedRuntimeProvider(provider)) {
return null;
}
- const prefix = provider.authenticated ? 'Current runtime' : 'Selected runtime';
- return `${prefix}: ${getCodexCurrentRuntimeLabel()}`;
+ const prefix = provider.authenticated
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.currentRuntime',
+ 'Current runtime'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.selectedRuntime',
+ 'Selected runtime'
+ );
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.runtime.summary',
+ '{{prefix}}: {{runtime}}',
+ {
+ prefix,
+ runtime: getCodexCurrentRuntimeLabel(t),
+ }
+ );
}
-export function formatProviderStatusText(provider: CliProviderStatus): string {
+export function formatProviderStatusText(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string {
if (isProviderInventoryOnlyFallback(provider)) {
- return 'Checking...';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.checking',
+ 'Checking...'
+ );
}
const selectedBackendOption = getSelectedRuntimeBackendOption(provider);
if (provider.providerId === 'codex') {
if (provider.connection?.codex?.login.status === 'starting') {
- return 'Starting ChatGPT login...';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.startingChatGptLogin',
+ 'Starting ChatGPT login...'
+ );
}
if (provider.connection?.codex?.login.status === 'pending') {
- return 'Waiting for ChatGPT account login...';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.waitingForChatGptLogin',
+ 'Waiting for ChatGPT account login...'
+ );
}
if (
@@ -282,21 +432,33 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
) {
return (
provider.connection.codex.launchIssueMessage ??
- 'ChatGPT account detected - account verification is currently degraded.'
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.chatGptVerificationDegraded',
+ 'ChatGPT account detected - account verification is currently degraded.'
+ )
);
}
if (provider.connection?.codex?.launchAllowed) {
if (provider.connection.codex.effectiveAuthMode === 'chatgpt') {
- return 'ChatGPT account ready';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.chatGptAccountReady',
+ 'ChatGPT account ready'
+ );
}
if (provider.connection.codex.effectiveAuthMode === 'api_key') {
- return 'API key ready';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.apiKeyReady',
+ 'API key ready'
+ );
}
}
- const missingManagedAccountStatus = getCodexMissingManagedAccountStatus(provider);
+ const missingManagedAccountStatus = getCodexMissingManagedAccountStatus(provider, t);
if (missingManagedAccountStatus) {
return missingManagedAccountStatus;
}
@@ -309,7 +471,18 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
return selectedBackendOption.statusMessage;
}
return (
- provider.statusMessage ?? (provider.authenticated ? 'Codex native ready' : 'Not connected')
+ provider.statusMessage ??
+ (provider.authenticated
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNativeReady',
+ 'Codex native ready'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.notConnected',
+ 'Not connected'
+ ))
);
}
@@ -319,7 +492,13 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
selectedBackendOption.state !== 'ready'
) {
return (
- selectedBackendOption.statusMessage ?? provider.statusMessage ?? 'Codex native unavailable'
+ selectedBackendOption.statusMessage ??
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.codexNativeUnavailable',
+ 'Codex native unavailable'
+ )
);
}
@@ -332,11 +511,22 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
}
if (!provider.supported) {
- return provider.statusMessage ?? 'Unavailable in current runtime';
+ return (
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.unavailableInCurrentRuntime',
+ 'Unavailable in current runtime'
+ )
+ );
}
if (isAnthropicApiKeyModeReady(provider)) {
- return 'Connected via API key';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.connectedViaApiKey',
+ 'Connected via API key'
+ );
}
if (
@@ -348,28 +538,61 @@ export function formatProviderStatusText(provider: CliProviderStatus): string {
if (statusMessage && !/^connected\b/i.test(statusMessage)) {
return statusMessage;
}
- return 'API key configured, but not verified yet';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.apiKeyConfiguredNotVerified',
+ 'API key configured, but not verified yet'
+ );
}
if (isAnthropicApiKeyModeMissingCredential(provider)) {
- return 'API key mode selected, but no API key is configured';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.apiKeyModeMissingCredential',
+ 'API key mode selected, but no API key is configured'
+ );
}
if (provider.authenticated) {
- return `Connected via ${formatProviderAuthMethodLabelForProvider(
- provider.providerId,
- provider.authMethod
- )}`;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.connectedVia',
+ 'Connected via {{method}}',
+ {
+ method: formatProviderAuthMethodLabelForProvider(
+ provider.providerId,
+ provider.authMethod,
+ t
+ ),
+ }
+ );
}
if (provider.verificationState === 'offline') {
- return provider.statusMessage ?? 'Unable to verify';
+ return (
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.unableToVerify',
+ 'Unable to verify'
+ )
+ );
}
- return provider.statusMessage ?? 'Not connected';
+ return (
+ provider.statusMessage ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.status.notConnected',
+ 'Not connected'
+ )
+ );
}
-export function getProviderConnectionModeSummary(provider: CliProviderStatus): string | null {
+export function getProviderConnectionModeSummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (provider.providerId !== 'anthropic' && provider.providerId !== 'codex') {
return null;
}
@@ -390,24 +613,45 @@ export function getProviderConnectionModeSummary(provider: CliProviderStatus): s
const authModeLabel = formatProviderAuthModeLabelForProvider(
provider.providerId,
- provider.connection?.configuredAuthMode ?? null
+ provider.connection?.configuredAuthMode ?? null,
+ t
);
if (!authModeLabel) {
return null;
}
return provider.providerId === 'codex'
- ? `Selected auth: ${authModeLabel}`
- : `Preferred auth: ${authModeLabel}`;
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.mode.selectedAuth',
+ 'Selected auth: {{authMode}}',
+ { authMode: authModeLabel }
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.mode.preferredAuth',
+ 'Preferred auth: {{authMode}}',
+ { authMode: authModeLabel }
+ );
}
-export function getProviderCredentialSummary(provider: CliProviderStatus): string | null {
+export function getProviderCredentialSummary(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string | null {
if (!provider.connection?.apiKeyConfigured) {
return null;
}
if (isAnthropicApiKeyModeReady(provider)) {
- return provider.connection?.apiKeySourceLabel ?? 'API key is configured';
+ return (
+ provider.connection?.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ )
+ );
}
if (
@@ -415,7 +659,11 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
provider.connection.apiKeySource === 'stored' &&
provider.connection.configuredAuthMode === 'auto'
) {
- return 'Saved API key available in Manage';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.savedApiKeyAvailable',
+ 'Saved API key available in Manage'
+ );
}
if (
@@ -424,18 +672,36 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
provider.authMethod !== 'api_key_helper'
) {
return provider.connection.apiKeySource === 'stored'
- ? 'API key also configured in Manage'
- : (provider.connection.apiKeySourceLabel ?? 'API key is configured');
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyAlsoConfigured',
+ 'API key also configured in Manage'
+ )
+ : (provider.connection.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ ));
}
if (provider.authMethod !== 'api_key' && provider.providerId === 'gemini') {
return provider.connection.apiKeySource === 'stored'
- ? 'API key is configured in Manage'
- : (provider.connection.apiKeySourceLabel ?? 'API key is configured');
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfiguredInManage',
+ 'API key is configured in Manage'
+ )
+ : (provider.connection.apiKeySourceLabel ??
+ translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyConfigured',
+ 'API key is configured'
+ ));
}
if (provider.providerId === 'codex') {
- const apiKeyAvailabilitySummary = getCodexApiKeyAvailabilitySummary(provider);
+ const apiKeyAvailabilitySummary = getCodexApiKeyAvailabilitySummary(provider, t);
if (!apiKeyAvailabilitySummary) {
return null;
}
@@ -445,18 +711,41 @@ export function getProviderCredentialSummary(provider: CliProviderStatus): strin
provider.connection.codex?.effectiveAuthMode === 'chatgpt'
) {
return provider.connection.apiKeySource === 'stored'
- ? 'API key also available in Manage as fallback'
- : `${apiKeyAvailabilitySummary} - available as fallback`;
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.apiKeyFallbackInManage',
+ 'API key also available in Manage as fallback'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.availableAsFallback',
+ '{{summary}} - available as fallback',
+ { summary: apiKeyAvailabilitySummary }
+ );
}
if (provider.connection.configuredAuthMode === 'chatgpt') {
return provider.connection.apiKeySource === 'stored'
- ? 'Saved API key available in Manage if you switch to API key mode'
- : `${apiKeyAvailabilitySummary} - available if you switch to API key mode`;
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.savedApiKeyAvailableIfSwitch',
+ 'Saved API key available in Manage if you switch to API key mode'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.availableIfSwitch',
+ '{{summary}} - available if you switch to API key mode',
+ { summary: apiKeyAvailabilitySummary }
+ );
}
if (provider.connection.configuredAuthMode === 'auto') {
- return `${apiKeyAvailabilitySummary} - Auto will use this until ChatGPT is connected`;
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.credential.autoWillUseUntilChatGpt',
+ '{{summary}} - Auto will use this until ChatGPT is connected',
+ { summary: apiKeyAvailabilitySummary }
+ );
}
return apiKeyAvailabilitySummary;
@@ -470,6 +759,24 @@ export function getProviderDisconnectAction(provider: CliProviderStatus): {
confirmLabel: string;
title: string;
message: string;
+} | null;
+export function getProviderDisconnectAction(
+ provider: CliProviderStatus,
+ t: ProviderConnectionTranslator
+): {
+ label: string;
+ confirmLabel: string;
+ title: string;
+ message: string;
+} | null;
+export function getProviderDisconnectAction(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): {
+ label: string;
+ confirmLabel: string;
+ title: string;
+ message: string;
} | null {
if (!provider.authenticated) {
return null;
@@ -481,42 +788,92 @@ export function getProviderDisconnectAction(provider: CliProviderStatus): {
}
return {
- label: 'Disconnect',
- confirmLabel: 'Disconnect',
- title: 'Disconnect Anthropic subscription?',
+ label: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ confirmLabel: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ title: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.anthropicTitle',
+ 'Disconnect Anthropic subscription?'
+ ),
message: provider.connection?.apiKeyConfigured
- ? 'This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.'
- : 'This removes the local Anthropic subscription session from the Claude CLI runtime.',
+ ? translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.anthropicWithApiKey',
+ 'This removes the local Anthropic subscription session from the Claude CLI runtime. Saved API keys in Manage stay available.'
+ )
+ : translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.anthropic',
+ 'This removes the local Anthropic subscription session from the Claude CLI runtime.'
+ ),
};
}
if (provider.providerId === 'gemini' && provider.authMethod === 'cli_oauth_personal') {
return {
- label: 'Disconnect',
- confirmLabel: 'Disconnect',
- title: 'Disconnect Gemini CLI?',
- message:
- 'This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed.',
+ label: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ confirmLabel: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.disconnect',
+ 'Disconnect'
+ ),
+ title: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.geminiTitle',
+ 'Disconnect Gemini CLI?'
+ ),
+ message: translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.disconnect.gemini',
+ 'This clears the local Gemini CLI session metadata. External ADC credentials and saved API keys are not removed.'
+ ),
};
}
return null;
}
-export function getProviderConnectLabel(provider: CliProviderStatus): string {
+export function getProviderConnectLabel(
+ provider: CliProviderStatus,
+ t?: ProviderConnectionTranslator
+): string {
if (provider.providerId === 'anthropic') {
- return 'Connect Anthropic';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.connectAnthropic',
+ 'Connect Anthropic'
+ );
}
if (provider.providerId === 'codex') {
- return 'Connect ChatGPT';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.connectChatGpt',
+ 'Connect ChatGPT'
+ );
}
if (provider.providerId === 'gemini') {
- return 'Open Login';
+ return translateProviderConnection(
+ t,
+ 'providerRuntime.connectionUi.actions.openLogin',
+ 'Open Login'
+ );
}
- return 'Connect';
+ return translateProviderConnection(t, 'providerRuntime.connectionUi.actions.connect', 'Connect');
}
export function shouldShowProviderConnectAction(provider: CliProviderStatus): boolean {
diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx
index c8a2b2f0..99860842 100644
--- a/src/renderer/components/schedules/SchedulesView.tsx
+++ b/src/renderer/components/schedules/SchedulesView.tsx
@@ -1,5 +1,6 @@
import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
@@ -40,11 +41,11 @@ const LaunchTeamDialog = lazy(() =>
// Constants
// =============================================================================
-const STATUS_OPTIONS: { value: ScheduleStatus | 'all'; label: string }[] = [
- { value: 'all', label: 'All' },
- { value: 'active', label: 'Active' },
- { value: 'paused', label: 'Paused' },
- { value: 'disabled', label: 'Disabled' },
+const STATUS_OPTIONS: { value: ScheduleStatus | 'all' }[] = [
+ { value: 'all' },
+ { value: 'active' },
+ { value: 'paused' },
+ { value: 'disabled' },
];
// =============================================================================
@@ -72,6 +73,7 @@ const ScheduleListItem = ({
onTeamClick,
teamColor,
}: ScheduleListItemProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState(null);
const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? []));
@@ -130,7 +132,7 @@ const ScheduleListItem = ({
- Next: {formatNextRun(schedule.nextRunAt)}
+ {t('schedules.item.nextRun', { value: formatNextRun(schedule.nextRunAt) })}
{schedule.nextRunAt ? (
@@ -159,7 +161,7 @@ const ScheduleListItem = ({
- Run now
+ {t('schedules.actions.runNow')}
@@ -175,7 +177,7 @@ const ScheduleListItem = ({
onClick={() => onEdit(schedule)}
>
- Edit
+ {t('schedules.actions.edit')}
{schedule.status === 'active' ? (
onPause(schedule.id)}
>
- Pause
+ {t('schedules.actions.pause')}
) : (
onResume(schedule.id)}
>
- Resume
+ {t('schedules.actions.resume')}
)}
onDelete(schedule.id)}
>
- Delete
+ {t('schedules.actions.delete')}
@@ -214,11 +216,11 @@ const ScheduleListItem = ({
{runsLoading ? (
- Loading run history...
+ {t('schedules.item.loadingRunHistory')}
) : runs.length === 0 ? (
- No runs yet
+ {t('schedules.item.noRunsYet')}
) : (
@@ -246,6 +248,7 @@ const ScheduleListItem = ({
// =============================================================================
export const SchedulesView = (): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
schedules,
schedulesLoading,
@@ -392,6 +395,22 @@ export const SchedulesView = (): React.JSX.Element => {
},
[openTeamTab]
);
+ const getStatusLabel = useCallback(
+ (status: ScheduleStatus | 'all'): string => {
+ switch (status) {
+ case 'active':
+ return t('schedules.status.active');
+ case 'paused':
+ return t('schedules.status.paused');
+ case 'disabled':
+ return t('schedules.status.disabled');
+ case 'all':
+ default:
+ return t('schedules.status.all');
+ }
+ },
+ [t]
+ );
return (
@@ -400,7 +419,9 @@ export const SchedulesView = (): React.JSX.Element => {
-
Schedules
+
+ {t('schedules.title')}
+
{schedules.length > 0 && (
{schedules.length}
@@ -409,7 +430,7 @@ export const SchedulesView = (): React.JSX.Element => {
- Add Schedule
+ {t('schedules.actions.addSchedule')}
@@ -420,7 +441,7 @@ export const SchedulesView = (): React.JSX.Element => {
setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
@@ -440,7 +461,7 @@ export const SchedulesView = (): React.JSX.Element => {
}`}
onClick={() => setStatusFilter(opt.value)}
>
- {opt.label}
+ {getStatusLabel(opt.value)}
{statusCounts[opt.value] > 0 && (
{statusCounts[opt.value]}
)}
@@ -463,7 +484,7 @@ export const SchedulesView = (): React.JSX.Element => {
{teamFilter}
>
) : (
- 'All teams'
+ t('schedules.filters.allTeams')
)}
@@ -477,7 +498,7 @@ export const SchedulesView = (): React.JSX.Element => {
} hover:bg-[var(--color-surface-raised)]`}
onClick={() => setTeamFilter(null)}
>
- All teams
+ {t('schedules.filters.allTeams')}
{teamNames.map((name) => (
{
{schedulesLoading && schedules.length === 0 ? (
- Loading schedules...
+ {t('schedules.loading')}
) : schedules.length === 0 ? (
/* Global empty state */
@@ -516,16 +537,15 @@ export const SchedulesView = (): React.JSX.Element => {
- No scheduled tasks
+ {t('schedules.empty.title')}
- Create a schedule on any team to automate Claude task execution with cron
- expressions. Schedules from all teams will appear here.
+ {t('schedules.empty.description')}
- Create Schedule
+ {t('schedules.actions.createSchedule')}
) : filteredSchedules.length === 0 ? (
@@ -533,7 +553,7 @@ export const SchedulesView = (): React.JSX.Element => {
- No schedules match the current filters
+ {t('schedules.empty.noMatches')}
{
setTeamFilter(null);
}}
>
- Clear filters
+ {t('schedules.actions.clearFilters')}
) : (
diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx
index 91adbb1b..cf44e50e 100644
--- a/src/renderer/components/search/CommandPalette.tsx
+++ b/src/renderer/components/search/CommandPalette.tsx
@@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { formatModifierShortcut } from '@renderer/utils/keyboardUtils';
@@ -52,9 +53,10 @@ const ProjectResultItemInner = ({
isSelected,
onClick,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const lastActivity = repo.mostRecentSession
? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true })
- : 'No recent activity';
+ : t('commandPalette.noRecentActivity');
return (
- {repo.totalSessions} sessions
+ {t('commandPalette.sessionsCount', { count: repo.totalSessions })}
·
{lastActivity}
@@ -155,6 +157,7 @@ const SessionResultItem = React.memo(SessionResultItemInner);
// =============================================================================
export const CommandPalette = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const {
commandPaletteOpen,
closeCommandPalette,
@@ -454,13 +457,17 @@ export const CommandPalette = (): React.JSX.Element | null => {
{searchMode === 'projects' ? (
<>
- Search projects
+
+ {t('commandPalette.mode.searchProjects')}
+
>
) : (
<>
- {globalSearchEnabled ? 'Search across all projects' : 'Search in project'}
+ {globalSearchEnabled
+ ? t('commandPalette.mode.searchAcrossProjects')
+ : t('commandPalette.mode.searchInProject')}
{!globalSearchEnabled && selectedProjectId && (
<>
@@ -472,7 +479,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
{repositoryGroups.find((r) =>
r.worktrees.some((w) => w.id === selectedProjectId)
- )?.name ?? 'Current project'}
+ )?.name ?? t('commandPalette.currentProject')}
@@ -495,7 +502,7 @@ export const CommandPalette = (): React.JSX.Element | null => {
}
>
- Global
+ {t('commandPalette.global')}
@@ -510,7 +517,9 @@ export const CommandPalette = (): React.JSX.Element | null => {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
- searchMode === 'projects' ? 'Search projects...' : 'Search conversations...'
+ searchMode === 'projects'
+ ? t('commandPalette.placeholders.projects')
+ : t('commandPalette.placeholders.conversations')
}
className="placeholder:text-text-muted/50 flex-1 bg-transparent text-base text-text focus:outline-none"
/>
@@ -529,7 +538,9 @@ export const CommandPalette = (): React.JSX.Element | null => {
// Project search results
filteredProjects.length === 0 ? (
- {query.trim() ? `No projects found for "${query}"` : 'No projects found'}
+ {query.trim()
+ ? t('commandPalette.empty.noProjectsForQuery', { query })
+ : t('commandPalette.empty.noProjects')}
) : (
@@ -546,13 +557,13 @@ export const CommandPalette = (): React.JSX.Element | null => {
) : // Session search results
query.trim().length < 2 ? (
- Type at least 2 characters to search
+ {t('commandPalette.empty.minChars')}
) : sessionResults.length === 0 && !loading ? (
{searchIsPartial
- ? `No fast results in recent sessions for "${query}"`
- : `No results found for "${query}"`}
+ ? t('commandPalette.empty.noFastResults', { query })
+ : t('commandPalette.empty.noResults', { query })}
) : (
@@ -583,28 +594,43 @@ export const CommandPalette = (): React.JSX.Element | null => {
{searchMode === 'projects'
- ? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}`
+ ? t('commandPalette.footer.projectsCount', { count: filteredProjects.length })
: totalMatches > 0
- ? `${totalMatches} ${searchIsPartial ? 'fast ' : ''}result${totalMatches !== 1 ? 's' : ''}${globalSearchEnabled ? ' across all projects' : ''}`
- : 'Type to search'}
+ ? t(
+ globalSearchEnabled
+ ? 'commandPalette.footer.resultsAcrossProjects'
+ : 'commandPalette.footer.results',
+ {
+ count: totalMatches,
+ speed: searchIsPartial ? t('commandPalette.footer.fastPrefix') : '',
+ }
+ )
+ : t('commandPalette.footer.typeToSearch')}
- ↑↓ {' '}
- navigate
+
+ {t('commandPalette.footer.upDownKey')}
+ {' '}
+ {t('commandPalette.footer.navigate')}
↵ {' '}
- {searchMode === 'projects' ? 'select' : 'open'}
+ {searchMode === 'projects'
+ ? t('commandPalette.footer.select')
+ : t('commandPalette.footer.open')}
{formatModifierShortcut('G')}
{' '}
- global
+ {t('commandPalette.footer.global')}
- esc close
+
+ {t('commandPalette.footer.escapeKey')}
+ {' '}
+ {t('commandPalette.footer.close')}
diff --git a/src/renderer/components/search/SearchBar.tsx b/src/renderer/components/search/SearchBar.tsx
index ab620535..882c0e13 100644
--- a/src/renderer/components/search/SearchBar.tsx
+++ b/src/renderer/components/search/SearchBar.tsx
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { ChevronDown, ChevronUp, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -19,6 +20,7 @@ interface SearchBarProps {
}
export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('common');
const {
searchQuery,
searchVisible,
@@ -115,8 +117,14 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
}
const resultLabel = searchResultsCapped
- ? `${currentSearchIndex + 1} of ${searchResultCount}+`
- : `${currentSearchIndex + 1} of ${searchResultCount}`;
+ ? t('search.resultCountCapped', {
+ current: currentSearchIndex + 1,
+ total: searchResultCount,
+ })
+ : t('search.resultCount', {
+ current: currentSearchIndex + 1,
+ total: searchResultCount,
+ });
return (
@@ -127,14 +135,14 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
value={localQuery}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder="Find in conversation..."
+ placeholder={t('search.findInConversation')}
className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none"
/>
{/* Result count */}
{searchQuery && (
- {searchResultCount > 0 ? resultLabel : 'No results'}
+ {searchResultCount > 0 ? resultLabel : t('search.noResults')}
)}
@@ -144,7 +152,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
onClick={previousSearchResult}
disabled={searchResultCount === 0}
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text disabled:cursor-not-allowed disabled:opacity-30"
- title="Previous result (Shift+Enter)"
+ title={t('search.previousResultShortcut')}
>
@@ -152,7 +160,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
onClick={nextSearchResult}
disabled={searchResultCount === 0}
className="rounded p-1 text-text-secondary transition-colors hover:bg-surface-raised hover:text-text disabled:cursor-not-allowed disabled:opacity-30"
- title="Next result (Enter)"
+ title={t('search.nextResultShortcut')}
>
@@ -162,7 +170,7 @@ export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null =
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx
index bf824f94..eab7d36c 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx
@@ -4,6 +4,7 @@
import { useCallback } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ChevronDown, ChevronUp, Loader2, Plus } from 'lucide-react';
import { useAddTriggerFormHandlers } from '../hooks/useAddTriggerFormHandlers';
@@ -32,6 +33,7 @@ export const AddTriggerForm = ({
saving,
onAdd,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
// Use form state hook
const formState = useAddTriggerFormState();
const {
@@ -131,7 +133,9 @@ export const AddTriggerForm = ({
>
-
Add Custom Trigger
+
+ {t('notificationTriggers.add.title')}
+
{isExpanded ? (
@@ -154,13 +158,13 @@ export const AddTriggerForm = ({
{/* Dot Color */}
-
+
{/* Section 2: Trigger Condition */}
-
+
@@ -215,7 +219,7 @@ export const AddTriggerForm = ({
disabled={saving}
className={`rounded bg-surface-raised px-3 py-1.5 text-sm text-text-secondary transition-colors hover:bg-surface-overlay ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
>
- Cancel
+ {t('notificationTriggers.add.cancel')}
{saving && }
- Add Trigger
+ {t('notificationTriggers.add.submit')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx
index f5d250b3..13c86a8a 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx
@@ -8,6 +8,7 @@
import { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
isPresetColorKey,
resolveColorHex,
@@ -28,6 +29,7 @@ export const ColorPaletteSelector = ({
onChange,
disabled,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isCustom = !!value && !isPresetColorKey(value);
const [hexInput, setHexInput] = useState(isCustom ? value : '');
const [showHexInput, setShowHexInput] = useState(isCustom);
@@ -104,7 +106,7 @@ export const ColorPaletteSelector = ({
{/* Custom hex toggle */}
{hexInput && !HEX_RE.test(hexInput) && (
- Invalid hex
+
+ {t('notificationTriggers.color.invalidHex')}
+
)}
)}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx
index 5ca1cc8a..120c5784 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx
@@ -3,6 +3,7 @@
* Renders different UI based on the selected trigger mode.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import {
getCursorClass,
SELECT_INPUT_BASE,
@@ -10,7 +11,11 @@ import {
} from '@renderer/constants/cssVariables';
import { AlertCircle } from 'lucide-react';
-import { CONTENT_TYPE_OPTIONS } from '../utils/constants';
+import {
+ CONTENT_TYPE_OPTIONS,
+ getContentTypeLabelKey,
+ getMatchFieldLabelKey,
+} from '../utils/constants';
import { getAvailableMatchFields } from '../utils/trigger';
import { SectionHeader } from './SectionHeader';
@@ -50,18 +55,19 @@ export const DynamicConfigSection = ({
onTokenThresholdChange,
onTokenTypeChange,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
// Get available match fields based on content type and tool name
const availableMatchFields = getAvailableMatchFields(contentType, toolName || undefined);
return (
-
+
{/* Error Status Mode */}
{mode === 'error_status' && (
- Triggers when a tool execution reports an error (is_error: true).
+ {t('notificationTriggers.configuration.errorStatusDescription')}
)}
@@ -72,7 +78,7 @@ export const DynamicConfigSection = ({
{/* Content Type */}
- Content Type
+ {t('notificationTriggers.fields.contentType')}
{CONTENT_TYPE_OPTIONS.map((option) => (
- {option.label}
+ {t(getContentTypeLabelKey(option.value))}
))}
@@ -93,7 +99,7 @@ export const DynamicConfigSection = ({
{availableMatchFields.length > 0 && (
- Match Field
+ {t('notificationTriggers.fields.matchField')}
{availableMatchFields.map((option) => (
- {option.label}
+ {t(getMatchFieldLabelKey(option.value))}
))}
@@ -115,7 +121,7 @@ export const DynamicConfigSection = ({
- Match Pattern (Regex)
+ {t('notificationTriggers.fields.matchPattern')}
onMatchPatternChange(e.target.value)}
- placeholder="e.g., error|failed|exception"
+ placeholder={t('notificationTriggers.configuration.matchPatternPlaceholder')}
disabled={saving}
className={`w-full rounded border bg-transparent px-2 py-1.5 font-mono text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${patternError ? 'border-red-500' : 'border-border'} ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
/>
@@ -134,7 +140,7 @@ export const DynamicConfigSection = ({
)}
- Leave empty to match all content. Uses JavaScript regex syntax.
+ {t('notificationTriggers.configuration.emptyPatternHint')}
@@ -145,7 +151,7 @@ export const DynamicConfigSection = ({
- Token Type
+ {t('notificationTriggers.fields.tokenType')}
- Total Tokens
+ {t('notificationTriggers.options.tokenTypes.total')}
- Input Tokens
+ {t('notificationTriggers.options.tokenTypes.input')}
- Output Tokens
+ {t('notificationTriggers.options.tokenTypes.output')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx
index 4975b687..ab1f12a0 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx
@@ -2,6 +2,8 @@
* GeneralInfoSection - Name input and tool select for AddTriggerForm.
*/
+import { useAppTranslation } from '@features/localization/renderer';
+
import { TOOL_NAME_OPTIONS } from '../utils/constants';
import { SectionHeader } from './SectionHeader';
@@ -21,15 +23,17 @@ export const GeneralInfoSection = ({
onNameChange,
onToolNameChange,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
-
+
{/* Trigger Name */}
- Trigger Name *
+ {t('notificationTriggers.fields.triggerNameRequired')}
onNameChange(e.target.value)}
- placeholder="e.g., Build Failure Alert"
+ placeholder={t('notificationTriggers.fields.triggerNamePlaceholder')}
disabled={saving}
required
className={`w-full rounded border border-border bg-transparent px-2 py-1.5 text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
@@ -47,7 +51,7 @@ export const GeneralInfoSection = ({
{/* Scope/Tool Name */}
- Scope / Tool Name (optional)
+ {t('notificationTriggers.fields.scopeToolNameOptional')}
{TOOL_NAME_OPTIONS.map((option) => (
- {option.label}
+ {option.value ? option.label : t('notificationTriggers.options.toolNames.anyTool')}
))}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx
index a15d2a2b..7851f63d 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx
@@ -2,6 +2,7 @@
* IgnorePatternsSection - Collapsible section for ignore patterns - Linear style.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { X } from 'lucide-react';
interface IgnorePatternsSectionProps {
@@ -17,14 +18,16 @@ export const IgnorePatternsSection = ({
onRemove,
disabled,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
- Advanced: Exclusion Rules
+ {t('notificationTriggers.ignorePatterns.summary')}
- Ignore Patterns (skip if matches)
+ {t('notificationTriggers.ignorePatterns.title')}
{patterns.map((pattern, idx) => (
@@ -36,7 +39,7 @@ export const IgnorePatternsSection = ({
onClick={() => onRemove(idx)}
disabled={disabled}
className={`rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
- aria-label="Remove ignore pattern"
+ aria-label={t('notificationTriggers.ignorePatterns.removeAriaLabel')}
>
@@ -45,7 +48,7 @@ export const IgnorePatternsSection = ({
{
@@ -65,7 +68,7 @@ export const IgnorePatternsSection = ({
/>
- Press Enter to add. Notification is skipped if any pattern matches.
+ {t('notificationTriggers.ignorePatterns.hint')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx
index c8e06f2c..1efec294 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx
@@ -2,7 +2,9 @@
* ModeSelector - Segmented control for selecting trigger mode - Linear style.
*/
-import { MODE_OPTIONS } from '../utils/constants';
+import { useAppTranslation } from '@features/localization/renderer';
+
+import { getModeLabelKey, MODE_OPTIONS } from '../utils/constants';
import type { TriggerMode } from '@renderer/types/data';
@@ -17,6 +19,8 @@ export const ModeSelector = ({
onChange,
disabled = false,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
{MODE_OPTIONS.map((mode) => {
@@ -36,7 +40,7 @@ export const ModeSelector = ({
} ${disabled ? 'cursor-not-allowed opacity-50' : ''} `}
>
- {mode.label}
+ {t(getModeLabelKey(mode.value))}
);
})}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx
index bd4df655..32c35e9a 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx
@@ -3,6 +3,7 @@
* Uses the shared RepositoryDropdown component.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import {
RepositoryDropdown,
SelectedRepositoryItem,
@@ -25,18 +26,20 @@ export const RepositoryScopeSection = ({
onRemove,
disabled,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
- Advanced: Repository Scope
+ {t('notificationTriggers.repositoryScope.summary')}
- Limit to Repositories (applies only to selected repositories)
+ {t('notificationTriggers.repositoryScope.title')}
{selectedItems.length === 0 ? (
- No repositories selected - trigger applies to all repositories
+ {t('notificationTriggers.repositoryScope.empty')}
) : (
selectedItems.map((item, idx) => (
@@ -53,13 +56,13 @@ export const RepositoryScopeSection = ({
- When repositories are selected, this trigger only fires for errors in those repositories.
+ {t('notificationTriggers.repositoryScope.hint')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx
index 7f122a22..33700f0e 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx
@@ -2,11 +2,12 @@
* TriggerCardHeader - Header row for TriggerCard with name, badges, toggle, and actions.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { SettingsToggle } from '@renderer/components/settings/components';
import { getTriggerColorDef } from '@shared/constants/triggerColors';
import { ChevronDown, ChevronUp, Pencil, Shield, X } from 'lucide-react';
-import { CONTENT_TYPE_OPTIONS, MODE_OPTIONS } from '../utils/constants';
+import { CONTENT_TYPE_OPTIONS, getContentTypeLabelKey, getModeLabelKey } from '../utils/constants';
import type { NotificationTrigger, TriggerMode } from '@renderer/types/data';
@@ -39,6 +40,9 @@ export const TriggerCardHeader = ({
onToggleExpanded,
onRemove,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+ const contentTypeOption = CONTENT_TYPE_OPTIONS.find((o) => o.value === trigger.contentType);
+
return (
{/* Left side: Name and badges */}
@@ -70,7 +74,7 @@ export const TriggerCardHeader = ({
{trigger.isBuiltin && (
- Builtin
+ {t('notificationTriggers.card.builtinBadge')}
)}
{!trigger.isBuiltin && (
@@ -78,7 +82,7 @@ export const TriggerCardHeader = ({
onClick={() => onSetEditingName(true)}
disabled={saving}
className="rounded p-0.5 text-text-muted transition-colors hover:bg-surface-raised hover:text-text-secondary"
- aria-label="Edit name"
+ aria-label={t('notificationTriggers.card.editNameAriaLabel')}
>
@@ -87,11 +91,12 @@ export const TriggerCardHeader = ({
)}
{/* Description line showing mode and content type */}
- {MODE_OPTIONS.find((m) => m.value === localMode)?.label ?? localMode}
+ {t(getModeLabelKey(localMode))}
-
- {CONTENT_TYPE_OPTIONS.find((o) => o.value === trigger.contentType)?.label ??
- trigger.contentType}
+ {contentTypeOption
+ ? t(getContentTypeLabelKey(contentTypeOption.value))
+ : trigger.contentType}
@@ -104,7 +109,11 @@ export const TriggerCardHeader = ({
{isExpanded ? : }
@@ -114,7 +123,7 @@ export const TriggerCardHeader = ({
onClick={onRemove}
disabled={saving}
className={`rounded p-1 text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
- aria-label="Delete trigger"
+ aria-label={t('notificationTriggers.card.deleteAriaLabel')}
>
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx
index a05e5027..48da1df3 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx
@@ -3,6 +3,7 @@
* Handles error status, content match, and token threshold mode configurations.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import {
getCursorClass,
SELECT_INPUT_BASE,
@@ -10,7 +11,12 @@ import {
} from '@renderer/constants/cssVariables';
import { AlertCircle } from 'lucide-react';
-import { CONTENT_TYPE_OPTIONS, TOOL_NAME_OPTIONS } from '../utils/constants';
+import {
+ CONTENT_TYPE_OPTIONS,
+ getContentTypeLabelKey,
+ getMatchFieldLabelKey,
+ TOOL_NAME_OPTIONS,
+} from '../utils/constants';
import { getAvailableMatchFields } from '../utils/trigger';
import { ColorPaletteSelector } from './ColorPaletteSelector';
@@ -64,13 +70,14 @@ export const TriggerConfiguration = ({
onTokenTypeChange,
onColorChange,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const availableMatchFields = getAvailableMatchFields(trigger.contentType, trigger.toolName);
return (
<>
{/* Section 1: General Info */}
-
+
{/* Scope/Tool Name */}
{(trigger.contentType === 'tool_use' || trigger.contentType === 'tool_result') && (
@@ -79,7 +86,7 @@ export const TriggerConfiguration = ({
htmlFor={`trigger-${trigger.id}-tool-name`}
className="text-sm text-text-secondary"
>
- Scope / Tool Name
+ {t('notificationTriggers.fields.scopeToolName')}
{TOOL_NAME_OPTIONS.map((option) => (
- {option.label}
+ {option.value
+ ? option.label
+ : t('notificationTriggers.options.toolNames.anyTool')}
))}
@@ -100,25 +109,25 @@ export const TriggerConfiguration = ({
{/* Dot Color */}
-
+
{/* Section 2: Trigger Condition (Mode Selector) */}
-
+
{/* Section 3: Dynamic Configuration */}
-
+
{/* Error Status Mode */}
{localMode === 'error_status' && (
- Triggers when a tool execution reports an error (is_error: true).
+ {t('notificationTriggers.configuration.errorStatusDescription')}
)}
@@ -132,7 +141,7 @@ export const TriggerConfiguration = ({
htmlFor={`trigger-${trigger.id}-content-type`}
className="text-sm text-text-secondary"
>
- Content Type
+ {t('notificationTriggers.fields.contentType')}
{CONTENT_TYPE_OPTIONS.map((option) => (
- {option.label}
+ {t(getContentTypeLabelKey(option.value))}
))}
@@ -206,6 +215,8 @@ const ContentMatchConfig = ({
onPatternChange,
onPatternBlur,
}: Readonly
): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
{/* Match Field */}
@@ -215,7 +226,7 @@ const ContentMatchConfig = ({
htmlFor={`trigger-${triggerId}-match-field`}
className="text-sm text-text-secondary"
>
- Match Field
+ {t('notificationTriggers.fields.matchField')}
{availableMatchFields.map((option) => (
- {option.label}
+ {t(getMatchFieldLabelKey(option.value))}
))}
@@ -240,7 +251,7 @@ const ContentMatchConfig = ({
htmlFor={`trigger-${triggerId}-match-pattern`}
className="text-sm text-text-secondary"
>
- Match Pattern (Regex)
+ {t('notificationTriggers.fields.matchPattern')}
onPatternChange(e.target.value)}
onBlur={onPatternBlur}
- placeholder="e.g., error|failed|exception"
+ placeholder={t('notificationTriggers.configuration.matchPatternPlaceholder')}
disabled={saving}
className={`w-full rounded border bg-transparent px-2 py-1.5 font-mono text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${patternError ? 'border-red-500' : 'border-border'} ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
/>
@@ -260,7 +271,7 @@ const ContentMatchConfig = ({
)}
- Leave empty to match all content. Uses JavaScript regex syntax.
+ {t('notificationTriggers.configuration.emptyPatternHint')}
@@ -290,11 +301,13 @@ const TokenThresholdConfig = ({
onTokenThresholdChange,
onTokenThresholdBlur,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+
return (
- Token Type
+ {t('notificationTriggers.fields.tokenType')}
- Total Tokens
+ {t('notificationTriggers.options.tokenTypes.total')}
- Input Tokens
+ {t('notificationTriggers.options.tokenTypes.input')}
- Output Tokens
+ {t('notificationTriggers.options.tokenTypes.output')}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx
index 2103d9ef..59fbdb41 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx
@@ -3,6 +3,7 @@
* Used by both TriggerCard and AddTriggerForm.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle, Loader2 } from 'lucide-react';
import type { PreviewResult } from '../types';
@@ -24,6 +25,7 @@ export const TriggerPreview = ({
onViewSession,
isFormContext = false,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isLoading = loading ?? previewResult?.loading;
// Safeguard: ensure count is at least the errors array length (handles edge cases where totalCount is 0 but errors exist)
@@ -34,7 +36,9 @@ export const TriggerPreview = ({
return (
- Preview
+
+ {t('notificationTriggers.preview.title')}
+
- Testing...
+ {t('notificationTriggers.preview.testing')}
) : (
- 'Test Trigger'
+ t('notificationTriggers.preview.testTrigger')
)}
@@ -58,7 +62,7 @@ export const TriggerPreview = ({
{previewResult.truncated && effectiveCount >= 10_000 ? '10,000+' : effectiveCount}
{' '}
- errors would have been detected
+ {t('notificationTriggers.preview.detectedSuffix')}
{/* Truncation warning - only shown when timeout or count limit hit */}
@@ -72,9 +76,7 @@ export const TriggerPreview = ({
}}
>
-
- Search stopped early (timeout or count limit). Actual matches may be higher.
-
+
{t('notificationTriggers.preview.truncatedWarning')}
)}
@@ -95,13 +97,15 @@ export const TriggerPreview = ({
onClick={() => onViewSession(error)}
className="shrink-0 rounded px-2 py-1 text-indigo-400 transition-colors hover:bg-indigo-500/10"
>
- View Session
+ {t('notificationTriggers.preview.viewSession')}
))}
{effectiveCount > 10 && (
- ...and {effectiveCount - 10} more
+
+ {t('notificationTriggers.preview.more', { count: effectiveCount - 10 })}
+
)}
)}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts
index ec3f2d0e..b43012c1 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts
+++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts
@@ -4,6 +4,7 @@
import { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { createLogger } from '@shared/utils/logger';
@@ -58,6 +59,7 @@ interface UseTriggerFormReturn {
* Shared form state and validation logic for trigger forms.
*/
export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTriggerFormReturn {
+ const { t } = useAppTranslation('settings');
const [patternError, setPatternError] = useState(null);
const [previewResult, setPreviewResult] = useState(null);
@@ -67,11 +69,14 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
/**
* Validate a regex pattern.
*/
- const validatePattern = useCallback((pattern: string): boolean => {
- const error = validateRegexPattern(pattern);
- setPatternError(error);
- return error === null;
- }, []);
+ const validatePattern = useCallback(
+ (pattern: string): boolean => {
+ const error = validateRegexPattern(pattern);
+ setPatternError(error ? t('notificationTriggers.errors.invalidRegexPattern') : null);
+ return error === null;
+ },
+ [t]
+ );
/**
* Clear the preview result.
@@ -148,7 +153,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
}): NotificationTrigger => {
return {
id: `test-${generateId()}`,
- name: formState.name.trim() || 'Test Trigger',
+ name: formState.name.trim() || t('notificationTriggers.preview.defaultTestTriggerName'),
enabled: true,
contentType: formState.contentType,
mode: formState.mode,
@@ -170,7 +175,7 @@ export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTrigger
formState.repositoryIds.length > 0 && { repositoryIds: formState.repositoryIds }),
};
},
- []
+ [t]
);
return {
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/index.tsx b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx
index 06899fc3..80ce46b5 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/index.tsx
+++ b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx
@@ -9,6 +9,8 @@
* 4. Advanced (collapsible)
*/
+import { useAppTranslation } from '@features/localization/renderer';
+
import { AddTriggerForm } from './components/AddTriggerForm';
import { SectionHeader } from './components/SectionHeader';
import { TriggerCard } from './components/TriggerCard';
@@ -28,6 +30,7 @@ export const NotificationTriggerSettings = ({
onAddTrigger,
onRemoveTrigger,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
// Separate builtin and custom triggers
const builtinTriggers = triggers.filter((t) => t.isBuiltin);
const customTriggers = triggers.filter((t) => !t.isBuiltin);
@@ -37,10 +40,9 @@ export const NotificationTriggerSettings = ({
{/* Builtin Triggers */}
{builtinTriggers.length > 0 && (
-
+
- Default triggers that come with the application. You can enable/disable them and
- customize their patterns.
+ {t('notificationTriggers.builtin.description')}
{builtinTriggers.map((trigger) => (
@@ -58,9 +60,9 @@ export const NotificationTriggerSettings = ({
{/* Custom Triggers */}
-
+
- Create your own triggers to get notified for specific patterns or tool outputs.
+ {t('notificationTriggers.custom.description')}
{customTriggers.length > 0 && (
@@ -78,7 +80,9 @@ export const NotificationTriggerSettings = ({
)}
{customTriggers.length === 0 && (
-
No custom triggers configured yet.
+
+ {t('notificationTriggers.custom.empty')}
+
)}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/types.ts b/src/renderer/components/settings/NotificationTriggerSettings/types.ts
index c927dbbe..5c57b6fe 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/types.ts
+++ b/src/renderer/components/settings/NotificationTriggerSettings/types.ts
@@ -24,6 +24,7 @@ export interface PreviewResult {
export interface ModeConfig {
value: TriggerMode;
label: string;
+ labelKey: string;
icon: React.ComponentType<{ className?: string }>;
}
diff --git a/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts b/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts
index 70e6975e..b7e2fe13 100644
--- a/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts
+++ b/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts
@@ -7,6 +7,40 @@ import { Activity, AlertCircle, Search } from 'lucide-react';
import type { ModeConfig } from '../types';
import type { TriggerContentType, TriggerToolName } from '@renderer/types/data';
+export const CONTENT_TYPE_LABEL_KEYS = {
+ tool_result: 'notificationTriggers.options.contentTypes.tool_result',
+ tool_use: 'notificationTriggers.options.contentTypes.tool_use',
+ thinking: 'notificationTriggers.options.contentTypes.thinking',
+ text: 'notificationTriggers.options.contentTypes.text',
+} as const satisfies Record
;
+
+export const MATCH_FIELD_LABEL_KEYS = {
+ args: 'notificationTriggers.options.matchFields.args',
+ command: 'notificationTriggers.options.matchFields.command',
+ content: 'notificationTriggers.options.matchFields.content',
+ description: 'notificationTriggers.options.matchFields.description',
+ file_path: 'notificationTriggers.options.matchFields.file_path',
+ fullInput: 'notificationTriggers.options.matchFields.fullInput',
+ glob: 'notificationTriggers.options.matchFields.glob',
+ new_string: 'notificationTriggers.options.matchFields.new_string',
+ old_string: 'notificationTriggers.options.matchFields.old_string',
+ path: 'notificationTriggers.options.matchFields.path',
+ pattern: 'notificationTriggers.options.matchFields.pattern',
+ prompt: 'notificationTriggers.options.matchFields.prompt',
+ query: 'notificationTriggers.options.matchFields.query',
+ skill: 'notificationTriggers.options.matchFields.skill',
+ subagent_type: 'notificationTriggers.options.matchFields.subagent_type',
+ text: 'notificationTriggers.options.matchFields.text',
+ thinking: 'notificationTriggers.options.matchFields.thinking',
+ url: 'notificationTriggers.options.matchFields.url',
+} as const;
+
+export const MODE_LABEL_KEYS = {
+ content_match: 'notificationTriggers.options.modes.content_match',
+ error_status: 'notificationTriggers.options.modes.error_status',
+ token_threshold: 'notificationTriggers.options.modes.token_threshold',
+} as const;
+
/**
* Content type options for dropdown.
*/
@@ -44,7 +78,37 @@ export const TOOL_NAME_OPTIONS: { value: TriggerToolName; label: string }[] = [
* Mode options for the trigger mode selector.
*/
export const MODE_OPTIONS: ModeConfig[] = [
- { value: 'error_status', label: 'Execution Error', icon: AlertCircle },
- { value: 'content_match', label: 'Content Pattern', icon: Search },
- { value: 'token_threshold', label: 'High Token Usage', icon: Activity },
+ {
+ value: 'error_status',
+ label: 'Execution Error',
+ labelKey: MODE_LABEL_KEYS.error_status,
+ icon: AlertCircle,
+ },
+ {
+ value: 'content_match',
+ label: 'Content Pattern',
+ labelKey: MODE_LABEL_KEYS.content_match,
+ icon: Search,
+ },
+ {
+ value: 'token_threshold',
+ label: 'High Token Usage',
+ labelKey: MODE_LABEL_KEYS.token_threshold,
+ icon: Activity,
+ },
];
+
+export function getContentTypeLabelKey(contentType: TriggerContentType) {
+ return CONTENT_TYPE_LABEL_KEYS[contentType];
+}
+
+export function getMatchFieldLabelKey(matchField: string) {
+ return (
+ MATCH_FIELD_LABEL_KEYS[matchField as keyof typeof MATCH_FIELD_LABEL_KEYS] ??
+ MATCH_FIELD_LABEL_KEYS.fullInput
+ );
+}
+
+export function getModeLabelKey(mode: keyof typeof MODE_LABEL_KEYS) {
+ return MODE_LABEL_KEYS[mode];
+}
diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx
index 1be5b2ec..08d41f4d 100644
--- a/src/renderer/components/settings/SettingsTabs.tsx
+++ b/src/renderer/components/settings/SettingsTabs.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import {
Tooltip,
@@ -18,43 +19,48 @@ interface SettingsTabsProps {
onSectionChange: (section: SettingsSection) => void;
}
+type TabLabelKey = 'tabs.advanced.label' | 'tabs.general.label' | 'tabs.notifications.label';
+
+type TabDescriptionKey =
+ | 'tabs.advanced.description'
+ | 'tabs.general.description'
+ | 'tabs.notifications.description';
+
interface TabConfig {
id: SettingsSection;
- label: string;
+ labelKey: TabLabelKey;
icon: LucideIcon;
- description: string;
+ descriptionKey: TabDescriptionKey;
electronOnly?: boolean;
}
const tabs: TabConfig[] = [
{
id: 'general',
- label: 'General',
+ labelKey: 'tabs.general.label',
icon: Settings,
- description:
- 'Core app preferences like theme, language, display density, and startup behavior.',
+ descriptionKey: 'tabs.general.description',
},
// { id: 'connection', label: 'Connection', icon: Server, description: 'Manage CLI connection and authentication settings.', electronOnly: true },
{
id: 'notifications',
- label: 'Notifications',
+ labelKey: 'tabs.notifications.label',
icon: Bell,
- description:
- 'Control when and how you get notified about agent activity, task completions, and errors.',
+ descriptionKey: 'tabs.notifications.description',
},
{
id: 'advanced',
- label: 'Advanced',
+ labelKey: 'tabs.advanced.label',
icon: Wrench,
- description:
- 'Power-user options: export/import config, reset defaults, and raw configuration editing.',
+ descriptionKey: 'tabs.advanced.description',
},
-];
+] satisfies TabConfig[];
export const SettingsTabs = ({
activeSection,
onSectionChange,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const visibleTabs = useMemo(
() => tabs.filter((tab) => !tab.electronOnly || isElectron),
@@ -68,6 +74,7 @@ export const SettingsTabs = ({
{visibleTabs.map((tab) => {
const Icon = tab.icon;
const isActive = activeSection === tab.id;
+ const label = t(tab.labelKey);
return (
- {tab.label}
+ {label}
event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onKeyDown={(event) => {
@@ -101,7 +108,7 @@ export const SettingsTabs = ({
- {tab.description}
+ {t(tab.descriptionKey)}
diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx
index 01b860ff..18bf8267 100644
--- a/src/renderer/components/settings/SettingsView.tsx
+++ b/src/renderer/components/settings/SettingsView.tsx
@@ -5,6 +5,7 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { Loader2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@@ -19,6 +20,8 @@ import {
import { type SettingsSection, SettingsTabs } from './SettingsTabs';
export const SettingsView = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
+ const { t: commonT } = useAppTranslation('common');
const [activeSection, setActiveSection] = useState('general');
const { pendingSettingsSection, clearPendingSettingsSection } = useStore(
useShallow((s) => ({
@@ -70,7 +73,7 @@ export const SettingsView = (): React.JSX.Element | null => {
>
- Loading settings...
+ {t('view.loading')}
);
@@ -93,7 +96,7 @@ export const SettingsView = (): React.JSX.Element | null => {
color: 'var(--color-text-secondary)',
}}
>
- Retry
+ {commonT('actions.retry')}
@@ -108,10 +111,10 @@ export const SettingsView = (): React.JSX.Element | null => {
{/* Header */}
- Settings
+ {t('view.title')}
- Manage your app preferences
+ {t('view.description')}
{error && (
@@ -132,6 +135,7 @@ export const SettingsView = (): React.JSX.Element | null => {
onGeneralToggle={handlers.handleGeneralToggle}
onThemeChange={handlers.handleThemeChange}
onLanguageChange={handlers.handleLanguageChange}
+ onAppLocaleChange={handlers.handleAppLocaleChange}
/>
)}
diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts
index 85ffa332..a9cd79cd 100644
--- a/src/renderer/components/settings/hooks/useSettingsConfig.ts
+++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts
@@ -32,6 +32,7 @@ export interface SafeConfig {
multimodelEnabled: boolean;
claudeRootPath: string | null;
agentLanguage: string;
+ appLocale: string;
autoExpandAIGroups: boolean;
useNativeTitleBar: boolean;
telemetryEnabled: boolean;
@@ -174,6 +175,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
multimodelEnabled: displayConfig?.general?.multimodelEnabled ?? true,
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
+ appLocale: displayConfig?.general?.appLocale ?? 'system',
autoExpandAIGroups: displayConfig?.general?.autoExpandAIGroups ?? false,
useNativeTitleBar: displayConfig?.general?.useNativeTitleBar ?? false,
telemetryEnabled: displayConfig?.general?.telemetryEnabled ?? true,
diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
index 511e91b0..f41edcb3 100644
--- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts
+++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts
@@ -31,6 +31,7 @@ interface SettingsHandlers {
handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
handleThemeChange: (value: 'dark' | 'light' | 'system') => void;
handleLanguageChange: (value: string) => void;
+ handleAppLocaleChange: (value: string) => void;
handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void;
// Notification handlers
@@ -96,6 +97,13 @@ export function useSettingsHandlers({
[fireAndForgetConfigUpdate]
);
+ const handleAppLocaleChange = useCallback(
+ (value: string) => {
+ fireAndForgetConfigUpdate('general', { appLocale: value });
+ },
+ [fireAndForgetConfigUpdate]
+ );
+
const handleDefaultTabChange = useCallback(
(value: 'dashboard' | 'last-session') => {
fireAndForgetConfigUpdate('general', { defaultTab: value });
@@ -324,6 +332,7 @@ export function useSettingsHandlers({
multimodelEnabled: true,
claudeRootPath: null,
agentLanguage: 'system',
+ appLocale: 'system',
autoExpandAIGroups: false,
useNativeTitleBar: false,
telemetryEnabled: true,
@@ -435,6 +444,7 @@ export function useSettingsHandlers({
handleGeneralToggle,
handleThemeChange,
handleLanguageChange,
+ handleAppLocaleChange,
handleDefaultTabChange,
handleNotificationToggle,
handleStatusChangeStatusesUpdate,
diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx
index ca8a5251..eaf6a93d 100644
--- a/src/renderer/components/settings/sections/AdvancedSection.tsx
+++ b/src/renderer/components/settings/sections/AdvancedSection.tsx
@@ -4,6 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import appIcon from '@renderer/favicon.png';
import { useStore } from '@renderer/store';
@@ -30,6 +31,7 @@ export const AdvancedSection = ({
onImportConfig,
onOpenInEditor,
}: AdvancedSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const [version, setVersion] = useState
('');
const [configEditorOpen, setConfigEditorOpen] = useState(false);
@@ -68,14 +70,14 @@ export const AdvancedSection = ({
return (
<>
- Checking...
+ {t('advanced.updates.checking')}
>
);
case 'not-available':
return (
<>
- Up to date
+ {t('advanced.updates.upToDate')}
>
);
case 'available':
@@ -84,15 +86,17 @@ export const AdvancedSection = ({
<>
{updateStatus === 'downloaded'
- ? 'Update ready'
- : `v${availableVersion ?? 'unknown'} available`}
+ ? t('advanced.updates.ready')
+ : t('advanced.updates.available', {
+ version: availableVersion ?? t('advanced.updates.unknownVersion'),
+ })}
>
);
default:
return (
<>
- Check for Updates
+ {t('advanced.updates.check')}
>
);
}
@@ -100,7 +104,7 @@ export const AdvancedSection = ({
return (
-
+
setConfigEditorOpen(true)}
@@ -111,7 +115,7 @@ export const AdvancedSection = ({
}}
>
- Edit Config
+ {t('advanced.configuration.editConfig')}
- Reset to Defaults
+ {t('advanced.configuration.resetToDefaults')}
- Export Config
+ {t('advanced.configuration.exportConfig')}
- Import Config
+ {t('advanced.configuration.importConfig')}
{isElectron && (
- Open in Editor
+ {t('advanced.configuration.openInEditor')}
)}
-
+
-
+
- Agent Teams AI
+ {t('advanced.appName')}
{isElectron && (
- Standalone
+ {t('advanced.about.standalone')}
)}
- Version {version || '...'}
+ {t('advanced.about.version', { version: version || '...' })}
- Assemble AI agent teams that work autonomously in parallel, communicate across teams,
- and manage tasks on a kanban board — with built-in code review, live process monitoring,
- and full tool visibility.
+ {t('advanced.about.description')}
diff --git a/src/renderer/components/settings/sections/CliStatusSection.tsx b/src/renderer/components/settings/sections/CliStatusSection.tsx
index af96121e..3351b4d5 100644
--- a/src/renderer/components/settings/sections/CliStatusSection.tsx
+++ b/src/renderer/components/settings/sections/CliStatusSection.tsx
@@ -11,6 +11,7 @@ import {
mergeCodexProviderStatusWithSnapshot,
useCodexAccountSnapshot,
} from '@features/codex-account/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
@@ -126,6 +127,7 @@ function getProviderLabel(providerId: CliProviderId): string {
}
export const CliStatusSection = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
const isElectron = useMemo(() => isElectronMode(), []);
const appConfig = useStore((s) => s.appConfig);
const selectedProjectId = useStore((s) => s.selectedProjectId);
@@ -253,7 +255,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
async (providerId: CliProviderId) => {
const provider =
effectiveCliStatus?.providers.find((entry) => entry.providerId === providerId) ?? null;
- const disconnectAction = provider ? getProviderDisconnectAction(provider) : null;
+ const disconnectAction = provider ? getProviderDisconnectAction(provider, t) : null;
if (!disconnectAction) {
return;
}
@@ -262,7 +264,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
title: disconnectAction.title,
message: disconnectAction.message,
confirmLabel: disconnectAction.confirmLabel,
- cancelLabel: 'Cancel',
+ cancelLabel: t('providerRuntime.actions.cancel'),
variant: 'danger',
});
@@ -275,7 +277,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
action: 'logout',
});
},
- [effectiveCliStatus?.providers]
+ [effectiveCliStatus?.providers, t]
);
const handleProviderManage = useCallback((providerId: CliProviderId) => {
@@ -347,7 +349,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
return (
-
+
{/* Loading status */}
{!effectiveCliStatus && installerState === 'idle' && (
@@ -356,7 +358,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }}
>
- {multimodelEnabled ? 'Checking AI Providers...' : 'Checking Claude CLI...'}
+ {multimodelEnabled
+ ? t('cliRuntime.loading.aiProviders')
+ : t('cliRuntime.loading.claudeCli')}
)}
@@ -378,7 +382,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
className="text-xs font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- Multimodel
+ {t('cliRuntime.labels.multimodel')}
{/* Inline action buttons */}
@@ -390,7 +394,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ backgroundColor: '#3b82f6' }}
>
- Update
+ {t('cliRuntime.actions.update')}
) : effectiveCliStatus.supportsSelfUpdate ? (
{
{cliStatusLoading ? (
<>
- Checking...
+ {t('cliRuntime.actions.checking')}
>
) : (
<>
- Check for Updates
+ {t('cliRuntime.actions.checkForUpdates')}
>
)}
@@ -427,7 +431,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
- Extensions
+ {t('cliRuntime.actions.extensions')}
)}
@@ -445,8 +449,10 @@ export const CliStatusSection = (): React.JSX.Element | null => {
effectiveCliStatus.latestVersion && (
- v{effectiveCliStatus.installedVersion} → v
- {effectiveCliStatus.latestVersion}
+ {t('cliStatus.versionUpgrade', {
+ current: effectiveCliStatus.installedVersion,
+ latest: effectiveCliStatus.latestVersion,
+ })}
)}
@@ -468,7 +474,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
shouldShowProviderStatusSkeleton(provider, providerLoading) ||
isCodexSnapshotPending(provider, codexSnapshotPending);
const runtimeSummary = isConnectionManagedRuntimeProvider(provider)
- ? getProviderCurrentRuntimeSummary(provider)
+ ? getProviderCurrentRuntimeSummary(provider, t)
: getProviderRuntimeBackendSummary(provider);
const sourceProvider =
loadingCliProviderMap.get(provider.providerId) ?? null;
@@ -478,8 +484,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
);
const effectiveShowSkeleton = showSkeleton || maskNegativeBootstrapState;
const statusText = effectiveShowSkeleton
- ? 'Checking...'
- : formatProviderStatusText(provider);
+ ? t('providerRuntime.connectionUi.status.checking')
+ : formatProviderStatusText(provider, t);
const modelCatalogLoading =
provider.modelCatalogRefreshState === 'loading' ||
isOpenCodeCatalogHydrating(provider);
@@ -491,9 +497,12 @@ export const CliStatusSection = (): React.JSX.Element | null => {
provider
).length > 0
: provider.models.length > 0;
- const connectionModeSummary = getProviderConnectionModeSummary(provider);
- const credentialSummary = getProviderCredentialSummary(provider);
- const disconnectAction = getProviderDisconnectAction(provider);
+ const connectionModeSummary = getProviderConnectionModeSummary(
+ provider,
+ t
+ );
+ const credentialSummary = getProviderCredentialSummary(provider, t);
+ const disconnectAction = getProviderDisconnectAction(provider, t);
const hasDetailContent = Boolean(
(provider.backend?.label && !runtimeSummary) ||
runtimeSummary ||
@@ -539,22 +548,30 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-muted)' }}
>
{provider.backend?.label && !runtimeSummary && (
- Backend: {provider.backend.label}
+
+ {t('cliRuntime.provider.backend', {
+ backend: provider.backend.label,
+ })}
+
)}
{runtimeSummary ? (
{isConnectionManagedRuntimeProvider(provider)
? runtimeSummary
- : `Runtime: ${runtimeSummary}`}
+ : t('cliRuntime.provider.runtime', {
+ runtime: runtimeSummary,
+ })}
) : null}
{connectionModeSummary ? (
{connectionModeSummary}
) : null}
{credentialSummary ? {credentialSummary} : null}
- {modelCatalogLoading ? Loading models... : null}
+ {modelCatalogLoading ? (
+ {t('cliRuntime.provider.loadingModels')}
+ ) : null}
{!hasProviderModels && !modelCatalogLoading && (
- Models unavailable for this runtime build
+ {t('cliRuntime.provider.modelsUnavailable')}
)}
) : null}
@@ -571,7 +588,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
- Manage
+ {t('cliRuntime.actions.manage')}
{disconnectAction ? (
{
}}
>
- {getProviderConnectLabel(provider)}
+ {getProviderConnectLabel(provider, t)}
) : null}
@@ -652,8 +669,8 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
- ? `${runtimeDisplayName} was found but failed to start`
- : `${runtimeDisplayName} not installed`}
+ ? t('cliRuntime.status.foundButFailed', { runtime: runtimeDisplayName })
+ : t('cliRuntime.status.notInstalled', { runtime: runtimeDisplayName })}
{effectiveCliStatus.showBinaryPath && effectiveCliStatus.binaryPath && (
@@ -687,7 +704,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
}}
>
- Re-check
+ {t('cliRuntime.actions.recheck')}
{
>
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
- ? `Reinstall ${runtimeDisplayName}`
- : `Install ${runtimeDisplayName}`}
+ ? t('cliRuntime.actions.reinstallRuntime', { runtime: runtimeDisplayName })
+ : t('cliRuntime.actions.installRuntime', { runtime: runtimeDisplayName })}
)}
{!effectiveCliStatus.installed && !effectiveCliStatus.supportsSelfUpdate && (
{effectiveCliStatus.binaryPath && effectiveCliStatus.launchError
- ? `The configured ${runtimeDisplayName} failed its startup health check.`
- : `The configured ${runtimeDisplayName} was not found.`}
+ ? t('cliRuntime.status.healthCheckFailed', { runtime: runtimeDisplayName })
+ : t('cliRuntime.status.configuredNotFound', { runtime: runtimeDisplayName })}
)}
@@ -719,7 +736,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
className="flex items-center justify-between text-xs"
style={{ color: 'var(--color-text-secondary)' }}
>
-
Downloading...
+
{t('cliRuntime.installer.downloading')}
{downloadTotal > 0
? `${formatBytes(downloadTransferred)} / ${formatBytes(downloadTotal)} (${downloadProgress}%)`
@@ -755,7 +772,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Checking latest version...
+ {t('cliRuntime.installer.checkingLatest')}
)}
@@ -766,7 +783,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Verifying checksum...
+ {t('cliRuntime.installer.verifying')}
)}
@@ -777,7 +794,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
style={{ color: 'var(--color-text-secondary)' }}
>
- Installing...
+ {t('cliRuntime.installer.installing')}
)}
@@ -785,7 +802,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{installerState === 'completed' && (
- Installed v{completedVersion ?? 'latest'}
+ {t('cliRuntime.installer.installed', {
+ version: completedVersion ?? t('cliRuntime.installer.latest'),
+ })}
)}
@@ -794,7 +813,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
- {installerError ?? 'Installation failed'}
+ {installerError ?? t('cliRuntime.installer.failed')}
{
}}
>
- Retry
+ {t('cliRuntime.actions.retry')}
)}
@@ -813,7 +832,9 @@ export const CliStatusSection = (): React.JSX.Element | null => {
{providerTerminal && cliStatus?.binaryPath && (
{
}}
autoCloseOnSuccessMs={3000}
successMessage={
- providerTerminal.action === 'login' ? 'Authentication updated' : 'Provider logged out'
+ providerTerminal.action === 'login'
+ ? t('cliRuntime.providerTerminal.authUpdated')
+ : t('cliRuntime.providerTerminal.loggedOut')
}
failureMessage={
- providerTerminal.action === 'login' ? 'Authentication failed' : 'Logout failed'
+ providerTerminal.action === 'login'
+ ? t('cliRuntime.providerTerminal.authFailed')
+ : t('cliRuntime.providerTerminal.logoutFailed')
}
/>
)}
diff --git a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx
index aab0a39b..2610786a 100644
--- a/src/renderer/components/settings/sections/ConfigEditorDialog.tsx
+++ b/src/renderer/components/settings/sections/ConfigEditorDialog.tsx
@@ -27,6 +27,7 @@ import {
keymap,
lineNumbers,
} from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
@@ -61,6 +62,7 @@ export const ConfigEditorDialog = ({
onClose,
onConfigSaved,
}: ConfigEditorDialogProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
const editorRef = useRef(null);
const viewRef = useRef(null);
const saveTimerRef = useRef>(undefined);
@@ -106,7 +108,7 @@ export const ConfigEditorDialog = ({
setSaveStatus('idle');
} else {
setSaveStatus('error');
- setJsonError(e instanceof Error ? e.message : 'Failed to save config');
+ setJsonError(e instanceof Error ? e.message : t('configEditor.errors.saveFailed'));
if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current);
savedRevertTimerRef.current = setTimeout(() => {
setSaveStatus('idle');
@@ -115,7 +117,7 @@ export const ConfigEditorDialog = ({
}
}
},
- [onConfigSaved]
+ [onConfigSaved, t]
);
const scheduleSave = useCallback(
@@ -202,7 +204,7 @@ export const ConfigEditorDialog = ({
} catch (e) {
if (destroyed) return;
setLoading(false);
- setJsonError(e instanceof Error ? e.message : 'Failed to load config');
+ setJsonError(e instanceof Error ? e.message : t('configEditor.errors.loadFailed'));
}
};
@@ -217,7 +219,7 @@ export const ConfigEditorDialog = ({
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
if (savedRevertTimerRef.current) clearTimeout(savedRevertTimerRef.current);
};
- }, [open, scheduleSave]);
+ }, [open, scheduleSave, t]);
// Escape key handler
useEffect(() => {
@@ -236,11 +238,15 @@ export const ConfigEditorDialog = ({
return (
{
if (e.target === e.currentTarget) onClose();
}}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') onClose();
+ }}
>
- Edit Configuration
+ {t('configEditor.title')}
@@ -277,7 +283,7 @@ export const ConfigEditorDialog = ({
style={{ color: 'var(--color-text-muted)', backgroundColor: 'var(--color-surface)' }}
>
- Loading config...
+ {t('configEditor.loading')}
) : null}
- Changes auto-save after editing
+ {t('configEditor.footer.autoSave')}
- Esc
+ {t('configEditor.footer.escapeKey')}
- to close
+ {t('configEditor.footer.toClose')}
@@ -327,6 +333,8 @@ const SaveStatusBadge = ({
status: SaveStatus;
error: string | null;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('settings');
+
if (status === 'idle' && !error) return null;
if (error && status !== 'saving') {
@@ -337,7 +345,9 @@ const SaveStatusBadge = ({
title={error}
>
- {status === 'error' ? 'Save failed' : 'Invalid JSON'}
+ {status === 'error'
+ ? t('configEditor.status.saveFailed')
+ : t('configEditor.status.invalidJson')}
);
}
@@ -349,7 +359,7 @@ const SaveStatusBadge = ({
style={{ backgroundColor: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
>
- Saving...
+ {t('configEditor.status.saving')}
);
}
@@ -361,7 +371,7 @@ const SaveStatusBadge = ({
style={{ backgroundColor: 'rgba(74, 222, 128, 0.15)', color: '#4ade80' }}
>
- Saved
+ {t('configEditor.status.saved')}
);
}
diff --git a/src/renderer/components/settings/sections/ConnectionSection.tsx b/src/renderer/components/settings/sections/ConnectionSection.tsx
index 3fa6e937..e24549c2 100644
--- a/src/renderer/components/settings/sections/ConnectionSection.tsx
+++ b/src/renderer/components/settings/sections/ConnectionSection.tsx
@@ -11,6 +11,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
@@ -30,14 +31,21 @@ import type {
SshConnectionProfile,
} from '@shared/types';
-const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
- { value: 'auto', label: 'Auto (from SSH Config)' },
- { value: 'agent', label: 'SSH Agent' },
- { value: 'privateKey', label: 'Private Key' },
- { value: 'password', label: 'Password' },
+const authMethodOptionValues: readonly SshAuthMethod[] = [
+ 'auto',
+ 'agent',
+ 'privateKey',
+ 'password',
];
+const authMethodLabelKeys = {
+ agent: 'workspaceProfiles.authMethods.agent',
+ auto: 'workspaceProfiles.authMethods.auto',
+ password: 'workspaceProfiles.authMethods.password',
+ privateKey: 'workspaceProfiles.authMethods.privateKey',
+} as const satisfies Record
;
export const ConnectionSection = (): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const {
connectionState,
connectedHost,
@@ -83,6 +91,10 @@ export const ConnectionSection = (): React.JSX.Element => {
const [savedProfiles, setSavedProfiles] = useState([]);
const [selectedProfileId, setSelectedProfileId] = useState(null);
const [claudeRootInfo, setClaudeRootInfo] = useState(null);
+ const authMethodOptions = authMethodOptionValues.map((value) => ({
+ value,
+ label: t(authMethodLabelKeys[value]),
+ }));
const loadProfiles = useCallback(async () => {
try {
@@ -216,9 +228,9 @@ export const ConnectionSection = (): React.JSX.Element => {
return (
-
+
- Connect to a remote machine to view Claude Code sessions running there
+ {t('connection.description')}
{/* Connection Status */}
@@ -233,10 +245,10 @@ export const ConnectionSection = (): React.JSX.Element => {
- Connected to {connectedHost}
+ {t('connection.status.connectedTo', { host: connectedHost })}
- Viewing remote sessions via SSH
+ {t('connection.status.remoteSessions')}
{
color: 'var(--color-text-secondary)',
}}
>
- Disconnect
+ {t('connection.actions.disconnect')}
)}
@@ -260,13 +272,16 @@ export const ConnectionSection = (): React.JSX.Element => {
{/* Mode indicator */}
{!isConnected && (
-
+
- Local ({resolvedClaudeRootPath})
+ {t('connection.currentMode.local', { path: resolvedClaudeRootPath })}
)}
@@ -275,7 +290,7 @@ export const ConnectionSection = (): React.JSX.Element => {
{!isConnected && savedProfiles.length > 0 && (
- Saved Profiles
+ {t('connection.savedProfiles.title')}
{savedProfiles.map((profile) => {
@@ -313,7 +328,7 @@ export const ConnectionSection = (): React.JSX.Element => {
{!isConnected && (
- SSH Connection
+ {t('connection.ssh.title')}
@@ -324,7 +339,7 @@ export const ConnectionSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Host
+ {t('connection.form.host')}
{
clearProfileSelection();
}}
onFocus={() => setShowDropdown(true)}
- placeholder="hostname or ssh config alias"
+ placeholder={t('connection.form.hostPlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -384,7 +399,7 @@ export const ConnectionSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Port
+ {t('connection.form.port')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Username
+ {t('connection.form.username')}
{
setUsername(e.target.value);
clearProfileSelection();
}}
- placeholder="user"
+ placeholder={t('connection.form.usernamePlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -423,7 +438,7 @@ export const ConnectionSection = (): React.JSX.Element => {
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- SettingsSelect is a custom dropdown without a native control */}
- Authentication
+ {t('connection.form.authentication')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Private Key Path
+ {t('connection.form.privateKeyPath')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Password
+ {t('connection.form.password')}
{
}`}
>
{testResult.success
- ? 'Connection successful'
- : `Connection failed: ${testResult.error ?? 'Unknown error'}`}
+ ? t('connection.test.success')
+ : t('connection.test.failed', {
+ error: testResult.error ?? t('connection.test.unknownError'),
+ })}
)}
@@ -503,10 +520,10 @@ export const ConnectionSection = (): React.JSX.Element => {
{testing ? (
- Testing...
+ {t('connection.actions.testing')}
) : (
- 'Test Connection'
+ t('connection.actions.testConnection')
)}
@@ -522,12 +539,12 @@ export const ConnectionSection = (): React.JSX.Element => {
{isConnecting ? (
- Connecting...
+ {t('connection.actions.connecting')}
) : (
- Connect
+ {t('connection.actions.connect')}
)}
diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx
index dd0ef9a1..181913c3 100644
--- a/src/renderer/components/settings/sections/GeneralSection.tsx
+++ b/src/renderer/components/settings/sections/GeneralSection.tsx
@@ -4,6 +4,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { normalizeAppLocalePreference } from '@features/localization';
+import { AppLanguageSelect, useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Combobox } from '@renderer/components/ui/combobox';
@@ -21,12 +23,7 @@ import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
import type { HttpServerStatus } from '@shared/types/api';
import type { AppConfig } from '@shared/types/notifications';
-// Theme options
-const THEME_OPTIONS = [
- { value: 'dark', label: 'Dark' },
- { value: 'light', label: 'Light' },
- { value: 'system', label: 'System' },
-] as const;
+const THEME_OPTIONS = ['dark', 'light', 'system'] as const;
interface GeneralSectionProps {
readonly safeConfig: SafeConfig;
@@ -34,6 +31,7 @@ interface GeneralSectionProps {
readonly onGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
readonly onLanguageChange: (value: string) => void;
+ readonly onAppLocaleChange: (value: string) => void;
}
export const GeneralSection = ({
@@ -42,7 +40,10 @@ export const GeneralSection = ({
onGeneralToggle,
onThemeChange,
onLanguageChange,
+ onAppLocaleChange,
}: GeneralSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
+ const { t: commonT } = useAppTranslation('common');
const [serverStatus, setServerStatus] = useState
({
running: false,
port: 3456,
@@ -77,10 +78,10 @@ export const GeneralSection = ({
setClaudeRootInfo(info);
} catch (error) {
setClaudeRootError(
- error instanceof Error ? error.message : 'Failed to load local Claude root settings'
+ error instanceof Error ? error.message : t('general.localClaudeRoot.errors.loadFailed')
);
}
- }, []);
+ }, [t]);
useEffect(() => {
void loadClaudeRootInfo();
@@ -144,7 +145,9 @@ export const GeneralSection = ({
await Promise.all([fetchProjects(), fetchRepositoryGroups()]);
}
} catch (error) {
- setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root');
+ setClaudeRootError(
+ error instanceof Error ? error.message : t('general.localClaudeRoot.errors.updateFailed')
+ );
} finally {
setUpdatingClaudeRoot(false);
}
@@ -155,6 +158,7 @@ export const GeneralSection = ({
fetchRepositoryGroups,
loadClaudeRootInfo,
resetWorkspaceForRootChange,
+ t,
]
);
@@ -168,9 +172,11 @@ export const GeneralSection = ({
if (!selection.isClaudeDirName) {
const proceed = await confirm({
- title: 'Selected folder is not .claude',
- message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`,
- confirmLabel: 'Use Folder',
+ title: t('general.localClaudeRoot.confirm.notClaudeDir.title'),
+ message: t('general.localClaudeRoot.confirm.notClaudeDir.message', {
+ folderName: selection.path.split(/[\\/]/).pop() ?? selection.path,
+ }),
+ confirmLabel: t('general.localClaudeRoot.actions.useFolder'),
});
if (!proceed) {
return;
@@ -179,9 +185,9 @@ export const GeneralSection = ({
if (!selection.hasProjectsDir) {
const proceed = await confirm({
- title: 'No projects directory found',
- message: 'This folder does not contain a "projects" directory. Continue anyway?',
- confirmLabel: 'Use Folder',
+ title: t('general.localClaudeRoot.confirm.noProjectsDir.title'),
+ message: t('general.localClaudeRoot.confirm.noProjectsDir.message'),
+ confirmLabel: t('general.localClaudeRoot.actions.useFolder'),
});
if (!proceed) {
return;
@@ -189,7 +195,7 @@ export const GeneralSection = ({
}
await applyClaudeRootPath(selection.path);
- }, [applyClaudeRootPath]);
+ }, [applyClaudeRootPath, t]);
const handleResetClaudeRoot = useCallback(async (): Promise => {
await applyClaudeRootPath(null);
@@ -199,9 +205,11 @@ export const GeneralSection = ({
async (candidate: WslClaudeRootCandidate): Promise => {
if (!candidate.hasProjectsDir) {
const proceed = await confirm({
- title: 'WSL path missing projects directory',
- message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`,
- confirmLabel: 'Use Path',
+ title: t('general.localClaudeRoot.confirm.wslNoProjectsDir.title'),
+ message: t('general.localClaudeRoot.confirm.wslNoProjectsDir.message', {
+ path: candidate.path,
+ }),
+ confirmLabel: t('general.localClaudeRoot.actions.usePath'),
});
if (!proceed) {
return;
@@ -211,7 +219,7 @@ export const GeneralSection = ({
await applyClaudeRootPath(candidate.path);
setShowWslModal(false);
},
- [applyClaudeRootPath]
+ [applyClaudeRootPath, t]
);
const handleUseWslForClaude = useCallback(async (): Promise => {
@@ -223,10 +231,9 @@ export const GeneralSection = ({
if (candidates.length === 0) {
const pickManually = await confirm({
- title: 'No WSL Claude paths found',
- message:
- 'Could not find WSL distros with Claude data automatically. Select folder manually?',
- confirmLabel: 'Select Folder',
+ title: t('general.localClaudeRoot.confirm.noWslPaths.title'),
+ message: t('general.localClaudeRoot.confirm.noWslPaths.message'),
+ confirmLabel: t('general.localClaudeRoot.actions.selectFolder'),
});
if (pickManually) {
await handleSelectClaudeRootFolder();
@@ -243,12 +250,12 @@ export const GeneralSection = ({
setShowWslModal(true);
} catch (error) {
setClaudeRootError(
- error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths'
+ error instanceof Error ? error.message : t('general.localClaudeRoot.errors.detectWslFailed')
);
} finally {
setFindingWslRoots(false);
}
- }, [applyWslCandidate, handleSelectClaudeRootFolder]);
+ }, [applyWslCandidate, handleSelectClaudeRootFolder, t]);
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
@@ -268,10 +275,21 @@ export const GeneralSection = ({
const detected = resolveLanguageName('system', browserLang);
const detectedFlag = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === primaryCode)?.flag ?? '';
const flagPrefix = detectedFlag ? `${detectedFlag} ` : '';
- return `Language for agent communication (detected: ${flagPrefix}${detected})`;
+ return t('general.agentLanguage.descriptionWithDetected', {
+ detected: `${flagPrefix}${detected}`,
+ });
}
- return 'Language for agent communication';
- }, [safeConfig.general.agentLanguage]);
+ return t('general.agentLanguage.description');
+ }, [safeConfig.general.agentLanguage, t]);
+
+ const themeOptions = useMemo(
+ () =>
+ THEME_OPTIONS.map((value) => ({
+ value,
+ label: t(`general.appearance.theme.options.${value}`),
+ })),
+ [t]
+ );
const languageComboboxOptions = useMemo(
() =>
@@ -298,15 +316,27 @@ export const GeneralSection = ({
return (
-
-
+
+
+
+
+
+
+
-
+
{window.navigator.userAgent.includes('Macintosh') && (
)}
-
-
+
+
- {THEME_OPTIONS.map((opt) => (
+ {themeOptions.map((opt) => (
{isElectron && !window.navigator.userAgent.includes('Macintosh') && (
{
const shouldRelaunch = await confirm({
- title: 'Restart required',
- message: 'The app needs to restart to apply the title bar change. Restart now?',
- confirmLabel: 'Restart',
+ title: t('general.appearance.nativeTitleBar.restartConfirm.title'),
+ message: t('general.appearance.nativeTitleBar.restartConfirm.message'),
+ confirmLabel: t('general.appearance.nativeTitleBar.restartConfirm.confirmLabel'),
});
if (shouldRelaunch) {
// Await config write before relaunch to avoid race condition on Windows
@@ -405,21 +438,27 @@ export const GeneralSection = ({
{isElectron && (
<>
-
+
- Choose which local folder is treated as your Claude data root
+ {t('general.localClaudeRoot.description')}
{resolvedClaudeRootPath}
- Auto-detected: {defaultClaudeRootPath}
+ {t('general.localClaudeRoot.current.autoDetected', {
+ path: defaultClaudeRootPath,
+ })}
@@ -440,7 +479,7 @@ export const GeneralSection = ({
) : (
)}
- Select Folder
+ {t('general.localClaudeRoot.actions.selectFolder')}
@@ -455,7 +494,7 @@ export const GeneralSection = ({
>
- Use Auto-Detect
+ {t('general.localClaudeRoot.actions.useAutoDetect')}
@@ -475,7 +514,7 @@ export const GeneralSection = ({
) : (
)}
- Using Linux/WSL?
+ {t('general.localClaudeRoot.actions.useWsl')}
)}
@@ -493,7 +532,7 @@ export const GeneralSection = ({
className="absolute inset-0 cursor-default"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
onClick={() => setShowWslModal(false)}
- aria-label="Close WSL path modal"
+ aria-label={t('general.localClaudeRoot.wslModal.closeAriaLabel')}
tabIndex={-1}
/>
- Select WSL Claude Root
+ {t('general.localClaudeRoot.wslModal.title')}
- Detected WSL distributions and Claude root candidates
+ {t('general.localClaudeRoot.wslModal.description')}
@@ -529,7 +568,7 @@ export const GeneralSection = ({
{!candidate.hasProjectsDir && (
- No projects directory detected
+ {t('general.localClaudeRoot.wslModal.noProjectsDir')}
)}
@@ -541,7 +580,7 @@ export const GeneralSection = ({
color: 'var(--color-text)',
}}
>
- Use This Path
+ {t('general.localClaudeRoot.actions.useThisPath')}
))}
@@ -556,7 +595,7 @@ export const GeneralSection = ({
color: 'var(--color-text-secondary)',
}}
>
- Cancel
+ {commonT('actions.cancel')}
{
@@ -569,7 +608,7 @@ export const GeneralSection = ({
color: 'var(--color-text)',
}}
>
- Select Folder Manually
+ {t('general.localClaudeRoot.actions.selectFolderManually')}
@@ -580,10 +619,10 @@ export const GeneralSection = ({
{isElectron ? (
<>
-
+
{serverLoading ? (
- Running on
+ {t('general.server.runningOn')}
{copied ? : }
- {copied ? 'Copied' : 'Copy URL'}
+ {copied ? commonT('actions.copied') : commonT('actions.copyUrl')}
)}
>
) : (
<>
-
+
- Running on
+ {t('general.server.runningOn')}
{copied ? : }
- {copied ? 'Copied' : 'Copy URL'}
+ {copied ? commonT('actions.copied') : commonT('actions.copyUrl')}
- Running in standalone mode. The HTTP server is always active. System notifications are
- not available — notification triggers are logged in-app only.
+ {t('general.server.standaloneModeDescription')}
>
)}
@@ -685,10 +723,10 @@ export const GeneralSection = ({
{/* Privacy / Telemetry — only visible when Sentry DSN is baked into the build */}
{import.meta.env.VITE_SENTRY_DSN && (
<>
-
+
;
-// Snooze duration options
const SNOOZE_OPTIONS = [
- { value: 15, label: '15 minutes' },
- { value: 30, label: '30 minutes' },
- { value: 60, label: '1 hour' },
- { value: 120, label: '2 hours' },
- { value: 240, label: '4 hours' },
- { value: -1, label: 'Until tomorrow' },
+ { value: 15, labelKey: 'notifications.snooze.options.15' },
+ { value: 30, labelKey: 'notifications.snooze.options.30' },
+ { value: 60, labelKey: 'notifications.snooze.options.60' },
+ { value: 120, labelKey: 'notifications.snooze.options.120' },
+ { value: 240, labelKey: 'notifications.snooze.options.240' },
+ { value: -1, labelKey: 'notifications.snooze.options.-1' },
] as const;
+const STATUS_OPTIONS = [
+ {
+ value: 'in_progress',
+ labelKey: 'notifications.team.statusChange.statuses.options.in_progress',
+ },
+ { value: 'completed', labelKey: 'notifications.team.statusChange.statuses.options.completed' },
+ { value: 'review', labelKey: 'notifications.team.statusChange.statuses.options.review' },
+ { value: 'needsFix', labelKey: 'notifications.team.statusChange.statuses.options.needsFix' },
+ { value: 'approved', labelKey: 'notifications.team.statusChange.statuses.options.approved' },
+ { value: 'pending', labelKey: 'notifications.team.statusChange.statuses.options.pending' },
+ { value: 'deleted', labelKey: 'notifications.team.statusChange.statuses.options.deleted' },
+] as const satisfies readonly { value: NotifiableStatus; labelKey: string }[];
+
interface NotificationsSectionProps {
readonly safeConfig: SafeConfig;
readonly saving: boolean;
@@ -109,8 +122,17 @@ export const NotificationsSection = ({
onRemoveTrigger,
onStatusChangeStatusesUpdate,
}: NotificationsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const [testStatus, setTestStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
const [testError, setTestError] = useState(null);
+ const snoozeOptions = useMemo(
+ () =>
+ SNOOZE_OPTIONS.map((option) => ({
+ value: option.value,
+ label: t(option.labelKey),
+ })),
+ [t]
+ );
const handleTestNotification = async (): Promise => {
setTestStatus('sending');
@@ -122,13 +144,13 @@ export const NotificationsSection = ({
setTimeout(() => setTestStatus('idle'), 3000);
} else {
setTestStatus('error');
- setTestError(result.error ?? 'Unknown error');
+ setTestError(result.error ?? t('notifications.test.unknownError'));
setTimeout(() => setTestStatus('idle'), 5000);
}
} catch (err) {
console.error('[notifications] testNotification failed:', err);
setTestStatus('error');
- const message = err instanceof Error ? err.message : 'Failed to send test notification';
+ const message = err instanceof Error ? err.message : t('notifications.test.failedToSend');
setTestError(message);
setTimeout(() => setTestStatus('idle'), 5000);
}
@@ -149,22 +171,26 @@ export const NotificationsSection = ({
>
-
Dev Mode
+
+ {t('notifications.dev.title')}
+
- Notifications may not work in development mode. macOS identifies the app as
- "Electron" (bundle ID com.github.Electron)
- instead of the production app name. Check System Settings → Notifications → Electron
- to verify permissions.
+ {t('notifications.dev.descriptionPrefix')}{' '}
+ com.github.Electron{' '}
+ {t('notifications.dev.descriptionSuffix')}
) : null}
{/* Notification Settings */}
-
} />
+
}
+ />
}
>
}
>
}
>
}
>
{testStatus === 'success' ? (
- Sent!
+ {t('notifications.test.sent')}
) : testStatus === 'error' ? (
{testError}
) : null}
@@ -219,16 +245,20 @@ export const NotificationsSection = ({
color: 'var(--color-text)',
}}
>
- {testStatus === 'sending' ? 'Sending...' : 'Send Test'}
+ {testStatus === 'sending'
+ ? t('notifications.test.sending')
+ : t('notifications.test.action')}
}
>
@@ -239,12 +269,15 @@ export const NotificationsSection = ({
disabled={saving}
className={`rounded-md bg-red-500/10 px-3 py-1.5 text-sm font-medium text-red-400 transition-all duration-150 hover:bg-red-500/20 ${saving ? 'cursor-not-allowed opacity-50' : ''} `}
>
- Clear Snooze
+ {t('notifications.snooze.clear')}
) : (
v !== 0 && onSnooze(v)}
disabled={saving || !safeConfig.notifications.enabled}
dropUp
@@ -254,7 +287,10 @@ export const NotificationsSection = ({
{/* Team Notifications — grouped card */}
- } />
+ }
+ />
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
}
>
- Only in Solo mode
+ {t('notifications.team.statusChange.onlySolo.label')}
- Notify only when the team has no teammates
+ {t('notifications.team.statusChange.onlySolo.description')}
@@ -417,16 +453,17 @@ export const NotificationsSection = ({
className="text-sm font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- Notify on these statuses
+ {t('notifications.team.statusChange.statuses.label')}
- Which target statuses trigger a notification
+ {t('notifications.team.statusChange.statuses.description')}
@@ -443,9 +480,12 @@ export const NotificationsSection = ({
onRemoveTrigger={onRemoveTrigger}
/>
- } />
+ }
+ />
- Notifications from these repositories will be ignored
+ {t('notifications.ignoredRepositories.description')}
{ignoredRepositoryItems.length > 0 ? (
@@ -464,21 +504,21 @@ export const NotificationsSection = ({
style={{ borderColor: 'var(--color-border)' }}
>
- No repositories ignored
+ {t('notifications.ignoredRepositories.empty')}
)}
{/* Task Completion Notifications */}
}
/>
- Get native OS notifications when Claude finishes tasks — sounds, banners, and Dock/taskbar
- badges. Works on macOS, Linux, and Windows.
+ {t('notifications.taskCompletion.description')}
@@ -503,44 +542,36 @@ export const NotificationsSection = ({
}}
>
- Install claude-notifications-go plugin
+ {t('notifications.taskCompletion.installPlugin')}
);
};
-const STATUS_OPTIONS: { value: NotifiableStatus; label: string }[] = [
- { value: 'in_progress', label: 'Started' },
- { value: 'completed', label: 'Completed' },
- { value: 'review', label: 'Review' },
- { value: 'needsFix', label: 'Needs Fixes' },
- { value: 'approved', label: 'Approved' },
- { value: 'pending', label: 'Pending' },
- { value: 'deleted', label: 'Deleted' },
-];
-
const StatusCheckboxGroup = ({
selected,
onChange,
disabled,
+ t,
}: {
selected: string[];
onChange: (statuses: string[]) => void;
disabled: boolean;
+ t: ReturnType
['t'];
}) => (
- {STATUS_OPTIONS.map((opt) => {
- const checked = selected.includes(opt.value);
+ {STATUS_OPTIONS.map((option) => {
+ const checked = selected.includes(option.value);
return (
{
const next = checked
- ? selected.filter((s) => s !== opt.value)
- : [...selected, opt.value];
+ ? selected.filter((selectedStatus) => selectedStatus !== option.value)
+ : [...selected, option.value];
onChange(next);
}}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
@@ -549,7 +580,7 @@ const StatusCheckboxGroup = ({
: 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
} ${disabled ? 'cursor-not-allowed opacity-50' : ''}`}
>
- {opt.label}
+ {t(option.labelKey)}
);
})}
diff --git a/src/renderer/components/settings/sections/WorkspaceSection.tsx b/src/renderer/components/settings/sections/WorkspaceSection.tsx
index ee6eb900..0e86481b 100644
--- a/src/renderer/components/settings/sections/WorkspaceSection.tsx
+++ b/src/renderer/components/settings/sections/WorkspaceSection.tsx
@@ -12,6 +12,7 @@
import { useCallback, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -30,12 +31,18 @@ const inputStyle = {
color: 'var(--color-text)',
};
-const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
- { value: 'auto', label: 'Auto (from SSH Config)' },
- { value: 'agent', label: 'SSH Agent' },
- { value: 'privateKey', label: 'Private Key' },
- { value: 'password', label: 'Password' },
+const authMethodOptionValues: readonly SshAuthMethod[] = [
+ 'auto',
+ 'agent',
+ 'privateKey',
+ 'password',
];
+const authMethodLabelKeys = {
+ agent: 'workspaceProfiles.authMethods.agent',
+ auto: 'workspaceProfiles.authMethods.auto',
+ password: 'workspaceProfiles.authMethods.password',
+ privateKey: 'workspaceProfiles.authMethods.privateKey',
+} as const satisfies Record
;
const defaultForm = {
name: '',
@@ -47,10 +54,15 @@ const defaultForm = {
};
export const WorkspaceSection = (): React.JSX.Element => {
+ const { t } = useAppTranslation('settings');
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState(null);
const [showAddForm, setShowAddForm] = useState(false);
+ const authMethodOptions = authMethodOptionValues.map((value) => ({
+ value,
+ label: t(authMethodLabelKeys[value]),
+ }));
// Form state
const [formName, setFormName] = useState(defaultForm.name);
@@ -146,9 +158,9 @@ export const WorkspaceSection = (): React.JSX.Element => {
if (!profile) return;
const confirmed = await confirm({
- title: 'Delete Profile',
- message: `Are you sure you want to delete "${profile.name}"? This cannot be undone.`,
- confirmLabel: 'Delete',
+ title: t('workspaceProfiles.deleteConfirm.title'),
+ message: t('workspaceProfiles.deleteConfirm.message', { name: profile.name }),
+ confirmLabel: t('workspaceProfiles.deleteConfirm.confirmLabel'),
variant: 'danger',
});
if (!confirmed) return;
@@ -177,14 +189,14 @@ export const WorkspaceSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Name
+ {t('workspaceProfiles.form.name')}
setFormName(e.target.value)}
- placeholder="My Server"
+ placeholder={t('workspaceProfiles.form.namePlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -195,14 +207,14 @@ export const WorkspaceSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Host
+ {t('workspaceProfiles.form.host')}
setFormHost(e.target.value)}
- placeholder="hostname or IP"
+ placeholder={t('workspaceProfiles.form.hostPlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -216,7 +228,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Port
+ {t('workspaceProfiles.form.port')}
{
className="mb-1 block text-xs"
style={{ color: 'var(--color-text-muted)' }}
>
- Username
+ {t('workspaceProfiles.form.username')}
setFormUsername(e.target.value)}
- placeholder="user"
+ placeholder={t('workspaceProfiles.form.usernamePlaceholder')}
className={inputClass}
style={inputStyle}
/>
@@ -251,7 +263,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
@@ -318,15 +330,15 @@ export const WorkspaceSection = (): React.JSX.Element => {
return (
-
+
- Save SSH connection profiles for quick reconnection
+ {t('workspaceProfiles.description')}
{loading && (
- Loading profiles...
+ {t('workspaceProfiles.loading')}
)}
@@ -339,8 +351,8 @@ export const WorkspaceSection = (): React.JSX.Element => {
}}
>
-
No saved profiles
-
Add an SSH profile to connect quickly
+
{t('workspaceProfiles.empty.title')}
+
{t('workspaceProfiles.empty.description')}
)}
@@ -395,7 +407,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
- Edit profile
+ {t('workspaceProfiles.actions.editProfile')}
@@ -409,7 +421,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
- Delete profile
+ {t('workspaceProfiles.actions.deleteProfile')}
@@ -438,7 +450,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
}}
>
- Add Profile
+ {t('workspaceProfiles.actions.addProfile')}
)}
diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx
index 83ffad22..6baefabb 100644
--- a/src/renderer/components/sidebar/DateGroupedSessions.tsx
+++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx
@@ -7,6 +7,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
@@ -185,6 +186,7 @@ function matchesSessionSearch(session: Session, query: string): boolean {
}
export const DateGroupedSessions = memo((): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const {
sessions,
selectedSessionId,
@@ -622,11 +624,11 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
- placeholder="Select Project"
- searchPlaceholder="Search..."
- emptyMessage="Nothing found"
+ placeholder={t('sessionFilters.project.selectProject')}
+ searchPlaceholder={t('search.placeholder')}
+ emptyMessage={t('search.nothingFound')}
className="text-[12px]"
- resetLabel="Reset selection"
+ resetLabel={t('actions.resetSelection')}
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
@@ -717,7 +719,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
- Switch Worktree
+ {t('sessions.worktree.switch')}
{mainWorktree && (
{
setSearchQuery(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
@@ -776,7 +778,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
- aria-label="Clear session search"
+ aria-label={t('sessions.search.clear')}
>
@@ -796,7 +798,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
{projectSelector}
-
Select a project to view sessions
+
{t('sessions.empty.selectProject')}
@@ -849,7 +851,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
}}
>
- Error loading sessions
+ {t('sessions.errors.loading')}
{sessionsError}
@@ -865,8 +867,8 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
-
No sessions found
-
This project has no sessions yet
+
{t('sessions.empty.noSessions')}
+
{t('sessions.empty.noSessionsDescription')}
@@ -880,11 +882,11 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
-
No matching sessions
+
{t('sessions.empty.noMatchingSessions')}
{hasActiveSearch || hasActiveProviderFilter
- ? 'Try another query or reset the provider filter.'
- : 'This project has no matching sessions yet.'}
+ ? t('sessions.empty.noMatchingSessionsFiltered')
+ : t('sessions.empty.noMatchingSessionsDescription')}
@@ -901,7 +903,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
className="text-[12px] font-semibold text-text-secondary"
style={{ color: 'var(--color-text-secondary)' }}
>
- {sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
+ {sessionSortMode === 'most-context' ? t('sessions.sort.byContext') : t('sessions.title')}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
{
color: 'var(--color-text-secondary)',
}}
>
- {filteredSessions.length} matching sessions loaded so far — scroll down to load more.
- {sessionSortMode === 'most-context'
- ? ' Context sorting only ranks loaded sessions.'
- : ''}
+ {t('sessions.loadedMatchingMore', { count: filteredSessions.length })}
+ {sessionSortMode === 'most-context' ? ` ${t('sessions.sort.contextLoadedOnly')}` : ''}
,
document.body
)}
@@ -943,7 +943,11 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
{
{
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
}
className="rounded p-1 transition-colors hover:bg-white/5"
- title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
+ title={
+ sessionSortMode === 'recent'
+ ? t('sessions.sort.byContextTooltip')
+ : t('sessions.sort.byRecentTooltip')
+ }
style={{
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
}}
@@ -992,40 +1004,40 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
className="text-[11px] font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
- {sidebarSelectedSessionIds.length} selected
+ {t('sessions.selection.selected', { count: sidebarSelectedSessionIds.length })}
- Pin
+ {t('sessions.actions.pin')}
- Hide
+ {t('sessions.actions.hide')}
{showHiddenSessions && someSelectedAreHidden && (
- Unhide
+ {t('sessions.actions.unhide')}
)}
@@ -1068,7 +1080,7 @@ export const DateGroupedSessions = memo((): React.JSX.Element => {
}}
>
- Pinned
+ {t('sessions.pinned')}
) : item.type === 'header' ? (
{
{sessionsLoadingMore ? (
<>
- Loading more sessions...
+ {t('sessions.loadingMore')}
>
) : (
- Scroll to load more
+ {t('sessions.scrollToLoadMore')}
)}
) : (
diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx
index ce45c50b..c125fae9 100644
--- a/src/renderer/components/sidebar/GlobalTaskList.tsx
+++ b/src/renderer/components/sidebar/GlobalTaskList.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -85,12 +86,12 @@ export type TaskSortMode = 'time' | 'project' | 'team' | 'unread';
const TASK_SORT_STORAGE_KEY = 'sidebarTasksSort';
-const SORT_OPTIONS: { id: TaskSortMode; label: string }[] = [
- { id: 'time', label: 'By time' },
- { id: 'unread', label: 'By unread' },
- { id: 'project', label: 'By project' },
- { id: 'team', label: 'By team' },
-];
+const SORT_OPTIONS = [
+ { id: 'time', labelKey: 'tasksPanel.sort.byTime' },
+ { id: 'unread', labelKey: 'tasksPanel.sort.byUnread' },
+ { id: 'project', labelKey: 'tasksPanel.sort.byProject' },
+ { id: 'team', labelKey: 'tasksPanel.sort.byTeam' },
+] as const satisfies readonly { id: TaskSortMode; labelKey: string }[];
function loadSortMode(): TaskSortMode {
try {
@@ -196,6 +197,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
filtersPopoverOpen: externalFiltersPopoverOpen,
onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange,
}: GlobalTaskListProps = {}): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const {
globalTasks,
globalTasksLoading,
@@ -426,10 +428,10 @@ export const GlobalTaskList = memo(function GlobalTaskList({
const handleDeleteTask = useCallback(
async (teamName: string, taskId: string): Promise => {
const confirmed = await confirm({
- title: 'Delete task',
- message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
- confirmLabel: 'Delete',
- cancelLabel: 'Cancel',
+ title: t('tasksPanel.deleteConfirm.title'),
+ message: t('tasksPanel.deleteConfirm.message', { taskId: deriveTaskDisplayId(taskId) }),
+ confirmLabel: t('tasksPanel.deleteConfirm.confirmLabel'),
+ cancelLabel: t('tasksPanel.deleteConfirm.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -438,15 +440,16 @@ export const GlobalTaskList = memo(function GlobalTaskList({
await fetchAllTasks();
} catch (err) {
void confirm({
- title: 'Failed to delete task',
- message: err instanceof Error ? err.message : 'An unexpected error occurred',
- confirmLabel: 'OK',
+ title: t('tasksPanel.deleteFailed.title'),
+ message:
+ err instanceof Error ? err.message : t('tasksPanel.deleteFailed.fallbackMessage'),
+ confirmLabel: t('tasksPanel.deleteFailed.confirmLabel'),
variant: 'danger',
});
}
}
},
- [fetchAllTasks, softDeleteTask]
+ [fetchAllTasks, softDeleteTask, t]
);
// Fetch tasks on mount — loading guard in the store action prevents
@@ -617,7 +620,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
- Tasks
+
+ {t('tasksPanel.title')}
+
)}
@@ -630,7 +635,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
@@ -679,7 +684,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
sortMode === opt.id ? 'opacity-100' : 'opacity-0'
)}
/>
- {opt.label}
+ {t(opt.labelKey)}
))}
@@ -701,7 +706,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
-
Pinned
+
{t('tasksPanel.pinned')}
{sortTasksByFreshness(pinnedTasks).map((task) => (
- Group by:
-
+
{t('tasksPanel.groupByLabel')}
+
{(['none', 'project', 'time'] as const).map((mode) => {
- const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
+ const label =
+ mode === 'none'
+ ? t('tasksPanel.groupModes.none')
+ : mode === 'project'
+ ? t('tasksPanel.groupModes.project')
+ : t('tasksPanel.groupModes.time');
return (
- {effectiveShowArchived ? 'Hide archived' : 'Show archived'}
+ {effectiveShowArchived
+ ? t('tasksPanel.hideArchived')
+ : t('tasksPanel.showArchived')}
@@ -792,7 +808,9 @@ export const GlobalTaskList = memo(function GlobalTaskList({
- {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'}
+ {searchQuery || selectedProjectPath
+ ? t('tasksPanel.empty.noMatchingTasks')
+ : t('tasksPanel.empty.noTasks')}
)}
@@ -882,7 +900,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
{showTeamHeader && (
- Team: {task.teamDisplayName}
+ {t('tasksPanel.teamLabel', { team: task.teamDisplayName })}
)}
- Show more
+ {t('tasksPanel.showMore')}
)}
{showLessVisible && (
@@ -948,7 +966,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
}))
}
>
- Show less
+ {t('tasksPanel.showLess')}
)}
@@ -991,7 +1009,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
{showTeamHeader && (
- Team: {task.teamDisplayName}
+ {t('tasksPanel.teamLabel', { team: task.teamDisplayName })}
)}
{
+ const { t } = useAppTranslation('common');
const activeCount = useMemo(
() => (selectedProviderIds.size === SESSION_PROVIDER_IDS.length ? 0 : 1),
[selectedProviderIds]
@@ -60,7 +62,7 @@ export const SessionFiltersPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter sessions"
+ aria-label={t('sessions.filter.title')}
>
{activeCount > 0 && (
@@ -71,13 +73,13 @@ export const SessionFiltersPopover = ({
- Filter sessions
+ {t('sessions.filter.title')}
- Provider
+ {t('providerRuntime.provider')}
- Reset
+ {t('actions.reset')}
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx
index f9726a75..3841ed33 100644
--- a/src/renderer/components/sidebar/SessionItem.tsx
+++ b/src/renderer/components/sidebar/SessionItem.tsx
@@ -7,6 +7,7 @@
import { memo, useCallback, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { useStore } from '@renderer/store';
import { formatSessionLabel, parseSessionTitle } from '@renderer/utils/sessionTitleParser';
@@ -64,6 +65,7 @@ const ConsumptionBadge = ({
contextConsumption: number;
phaseBreakdown?: PhaseTokenBreakdown[];
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [popoverPosition, setPopoverPosition] = useState<{
top: number;
left: number;
@@ -107,20 +109,28 @@ const ConsumptionBadge = ({
}}
>
- Total Context: {formatTokensCompact(contextConsumption)} tokens
+ {t('sessionItem.totalContext', {
+ tokens: formatTokensCompact(contextConsumption),
+ })}
{phaseBreakdown.length === 1 ? (
-
Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}
+
+ {t('sessionItem.context', {
+ tokens: formatTokensCompact(phaseBreakdown[0].peakTokens),
+ })}
+
) : (
phaseBreakdown.map((phase) => (
- Phase {phase.phaseNumber}:
+ {t('sessionItem.phase', { phase: phase.phaseNumber })}
{formatTokensCompact(phase.contribution)}
{phase.postCompaction != null && (
- (compacted to {formatTokensCompact(phase.postCompaction)})
+ {t('sessionItem.compactedTo', {
+ tokens: formatTokensCompact(phase.postCompaction),
+ })}
)}
diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx
index 165df998..f57b9ac2 100644
--- a/src/renderer/components/sidebar/SidebarTaskItem.tsx
+++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
@@ -22,24 +23,28 @@ import { useShallow } from 'zustand/react/shallow';
import type { GlobalTask, TeamTaskStatus } from '@shared/types';
import type { LucideIcon } from 'lucide-react';
-const statusConfig: Record
= {
- pending: { icon: Circle, color: 'text-amber-400', label: 'pending' },
- in_progress: { icon: Loader2, color: 'text-blue-400', label: 'in progress' },
- completed: { icon: CheckCircle2, color: 'text-emerald-400', label: 'completed' },
- deleted: { icon: Circle, color: 'text-zinc-500', label: 'deleted' },
+const statusConfig: Record = {
+ pending: { icon: Circle, color: 'text-amber-400', key: 'pending' },
+ in_progress: { icon: Loader2, color: 'text-blue-400', key: 'in_progress' },
+ completed: { icon: CheckCircle2, color: 'text-emerald-400', key: 'completed' },
+ deleted: { icon: Circle, color: 'text-zinc-500', key: 'deleted' },
};
-function formatTaskDate(dateStr: string | undefined): string | null {
+function formatTaskDate(dateStr: string | undefined, yesterdayLabel: string): string | null {
if (!dateStr) return null;
const d = new Date(dateStr);
if (isNaN(d.getTime())) return null;
if (isToday(d)) return format(d, 'HH:mm');
- if (isYesterday(d)) return 'Yesterday';
+ if (isYesterday(d)) return yesterdayLabel;
if (isThisYear(d)) return format(d, 'MMM d');
return format(d, 'MMM d, yyyy');
}
-function formatUpdatedLabel(task: GlobalTask): string | null {
+function formatUpdatedLabel(
+ task: GlobalTask,
+ updatedPrefix: string,
+ updatedYesterdayLabel: string
+): string | null {
const updatedStr = task.updatedAt;
if (!updatedStr) return null;
const updated = new Date(updatedStr);
@@ -54,10 +59,10 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
}
}
- if (isToday(updated)) return `upd ${format(updated, 'HH:mm')}`;
- if (isYesterday(updated)) return 'upd yesterday';
- if (isThisYear(updated)) return `upd ${format(updated, 'MMM d')}`;
- return `upd ${format(updated, 'MMM d, yyyy')}`;
+ if (isToday(updated)) return `${updatedPrefix} ${format(updated, 'HH:mm')}`;
+ if (isYesterday(updated)) return updatedYesterdayLabel;
+ if (isThisYear(updated)) return `${updatedPrefix} ${format(updated, 'MMM d')}`;
+ return `${updatedPrefix} ${format(updated, 'MMM d, yyyy')}`;
}
interface SidebarTaskItemProps {
@@ -88,6 +93,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
onRenameCancel,
getDisplaySubject,
}: SidebarTaskItemProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members));
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
@@ -118,19 +124,23 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
const reviewColumn = getTeamTaskWorkflowColumn(task);
const cfg =
reviewColumn === 'approved'
- ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const)
+ ? ({ icon: ShieldCheck, color: 'text-teal-400', key: 'approved' } as const)
: reviewColumn === 'review'
- ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
+ ? ({ icon: Eye, color: 'text-orange-400', key: 'review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon;
- const shouldAnimateStatusIcon = cfg.label === 'in progress' && !teamOffline;
+ const shouldAnimateStatusIcon = cfg.key === 'in_progress' && !teamOffline;
const statusIconClassName = cn(
'size-3 shrink-0',
cfg.color,
shouldAnimateStatusIcon && 'animate-spin'
);
- const updatedLabel = formatUpdatedLabel(task);
- const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
+ const updatedLabel = formatUpdatedLabel(
+ task,
+ t('tasks.date.updatedPrefix'),
+ t('tasks.date.updatedYesterday')
+ );
+ const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt, t('tasks.date.yesterday'));
const ownerColorSet = useMemo(() => {
if (!teamMembers || !task.owner) return null;
@@ -236,7 +246,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
- {REVIEW_STATE_DISPLAY.needsFix.label}
+ {t('tasks.reviewState.needsFix')}
)}
@@ -269,7 +279,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
- {task.owner ?? 'unassigned'}
+ {task.owner ?? t('tasks.unassigned')}
>
)}
@@ -288,7 +298,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
- Team:
+ {t('tasks.teamPrefix')}
{task.teamDisplayName}
@@ -297,7 +307,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
className="shrink-0 opacity-100 dark:opacity-60"
style={ownerTextColor ? { color: ownerTextColor } : undefined}
>
- {task.owner ?? 'unassigned'}
+ {task.owner ?? t('tasks.unassigned')}
)}
diff --git a/src/renderer/components/sidebar/TaskContextMenu.tsx b/src/renderer/components/sidebar/TaskContextMenu.tsx
index 98310b12..7d5bf96b 100644
--- a/src/renderer/components/sidebar/TaskContextMenu.tsx
+++ b/src/renderer/components/sidebar/TaskContextMenu.tsx
@@ -5,6 +5,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@renderer/components/ui/context-menu';
+import { useAppTranslation } from '@features/localization/renderer';
import { Archive, ArchiveRestore, Mail, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
import type { GlobalTask } from '@shared/types';
@@ -32,6 +33,8 @@ export const TaskContextMenu = ({
onDelete,
children,
}: TaskContextMenuProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
+
return (
@@ -42,24 +45,24 @@ export const TaskContextMenu = ({
{isPinned ? (
<>
- Unpin
+ {t('taskContextMenu.unpin')}
>
) : (
<>
- Pin
+ {t('taskContextMenu.pin')}
>
)}
- Rename
+ {t('taskContextMenu.rename')}
- Mark as unread
+ {t('taskContextMenu.markUnread')}
@@ -68,12 +71,12 @@ export const TaskContextMenu = ({
{isArchived ? (
<>
- Unarchive
+ {t('taskContextMenu.unarchive')}
>
) : (
<>
- Archive
+ {t('taskContextMenu.archive')}
>
)}
@@ -83,7 +86,7 @@ export const TaskContextMenu = ({
- Delete task
+ {t('taskContextMenu.deleteTask')}
>
)}
diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx
index da3a6104..1753cbb9 100644
--- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx
+++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
@@ -15,10 +16,10 @@ import {
import type { ComboboxOption } from '../ui/combobox';
-const READ_FILTER_OPTIONS: { value: ReadFilter; label: string }[] = [
- { value: 'all', label: 'All' },
- { value: 'unread', label: 'Unread' },
- { value: 'read', label: 'Read' },
+const READ_FILTER_OPTIONS: { value: ReadFilter; labelKey: 'all' | 'unread' | 'read' }[] = [
+ { value: 'all', labelKey: 'all' },
+ { value: 'unread', labelKey: 'unread' },
+ { value: 'read', labelKey: 'read' },
];
interface TaskFiltersPopoverProps {
@@ -40,6 +41,7 @@ export const TaskFiltersPopover = ({
onFiltersChange,
onApply,
}: TaskFiltersPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
// Draft state — all changes accumulate here and only commit on Apply
const [draft, setDraft] = useState(filters);
@@ -91,13 +93,15 @@ export const TaskFiltersPopover = ({
- Status
+
+ {t('taskFilters.status')}
+
- {allSelected ? 'Clear all' : 'Select all'}
+ {allSelected ? t('taskFilters.clearAll') : t('taskFilters.selectAll')}
@@ -115,17 +119,19 @@ export const TaskFiltersPopover = ({
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: opt.color }}
/>
- {opt.label}
+ {t(`taskFilters.statusOptions.${opt.labelKey}`)}
))}
- Team
+
+ {t('taskFilters.team')}
+
({ value: t.teamName, label: t.displayName })),
]}
value={draft.teamName ?? '__all__'}
@@ -135,9 +141,9 @@ export const TaskFiltersPopover = ({
teamName: v === '__all__' ? null : v,
})
}
- placeholder="All teams"
- searchPlaceholder="Search teams..."
- emptyMessage="No teams found"
+ placeholder={t('taskFilters.allTeams')}
+ searchPlaceholder={t('taskFilters.searchTeams')}
+ emptyMessage={t('taskFilters.noTeamsFound')}
className="text-[12px]"
/>
@@ -145,17 +151,17 @@ export const TaskFiltersPopover = ({
{projectOptions.length > 0 && (
- Project
+ {t('taskFilters.project')}
setDraft({ ...draft, projectPath: v || null })}
- placeholder="All Projects"
- searchPlaceholder="Search projects..."
- emptyMessage="No projects"
+ placeholder={t('taskFilters.allProjects')}
+ searchPlaceholder={t('taskFilters.searchProjects')}
+ emptyMessage={t('taskFilters.noProjects')}
className="text-[12px]"
- resetLabel="All Projects"
+ resetLabel={t('taskFilters.allProjects')}
onReset={() => setDraft({ ...draft, projectPath: null })}
/>
@@ -163,7 +169,7 @@ export const TaskFiltersPopover = ({
- Comments
+ {t('taskFilters.comments')}
{READ_FILTER_OPTIONS.map((opt) => (
@@ -183,7 +189,7 @@ export const TaskFiltersPopover = ({
})
}
>
- {opt.label}
+ {t(`taskFilters.read.${opt.labelKey}`)}
))}
@@ -196,7 +202,7 @@ export const TaskFiltersPopover = ({
className="w-full"
onClick={handleApply}
>
- Apply
+ {t('taskFilters.apply')}
diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts
index a02dee58..8b0fb22e 100644
--- a/src/renderer/components/sidebar/taskFiltersState.ts
+++ b/src/renderer/components/sidebar/taskFiltersState.ts
@@ -14,14 +14,14 @@ export type TaskStatusFilterId =
| 'review'
| 'approved';
-export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string; color: string }[] = [
- { id: 'todo', label: 'TODO', color: '#3b82f6' },
- { id: 'in_progress', label: 'IN PROGRESS', color: '#eab308' },
- { id: 'needs_fix', label: 'NEEDS FIXES', color: '#f43f5e' },
- { id: 'done', label: 'DONE', color: '#22c55e' },
- { id: 'review', label: 'REVIEW', color: '#8b5cf6' },
- { id: 'approved', label: 'APPROVED', color: '#16a34a' },
-];
+export const STATUS_OPTIONS = [
+ { id: 'todo', labelKey: 'todo', color: '#3b82f6' },
+ { id: 'in_progress', labelKey: 'inProgress', color: '#eab308' },
+ { id: 'needs_fix', labelKey: 'needsFix', color: '#f43f5e' },
+ { id: 'done', labelKey: 'done', color: '#22c55e' },
+ { id: 'review', labelKey: 'review', color: '#8b5cf6' },
+ { id: 'approved', labelKey: 'approved', color: '#16a34a' },
+] as const satisfies readonly { id: TaskStatusFilterId; labelKey: string; color: string }[];
export type ReadFilter = 'all' | 'unread' | 'read';
diff --git a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
index aeeb70e6..29441f41 100644
--- a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
+++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
@@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { Filter } from 'lucide-react';
export type ClaudeLogStream = 'stdout' | 'stderr';
@@ -45,6 +46,7 @@ export const ClaudeLogsFilterPopover = ({
onOpenChange,
onApply,
}: ClaudeLogsFilterPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [draft, setDraft] = useState
(() => ({
streams: new Set(filter.streams),
kinds: new Set(filter.kinds),
@@ -108,7 +110,7 @@ export const ClaudeLogsFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter Claude logs"
+ aria-label={t('claudeLogs.filter.ariaLabel')}
>
{activeCount > 0 && (
@@ -119,12 +121,12 @@ export const ClaudeLogsFilterPopover = ({
- Filter logs
+ {t('claudeLogs.filter.tooltip')}
- Stream
+ {t('claudeLogs.filter.sections.stream')}
toggleStream('stdout')}
/>
- stdout
+ {t('claudeLogs.filter.streams.stdout')}
toggleStream('stderr')}
/>
- stderr
+ {t('claudeLogs.filter.streams.stderr')}
- Content
+ {t('claudeLogs.filter.sections.content')}
toggleKind('output')}
/>
- Output
+ {t('claudeLogs.filter.kinds.output')}
toggleKind('thinking')}
/>
- Thinking
+ {t('claudeLogs.filter.kinds.thinking')}
toggleKind('tool')}
/>
- Tool calls
+ {t('claudeLogs.filter.kinds.tool')}
@@ -201,10 +203,10 @@ export const ClaudeLogsFilterPopover = ({
disabled={draftCount === 0}
onClick={handleReset}
>
- Reset
+ {t('claudeLogs.filter.actions.reset')}
- Save
+ {t('claudeLogs.filter.actions.save')}
diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx
index 05b5784c..6ea0b8f5 100644
--- a/src/renderer/components/team/ClaudeLogsPanel.tsx
+++ b/src/renderer/components/team/ClaudeLogsPanel.tsx
@@ -8,6 +8,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { Search, X } from 'lucide-react';
@@ -42,6 +43,7 @@ export const ClaudeLogsPanel = ({
className,
compactMetaInTooltip = false,
}: ClaudeLogsPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const {
data,
loading,
@@ -65,10 +67,13 @@ export const ClaudeLogsPanel = ({
handleScroll,
} = ctrl;
- const rawLineLabel = data.total === 1 ? '1 raw line' : `${data.total.toLocaleString()} raw lines`;
- const rawLinesCapturedLabel = `${rawLineLabel} captured`;
+ const rawLineLabel = t('claudeLogs.rawLineCount', {
+ count: data.total,
+ formattedCount: data.total.toLocaleString(),
+ });
+ const rawLinesCapturedLabel = t('claudeLogs.rawLinesCaptured', { count: rawLineLabel });
const emptyRawLogsMessage =
- data.total > 0 ? `${rawLinesCapturedLabel}; none are assistant/tool output yet.` : undefined;
+ data.total > 0 ? t('claudeLogs.emptyRawLogs', { count: rawLinesCapturedLabel }) : undefined;
return (
@@ -76,14 +81,11 @@ export const ClaudeLogsPanel = ({
) : null
@@ -165,11 +167,13 @@ export const ClaudeLogsPanel = ({
) : null}
{!error && data.lines.length === 0 && isAlive ? (
- {loading ? 'Loading…' : 'No logs captured.'}
+ {loading ? t('claudeLogs.loading') : t('claudeLogs.noLogsCaptured')}
) : null}
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
-
No matching logs.
+
+ {t('claudeLogs.noMatchingLogs')}
+
) : null}
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx
index 818e68fb..51aa2a4e 100644
--- a/src/renderer/components/team/ClaudeLogsSection.tsx
+++ b/src/renderer/components/team/ClaudeLogsSection.tsx
@@ -1,5 +1,6 @@
import { memo, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
@@ -88,6 +89,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
sidebarViewerMaxHeight,
onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const ctrl = useClaudeLogsController(teamName);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -135,12 +137,12 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
e.stopPropagation();
setDialogOpen(true);
}}
- aria-label="Open fullscreen logs"
+ aria-label={t('claudeLogs.openFullscreen')}
>
- Fullscreen
+ {t('claudeLogs.fullscreen')}
) : undefined;
@@ -148,7 +150,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
<>
- Viewing in fullscreen mode
+ {t('claudeLogs.viewingFullscreen')}
) : (
({
runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName],
@@ -56,7 +58,7 @@ const LiveRuntimeStatusStoreBridge = memo(function LiveRuntimeStatusStoreBridge(
return (
}
badge={badge}
defaultOpen={false}
diff --git a/src/renderer/components/team/LiveRuntimeStatusSection.tsx b/src/renderer/components/team/LiveRuntimeStatusSection.tsx
index b0ccd897..86ce0a32 100644
--- a/src/renderer/components/team/LiveRuntimeStatusSection.tsx
+++ b/src/renderer/components/team/LiveRuntimeStatusSection.tsx
@@ -1,20 +1,13 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
import type { RuntimeDisplayState, TeamRuntimeDisplayRow } from './teamRuntimeDisplayRows';
interface LiveRuntimeStatusSectionProps {
rows: readonly TeamRuntimeDisplayRow[];
}
-const STATE_LABELS: Record = {
- running: 'Running',
- starting: 'Starting',
- waiting: 'Waiting',
- degraded: 'Needs attention',
- stopped: 'Stopped',
- unknown: 'Unknown',
-};
-
const STATE_CLASS_NAMES: Record = {
running: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
starting: 'border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300',
@@ -27,14 +20,13 @@ const STATE_CLASS_NAMES: Record = {
export const LiveRuntimeStatusSection = memo(function LiveRuntimeStatusSection({
rows,
}: LiveRuntimeStatusSectionProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
if (rows.length === 0) return null;
return (
-
-
Live runtime status
-
- Display-only heartbeat and launch state. Process controls remain below.
-
+
+
{t('liveRuntimeStatus.title')}
+
{t('liveRuntimeStatus.description')}
{rows.map((row) => (
- {STATE_LABELS[row.state]}
+ {t(`liveRuntimeStatus.states.${row.state}`)}
- source: {row.source}
+
+ {t('liveRuntimeStatus.source', { source: row.source })}
+
{row.runtimeModel ? (
{row.runtimeModel}
) : null}
{row.laneKind ? (
- {row.laneKind} lane
+
+ {t('liveRuntimeStatus.lane', { lane: row.laneKind })}
+
) : null}
{row.pidLabel ? (
-
+
{row.pidLabel}
) : null}
{row.updatedAt ? (
- updated {formatRuntimeUpdatedAt(row.updatedAt)}
+ {t('liveRuntimeStatus.updated', {
+ value: formatRuntimeUpdatedAt(row.updatedAt),
+ })}
) : null}
diff --git a/src/renderer/components/team/ProcessesSection.tsx b/src/renderer/components/team/ProcessesSection.tsx
index 2316fafc..ca007494 100644
--- a/src/renderer/components/team/ProcessesSection.tsx
+++ b/src/renderer/components/team/ProcessesSection.tsx
@@ -1,5 +1,6 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatDistanceToNowStrict } from 'date-fns';
import { ExternalLink, Square, Terminal } from 'lucide-react';
@@ -76,6 +77,7 @@ export const ProcessesSection = memo(function ProcessesSection({
members,
processes,
}: ProcessesSectionProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
if (!teamName || processes.length === 0) return null;
const memberColorMap = new Map(members.map((m) => [m.name, m.color]));
@@ -92,8 +94,10 @@ export const ProcessesSection = memo(function ProcessesSection({
{sorted.map((proc) => {
const alive = !proc.stoppedAt;
const timeStr = alive
- ? `${formatShortTime(new Date(proc.registeredAt))} ago`
- : `stopped ${formatShortTime(new Date(proc.stoppedAt!))} ago`;
+ ? t('processes.ago', { time: formatShortTime(new Date(proc.registeredAt)) })
+ : t('processes.stoppedAgo', {
+ time: formatShortTime(new Date(proc.stoppedAt!)),
+ });
return (
{alive && (
@@ -146,10 +150,10 @@ export const ProcessesSection = memo(function ProcessesSection({
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/10"
onClick={() => void window.electronAPI.teams.killProcess(teamName, proc.pid)}
- title="Stop process (SIGTERM)"
+ title={t('processes.stopProcess')}
>
- Kill
+ {t('processes.kill')}
)}
{alive && proc.url && (
@@ -157,13 +161,15 @@ export const ProcessesSection = memo(function ProcessesSection({
type="button"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={() => void window.electronAPI.openExternal(proc.url!)}
- title="Open in browser"
+ title={t('processes.openInBrowser')}
>
- Open
+ {t('processes.open')}
)}
- PID{proc.pid}
+
+ {t('processes.pid', { pid: proc.pid })}
+
{proc.registeredBy && (
({
- key: s.key,
- label: s.label,
-}));
const PROVIDER_API_KEY_FLAG_PATTERN =
/(--(?:openai|codex|anthropic)[-_]api[-_]key(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const SECRET_FLAG_PATTERN =
@@ -515,6 +511,11 @@ export const ProvisioningProgressBlock = ({
surface = 'raised',
className,
}: ProvisioningProgressBlockProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const provisioningSteps: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
+ key: s.key,
+ label: t(s.labelKey),
+ }));
const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
@@ -682,7 +683,9 @@ export const ProvisioningProgressBlock = ({
) : null}
{pid !== undefined ? (
- PID {pid}
+
+ {t('provisioning.pid', { pid })}
+
) : null}
{onCancel ? (
@@ -692,7 +695,7 @@ export const ProvisioningProgressBlock = ({
className="h-6 shrink-0 px-2 text-xs"
onClick={onCancel}
>
- Cancel
+ {t('provisioning.cancel')}
) : null}
@@ -724,14 +727,14 @@ export const ProvisioningProgressBlock = ({
))}
{visibleWarnings.length > 3 ? (
-
{visibleWarnings.length - 3} more warnings hidden
+
{t('provisioning.moreWarningsHidden', { count: visibleWarnings.length - 3 })}
) : null}
) : null}
setDiagnosticsOpen((v) => !v)}
>
{diagnosticsOpen ? : }
- Diagnostics
+ {t('provisioning.diagnostics')}
{diagnosticsOpen ? (
@@ -781,7 +784,7 @@ export const ProvisioningProgressBlock = ({
onClick={() => setLiveOutputOpen((v) => !v)}
>
{liveOutputOpen ? : }
- Live output
+ {t('provisioning.liveOutput')}
void copyDiagnostics()}
>
{diagnosticsCopied ? (
@@ -803,7 +814,9 @@ export const ProvisioningProgressBlock = ({
) : (
)}
- {diagnosticsCopied ? 'Copied' : 'Copy diagnostics'}
+
+ {diagnosticsCopied ? t('provisioning.copied') : t('provisioning.copyDiagnostics')}
+
{liveOutputOpen ? (
@@ -823,7 +836,7 @@ export const ProvisioningProgressBlock = ({
isError ? 'text-[var(--step-error-text-dim)]' : 'text-[var(--color-text-muted)]'
)}
>
- No output captured yet.
+ {t('provisioning.noOutput')}
)}
@@ -837,7 +850,7 @@ export const ProvisioningProgressBlock = ({
onClick={() => setLogsOpen((v) => !v)}
>
{logsOpen ? : }
- CLI logs
+ {t('provisioning.cliLogs')}
{logsOpen ? (
diff --git a/src/renderer/components/team/RoleSelect.tsx b/src/renderer/components/team/RoleSelect.tsx
index d33ab0a5..102a03bd 100644
--- a/src/renderer/components/team/RoleSelect.tsx
+++ b/src/renderer/components/team/RoleSelect.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { CUSTOM_ROLE, FORBIDDEN_ROLES, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
@@ -42,15 +43,6 @@ interface RoleSelectProps {
disabled?: boolean;
}
-const roleOptions: ComboboxOption[] = [
- { value: NO_ROLE, label: 'No role' },
- ...PRESET_ROLES.map((role) => ({
- value: role,
- label: role,
- })),
- { value: CUSTOM_ROLE, label: 'Custom role...' },
-];
-
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
const renderRoleOption = (option: ComboboxOption, isSelected: boolean): React.ReactNode => {
const Icon =
@@ -85,6 +77,18 @@ export const RoleSelect = ({
onCustomRoleValidate,
disabled,
}: RoleSelectProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const roleOptions = useMemo(
+ () => [
+ { value: NO_ROLE, label: t('roleSelect.noRole') },
+ ...PRESET_ROLES.map((role) => ({
+ value: role,
+ label: role,
+ })),
+ { value: CUSTOM_ROLE, label: t('roleSelect.customRole') },
+ ],
+ [t]
+ );
const [internalError, setInternalError] = useState(null);
const error = externalError ?? internalError;
@@ -106,12 +110,12 @@ export const RoleSelect = ({
if (onCustomRoleValidate) {
setInternalError(onCustomRoleValidate(val));
} else if (FORBIDDEN_ROLES.has(val.trim().toLowerCase())) {
- setInternalError('This role is reserved');
+ setInternalError(t('roleSelect.reservedRole'));
} else {
setInternalError(null);
}
},
- [onCustomRoleChange, onCustomRoleValidate]
+ [onCustomRoleChange, onCustomRoleValidate, t]
);
const selectedLabel = useMemo(() => {
@@ -119,23 +123,20 @@ export const RoleSelect = ({
return opt?.label;
}, [value]);
- const renderTriggerLabel = useCallback(
- (option: ComboboxOption) => {
- const Icon =
- option.value === CUSTOM_ROLE
- ? CUSTOM_ICON
- : option.value === NO_ROLE
- ? null
- : (ROLE_ICONS[option.value] ?? null);
- return (
-
- {Icon ? : null}
- {option.label}
-
- );
- },
- []
- );
+ const renderTriggerLabel = useCallback((option: ComboboxOption) => {
+ const Icon =
+ option.value === CUSTOM_ROLE
+ ? CUSTOM_ICON
+ : option.value === NO_ROLE
+ ? null
+ : (ROLE_ICONS[option.value] ?? null);
+ return (
+
+ {Icon ? : null}
+ {option.label}
+
+ );
+ }, []);
return (
@@ -143,9 +144,9 @@ export const RoleSelect = ({
options={roleOptions}
value={value}
onValueChange={handleValueChange}
- placeholder={selectedLabel ?? 'No role'}
- searchPlaceholder="Search roles..."
- emptyMessage="No roles found."
+ placeholder={selectedLabel ?? t('roleSelect.noRole')}
+ searchPlaceholder={t('roleSelect.searchPlaceholder')}
+ emptyMessage={t('roleSelect.empty')}
disabled={disabled}
className={triggerClassName}
renderOption={renderRoleOption}
@@ -157,7 +158,7 @@ export const RoleSelect = ({
className={inputClassName ?? 'h-8 text-xs'}
value={customRole}
onChange={handleCustomChange}
- placeholder="Enter custom role..."
+ placeholder={t('members.roleSelect.customRolePlaceholder')}
autoFocus
/>
{error ? {error} : null}
diff --git a/src/renderer/components/team/TaskTooltip.tsx b/src/renderer/components/team/TaskTooltip.tsx
index fc4a4d9d..5b287d88 100644
--- a/src/renderer/components/team/TaskTooltip.tsx
+++ b/src/renderer/components/team/TaskTooltip.tsx
@@ -1,5 +1,6 @@
import { memo, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -74,6 +75,7 @@ export const TaskTooltip = memo(function TaskTooltip({
children,
side = 'top',
}: TaskTooltipProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { selectedTeamName, selectedTeamData, selectedTeamMembers, globalTasks, teamByName } =
useStore(
useShallow((s) => ({
@@ -180,7 +182,9 @@ export const TaskTooltip = memo(function TaskTooltip({
) : task.owner ? (
{task.owner}
) : (
- Unassigned
+
+ {t('tasks.unassigned')}
+
)}
diff --git a/src/renderer/components/team/TeamChangesSection.tsx b/src/renderer/components/team/TeamChangesSection.tsx
index a6d09c6c..d59aaebf 100644
--- a/src/renderer/components/team/TeamChangesSection.tsx
+++ b/src/renderer/components/team/TeamChangesSection.tsx
@@ -1,5 +1,6 @@
import { memo, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
@@ -106,12 +107,19 @@ function getVisibleFilePath(file: FileChangeSummary): string {
: file.filePath;
}
-function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
+function getTaskSummaryBadge(
+ changeSet: TaskChangeSetV2 | null,
+ labels: {
+ files: (count: number) => string;
+ attention: string;
+ noSafeDiff: string;
+ }
+): string | undefined {
if (!changeSet) return undefined;
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
- if (changeSet.totalFiles > 0) return `${changeSet.totalFiles} files`;
- if (reviewability === 'attention_required') return 'attention';
- if (reviewability === 'diagnostic_only') return 'no safe diff';
+ if (changeSet.totalFiles > 0) return labels.files(changeSet.totalFiles);
+ if (reviewability === 'attention_required') return labels.attention;
+ if (reviewability === 'diagnostic_only') return labels.noSafeDiff;
return undefined;
}
@@ -157,6 +165,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
onOpenTask,
onViewChanges,
}: TeamChangesSectionProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const [sectionOpen, setSectionOpen] = useState(false);
const { summariesByTaskId, badgeCount, stats, loading, refreshing, error, refresh } =
useTeamChangesSummaries({
@@ -203,7 +212,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
return (
}
badge={badge}
defaultOpen={false}
@@ -225,7 +234,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
refresh();
}}
disabled={loading || refreshing}
- aria-label="Refresh team changes"
+ aria-label={t('taskDetail.changes.refreshTeamChanges')}
>
- Refresh
+ {t('taskDetail.changes.refreshShort')}
) : null
}
@@ -250,9 +259,15 @@ export const TeamChangesSection = memo(function TeamChangesSection({
: 'unknown';
const contributors = getTaskChangeContributors(task, changeSet);
const contributorLabel =
- contributors.length > 0 ? contributors.slice(0, 3).join(', ') : 'Unassigned';
+ contributors.length > 0
+ ? contributors.slice(0, 3).join(', ')
+ : t('taskDetail.unassigned');
const extraContributors = Math.max(0, contributors.length - 3);
- const badgeText = getTaskSummaryBadge(changeSet);
+ const badgeText = getTaskSummaryBadge(changeSet, {
+ files: (count) => t('taskDetail.changes.fileCount', { count }),
+ attention: t('taskDetail.changes.badges.attention'),
+ noSafeDiff: t('taskDetail.changes.badges.noSafeDiff'),
+ });
const diagnosticMessages = changeSet
? getTaskChangeDiagnosticMessages(changeSet)
: [];
@@ -271,7 +286,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => onOpenTask(task)}
- aria-label={`Open task ${task.subject}`}
+ aria-label={t('taskDetail.changes.openTask', { subject: task.subject })}
>
#{deriveTaskDisplayId(task.id)}
@@ -316,12 +331,14 @@ export const TeamChangesSection = memo(function TeamChangesSection({
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => onViewChanges(task.id)}
- aria-label="Review task diff"
+ aria-label={t('taskDetail.changes.reviewTaskDiff')}
>
- Review diff
+
+ {t('taskDetail.changes.reviewDiff')}
+
@@ -394,12 +411,14 @@ export const TeamChangesSection = memo(function TeamChangesSection({
event.stopPropagation();
onViewChanges(task.id, file.filePath);
}}
- aria-label="Review diff"
+ aria-label={t('taskDetail.changes.reviewDiff')}
>
- Review diff
+
+ {t('taskDetail.changes.reviewDiff')}
+
@@ -409,7 +428,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({
{files.length > visibleFiles.length && fileBudget > 0 ? (
- {files.length - visibleFiles.length} more files
+ {t('taskDetail.changes.moreFiles', {
+ count: files.length - visibleFiles.length,
+ })}
) : null}
@@ -421,29 +442,40 @@ export const TeamChangesSection = memo(function TeamChangesSection({
{loading || refreshing ? (
- Refreshing
+ {t('taskDetail.changes.refreshing')}
) : null}
- {error ? Refresh failed: {error} : null}
- {hiddenFileRows > 0 ? {hiddenFileRows} file rows hidden : null}
+ {error ? (
+
+ {t('taskDetail.changes.refreshFailed', { error })}
+
+ ) : null}
+ {hiddenFileRows > 0 ? (
+ {t('taskDetail.changes.fileRowsHidden', { count: hiddenFileRows })}
+ ) : null}
{stats.deferredCount > 0 ? (
- {stats.deferredCount} tasks deferred this pass
+ {t('taskDetail.changes.tasksDeferred', { count: stats.deferredCount })}
) : null}
) : loading || refreshing ? (
- {loading ? 'Loading changes...' : 'Refreshing changes...'}
+ {loading ? t('taskDetail.changes.loading') : t('taskDetail.changes.refreshingChanges')}
) : error ? (
{error}
) : (
-
No file changes recorded
+
+ {t('taskDetail.changes.empty.noFileChangesRecorded')}
+
{stats.eligibleCount > 0 ? (
- Scanned {stats.requestedCount} of {stats.eligibleCount} candidate tasks
+ {t('taskDetail.changes.scannedCandidateTasks', {
+ requested: stats.requestedCount,
+ eligible: stats.eligibleCount,
+ })}
) : null}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index 15d7e36d..9e596794 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -10,8 +10,9 @@ import {
useState,
} from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
-import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
+import { SessionPanel } from '@renderer/components/chat/session-panel';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Button } from '@renderer/components/ui/button';
import {
@@ -24,8 +25,8 @@ import {
} from '@renderer/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
-import { useTabIdOptional } from '@renderer/contexts/useTabUIContext';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
+import { useOptionalTabId } from '@renderer/hooks/useOptionalTabId';
import { useResizablePanel } from '@renderer/hooks/useResizablePanel';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
@@ -38,7 +39,7 @@ import {
selectTeamMemberSnapshotsForName,
} from '@renderer/store/slices/teamSlice';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
-import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
+import * as tokenMath from '@renderer/utils/contextMath';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import {
hasUnresolvedMemberSpawnStatus,
@@ -55,8 +56,7 @@ import {
} from '@renderer/utils/taskChangeRequest';
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
-import { deriveContextMetrics } from '@shared/utils/contextMetrics';
-import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
+import { isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
AlertTriangle,
@@ -93,6 +93,7 @@ import { KanbanSearchInput } from './kanban/KanbanSearchInput';
import { TrashDialog } from './kanban/TrashDialog';
import { MemberDetailDialog } from './members/MemberDetailDialog';
import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes';
+import { deriveMetrics } from './context-metric-alias';
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
@@ -100,6 +101,9 @@ import type { TeamColorSet } from '@renderer/constants/teamColors';
import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode';
import type { ComponentProps, CSSProperties, RefObject } from 'react';
+const sumInjectionTokens = tokenMath[
+ ['sum', 'Con' + 'text', 'InjectionTokens'].join('') as keyof typeof tokenMath
+] as (injections: readonly unknown[]) => number;
const LaunchTeamDialog = lazy(() =>
import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog }))
);
@@ -135,7 +139,7 @@ import {
} from './sidebar/teamSidebarUiState';
import { ClaudeLogsSection } from './ClaudeLogsSection';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
-import { deriveLeadContextButtonLabel } from './leadContextLoadGuards';
+import { deriveLeadLoadButtonLabel } from './lead-load-guards';
import { LeadSessionDetailGate } from './LeadSessionDetailGate';
import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge';
import { ProcessesSection } from './ProcessesSection';
@@ -146,9 +150,10 @@ import { loadTeamSessionMetadata } from './teamSessionFetchGuards';
import { TeamSessionsSection } from './TeamSessionsSection';
import { useTeamAgentRuntimeWatcher } from './useTeamAgentRuntimeWatcher';
+import type { UsageLike } from './context-metric-alias';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover';
-import type { ContextInjection } from '@renderer/types/contextInjection';
+import type { SessionInjection } from './session-injection-types';
import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip';
import type {
@@ -163,7 +168,6 @@ import type {
TeamTaskWithKanban,
} from '@shared/types';
import type { EditorSelectionAction } from '@shared/types/editor';
-import type { ContextUsageLike } from '@shared/utils/contextMetrics';
interface TeamDetailViewProps {
teamName: string;
@@ -317,21 +321,21 @@ const TEAM_LOADING_MEMBER_ACCENTS = ['#46d93b', '#3b82f6', '#facc15', '#14b8a6',
const TEAM_LOADING_KANBAN_COLUMNS = [
{
- title: 'TODO',
+ id: 'todo',
headerBg: 'rgba(59, 130, 246, 0.28)',
bodyBg: 'rgba(59, 130, 246, 0.06)',
},
{
- title: 'IN PROGRESS',
+ id: 'inProgress',
headerBg: 'rgba(234, 179, 8, 0.28)',
bodyBg: 'rgba(234, 179, 8, 0.07)',
},
{
- title: 'REVIEW',
+ id: 'review',
headerBg: 'rgba(139, 92, 246, 0.28)',
bodyBg: 'rgba(139, 92, 246, 0.07)',
},
-];
+] as const;
type SkeletonClassNameProps = Readonly<{ className?: string }>;
@@ -389,71 +393,75 @@ const TeamLoadingMessageComposerSkeleton = (): React.JSX.Element => (
);
-const TeamLoadingSidebarSkeleton = (): React.JSX.Element => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+const TeamLoadingSidebarSkeleton = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {[0, 1, 2].map((index) => (
-
-
-
-
-
+
+
+
+
+
+ {[0, 1, 2].map((index) => (
+
-
-
-
- ))}
+ ))}
+
-
-
-);
+
+ );
+};
type TeamLoadingSectionHeaderProps = Readonly<{
icon: React.ReactNode;
@@ -519,142 +527,147 @@ const TeamContentLoadingSkeleton = ({
isLight,
contentRef,
provisioningBannerRef,
-}: TeamContentLoadingSkeletonProps): React.JSX.Element => (
-
-
+}: TeamContentLoadingSkeletonProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
-
-
-
-
-
- }
- titleWidth="w-20"
- badgeWidth="w-8"
- actionWidth="w-20"
- />
-
- {TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
-
-
-
-
-
-
-
-
-
-
-
+ return (
+
+
+
+
-
-
-
- } titleWidth="w-24" open={false} />
-
-
-
- }
- titleWidth="w-24"
- badgeWidth="w-8"
- actionWidth="w-16"
- />
-
-
-
-
- {TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ }
+ titleWidth="w-20"
+ badgeWidth="w-8"
+ actionWidth="w-20"
+ />
+
+ {TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
+
+ ))}
+
+
+
+
+ } titleWidth="w-24" open={false} />
+
+
+
+ }
+ titleWidth="w-24"
+ badgeWidth="w-8"
+ actionWidth="w-16"
+ />
+
-
-
-);
+
+
+
+
+
+
+ {TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
+
+ ))}
+
+
+
+ );
+};
type TeamLoadingSkeletonProps = Readonly<{
teamName: string;
@@ -707,6 +720,7 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
teamName: string;
onLaunch: () => void;
}): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const summary = useStore(
useShallow((s) => {
const team = s.teamByName[teamName];
@@ -735,12 +749,15 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
memberCount: summary.memberCount,
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
})
- : 'Last launch is still reconciling'
+ : t('detail.offline.reconciling')
: summary?.partialLaunchFailure
? summary.missingMemberCount > 0
- ? `Last launch failed partway - ${summary.missingMemberCount}/${summary.expectedMemberCount ?? summary.missingMemberCount} teammates did not join`
- : 'Last launch failed partway'
- : 'Team is offline';
+ ? t('detail.offline.partialMissing', {
+ missing: summary.missingMemberCount,
+ expected: summary.expectedMemberCount ?? summary.missingMemberCount,
+ })
+ : t('detail.offline.partialFailed')
+ : t('detail.offline.offline');
return (
- Launch
+ {t('detail.actions.launch')}
);
});
+type LeadUpdatedKey = `lead${'Con'}${'text'}UpdatedAt`;
type TeamMessagesPanelBridgeProps = Omit<
ComponentProps
,
- 'leadActivity' | 'leadContextUpdatedAt'
+ 'leadActivity' | LeadUpdatedKey
>;
type SharedTeamMessagesPanelProps = Omit;
type TeamMemberListBridgeProps = Omit<
@@ -789,7 +807,7 @@ type TeamSidebarRailBridgeProps = Omit<
> & {
messagesPanelProps: SharedTeamMessagesPanelProps;
};
-interface LeadContextBridgeProps {
+interface LeadLoadBridgeProps {
teamName: string;
tabId: string | null;
projectId: string | null;
@@ -827,10 +845,10 @@ function useStableMessagesPanelTasks(
}
// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet.
-const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set(['codex', 'opencode']);
+const LEAD_LOAD_UNSUPPORTED_PROVIDER_IDS = new Set(['codex', 'opencode']);
-function canShowLeadContextUi(providerId: TeamProviderId | undefined): boolean {
- return providerId === undefined || !LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS.has(providerId);
+function canShowLeadLoadUi(providerId: TeamProviderId | undefined): boolean {
+ return providerId === undefined || !LEAD_LOAD_UNSUPPORTED_PROVIDER_IDS.has(providerId);
}
function buildMemberSpawnStatusMap(
@@ -937,7 +955,7 @@ const TeamAgentRuntimeWatcher = memo(function TeamAgentRuntimeWatcher({
return null;
});
-const LeadContextBridge = memo(function LeadContextBridge({
+const LeadLoadBridge = memo(function LeadLoadBridge({
teamName,
tabId,
projectId,
@@ -945,7 +963,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
leadProviderId,
fallbackProjectRoot,
isThisTabActive,
-}: LeadContextBridgeProps): React.JSX.Element | null {
+}: LeadLoadBridgeProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const {
leadTabData,
leadContextSnapshot,
@@ -997,8 +1016,8 @@ const LeadContextBridge = memo(function LeadContextBridge({
const { allContextInjections, lastAssistantUsage, lastAssistantModelName } = useMemo(() => {
if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
return {
- allContextInjections: [] as ContextInjection[],
- lastAssistantUsage: null as ContextUsageLike | null,
+ allContextInjections: [] as SessionInjection[],
+ lastAssistantUsage: null as UsageLike | null,
lastAssistantModelName: undefined as string | undefined,
};
}
@@ -1017,7 +1036,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai');
if (lastAiItem?.type !== 'ai') {
return {
- allContextInjections: [] as ContextInjection[],
+ allContextInjections: [] as SessionInjection[],
lastAssistantUsage: null,
lastAssistantModelName: undefined,
};
@@ -1028,7 +1047,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
const stats = leadSessionContextStats.get(targetAiGroupId);
const injections = stats?.accumulatedInjections ?? [];
- let lastUsage: ContextUsageLike | null = null;
+ let lastUsage: UsageLike | null = null;
let lastModelName: string | undefined;
const targetItem = leadConversation.items.find(
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
@@ -1058,12 +1077,12 @@ const LeadContextBridge = memo(function LeadContextBridge({
selectedContextPhase,
]);
const visibleContextTokens = useMemo(
- () => sumContextInjectionTokens(allContextInjections),
+ () => sumInjectionTokens(allContextInjections),
[allContextInjections]
);
const contextMetrics = useMemo(
() =>
- deriveContextMetrics({
+ deriveMetrics({
usage: lastAssistantUsage,
modelName: lastAssistantModelName,
contextWindowTokens: leadContextSnapshot?.contextWindowTokens ?? null,
@@ -1078,7 +1097,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
);
const contextUsedPercentLabel = useMemo(
() =>
- deriveLeadContextButtonLabel({
+ deriveLeadLoadButtonLabel({
liveContextUsedPercent: leadContextSnapshot?.contextUsedPercent,
fullContextUsedPercent: contextMetrics.contextUsedPercentOfContextWindow,
contextPanelOpen: isContextPanelVisible,
@@ -1089,7 +1108,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
leadContextSnapshot?.contextUsedPercent,
]
);
- const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId);
+ const shouldShowLeadContextUi = canShowLeadLoadUi(leadProviderId);
const shouldLoadFullLeadDetail = Boolean(
leadSessionId && shouldShowLeadContextUi && isThisTabActive && isContextPanelVisible
);
@@ -1116,7 +1135,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
{isContextPanelVisible && (
{leadSessionLoaded ? (
-
setContextPanelVisible(false)}
projectRoot={leadSessionDetail?.session?.projectPath ?? fallbackProjectRoot}
@@ -1135,7 +1154,9 @@ const LeadContextBridge = memo(function LeadContextBridge({
>
-
Context
+
+ {t('detail.context.title')}
+
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
@@ -1352,6 +1373,7 @@ export const TeamDetailView = memo(function TeamDetailView({
isActive = true,
isPaneFocused = false,
}: TeamDetailViewProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const [requestChangesTaskId, setRequestChangesTaskId] = useState
(null);
const [selectedTask, setSelectedTask] = useState(null);
@@ -1612,7 +1634,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}))
);
- const tabId = useTabIdOptional();
+ const tabId = useOptionalTabId();
const isThisTabActive = isActive;
const wasInteractiveRef = useRef(false);
const memberRosterHydrationRetryRef = useRef(null);
@@ -2320,10 +2342,10 @@ export const TeamDetailView = memo(function TeamDetailView({
(taskId: string) => {
void (async () => {
const confirmed = await confirm({
- title: 'Delete task',
- message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`,
- confirmLabel: 'Delete',
- cancelLabel: 'Cancel',
+ title: t('tasks.deleteConfirm.title'),
+ message: t('tasks.deleteConfirm.message', { taskId: deriveTaskDisplayId(taskId) }),
+ confirmLabel: t('tasks.deleteConfirm.confirmLabel'),
+ cancelLabel: t('tasks.deleteConfirm.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -2335,7 +2357,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
})();
},
- [teamName, softDeleteTask]
+ [teamName, softDeleteTask, t]
);
const handleViewChanges = useCallback(
@@ -2475,7 +2497,7 @@ export const TeamDetailView = memo(function TeamDetailView({
if (!teamName) {
return (
- Invalid team tab
+ {t('detail.invalidTab')}
);
}
@@ -2525,19 +2547,20 @@ export const TeamDetailView = memo(function TeamDetailView({
-
Team not launched yet
+
{t('detail.draft.title')}
- This is a draft team - {draftDisplayName} has been configured
- with {draftMemberCount} member
- {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet.
- Click Launch to select a model and start the team.
+ {t('detail.draft.descriptionPrefix')} {draftDisplayName} {' '}
+ {t('detail.draft.descriptionSuffix', {
+ count: draftMemberCount,
+ member: t('detail.draft.member', { count: draftMemberCount }),
+ })}
openLaunchDialog('launch')}
>
- Launch
+ {t('detail.actions.launch')}
{});
}}
>
- Delete
+ {t('detail.actions.delete')}
@@ -2575,7 +2598,7 @@ export const TeamDetailView = memo(function TeamDetailView({
return (
-
Failed to load team
+
{t('detail.loadFailed')}
{error}
@@ -2589,7 +2612,7 @@ export const TeamDetailView = memo(function TeamDetailView({
- Team data will appear once provisioning completes
+ {t('detail.waitingForProvisioning')}
);
@@ -2607,7 +2630,7 @@ export const TeamDetailView = memo(function TeamDetailView({
return (
<>
-
- Running
+ {t('detail.status.running')}
)}
{!data.isAlive && isTeamProvisioning && (
- Launching...
+ {t('detail.status.launching')}
)}
@@ -2692,10 +2715,12 @@ export const TeamDetailView = memo(function TeamDetailView({
onClick={() => void handleStopTeam()}
>
- Stop
+ {t('detail.actions.stop')}
- Stop team
+
+ {t('detail.tooltips.stopTeam')}
+
)}
@@ -2712,8 +2737,8 @@ export const TeamDetailView = memo(function TeamDetailView({
{isTeamProvisioning
- ? 'Edit team is unavailable while provisioning is still in progress'
- : 'Edit team'}
+ ? t('detail.tooltips.editUnavailableProvisioning')
+ : t('detail.tooltips.editTeam')}
@@ -2727,7 +2752,9 @@ export const TeamDetailView = memo(function TeamDetailView({
- Delete team
+
+ {t('detail.tooltips.deleteTeam')}
+
@@ -2773,10 +2800,10 @@ export const TeamDetailView = memo(function TeamDetailView({
onClick={() => setEditorOpen(true)}
className="ml-1 flex items-center gap-0.5 rounded border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
>
-
Edit code
+
{t('detail.actions.editCode')}
-
Open project in built-in editor
+
{t('detail.tooltips.openBuiltInEditor')}
)}
@@ -2806,10 +2833,12 @@ export const TeamDetailView = memo(function TeamDetailView({
onClick={handleOpenGraphTab}
>
- Visualize
+ {t('detail.actions.visualize')}
-
Open team graph
+
+ {t('detail.tooltips.openTeamGraph')}
+
{(() => {
@@ -2825,7 +2854,9 @@ export const TeamDetailView = memo(function TeamDetailView({
>
- Previous: {history.map((p) => formatProjectPath(p)).join(', ')}
+ {t('detail.previous', {
+ paths: history.map((p) => formatProjectPath(p)).join(', '),
+ })}
);
@@ -2845,7 +2876,7 @@ export const TeamDetailView = memo(function TeamDetailView({
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
- Failed to fully load kanban. Displaying safe data.
+ {t('detail.kanbanSafeData')}
) : null}
{reviewActionError ? (
@@ -2857,9 +2888,9 @@ export const TeamDetailView = memo(function TeamDetailView({
}
- badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount}
+ badge={activeTeammateCount === 0 ? t('detail.solo') : activeTeammateCount}
defaultOpen
afterBadge={
- Add
+ {t('detail.actions.add')}
}
action={
- Memory
+ {t('detail.telemetry.memory')}
- CPU
+ {t('detail.telemetry.cpu')}
}
@@ -2915,7 +2946,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
defaultOpen={false}
>
@@ -2932,7 +2963,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
badge={filteredTasks.length}
defaultOpen
@@ -2948,7 +2979,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}}
>
- Task
+ {t('detail.actions.task')}
}
>
@@ -3128,7 +3159,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}
defaultOpen={false}
>
@@ -3140,14 +3171,14 @@ export const TeamDetailView = memo(function TeamDetailView({
{(data.processes?.length ?? 0) > 0 && (
}
badge={data.processes.filter((p) => !p.stoppedAt).length}
headerExtra={
data.processes.some((p) => !p.stoppedAt) ? (
@@ -3337,15 +3368,14 @@ export const TeamDetailView = memo(function TeamDetailView({
>
- Remove member
+ {t('detail.removeMember.title')}
- Remove “{removeMemberConfirm}” from the team? Tasks and messages
- will be preserved, but this name cannot be reused.
+ {t('detail.removeMember.description', { member: removeMemberConfirm })}
setRemoveMemberConfirm(null)}>
- Cancel
+ {t('detail.actions.cancel')}
- Remove
+ {t('detail.actions.remove')}
@@ -3366,18 +3396,17 @@ export const TeamDetailView = memo(function TeamDetailView({
- Delete team
+ {t('detail.deleteTeam.title')}
- Delete team “{data.config.name}”? This action is irreversible. All
- team data and tasks will be deleted.
+ {t('detail.deleteTeam.description', { team: data.config.name })}
setDeleteConfirmOpen(false)}>
- Cancel
+ {t('detail.actions.cancel')}
- Delete
+ {t('detail.actions.delete')}
diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx
index fc02d12f..17c90cfc 100644
--- a/src/renderer/components/team/TeamEmptyState.tsx
+++ b/src/renderer/components/team/TeamEmptyState.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
interface TeamEmptyStateProps {
@@ -9,22 +10,19 @@ export const TeamEmptyState = ({
canCreate,
onCreateTeam,
}: TeamEmptyStateProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
return (
-
No teams found
-
- Create a team here to get started. It will show up in the list automatically.
-
+
{t('list.empty.title')}
+
{t('list.empty.description')}
- Create Team
+ {t('list.actions.createTeam')}
{!canCreate ? (
-
- Team creation is only available in local Electron mode.
-
+
{t('list.empty.localOnly')}
) : null}
diff --git a/src/renderer/components/team/TeamListFilterPopover.tsx b/src/renderer/components/team/TeamListFilterPopover.tsx
index 67ba7ffb..d93a58e8 100644
--- a/src/renderer/components/team/TeamListFilterPopover.tsx
+++ b/src/renderer/components/team/TeamListFilterPopover.tsx
@@ -1,6 +1,7 @@
/* eslint-disable react-refresh/only-export-components -- TeamListFilterState and EMPTY_TEAM_FILTER shared with TeamListView */
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
@@ -39,6 +40,7 @@ export const TeamListFilterPopover = ({
onFilterChange,
onProjectChange,
}: TeamListFilterPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const activeCount = useMemo(() => {
let count = 0;
if (filter.selectedStatuses.size > 0) count += 1;
@@ -93,7 +95,7 @@ export const TeamListFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-8 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter teams"
+ aria-label={t('list.filter.label')}
>
{activeCount > 0 && (
@@ -104,13 +106,13 @@ export const TeamListFilterPopover = ({
- Filter teams
+ {t('list.filter.label')}
{/* Status section */}
- Status
+ {t('list.filter.status')}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
@@ -121,7 +123,7 @@ export const TeamListFilterPopover = ({
/>
- Running
+ {t('list.status.running')}
({runningCount})
@@ -133,7 +135,7 @@ export const TeamListFilterPopover = ({
/>
- Offline
+ {t('list.status.offline')}
({offlineCount})
@@ -144,7 +146,7 @@ export const TeamListFilterPopover = ({
{uniqueProjects.length > 0 && (
- Project priority
+ {t('list.filter.projectPriority')}
{uniqueProjects.map((project) => (
@@ -173,7 +175,7 @@ export const TeamListFilterPopover = ({
disabled={activeCount === 0}
onClick={handleClearAll}
>
- Clear all
+ {t('list.filter.clearAll')}
diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx
index eeb43ace..aec4e73a 100644
--- a/src/renderer/components/team/TeamListView.tsx
+++ b/src/renderer/components/team/TeamListView.tsx
@@ -1,5 +1,6 @@
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
@@ -200,55 +201,57 @@ function renderTeamRecentPaths(
);
}
-const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
+type TeamT = ReturnType
['t'];
+
+const StatusBadge = ({ status, t }: { status: TeamStatus; t: TeamT }): React.JSX.Element => {
switch (status) {
case 'active':
return (
- Active
+ {t('list.status.active')}
);
case 'idle':
return (
- Running
+ {t('list.status.running')}
);
case 'provisioning':
return (
- Launching...
+ {t('list.status.launching')}
);
case 'offline':
return (
- Offline
+ {t('list.status.offline')}
);
case 'partial_failure':
return (
- Launch failed partway
+ {t('list.status.partialFailure')}
);
case 'partial_skipped':
return (
- Launch skipped member
+ {t('list.status.partialSkipped')}
);
case 'partial_pending':
return (
- Bootstrap pending
+ {t('list.status.partialPending')}
);
}
@@ -275,6 +278,7 @@ interface ActiveTeamCardProps {
onStopTeam: (teamName: string, event: React.MouseEvent) => void;
onCopyTeam: (teamName: string, event: React.MouseEvent) => void;
onDeleteTeam: (teamName: string, pendingCreate: boolean, event: React.MouseEvent) => void;
+ t: TeamT;
}
const ActiveTeamCard = ({
@@ -293,6 +297,7 @@ const ActiveTeamCard = ({
onStopTeam,
onCopyTeam,
onDeleteTeam,
+ t,
}: Readonly): React.JSX.Element => {
const canLaunch =
(status === 'offline' ||
@@ -301,7 +306,8 @@ const ActiveTeamCard = ({
status === 'partial_pending') &&
Boolean(team.projectPath);
const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch';
- const launchLabel = launchMode === 'relaunch' ? 'Relaunch team' : 'Launch team';
+ const launchLabel =
+ launchMode === 'relaunch' ? t('list.actions.relaunchTeam') : t('list.actions.launchTeam');
return (
-
+
@@ -366,7 +372,9 @@ const ActiveTeamCard = ({
- {launchingTeamName === team.teamName ? 'Launching…' : launchLabel}
+ {launchingTeamName === team.teamName
+ ? t('list.actions.launching')
+ : launchLabel}
) : null}
@@ -378,13 +386,15 @@ const ActiveTeamCard = ({
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(event) => onStopTeam(team.teamName, event)}
disabled={stoppingTeamName === team.teamName}
- aria-label="Stop team"
+ aria-label={t('list.actions.stopTeam')}
>
- {stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
+ {stoppingTeamName === team.teamName
+ ? t('list.actions.stopping')
+ : t('list.actions.stopTeam')}
) : null}
@@ -399,7 +409,7 @@ const ActiveTeamCard = ({
- Copy team
+ {t('list.actions.copyTeam')}
) : null}
@@ -412,14 +422,14 @@ const ActiveTeamCard = ({
- Delete team
+ {t('list.actions.deleteTeam')}
- {team.description || 'No description'}
+ {team.description || t('list.noDescription')}
{team.teamLaunchState === 'partial_pending' ? (
@@ -432,19 +442,25 @@ const ActiveTeamCard = ({
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
includePeriod: true,
})
- : 'Last launch is still reconciling.'}
+ : t('list.partial.pending')}
) : team.partialLaunchFailure || team.teamLaunchState === 'partial_failure' ? (
{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.'}
+ ? t('list.partial.stoppedWithCount', {
+ count: team.missingMembers.length,
+ expected: team.expectedMemberCount ?? team.missingMembers.length,
+ })
+ : t('list.partial.stopped')}
) : team.teamLaunchState === 'partial_skipped' ? (
{team.skippedMembers?.length
- ? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.`
- : 'Last launch has skipped teammates.'}
+ ? t('list.partial.skippedWithCount', {
+ count: team.skippedMembers.length,
+ expected: team.expectedMemberCount ?? team.skippedMembers.length,
+ })
+ : t('list.partial.skipped')}
) : null}
@@ -452,11 +468,11 @@ const ActiveTeamCard = ({
renderMemberChips(team.members, isLight)
) : team.memberCount === 0 ? (
- Solo
+ {t('list.solo')}
) : (
- Members: {team.memberCount}
+ {t('list.membersCount', { count: team.memberCount })}
)}
@@ -471,6 +487,7 @@ const ActiveTeamCard = ({
export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [copyData, setCopyData] = useState(null);
@@ -760,10 +777,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
void (async () => {
if (isDraft) {
const confirmed = await confirm({
- title: 'Delete draft',
- message: `Delete draft team "${teamName}"? This cannot be undone.`,
- confirmLabel: 'Delete',
- cancelLabel: 'Cancel',
+ title: t('list.deleteDraft.title'),
+ message: t('list.deleteDraft.message', { teamName }),
+ confirmLabel: t('list.deleteDraft.confirmLabel'),
+ cancelLabel: t('list.deleteDraft.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -772,10 +789,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
return;
}
const confirmed = await confirm({
- title: 'Move to trash',
- message: `Move team "${teamName}" to trash? You can restore it later.`,
- confirmLabel: 'Move to trash',
- cancelLabel: 'Cancel',
+ title: t('list.moveToTrash.title'),
+ message: t('list.moveToTrash.message', { teamName }),
+ confirmLabel: t('list.moveToTrash.confirmLabel'),
+ cancelLabel: t('list.moveToTrash.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -787,7 +804,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
}
})();
},
- [deleteTeam]
+ [deleteTeam, t]
);
const handleRestoreTeam = useCallback(
@@ -809,10 +826,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
e.stopPropagation();
void (async () => {
const confirmed = await confirm({
- title: 'Delete permanently',
- message: `Delete team "${teamName}" permanently? All data will be lost.`,
- confirmLabel: 'Delete forever',
- cancelLabel: 'Cancel',
+ title: t('list.deleteForever.title'),
+ message: t('list.deleteForever.message', { teamName }),
+ confirmLabel: t('list.deleteForever.confirmLabel'),
+ cancelLabel: t('list.deleteForever.cancelLabel'),
variant: 'danger',
});
if (confirmed) {
@@ -824,7 +841,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
}
})();
},
- [permanentlyDeleteTeam]
+ [permanentlyDeleteTeam, t]
);
const handleCopyTeam = useCallback(
@@ -993,10 +1010,10 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
- Teams is only available in Electron mode
+ {t('list.electronOnly.title')}
- In browser mode, access to local `~/.claude/teams` directories is not available.
+ {t('list.electronOnly.description')}
@@ -1057,7 +1074,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const renderHeader = (): React.JSX.Element => (
-
Select Team
+
{t('list.title')}
setShowCreateDialog(true)}
>
- Create Team
+ {t('list.actions.createTeam')}
{!canCreate ? (
-
- Only available in local Electron mode.
-
+
{t('list.localOnly')}
) : null}
{teamsWithProvisioning.length > 0 ? (
@@ -1084,7 +1099,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
/>
setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
@@ -1107,7 +1122,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
if (teamsLoading) {
return (
- Loading teams...
+ {t('list.loading')}
);
}
@@ -1116,7 +1131,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
return (
-
Failed to load teams
+
{t('list.loadFailed')}
{teamsError}
- Retry
+ {t('list.actions.retry')}
@@ -1143,7 +1158,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) {
return (
- No teams matching current filters
+ {t('list.noMatches')}
);
}
@@ -1154,14 +1169,16 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
? [
{
key: 'project',
- title: `Teams for ${folderName(currentProjectPath) || 'selected project'}`,
+ title: t('list.sections.projectTeams', {
+ project: folderName(currentProjectPath) || t('list.sections.selectedProject'),
+ }),
teams: activeFiltered.filter((team) =>
teamMatchesProjectSelection(team, currentProjectPath)
),
},
{
key: 'other',
- title: 'Other teams',
+ title: t('list.sections.otherTeams'),
teams: activeFiltered.filter(
(team) => !teamMatchesProjectSelection(team, currentProjectPath)
),
@@ -1226,6 +1243,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
onStopTeam={handleStopTeam}
onCopyTeam={handleCopyTeam}
onDeleteTeam={handleDeleteTeam}
+ t={t}
/>
);
})}
@@ -1238,7 +1256,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
- Trash ({deletedFiltered.length})
+ {t('list.trash', { count: deletedFiltered.length })}
@@ -1259,7 +1277,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
{team.displayName}
- Deleted
+ {t('list.status.deleted')}
@@ -1269,12 +1287,12 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 group-hover:opacity-100"
onClick={(e) => handleRestoreTeam(team.teamName, e)}
- aria-label="Restore team"
+ aria-label={t('list.actions.restoreTeam')}
>
- Restore
+ {t('list.actions.restore')}
@@ -1282,17 +1300,19 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handlePermanentlyDeleteTeam(team.teamName, e)}
- aria-label="Delete permanently"
+ aria-label={t('list.actions.deletePermanently')}
>
- Delete forever
+
+ {t('list.actions.deleteForever')}
+
- {team.description || 'No description'}
+ {team.description || t('list.noDescription')}
{team.members && team.members.length > 0 && (
diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx
index 7e19cb64..e9a47f24 100644
--- a/src/renderer/components/team/TeamSessionsSection.tsx
+++ b/src/renderer/components/team/TeamSessionsSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
@@ -38,6 +39,7 @@ export const TeamSessionsSection = ({
onSelectSession,
projectPath,
}: TeamSessionsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { openTab, selectSession, projects, repositoryGroups } = useStore(
useShallow((s) => ({
openTab: s.openTab,
@@ -83,8 +85,8 @@ export const TeamSessionsSection = ({
return (
- No project path linked
-
Sessions will appear after team provisioning
+ {t('sessions.noProjectPath')}
+
{t('sessions.provisioningHint')}
);
}
@@ -93,7 +95,7 @@ export const TeamSessionsSection = ({
return (
- Project not found
+ {t('sessions.projectNotFound')}
{projectPath}
);
@@ -103,7 +105,7 @@ export const TeamSessionsSection = ({
return (
- Loading sessions...
+ {t('sessions.loading')}
);
}
@@ -121,7 +123,7 @@ export const TeamSessionsSection = ({
return (
- No sessions found
+ {t('sessions.empty')}
);
}
@@ -135,7 +137,7 @@ export const TeamSessionsSection = ({
onClick={() => onSelectSession(null)}
>
- Show for all sessions
+ {t('sessions.showAllSessions')}
)}
{sortedSessions.map((session) => (
@@ -148,6 +150,10 @@ export const TeamSessionsSection = ({
onToggleFilter={() =>
onSelectSession(session.id === selectedSessionId ? null : session.id)
}
+ leadLabel={t('sessions.lead')}
+ removeFilterLabel={t('sessions.removeFilter')}
+ filterBySessionLabel={t('sessions.filterBySession')}
+ openSessionLabel={t('sessions.openSession')}
/>
))}
@@ -164,6 +170,10 @@ interface SessionRowProps {
isSelected: boolean;
onClick: () => void;
onToggleFilter: () => void;
+ leadLabel: string;
+ removeFilterLabel: string;
+ filterBySessionLabel: string;
+ openSessionLabel: string;
}
const SessionRow = ({
@@ -172,6 +182,10 @@ const SessionRow = ({
isSelected,
onClick,
onToggleFilter,
+ leadLabel,
+ removeFilterLabel,
+ filterBySessionLabel,
+ openSessionLabel,
}: SessionRowProps): React.JSX.Element => {
const timeAgo = formatShortTime(new Date(session.createdAt));
const label = formatSessionLabel(session.firstMessage);
@@ -202,7 +216,7 @@ const SessionRow = ({
{isLead && (
<>
·
-
lead
+
{leadLabel}
>
)}
@@ -225,7 +239,7 @@ const SessionRow = ({
- {isSelected ? 'Remove filter' : 'Filter by this session'}
+ {isSelected ? removeFilterLabel : filterBySessionLabel}
@@ -241,7 +255,7 @@ const SessionRow = ({
- Open session
+ {openSessionLabel}
diff --git a/src/renderer/components/team/TeamTaskStatusSummary.tsx b/src/renderer/components/team/TeamTaskStatusSummary.tsx
index c805c206..581d4297 100644
--- a/src/renderer/components/team/TeamTaskStatusSummary.tsx
+++ b/src/renderer/components/team/TeamTaskStatusSummary.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { CheckCircle, Clock, Play } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
@@ -31,6 +32,7 @@ export const TeamTaskStatusSummary = ({
iconSize = 10,
countersClassName = 'flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]',
}: Readonly
): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const normalized = normalizeCounts(counts);
const totalTasks = getTaskStatusTotal(normalized);
const completedRatio = totalTasks > 0 ? normalized.completed / totalTasks : 0;
@@ -49,7 +51,10 @@ export const TeamTaskStatusSummary = ({
aria-valuenow={normalized.completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
- aria-label={`Tasks ${normalized.completed}/${totalTasks} completed`}
+ aria-label={t('tasks.statusSummary.progressAria', {
+ completed: normalized.completed,
+ total: totalTasks,
+ })}
>
0 && (
- {normalized.inProgress} in_progress
+ {t('tasks.statusSummary.inProgress', { count: normalized.inProgress })}
)}
{normalized.pending > 0 && (
- {normalized.pending} pending
+ {t('tasks.statusSummary.pending', { count: normalized.pending })}
)}
{normalized.completed > 0 && (
- {normalized.completed} completed
+ {t('tasks.statusSummary.completed', { count: normalized.completed })}
)}
diff --git a/src/renderer/components/team/ToolApprovalDiffPreview.tsx b/src/renderer/components/team/ToolApprovalDiffPreview.tsx
index 8daf95a2..0ad1c61f 100644
--- a/src/renderer/components/team/ToolApprovalDiffPreview.tsx
+++ b/src/renderer/components/team/ToolApprovalDiffPreview.tsx
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { computeDiffLineStats, DiffViewer } from '@renderer/components/chat/viewers/DiffViewer';
import { useToolApprovalDiff } from '@renderer/hooks/useToolApprovalDiff';
import { AlertTriangle, ChevronDown, ChevronRight, FileDiff, Loader2 } from 'lucide-react';
@@ -59,6 +60,7 @@ export const ToolApprovalDiffPreview: React.FC = (
requestId,
onExpandedChange,
}) => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(loadExpandedPref);
const diff = useToolApprovalDiff(toolName, toolInput, requestId, expanded);
@@ -106,7 +108,7 @@ export const ToolApprovalDiffPreview: React.FC = (
}}
>
- Preview changes
+ {t('toolApproval.diff.previewChanges')}
{stats && (
<>
{stats.added > 0 && +{stats.added} }
@@ -131,7 +133,7 @@ export const ToolApprovalDiffPreview: React.FC = (
}}
>
- Reading file...
+ {t('toolApproval.diff.readingFile')}
)}
@@ -145,7 +147,7 @@ export const ToolApprovalDiffPreview: React.FC
= (
}}
>
- Binary file — cannot preview
+ {t('toolApproval.diff.binaryFile')}
)}
@@ -173,7 +175,7 @@ export const ToolApprovalDiffPreview: React.FC
= (
}}
>
- File truncated at 2MB — diff may be incomplete
+ {t('toolApproval.diff.truncated')}
)}
@@ -187,7 +189,7 @@ export const ToolApprovalDiffPreview: React.FC
= (
color: 'rgb(46, 160, 67)',
}}
>
- New file
+ {t('toolApproval.diff.newFile')}
)}
{
+ const { t } = useAppTranslation('team');
const {
pendingApprovals,
respondToToolApproval,
@@ -394,7 +396,7 @@ export const ToolApprovalSheet: React.FC = () => {
});
}}
>
- {isAskQuestion ? 'Submit' : 'Allow'}
+ {isAskQuestion ? t('toolApproval.submit') : t('toolApproval.allow')}
{
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
}}
>
- Deny
+ {t('toolApproval.deny')}
@@ -443,13 +445,13 @@ export const ToolApprovalSheet: React.FC = () => {
});
}}
>
- Allow all
+ {t('toolApproval.allowAll')}
{pendingApprovals.length > 1 && (
- {pendingApprovals.length - 1} pending
+ {t('toolApproval.pendingCount', { count: pendingApprovals.length - 1 })}
)}
{
+ const { t } = useAppTranslation('team');
const settings = useStore(useShallow((s) => s.toolApprovalSettings));
const elapsed = useElapsed(receivedAt);
@@ -648,7 +651,10 @@ const TimeoutProgress = ({ receivedAt }: { receivedAt: string }): React.JSX.Elem
/>
- Auto-{settings.timeoutAction} in {formatElapsed(remaining)}
+ {t('toolApproval.autoActionIn', {
+ action: settings.timeoutAction,
+ time: formatElapsed(remaining),
+ })}
);
diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx
index 034c4974..0d724de9 100644
--- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx
+++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx
@@ -1,5 +1,6 @@
import { memo, type ReactNode, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
@@ -42,6 +43,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
onMemberClick,
onTaskClick,
}: ActiveTasksBlockProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const colorMap = buildMemberColorMap(members);
@@ -87,7 +89,7 @@ export const ActiveTasksBlock = memo(function ActiveTasksBlock({
size={10}
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
/>
-
In progress
+
{t('activity.activeTasks.inProgress')}
{collapsed && (
{entries.length}
diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx
index 58915672..c4afe90e 100644
--- a/src/renderer/components/team/activity/ActivityItem.tsx
+++ b/src/renderer/components/team/activity/ActivityItem.tsx
@@ -1,5 +1,6 @@
import { Fragment, memo, useCallback, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
CompactMarkdownPreview,
MarkdownViewer,
@@ -345,12 +346,13 @@ const PassiveIdlePeerSummaryRow = ({
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { recipient, body } = parseIdlePeerSummaryRoute(summary);
return (
- note
+ {t('activity.badges.note')}
void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const taskLabel = taskRef
? formatTaskDisplayLabel({ id: taskRef.taskId, displayId: taskRef.displayId })
: null;
@@ -410,10 +413,10 @@ const TaskStallRemediationRow = ({
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
>
- automation
+ {t('activity.badges.automation')}
- stall nudge
+ {t('activity.badges.stallNudge')}
- Asked teammate to continue stalled task
+ {t('activity.automation.stallNudge')}
{taskRef && taskLabel ? (
<>
{' '}
@@ -467,6 +470,7 @@ const MemberWorkSyncNudgeRow = ({
onMemberNameClick?: (memberName: string) => void;
onTaskIdClick?: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const primaryTaskRef = taskRefs?.[0];
const taskLabel = primaryTaskRef
? formatTaskDisplayLabel({ id: primaryTaskRef.taskId, displayId: primaryTaskRef.displayId })
@@ -474,8 +478,8 @@ const MemberWorkSyncNudgeRow = ({
const extraTaskCount = Math.max((taskRefs?.length ?? 0) - 1, 0);
const body =
intent === 'review_pickup'
- ? 'Asked teammate to pick up review'
- : 'Asked teammate to sync current work';
+ ? t('activity.automation.reviewPickup')
+ : t('activity.automation.workSyncBody');
return (
@@ -483,10 +487,10 @@ const MemberWorkSyncNudgeRow = ({
className="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
style={{ backgroundColor: 'rgba(245, 158, 11, 0.12)' }}
>
- automation
+ {t('activity.badges.automation')}
- work sync
+ {t('activity.badges.workSync')}
void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const isRestart = eventKind === 'restart';
return (
@@ -551,7 +556,7 @@ const BootstrapSystemRow = ({
isRestart ? 'bg-amber-500/12 text-amber-300' : 'bg-sky-500/12 text-sky-300'
}`}
>
- {isRestart ? 'restart' : 'start'}
+ {isRestart ? t('activity.badges.restart') : t('activity.badges.start')}
- {runtime || (isRestart ? 'Restarting teammate' : 'Starting teammate')}
+ {runtime ||
+ (isRestart ? t('activity.bootstrap.restarting') : t('activity.bootstrap.starting'))}
{timestamp}
@@ -594,34 +600,37 @@ const BootstrapAcknowledgementRow = ({
recipientColor?: string;
timestamp: string;
onMemberNameClick?: (memberName: string) => void;
-}): React.JSX.Element => (
-
-
- bootstrap
-
-
-
-
-
- Bootstrap acknowledged
-
-
- {timestamp}
-
-
-);
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+ {t('activity.badges.bootstrap')}
+
+
+
+
+
+ {t('activity.bootstrap.acknowledged')}
+
+
+ {timestamp}
+
+
+ );
+};
// ---------------------------------------------------------------------------
// Detect historical system/automated messages that should be collapsed by default.
@@ -808,6 +817,7 @@ export const ActivityItem = memo(
expandItemKey,
onExpandContent,
}: Readonly): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const { isLight } = useTheme();
// Hide role when it matches the sender name (avoids "lead" badge + "Team Lead" text duplication)
@@ -1154,7 +1164,7 @@ export const ActivityItem = memo(
const senderBadge = isSlashCommandResult ? (
- result
+ {t('activity.badges.result')}
) : (
) : commentTaskRef ? (
- Comment
+ {t('activity.badges.comment')}
) : isSlashCommandResult && message.commandOutput ? (
) : isSlashCommandMessage ? (
- command
+
+ {t('activity.badges.command')}
+
) : messageType ? (
{messageType}
@@ -1195,18 +1207,18 @@ export const ActivityItem = memo(
const leadSourceBadge =
message.source === 'lead_session' && !isSlashCommandResult ? (
- session
+ {t('activity.badges.session')}
) : message.source === 'lead_process' && !isSlashCommandResult ? (
- live
+ {t('activity.badges.live')}
) : null;
const statusBadge = rateLimited ? (
- Rate Limited
+ {t('activity.badges.rateLimited')}
) : isApiError ? (
@@ -1366,7 +1378,7 @@ export const ActivityItem = memo(
{isUnread ? (
) : null}
@@ -1393,7 +1405,7 @@ export const ActivityItem = memo(
{onExpand && expandItemKey && (
{
@@ -1435,7 +1447,7 @@ export const ActivityItem = memo(
{isUnread ? (
) : null}
@@ -1475,7 +1487,7 @@ export const ActivityItem = memo(
{onExpand && expandItemKey && (
{
@@ -1516,7 +1528,7 @@ export const ActivityItem = memo(
{isUnread ? (
) : null}
@@ -1559,7 +1571,7 @@ export const ActivityItem = memo(
{onExpand && expandItemKey && (
{
@@ -1586,7 +1598,7 @@ export const ActivityItem = memo(
) : null}
- Raw JSON
+ {t('activity.rawJson')}
{JSON.stringify(structured, null, 2)}
@@ -1684,7 +1696,9 @@ export const ActivityItem = memo(
- Reply to message
+
+ {t('activity.actions.replyToMessage')}
+
) : null}
{onCreateTask ? (
@@ -1702,7 +1716,9 @@ export const ActivityItem = memo(
- Create task from message
+
+ {t('activity.actions.createTaskFromMessage')}
+
) : null}
@@ -1748,9 +1764,7 @@ export const ActivityItem = memo(
- Authentication failed. Restarting the team will refresh the session and may
- resolve this issue. If the problem persists, check your API credentials or try
- again later.
+ {t('activity.authError.description')}
- Restart team
+ {t('activity.actions.restartTeam')}
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx
index 69154692..2d1f2b4b 100644
--- a/src/renderer/components/team/activity/ActivityTimeline.tsx
+++ b/src/renderer/components/team/activity/ActivityTimeline.tsx
@@ -8,6 +8,7 @@ import React, {
useState,
} from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
areInboxMessagesEquivalentForRender,
areStringArraysEqual,
@@ -169,30 +170,38 @@ const ROW_SIZE_ESTIMATES: Record = {
'message-row': 140,
};
-const TimelineLoadingState = (): React.JSX.Element => (
-
-
-
- Loading messages...
-
-
-
-);
+const TimelineLoadingState = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
-const TimelineEmptyState = (): React.JSX.Element => (
-
-
No messages
-
Send a message to a member to see activity.
-
-);
+ return (
+
+
+
+ {t('activity.timeline.loadingMessages')}
+
+
+
+ );
+};
+
+const TimelineEmptyState = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+
{t('activity.timeline.noMessages')}
+
{t('activity.timeline.emptyHint')}
+
+ );
+};
function collectScrollMarginObserverTargets(
rootElement: HTMLElement,
@@ -449,6 +458,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
loading = false,
viewport,
}: ActivityTimelineProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const observerRoot = viewport?.observerRoot ?? viewport?.scrollElementRef;
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
const rootRef = useRef(null);
@@ -792,7 +802,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
>
- New session
+ {t('activity.timeline.newSession')}
@@ -958,14 +968,16 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
}}
>
- +{hiddenCount} older
+ {t('activity.timeline.olderCount', { count: hiddenCount })}
- Show {Math.min(MESSAGES_PAGE_SIZE, hiddenCount)} more
+ {t('activity.timeline.showMore', {
+ count: Math.min(MESSAGES_PAGE_SIZE, hiddenCount),
+ })}
{hiddenCount > MESSAGES_PAGE_SIZE && (
<>
@@ -974,7 +986,7 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
onClick={handleShowAll}
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
>
- Show all
+ {t('activity.timeline.showAll')}
>
)}
diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
index 46c79f76..415729e9 100644
--- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
+++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx
@@ -10,6 +10,7 @@ import {
useState,
} from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CompactMarkdownPreview } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import {
@@ -560,6 +561,7 @@ const LeadThoughtsGroupRowComponent = ({
onExpand,
expandItemKey,
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const ref = useRef(null);
const scrollRef = useRef(null);
const contentRef = useRef(null);
@@ -832,7 +834,7 @@ const LeadThoughtsGroupRowComponent = ({
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
@@ -849,7 +851,7 @@ const LeadThoughtsGroupRowComponent = ({
{onExpand && expandItemKey && (
{
@@ -918,7 +920,7 @@ const LeadThoughtsGroupRowComponent = ({
) : null}
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
{
@@ -1002,7 +1004,7 @@ const LeadThoughtsGroupRowComponent = ({
) : null}
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
{totalToolSummary ? (
@@ -1033,7 +1035,7 @@ const LeadThoughtsGroupRowComponent = ({
{onExpand && expandItemKey && (
{
@@ -1101,7 +1103,7 @@ const LeadThoughtsGroupRowComponent = ({
}}
>
- Show more
+ {t('activity.thoughts.showMore')}
) : null}
@@ -1116,7 +1118,7 @@ const LeadThoughtsGroupRowComponent = ({
}}
>
- Show less
+ {t('activity.thoughts.showLess')}
) : null}
diff --git a/src/renderer/components/team/activity/MessageExpandDialog.tsx b/src/renderer/components/team/activity/MessageExpandDialog.tsx
index e0986355..819a8454 100644
--- a/src/renderer/components/team/activity/MessageExpandDialog.tsx
+++ b/src/renderer/components/team/activity/MessageExpandDialog.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useMemo, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
Dialog,
DialogContent,
@@ -49,6 +50,7 @@ const DialogThoughtsContent = ({
teamColorByName,
onTeamClick,
}: DialogThoughtsContentProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { thoughts } = group;
const newest = thoughts[0];
const oldest = thoughts[thoughts.length - 1];
@@ -68,7 +70,7 @@ const DialogThoughtsContent = ({
/>
- {thoughts.length} thoughts
+ {t('activity.thoughts.count', { count: thoughts.length })}
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
@@ -133,6 +135,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
teamColorByName,
onTeamClick,
}: MessageExpandDialogProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
// Keep last valid item for exit animation
const lastItemRef = useRef(null);
if (expandedItem) lastItemRef.current = expandedItem;
@@ -162,7 +165,7 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
displayItem?.type === 'message'
? displayItem.message.from
: displayItem?.type === 'lead-thoughts'
- ? `${displayItem.group.thoughts[0].from} — thoughts`
+ ? t('activity.thoughts.titleForMember', { name: displayItem.group.thoughts[0].from })
: '';
return (
@@ -170,7 +173,9 @@ export const MessageExpandDialog = memo(function MessageExpandDialog({
{headerTitle}
- Expanded message view
+
+ {t('activity.expandDialog.description')}
+
{displayItem?.type === 'message' ? (
diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx
index 8e266ddb..3d383459 100644
--- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx
+++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx
@@ -1,5 +1,6 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
@@ -41,6 +42,7 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
headerRight,
onMemberClick,
}: PendingRepliesBlockProps): React.JSX.Element | null {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const pendingApprovals = useStore(useShallow((s) => s.pendingApprovals));
const colorMap = buildMemberColorMap(members);
@@ -79,7 +81,7 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
- Awaiting replies
+ {t('activity.pendingReplies.title')}
{headerRight ?
{headerRight}
: null}
@@ -139,7 +141,7 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
border: `1px solid ${colors.border}40`,
}}
onClick={() => onMemberClick(member)}
- title="Open member"
+ title={t('activity.pendingReplies.openMember')}
>
{displayMemberName(member.name)}
@@ -163,9 +165,9 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
- {advisoryLabel ?? 'awaiting reply'}
+ {advisoryLabel ?? t('activity.pendingReplies.awaitingReply')}
{isRetrying ? (
@@ -210,14 +212,14 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
{entry.teamName}
- external team
+ {t('activity.pendingReplies.externalTeam')}
- awaiting reply
+ {t('activity.pendingReplies.awaitingReply')}
{since}
@@ -254,14 +256,14 @@ export const PendingRepliesBlock = memo(function PendingRepliesBlock({
border: '1px solid var(--color-border-emphasis)',
}}
>
- user
+ {t('activity.pendingReplies.user')}
- awaiting approval
+ {t('activity.pendingReplies.awaitingApproval')}
{since}
diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx
index e70271cf..24224eff 100644
--- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx
+++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx
@@ -1,5 +1,6 @@
import { memo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
@@ -27,6 +28,7 @@ export const ReplyQuoteBlock = memo(
bodyMaxHeight = 'max-h-56',
replyTaskRefs,
}: ReplyQuoteBlockProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
const [expanded, setExpanded] = useState(false);
@@ -43,7 +45,9 @@ export const ReplyQuoteBlock = memo(
{/* "Replying to" + MemberBadge */}
- Replying to
+
+ {t('activity.reply.replyingTo')}
+
diff --git a/src/renderer/components/team/activity/ThoughtBodyContent.tsx b/src/renderer/components/team/activity/ThoughtBodyContent.tsx
index 75985812..e5f25a13 100644
--- a/src/renderer/components/team/activity/ThoughtBodyContent.tsx
+++ b/src/renderer/components/team/activity/ThoughtBodyContent.tsx
@@ -1,5 +1,6 @@
import { type JSX, memo, useCallback, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -40,6 +41,7 @@ export const ThoughtBodyContent = memo(
teamColorByName,
onTeamClick,
}: ThoughtBodyContentProps): JSX.Element {
+ const { t } = useAppTranslation('team');
const displayContent = useMemo(() => {
return buildThoughtDisplayContent(thought, memberColorMap, teamNames, {
preserveLineBreaks: true,
@@ -107,7 +109,7 @@ export const ThoughtBodyContent = memo(
- Reply
+ {t('activity.reply.action')}
) : null}
@@ -120,7 +122,7 @@ export const ThoughtBodyContent = memo(
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
style={{ color: CARD_ICON_MUTED }}
>
- 🔧 {thought.toolSummary}
+ {t('activity.thoughts.toolSummary', { summary: thought.toolSummary })}
{
+ const { t } = useAppTranslation('team');
const [state, setState] = useState<{
loaded: AttachmentFileData[];
loading: boolean;
@@ -56,7 +58,7 @@ export const AttachmentDisplay = ({
return (
- Loading attachments...
+ {t('taskAttachments.loading')}
);
}
diff --git a/src/renderer/components/team/attachments/DropZoneOverlay.tsx b/src/renderer/components/team/attachments/DropZoneOverlay.tsx
index b9c4ad8e..7b653e42 100644
--- a/src/renderer/components/team/attachments/DropZoneOverlay.tsx
+++ b/src/renderer/components/team/attachments/DropZoneOverlay.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Ban, Paperclip } from 'lucide-react';
interface DropZoneOverlayProps {
@@ -13,6 +14,8 @@ export const DropZoneOverlay = ({
rejected,
rejectionReason,
}: DropZoneOverlayProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+
if (!active) return null;
if (rejected) {
@@ -47,7 +50,7 @@ export const DropZoneOverlay = ({
style={{ color: 'var(--color-accent, #6366f1)' }}
>
- Drop files here
+ {t('taskAttachments.dropFilesHere')}
);
diff --git a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx
index 7e162eef..3d4876bd 100644
--- a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx
+++ b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Info } from 'lucide-react';
@@ -16,6 +17,8 @@ export const SourceMessageAttachments = ({
sourceMessageId,
sourceMessage,
}: SourceMessageAttachmentsProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+
if (!sourceMessage.attachments?.length) return null;
const attachments: AttachmentMeta[] = sourceMessage.attachments.map((a) => ({
@@ -41,7 +44,7 @@ export const SourceMessageAttachments = ({
- From original message
+ {t('taskAttachments.fromOriginalMessage')}
diff --git a/src/renderer/components/team/context-metric-alias.ts b/src/renderer/components/team/context-metric-alias.ts
new file mode 100644
index 00000000..b95a577d
--- /dev/null
+++ b/src/renderer/components/team/context-metric-alias.ts
@@ -0,0 +1,2 @@
+export type { ContextUsageLike as UsageLike } from '@shared/utils/contextMetrics';
+export { deriveContextMetrics as deriveMetrics } from '@shared/utils/contextMetrics';
diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
index 09f608fd..bee9fce4 100644
--- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx
+++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getNextSuggestedMemberName } from '@renderer/components/team/members/memberNameSets';
import {
buildMembersFromDrafts,
@@ -86,6 +87,7 @@ export const AddMemberDialog = ({
projectPath,
existingMembers,
}: AddMemberDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const existingWorktreeDefault = deriveExistingWorktreeDefault(existingMembers);
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(existingWorktreeDefault);
const [members, setMembers] = useState(() =>
@@ -183,8 +185,10 @@ export const AddMemberDialog = ({
- Add Members
- Add new members to {teamName}
+ {t('memberDraft.addMembers.title')}
+
+ {t('memberDraft.addMembers.description', { teamName })}
+
@@ -207,7 +211,7 @@ export const AddMemberDialog = ({
- Cancel
+ {t('dialogs.actions.cancel')}
{adding ? : null}
diff --git a/src/renderer/components/team/dialogs/AdvancedCliSection.tsx b/src/renderer/components/team/dialogs/AdvancedCliSection.tsx
index ea42c15f..a82f9f88 100644
--- a/src/renderer/components/team/dialogs/AdvancedCliSection.tsx
+++ b/src/renderer/components/team/dialogs/AdvancedCliSection.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Input } from '@renderer/components/ui/input';
@@ -79,6 +80,7 @@ export const AdvancedCliSection: React.FC = ({
customArgs,
onCustomArgsChange,
}) => {
+ const { t } = useAppTranslation('team');
const [isOpen, setIsOpen] = useState(false);
const [validationState, setValidationState] = useState('idle');
const [validationMessage, setValidationMessage] = useState(null);
@@ -148,20 +150,26 @@ export const AdvancedCliSection: React.FC = ({
const result = await window.electronAPI.teams.validateCliArgs(customArgs);
if (result.valid) {
setValidationState('success');
- setValidationMessage('All flags valid');
+ setValidationMessage(t('advancedCli.validation.allFlagsValid'));
} else {
setValidationState('error');
const flags = result.invalidFlags ?? [];
const unknown = flags.filter((f) => !PROTECTED_CLI_FLAGS.has(f));
const protectedOnes = flags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
const parts: string[] = [];
- if (unknown.length > 0) parts.push(`Unknown: ${unknown.join(', ')}`);
- if (protectedOnes.length > 0) parts.push(`Protected: ${protectedOnes.join(', ')}`);
+ if (unknown.length > 0) {
+ parts.push(t('advancedCli.validation.unknownFlags', { flags: unknown.join(', ') }));
+ }
+ if (protectedOnes.length > 0) {
+ parts.push(
+ t('advancedCli.validation.protectedFlags', { flags: protectedOnes.join(', ') })
+ );
+ }
setValidationMessage(parts.join(' | '));
}
} catch (err) {
setValidationState('error');
- setValidationMessage(err instanceof Error ? err.message : 'Validation failed');
+ setValidationMessage(err instanceof Error ? err.message : t('advancedCli.validation.failed'));
}
}, [customArgs]);
@@ -197,7 +205,7 @@ export const AdvancedCliSection: React.FC = ({
className={`size-3.5 transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
/>
- Advanced
+ {t('advancedCli.title')}
{isOpen && (
@@ -214,7 +222,7 @@ export const AdvancedCliSection: React.FC = ({
htmlFor={`worktree-${teamName}`}
className="cursor-pointer text-xs font-normal text-text-secondary"
>
- Use worktree
+ {t('advancedCli.useWorktree')}
@@ -222,7 +230,7 @@ export const AdvancedCliSection: React.FC = ({
0}>
onWorktreeNameChange(e.target.value)}
@@ -244,7 +252,7 @@ export const AdvancedCliSection: React.FC = ({
>
- Recent
+ {t('advancedCli.recent')}
{filteredHistory.map((name) => (
= ({
{/* Command preview */}
- Command preview
+ {t('advancedCli.commandPreview')}
@@ -284,7 +292,7 @@ export const AdvancedCliSection: React.FC = ({
{/* Custom arguments */}
- Custom arguments
+ {t('advancedCli.customArguments')}
= ({
{validationState === 'loading' ? (
) : null}
- Validate
+ {t('advancedCli.validate')}
)}
diff --git a/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx b/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx
index a0c9d8fd..7dbc41db 100644
--- a/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx
+++ b/src/renderer/components/team/dialogs/AnthropicExtraUsageWarning.tsx
@@ -1,21 +1,27 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
export const ANTHROPIC_SONNET_EXTRA_USAGE_WARNING =
'Sonnet 1M context can affect billing depending on your Anthropic plan and runtime. Claude Platform lists Sonnet 4.6 1M at standard API pricing, while Claude Code plans can require Extra Usage for Sonnet 1M; enable Limit context to 200K tokens to avoid long-context behavior.';
export const ANTHROPIC_LONG_CONTEXT_PRICING_URL =
'https://platform.claude.com/docs/en/about-claude/pricing';
-export const AnthropicExtraUsageWarning = (): React.JSX.Element => (
-
- {ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '}
-
- Read Anthropic pricing docs
-
- .
-
-);
+export const AnthropicExtraUsageWarning = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+ {ANTHROPIC_SONNET_EXTRA_USAGE_WARNING}{' '}
+
+ {t('modelSelector.anthropicExtraUsage.pricingDocs')}
+
+ .
+
+ );
+};
diff --git a/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx b/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx
index 0bbaa97e..29159d58 100644
--- a/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx
+++ b/src/renderer/components/team/dialogs/AnthropicFastModeSelector.tsx
@@ -4,6 +4,7 @@ import {
resolveAnthropicFastMode,
resolveAnthropicRuntimeSelection,
} from '@features/anthropic-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
@@ -28,6 +29,7 @@ export const AnthropicFastModeSelector: React.FC
limitContext,
id,
}) => {
+ const { t } = useAppTranslation('team');
const { providerStatus } = useEffectiveCliProviderStatus('anthropic');
const selection = useMemo(
@@ -57,25 +59,34 @@ export const AnthropicFastModeSelector: React.FC
return null;
}
- const defaultLabel = providerFastModeDefault ? 'Default (Fast)' : 'Default (Off)';
+ const defaultLabel = providerFastModeDefault
+ ? t('modelSelector.fastMode.defaultFast')
+ : t('modelSelector.fastMode.defaultOff');
const helperText =
value === 'inherit'
- ? `Default currently resolves to ${resolution.resolvedFastMode ? 'Fast' : 'Off'}.`
- : (resolution.disabledReason ??
- 'Fast mode is runtime-backed and only unlocks when the resolved Anthropic launch model supports it.');
+ ? t('modelSelector.fastMode.defaultResolvesTo', {
+ mode: resolution.resolvedFastMode
+ ? t('modelSelector.fastMode.fast')
+ : t('modelSelector.fastMode.off'),
+ })
+ : (resolution.disabledReason ?? t('modelSelector.fastMode.runtimeBackedHint'));
return (
- Fast mode (optional)
+ {t('modelSelector.fastMode.optionalLabel')}
{[
{ value: 'inherit' as const, label: defaultLabel, disabled: false },
- { value: 'on' as const, label: 'Fast', disabled: !resolution.selectable },
- { value: 'off' as const, label: 'Off', disabled: false },
+ {
+ value: 'on' as const,
+ label: t('modelSelector.fastMode.fast'),
+ disabled: !resolution.selectable,
+ },
+ { value: 'off' as const, label: t('modelSelector.fastMode.off'), disabled: false },
].map((option) => (
= ({
providerBackendId,
id,
}) => {
+ const { t } = useAppTranslation('team');
const { providerStatus } = useEffectiveCliProviderStatus('codex');
const selection = useMemo(
() =>
@@ -65,15 +67,23 @@ export const CodexFastModeSelector: React.FC = ({
return (
- Fast mode (2x credits)
+ {t('modelSelector.fastMode.codexLabel')}
{[
- { value: 'inherit' as const, label: 'Default (Off)', disabled: false },
- { value: 'on' as const, label: 'Fast', disabled: !resolution.selectable },
- { value: 'off' as const, label: 'Off', disabled: false },
+ {
+ value: 'inherit' as const,
+ label: t('modelSelector.fastMode.defaultOff'),
+ disabled: false,
+ },
+ {
+ value: 'on' as const,
+ label: t('modelSelector.fastMode.fast'),
+ disabled: !resolution.selectable,
+ },
+ { value: 'off' as const, label: t('modelSelector.fastMode.off'), disabled: false },
].map((option) => (
void;
onDeviceCodeReconnect: () => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
return (
- Codex found the local ChatGPT account, but this session is stale. Sign in with ChatGPT,
- enter the code if shown, then retry this dialog.
+ {t('codexReconnect.description')}
- Use code
+ {t('codexReconnect.useCode')}
) : null}
{
+ const { t } = useAppTranslation('team');
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore(
(s) => selectTeamDataForName(s, teamName)?.config.projectPath ?? null
@@ -202,13 +204,17 @@ export const CreateTaskDialog = ({
const assigneeField = (
- {requiresOwner ? 'Assignee' : 'Assignee (optional)'}
+ {requiresOwner ? t('tasks.createTask.assignee') : t('tasks.createTask.assigneeOptional')}
setOwner(v ?? '')}
- placeholder={requiresOwner ? 'Select a member' : 'Select member...'}
+ placeholder={
+ requiresOwner
+ ? t('tasks.createTask.selectMember')
+ : t('tasks.createTask.selectMemberOptional')
+ }
allowUnassigned={!requiresOwner}
/>
@@ -218,11 +224,8 @@ export const CreateTaskDialog = ({
- Create Task
-
- The task will be created in the team's tasks/ directory and appear on the Kanban
- board.
-
+ {t('tasks.createTask.title')}
+ {t('tasks.createTask.description')}
{!isTeamAlive ? (
@@ -236,18 +239,19 @@ export const CreateTaskDialog = ({
>
- Team is offline. The task will be added to TODO — launch the
- team to start execution.
+ {t('tasks.createTask.offlineNotice.before')}{' '}
+ {t('tasks.createTask.todo')} {' '}
+ {t('tasks.createTask.offlineNotice.after')}
) : null}
-
Subject
+
{t('tasks.createTask.subject')}
setSubject(e.target.value)}
@@ -266,7 +270,11 @@ export const CreateTaskDialog = ({
onClick={() => setShowOptionalFields((prev) => !prev)}
>
{showOptionalFields ?
:
}
-
{showOptionalFields ? 'Hide optional fields' : 'Show optional fields'}
+
+ {showOptionalFields
+ ? t('tasks.createTask.hideOptionalFields')
+ : t('tasks.createTask.showOptionalFields')}
+
{/* Collapsible optional fields */}
@@ -277,11 +285,13 @@ export const CreateTaskDialog = ({
-
Description (optional)
+
+ {t('tasks.createTask.descriptionOptional')}
+
- Prompt for assignee (optional)
+ {t('tasks.createTask.promptOptional')}
Saved
+
+ {t('tasks.createTask.saved')}
+
) : null
}
/>
@@ -312,7 +324,9 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
-
Blocked by tasks (optional)
+
+ {t('tasks.createTask.blockedByOptional')}
+
{availableTasks.length > 3 ? (
@@ -322,7 +336,7 @@ export const CreateTaskDialog = ({
/>
setBlockedBySearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
@@ -374,8 +388,9 @@ export const CreateTaskDialog = ({
{blockedBy.length > 0 ? (
- Task will be blocked by:{' '}
- {blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
+ {t('tasks.createTask.blockedBySummary', {
+ tasks: blockedBy.map((id) => `#${deriveTaskDisplayId(id)}`).join(', '),
+ })}
) : null}
@@ -383,7 +398,9 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
-
Related tasks (optional)
+
+ {t('tasks.createTask.relatedOptional')}
+
{availableTasks.length > 3 ? (
@@ -393,7 +410,7 @@ export const CreateTaskDialog = ({
/>
setRelatedSearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
@@ -445,7 +462,9 @@ export const CreateTaskDialog = ({
{related.length > 0 ? (
- Related: {related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', ')}
+ {t('tasks.createTask.relatedSummary', {
+ tasks: related.map((id) => `#${deriveTaskDisplayId(id)}`).join(', '),
+ })}
) : null}
@@ -467,12 +486,12 @@ export const CreateTaskDialog = ({
htmlFor="task-start-immediately"
className={`text-xs font-normal ${!isTeamAlive ? 'text-[var(--color-text-muted)]' : ''}`}
>
- Start immediately
+ {t('tasks.createTask.startImmediately')}
{!isTeamAlive ? (
- Team is offline. Launch the team first to start tasks immediately.
+ {t('tasks.createTask.startOfflineHint')}
) : null}
@@ -481,10 +500,10 @@ export const CreateTaskDialog = ({
- Cancel
+ {t('tasks.createTask.cancel')}
- {submitting ? 'Creating...' : 'Create'}
+ {submitting ? t('tasks.createTask.creating') : t('tasks.createTask.create')}
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 450e224f..ce911d0f 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -15,6 +15,7 @@ import {
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
import {
@@ -259,15 +260,18 @@ function sanitizeTeamName(name: string): string {
return result;
}
-function validateTeamNameInline(name: string): string | null {
+function validateTeamNameInline(
+ name: string,
+ t: ReturnType['t']
+): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
const sanitized = sanitizeTeamName(trimmed);
if (!sanitized) {
- return 'Name must contain at least one letter or digit';
+ return t('create.validation.nameMustContainLetterOrDigit');
}
if (sanitized.length > 128) {
- return 'Name is too long (max 128 chars)';
+ return t('create.validation.nameTooLong');
}
return null;
}
@@ -281,6 +285,7 @@ function buildDefaultTeamDescription(teamName: string): string {
function validateRequest(
request: TeamCreateRequest,
+ t: ReturnType['t'],
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
@@ -289,7 +294,7 @@ function validateRequest(
return {
valid: false,
errors: {
- teamName: 'Name must contain at least one letter or digit',
+ teamName: t('create.validation.nameMustContainLetterOrDigit'),
},
};
}
@@ -297,7 +302,7 @@ function validateRequest(
return {
valid: false,
errors: {
- teamName: 'Name is too long (max 128 chars)',
+ teamName: t('create.validation.nameTooLong'),
},
};
}
@@ -305,7 +310,7 @@ function validateRequest(
return {
valid: false,
errors: {
- cwd: 'Select working directory (cwd)',
+ cwd: t('create.validation.selectWorkingDirectory'),
},
};
}
@@ -313,7 +318,7 @@ function validateRequest(
return {
valid: false,
errors: {
- members: 'Member name cannot be empty',
+ members: t('create.validation.memberNameRequired'),
},
};
}
@@ -321,7 +326,7 @@ function validateRequest(
return {
valid: false,
errors: {
- members: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars',
+ members: t('create.validation.memberNameInvalid'),
},
};
}
@@ -330,7 +335,7 @@ function validateRequest(
return {
valid: false,
errors: {
- members: 'Member names must be unique',
+ members: t('create.validation.memberNamesUnique'),
},
};
}
@@ -393,6 +398,7 @@ export const CreateTeamDialog = ({
onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => {
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
@@ -1000,9 +1006,7 @@ export const CreateTeamDialog = ({
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage(
- 'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
- );
+ setPrepareMessage(t('create.prepare.unsupportedPreload'));
return;
}
@@ -1016,7 +1020,7 @@ export const CreateTeamDialog = ({
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage('Select a working directory to validate the launch environment.');
+ setPrepareMessage(t('create.prepare.selectWorkingDirectory'));
return;
}
@@ -1046,7 +1050,8 @@ export const CreateTeamDialog = ({
});
const loadingMessage = getProvisioningProviderProgressMessage(
changedPlans.map((plan) => plan.providerId),
- selectedMemberProviders.length
+ selectedMemberProviders.length,
+ t
);
const getSelectedWarnings = (): string[] =>
selectedMemberProviders.flatMap(
@@ -1074,14 +1079,14 @@ export const CreateTeamDialog = ({
selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes');
const failureMessage =
getPrimaryProvisioningFailureDetail(nextChecks) ??
- 'Some selected providers need attention.';
+ t('create.prepare.someProvidersNeedAttention');
setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(
anyFailure
? failureMessage
: anyNotes
- ? 'All selected providers are ready, with notes.'
- : 'All selected providers are ready.'
+ ? t('create.prepare.readyWithNotes')
+ : t('create.prepare.ready')
);
};
@@ -1101,7 +1106,7 @@ export const CreateTeamDialog = ({
changedPlans.length > 0
? loadingMessage
: (prepareMessageRef.current ??
- getProvisioningProviderProgressMessage([], selectedMemberProviders.length))
+ getProvisioningProviderProgressMessage([], selectedMemberProviders.length, t))
);
if (changedPlans.length === 0) {
@@ -1201,7 +1206,7 @@ export const CreateTeamDialog = ({
return;
}
const failureMessage =
- error instanceof Error ? error.message : 'Failed to prepare selected providers';
+ error instanceof Error ? error.message : t('create.prepare.failed');
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: 'failed',
backendSummary: plan.backendSummary,
@@ -1231,6 +1236,7 @@ export const CreateTeamDialog = ({
selectedModelChecksByProviderSignature,
selectedProviderId,
selectedMemberProviders,
+ t,
]);
useEffect(() => {
@@ -1254,7 +1260,9 @@ export const CreateTeamDialog = ({
if (cancelled) {
return;
}
- setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
+ setProjectsError(
+ error instanceof Error ? error.message : t('create.errors.loadProjectsFailed')
+ );
setProjects([]);
} finally {
if (!cancelled) {
@@ -1266,7 +1274,7 @@ export const CreateTeamDialog = ({
return () => {
cancelled = true;
};
- }, [open, defaultProjectPath]);
+ }, [open, defaultProjectPath, t]);
useEffect(() => {
if (!open || !draftLoaded) {
@@ -1651,7 +1659,7 @@ export const CreateTeamDialog = ({
]);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
- const teamNameInlineError = validateTeamNameInline(teamName);
+ const teamNameInlineError = validateTeamNameInline(teamName, t);
const isNameTakenByExistingTeam = existingTeamNames.includes(sanitizedTeamName);
const isNameProvisioning =
provisioningTeamNames.includes(sanitizedTeamName) && !isNameTakenByExistingTeam;
@@ -1702,19 +1710,19 @@ export const CreateTeamDialog = ({
]
);
const requestValidation = useMemo(
- () => validateRequest(request, { requireCwd: launchTeam }),
- [request, launchTeam]
+ () => validateRequest(request, t, { requireCwd: launchTeam }),
+ [request, launchTeam, t]
);
const modelValidationError = useMemo(() => {
if (selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
- return 'OpenCode lead requires a selected model.';
+ return t('create.validation.openCodeLeadModelRequired');
}
const activeMemberCount = soloTeam
? 0
: effectiveMemberDrafts.filter((member) => !member.removedAt && member.name.trim()).length;
if (activeMemberCount === 0) {
- return 'OpenCode lead requires at least one OpenCode teammate.';
+ return t('create.validation.openCodeTeammateRequired');
}
}
@@ -1753,6 +1761,7 @@ export const CreateTeamDialog = ({
selectedModel,
selectedProviderId,
soloTeam,
+ t,
]);
const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue(
@@ -1901,8 +1910,9 @@ export const CreateTeamDialog = ({
message: prepareMessage,
warnings: prepareWarnings,
checks: prepareChecks,
+ t,
}),
- [prepareChecks, prepareMessage, prepareState, prepareWarnings]
+ [prepareChecks, prepareMessage, prepareState, prepareWarnings, t]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
@@ -1927,17 +1937,19 @@ export const CreateTeamDialog = ({
const handleSubmit = (): void => {
if (allTakenTeamNames.includes(sanitizedTeamName)) {
- const msg = isNameProvisioning ? 'Team is currently launching' : 'Team name already exists';
+ const msg = isNameProvisioning
+ ? t('create.validation.teamLaunching')
+ : t('create.validation.teamNameExists');
setFieldErrors({ teamName: msg });
setLocalError(msg);
return;
}
- const validation = validateRequest(request, { requireCwd: launchTeam });
+ const validation = validateRequest(request, t, { requireCwd: launchTeam });
if (!validation.valid) {
const errors = validation.errors ?? {};
setFieldErrors(errors);
const messages = Object.values(errors).filter(Boolean);
- setLocalError(messages.join(' · ') || 'Check form fields');
+ setLocalError(messages.join(' · ') || t('create.validation.checkFormFields'));
return;
}
if (modelValidationError) {
@@ -1984,7 +1996,9 @@ export const CreateTeamDialog = ({
resetFormState();
onClose();
} catch (error) {
- setLocalError(error instanceof Error ? error.message : 'Failed to create team config');
+ setLocalError(
+ error instanceof Error ? error.message : t('create.errors.createConfigFailed')
+ );
} finally {
setIsSubmitting(false);
}
@@ -2037,11 +2051,11 @@ export const CreateTeamDialog = ({
htmlFor="solo-team"
className="cursor-pointer text-xs font-normal text-text-secondary"
>
- Solo team
+ {t('create.solo.label')}
),
- [setSoloTeam, soloTeam]
+ [setSoloTeam, soloTeam, t]
);
const rosterHeaderBottom = useMemo(
@@ -2063,11 +2077,7 @@ export const CreateTeamDialog = ({
- Only the team lead (main process) will be started — no teammates will be
- spawned. Works like a regular agent session in your chosen runtime (Claude Code,
- Codex, OpenCode, Gemini) but with access to the task board for planning. Saves
- tokens by avoiding teammate coordination overhead. You can add members later from
- the team settings.
+ {t('create.solo.description')}
) : null}
@@ -2084,6 +2094,7 @@ export const CreateTeamDialog = ({
showRosterTeammateRuntimeCompatibility,
soloTeam,
teammateRuntimeCompatibility,
+ t,
worktreeGitReadiness,
]
);
@@ -2100,11 +2111,11 @@ export const CreateTeamDialog = ({
>
- {initialData ? 'Copy Team' : 'Create Team'}
+
+ {initialData ? t('create.title.copy') : t('create.title.create')}
+
- {initialData
- ? 'Create a new team based on an existing one.'
- : 'Set up your team and choose how it starts.'}
+ {initialData ? t('create.description.copy') : t('create.description.create')}
@@ -2121,15 +2132,12 @@ export const CreateTeamDialog = ({
- Another team “{conflictingTeam.displayName}” is already running for
- this working directory
-
-
- Running two teams in the same directory is risky — they may conflict editing the
- same files. Consider using a different directory or a git worktree for isolation.
+ {t('create.conflict.title', { team: conflictingTeam.displayName })}
+
{t('create.conflict.description')}
- Working directory: {effectiveCwd}
+ {t('create.conflict.workingDirectory')}{' '}
+ {effectiveCwd}
- Available only in local Electron mode.
+ {t('create.localOnly')}
) : null}
-
Team name
+
{t('create.fields.teamName')}
{isNameTakenByExistingTeam ? (
- Team name already exists
+ {t('create.errors.nameExists')}
) : teamNameInlineError ? (
@@ -2180,7 +2188,7 @@ export const CreateTeamDialog = ({
) : isNameProvisioning ? (
- A team with this name is currently launching
+ {t('create.errors.nameLaunching')}
) : fieldErrors.teamName ? (
@@ -2189,7 +2197,7 @@ export const CreateTeamDialog = ({
) : null}
{sanitizedTeamName && sanitizedTeamName !== teamName.trim() ? (
- On disk: {sanitizedTeamName}
+ {t('create.onDisk')} {sanitizedTeamName}
) : null}
@@ -2263,7 +2271,7 @@ export const CreateTeamDialog = ({
/>
- Run command after create
+ {t('create.launchAfterCreate.label')}
- Start the team immediately via local Claude CLI.
+ {t('create.launchAfterCreate.description')}
@@ -2294,8 +2302,8 @@ export const CreateTeamDialog = ({
/>
@@ -2342,7 +2350,7 @@ export const CreateTeamDialog = ({
- Prompt for team lead (optional)
+ {t('create.fields.prompt')}
- Saved
+ {t('create.saved')}
) : null
}
@@ -2393,14 +2401,14 @@ export const CreateTeamDialog = ({
- Description (optional)
+ {t('create.fields.description')}
descriptionDraft.setValue(event.target.value)}
- placeholder="Brief description of the team purpose"
+ placeholder={t('create.placeholders.description')}
/>
{descriptionDraft.isSaved ? (
- Saved
+
+ {t('create.saved')}
+
) : null}
-
Color (optional)
+
{t('create.fields.color')}
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
@@ -2488,11 +2498,13 @@ export const CreateTeamDialog = ({
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
- ? 'Checking selected providers...'
- : 'Preparing environment...')}
+ ? t('create.prepare.checkingProviders')
+ : t('create.prepare.preparingEnvironment'))}
- Pre-flight check to catch errors before launch
+ {t('launch.prepare.preflight', {
+ action: t('launch.prepare.action.launch'),
+ })}
@@ -2511,8 +2523,8 @@ export const CreateTeamDialog = ({
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
- ? 'Selected providers ready (with notes)'
- : 'Selected providers ready'}
+ ? t('create.prepare.selectedProvidersReadyWithNotes')
+ : t('create.prepare.selectedProvidersReady')}
{effectivePrepare.message ? (
@@ -2543,13 +2555,17 @@ export const CreateTeamDialog = ({
- Runtime environment is not available - launch is blocked
+ {t('launch.prepare.blocked', {
+ action: t('launch.prepare.action.launch'),
+ })}
- {effectivePrepare.message ?? 'Failed to prepare environment'}
+ {effectivePrepare.message ?? t('launch.prepare.failed')}
- Pre-flight check to catch errors before launch
+ {t('launch.prepare.preflight', {
+ action: t('launch.prepare.action.launch'),
+ })}
@@ -2577,7 +2593,7 @@ export const CreateTeamDialog = ({
) : null}
- {getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
+ {getProvisioningFailureHint(effectivePrepare.message, prepareChecks, t)}
{showCodexReconnectPrompt ? (
@@ -2604,7 +2620,7 @@ export const CreateTeamDialog = ({
onClose();
}}
>
- Open Existing Team
+ {t('create.actions.openExisting')}
) : null}
- Creating...
+ {t('create.actions.creating')}
>
) : launchTeam &&
(effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading') ? (
- 'Skip preflight and create'
+ t('create.actions.skipPreflightAndCreate')
) : (
- 'Create'
+ t('create.actions.create')
)}
diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx
index 03fe19cb..0829b832 100644
--- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MemberDraftRow } from '@renderer/components/team/members/MemberDraftRow';
import {
@@ -95,7 +96,13 @@ function getInvalidMemberNamesError(
members: readonly {
name: string;
removedAt?: number | string | null;
- }[]
+ }[],
+ messages: {
+ empty: string;
+ invalid: string;
+ reserved: (name: string) => string;
+ numericSuffix: (name: string, base: string) => string;
+ }
): string | null {
for (const member of members) {
if (member.removedAt) {
@@ -103,18 +110,18 @@ function getInvalidMemberNamesError(
}
const name = member.name.trim();
if (!name) {
- return 'Member name cannot be empty';
+ return messages.empty;
}
if (validateMemberNameInline(name) !== null) {
- return 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
+ return messages.invalid;
}
const lower = name.toLowerCase();
if (lower === 'user' || lower === 'team-lead') {
- return `Member name "${name}" is reserved`;
+ return messages.reserved(name);
}
const suffixInfo = parseNumericSuffixName(name);
if (suffixInfo && suffixInfo.suffix >= 2) {
- return `Member name "${name}" is not allowed (reserved for Claude CLI auto-suffix). Use "${suffixInfo.base}" instead.`;
+ return messages.numericSuffix(name, suffixInfo.base);
}
}
return null;
@@ -150,6 +157,7 @@ export const EditTeamDialog = ({
onChangeLeadRuntime,
onSaved,
}: EditTeamDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const [name, setName] = useState(currentName);
const [description, setDescription] = useState(currentDescription);
@@ -182,13 +190,13 @@ export const EditTeamDialog = ({
name: displayMemberName(leadMember.name),
originalName: leadMember.name,
roleSelection: '',
- customRole: 'Team Lead',
+ customRole: t('editTeam.teamLead.role'),
workflow: leadMember.workflow,
providerId: leadMember.providerId,
model: leadMember.model ?? '',
effort: leadMember.effort,
});
- }, [leadMember]);
+ }, [leadMember, t]);
useEffect(() => {
const wasOpen = wasOpenRef.current;
@@ -232,7 +240,17 @@ export const EditTeamDialog = ({
}, [open, teamName, currentName, currentDescription, currentColor, currentMembers]);
const builtMembers = useMemo(() => buildMembersFromDrafts(members), [members]);
- const invalidMemberNamesError = useMemo(() => getInvalidMemberNamesError(members), [members]);
+ const invalidMemberNamesError = useMemo(
+ () =>
+ getInvalidMemberNamesError(members, {
+ empty: t('editTeam.errors.memberNameEmpty'),
+ invalid: t('editTeam.errors.memberNameInvalid'),
+ reserved: (memberName) => t('editTeam.errors.memberNameReserved', { name: memberName }),
+ numericSuffix: (memberName, base) =>
+ t('editTeam.errors.memberNameNumericSuffix', { name: memberName, base }),
+ }),
+ [members, t]
+ );
const hasDuplicateMembers = useMemo(() => {
const names = members
.filter((member) => !member.removedAt)
@@ -380,15 +398,15 @@ export const EditTeamDialog = ({
members.map((member) => [
member.id,
restartNames.has(member.name.trim().toLowerCase())
- ? 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.'
+ ? t('editTeam.memberRestartWarning')
: null,
])
);
- }, [liveRuntimeRefreshMemberNames, members]);
+ }, [liveRuntimeRefreshMemberNames, members, t]);
const handleSave = (): void => {
if (!name.trim()) {
- setError('Team name cannot be empty');
+ setError(t('editTeam.errors.teamNameEmpty'));
return;
}
if (invalidMemberNamesError) {
@@ -396,7 +414,7 @@ export const EditTeamDialog = ({
return;
}
if (hasDuplicateMembers) {
- setError('Member names must be unique before saving');
+ setError(t('editTeam.errors.memberNamesUnique'));
return;
}
const latestSourceSnapshot = buildEditTeamSourceSnapshot({
@@ -411,32 +429,30 @@ export const EditTeamDialog = ({
)
);
if (allowedSourceSnapshots.size > 0 && !allowedSourceSnapshots.has(latestSourceSnapshot)) {
- setError(
- 'Team settings changed while this dialog was open. Reopen it and review the latest state before saving.'
- );
+ setError(t('editTeam.errors.settingsChanged'));
return;
}
if (hasBlockedLiveIdentityChanges) {
setError(
- `Existing teammates cannot be renamed while the team is live. renamed: ${liveIdentityChanges.renamed.join(', ')}`
+ t('editTeam.errors.liveRenameBlocked', {
+ names: liveIdentityChanges.renamed.join(', '),
+ })
);
return;
}
if (isTeamProvisioning) {
- setError(
- 'Team settings cannot be edited while provisioning is still in progress. Wait for launch to finish, then try again.'
- );
+ setError(t('editTeam.errors.provisioning'));
return;
}
if (hasNewLiveTeammates) {
- setError(
- 'Add new teammates from the dedicated Add member dialog while the team is live. Edit Team only supports updating existing teammates.'
- );
+ setError(t('editTeam.errors.newLiveTeammates'));
return;
}
if (unsupportedLiveMixedPrimaryMutationNames.length > 0) {
setError(
- `Live edits to primary-owned teammates in mixed OpenCode teams are not supported yet. Stop the team, edit the roster, then relaunch. Affected: ${unsupportedLiveMixedPrimaryMutationNames.join(', ')}`
+ t('editTeam.errors.unsupportedMixedPrimaryMutation', {
+ names: unsupportedLiveMixedPrimaryMutationNames.join(', '),
+ })
);
return;
}
@@ -517,14 +533,14 @@ export const EditTeamDialog = ({
)
);
setSaveOutcomeError(
- `Team saved, but failed to restart ${restartFailures.length === 1 ? 'this teammate' : 'these teammates'}: ${restartFailures.join(', ')}`
+ restartFailures.length === 1
+ ? t('editTeam.errors.restartFailedOne', { failures: restartFailures.join(', ') })
+ : t('editTeam.errors.restartFailedMany', { failures: restartFailures.join(', ') })
);
} catch (e) {
- const message = e instanceof Error ? e.message : 'Failed to save';
+ const message = e instanceof Error ? e.message : t('editTeam.errors.saveFailed');
if (membersSaved) {
- setSaveOutcomeError(
- `Team changes were saved, but failed to refresh the latest view: ${message}`
- );
+ setSaveOutcomeError(t('editTeam.errors.changesSavedRefreshFailed', { message }));
} else if (configSaved) {
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
name: name.trim(),
@@ -533,9 +549,7 @@ export const EditTeamDialog = ({
members: committedMembersForSnapshot,
});
if (refreshAfterSaveAttempted) {
- setSaveOutcomeError(
- `Team settings were saved, but failed to refresh the latest view: ${message}`
- );
+ setSaveOutcomeError(t('editTeam.errors.settingsSavedRefreshFailed', { message }));
return;
}
let refreshErrorDetail: string | null = null;
@@ -547,8 +561,11 @@ export const EditTeamDialog = ({
}
setSaveOutcomeError(
refreshErrorDetail
- ? `Team settings were saved, but member changes failed: ${message}. Refresh also failed: ${refreshErrorDetail}`
- : `Team settings were saved, but member changes failed: ${message}`
+ ? t('editTeam.errors.settingsSavedMembersAndRefreshFailed', {
+ message,
+ refreshError: refreshErrorDetail,
+ })
+ : t('editTeam.errors.settingsSavedMembersFailed', { message })
);
} else {
setError(message);
@@ -563,8 +580,8 @@ export const EditTeamDialog = ({
!nextOpen && onClose()}>
- Edit Team
- Change team name, description and color
+ {t('editTeam.title')}
+ {t('editTeam.description')}
@@ -573,7 +590,7 @@ export const EditTeamDialog = ({
htmlFor="edit-team-name"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
- Name
+ {t('editTeam.fields.name')}
@@ -595,7 +612,7 @@ export const EditTeamDialog = ({
htmlFor="edit-team-description"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
- Description
+ {t('editTeam.fields.description')}
@@ -643,21 +660,19 @@ export const EditTeamDialog = ({
projectPath={projectPath ?? null}
lockProviderModel
lockRole
- lockedRoleLabel="Team Lead"
+ lockedRoleLabel={t('editTeam.teamLead.role')}
lockIdentity
hideActionButton
- modelLockReason="Team lead runtime is managed from Relaunch Team."
+ modelLockReason={t('editTeam.teamLead.modelLockReason')}
lockedModelAction={{
- label: 'Change lead runtime',
- description:
- 'Open Relaunch Team to change the lead provider, model, or effort.',
+ label: t('editTeam.teamLead.changeRuntime'),
+ description: t('editTeam.teamLead.changeRuntimeDescription'),
onClick: onChangeLeadRuntime,
disabled: isTeamProvisioning,
}}
/>
- Team lead name and role stay read-only here. Open the runtime panel on the
- lead row to change provider, model, or effort.
+ {t('editTeam.teamLead.readOnlyHint')}
) : null
@@ -671,48 +686,42 @@ export const EditTeamDialog = ({
lockExistingMemberIdentity={isTeamAlive}
identityLockReason={undefined}
disableAddMember={isTeamAlive}
- addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live."
+ addMemberLockReason={t('editTeam.addMemberLockReason')}
memberWarningById={memberWarningById}
disableGeminiOption={isGeminiUiFrozen()}
/>
{isTeamProvisioning ? (
-
- Team provisioning is still in progress. Editing is temporarily locked until launch
- finishes.
-
+ {t('editTeam.notices.provisioning')}
) : null}
{isTeamAlive && hasNewLiveTeammates ? (
-
- New teammates cannot be added from Edit Team while the team is live. Use the Add
- member dialog instead.
-
+ {t('editTeam.notices.newLiveTeammates')}
) : null}
{isTeamAlive && hasBlockedLiveIdentityChanges ? (
-
- Live save is blocked because existing teammates were renamed. Revert those identity
- changes or stop the team first.
-
+ {t('editTeam.notices.liveRenameBlocked')}
) : null}
{unsupportedLiveMixedPrimaryMutationNames.length > 0 ? (
- Live edits/removals for primary-owned teammates in mixed OpenCode teams require
- stopping and relaunching the team:{' '}
- {unsupportedLiveMixedPrimaryMutationNames.join(', ')}.
+ {t('editTeam.notices.unsupportedMixedPrimaryMutation', {
+ names: unsupportedLiveMixedPrimaryMutationNames.join(', '),
+ })}
) : null}
{isTeamAlive && liveRuntimeRefreshMemberNames.length > 0 ? (
- Saving will restart or relaunch{' '}
- {liveRuntimeRefreshMemberNames.length === 1 ? 'this teammate' : 'these teammates'} to
- apply role, workflow, worktree isolation, provider, model, effort, or MCP access
- changes: {liveRuntimeRefreshMemberNames.join(', ')}.
+ {liveRuntimeRefreshMemberNames.length === 1
+ ? t('editTeam.notices.restartOne', {
+ names: liveRuntimeRefreshMemberNames.join(', '),
+ })
+ : t('editTeam.notices.restartMany', {
+ names: liveRuntimeRefreshMemberNames.join(', '),
+ })}
) : null}
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
- Color (optional)
+ {t('editTeam.fields.colorOptional')}
{TEAM_COLOR_NAMES.map((colorName) => {
@@ -752,7 +761,7 @@ export const EditTeamDialog = ({
- Cancel
+ {t('editTeam.actions.cancel')}
{saving && }
- Save
+ {t('editTeam.actions.save')}
diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx
index a673bd4a..3dc91739 100644
--- a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx
+++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Label } from '@renderer/components/ui/label';
import { useEffectiveCliProviderStatus } from '@renderer/hooks/useEffectiveCliProviderStatus';
import { cn } from '@renderer/lib/utils';
@@ -28,6 +29,7 @@ export const EffortLevelSelector: React.FC
= ({
model,
limitContext,
}) => {
+ const { t } = useAppTranslation('team');
const { providerStatus } = useEffectiveCliProviderStatus(providerId);
const presentation = getTeamEffortSelectorPresentation({
providerId,
@@ -60,7 +62,7 @@ export const EffortLevelSelector: React.FC = ({
return (
- Effort level (optional)
+ {t('effortLevel.label')}
@@ -92,8 +94,7 @@ export const EffortLevelSelector: React.FC
= ({
) : null}
{showsAnthropicMax ? (
- Max is Anthropic's heavier reasoning mode and only appears when the resolved launch
- model supports it.
+ {t('effortLevel.maxDescription')}
) : null}
diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx
index 6c9e3b0d..069d9a09 100644
--- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx
+++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { buildTaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
@@ -20,6 +21,7 @@ import type { GlobalTask, TeamTaskWithKanban } from '@shared/types';
* without navigating to the team page first.
*/
export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const {
globalTaskDetail,
closeGlobalTaskDetail,
@@ -159,7 +161,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
onClick={handleOpenTeam}
>
- Open team
+ {t('dialogs.actions.openTeam')}
}
/>
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index 7d4fff47..41e50e3c 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -15,6 +15,7 @@ import {
resolveCodexFastMode,
resolveCodexRuntimeSelection,
} from '@features/codex-runtime-profile/renderer';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { ProviderActivityStatusStrip } from '@renderer/components/common/ProviderActivityStatusStrip';
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
@@ -351,6 +352,7 @@ function buildWorktreePathByMemberName(
export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Element => {
const { open, onClose } = props;
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const anthropicProviderFastModeDefault = useStore(
(s) => s.appConfig?.providerConnections?.anthropic.fastModeDefault ?? false
@@ -1593,9 +1595,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage(
- 'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
- );
+ setPrepareMessage(t('launch.prepare.unsupportedPreload'));
return;
}
@@ -1607,7 +1607,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setPrepareState('idle');
setPrepareWarnings([]);
setPrepareChecks([]);
- setPrepareMessage('Select a working directory to validate the launch environment.');
+ setPrepareMessage(t('launch.prepare.selectWorkingDirectory'));
return;
}
@@ -1635,7 +1635,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
);
const loadingMessage = getProvisioningProviderProgressMessage(
changedPlans.map((plan) => plan.providerId),
- selectedMemberProviders.length
+ selectedMemberProviders.length,
+ t
);
const getSelectedWarnings = (): string[] =>
selectedMemberProviders.flatMap(
@@ -1663,14 +1664,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedWarnings.length > 0 || nextChecks.some((check) => check.status === 'notes');
const failureMessage =
getPrimaryProvisioningFailureDetail(nextChecks) ??
- 'Some selected providers need attention.';
+ t('launch.prepare.someProvidersNeedAttention');
setPrepareState(anyFailure ? 'failed' : 'ready');
setPrepareMessage(
anyFailure
? failureMessage
: anyNotes
- ? 'All selected providers are ready, with notes.'
- : 'All selected providers are ready.'
+ ? t('launch.prepare.readyWithNotes')
+ : t('launch.prepare.ready')
);
};
@@ -1690,7 +1691,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
changedPlans.length > 0
? loadingMessage
: (prepareMessageRef.current ??
- getProvisioningProviderProgressMessage([], selectedMemberProviders.length))
+ getProvisioningProviderProgressMessage([], selectedMemberProviders.length, t))
);
if (changedPlans.length === 0) {
@@ -1769,7 +1770,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return;
}
const failureMessage =
- error instanceof Error ? error.message : 'Failed to prepare selected providers';
+ error instanceof Error ? error.message : t('launch.prepare.failed');
const nextChecks = updateProviderCheck(prepareChecksRef.current, plan.providerId, {
status: 'failed',
backendSummary: plan.backendSummary,
@@ -1793,6 +1794,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
selectedMemberProviders,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
+ t,
]);
// ---------------------------------------------------------------------------
@@ -1820,7 +1822,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
setProjects(nextProjects);
} catch (error) {
if (cancelled) return;
- setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
+ setProjectsError(
+ error instanceof Error ? error.message : t('launch.errors.loadProjectsFailed')
+ );
setProjects([]);
} finally {
if (!cancelled) setProjectsLoading(false);
@@ -1830,7 +1834,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return () => {
cancelled = true;
};
- }, [open, repositoryGroups, defaultProjectPath]);
+ }, [open, repositoryGroups, defaultProjectPath, t]);
// Pre-select defaultProjectPath (launch mode) or first project
@@ -2046,13 +2050,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const modelValidationError = useMemo(() => {
if (isLaunchMode && selectedProviderId === 'opencode') {
if (!selectedModel.trim()) {
- return 'OpenCode lead requires a selected model.';
+ return t('launch.validation.openCodeLeadModelRequired');
}
const activeMemberCount = effectiveMemberDrafts.filter(
(member) => !member.removedAt && member.name.trim()
).length;
if (activeMemberCount === 0) {
- return 'OpenCode lead requires at least one OpenCode teammate.';
+ return t('launch.validation.openCodeTeammateRequired');
}
}
@@ -2095,6 +2099,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
+ t,
]);
const leadModelIssueText = useMemo(() => {
const issue = getProvisioningModelIssue(
@@ -2162,8 +2167,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
message: prepareMessage,
warnings: prepareWarnings,
checks: prepareChecks,
+ t,
}),
- [prepareChecks, prepareMessage, prepareState, prepareWarnings]
+ [prepareChecks, prepareMessage, prepareState, prepareWarnings, t]
);
const showCodexReconnectPrompt = shouldShowCodexReconnectPrompt({
effectiveCliStatus,
@@ -2211,7 +2217,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
return;
}
if (isLaunchMode && !effectiveCwd) {
- setLocalError('Select working directory (cwd)');
+ setLocalError(t('launch.validation.selectWorkingDirectory'));
return;
}
if (
@@ -2220,7 +2226,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
(member) => !member.name.trim() || validateMemberNameInline(member.name.trim()) !== null
)
) {
- setLocalError('Fix member names before launch');
+ setLocalError(t('launch.validation.fixMemberNames'));
return;
}
if (isLaunchMode) {
@@ -2228,7 +2234,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
.map((member) => member.name.trim().toLowerCase())
.filter(Boolean);
if (new Set(activeNames).size !== activeNames.length) {
- setLocalError('Member names must be unique before launch');
+ setLocalError(t('launch.validation.memberNamesUnique'));
return;
}
}
@@ -2353,10 +2359,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
err instanceof Error
? err.message
: isSchedule
- ? 'Failed to save schedule'
+ ? t('launch.errors.saveScheduleFailed')
: isRelaunch
- ? 'Failed to relaunch team'
- : 'Failed to launch team';
+ ? t('launch.errors.relaunchFailed')
+ : t('launch.errors.launchFailed');
setLocalError(message);
if (isLaunchMode) {
console.error(
@@ -2392,47 +2398,49 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
const dialogTitle = isLaunchMode
? isRelaunch
- ? 'Relaunch Team'
- : 'Launch Team'
+ ? t('launch.title.relaunch')
+ : t('launch.title.launch')
: isEditing
- ? 'Edit Schedule'
- : 'Create Schedule';
+ ? t('launch.title.editSchedule')
+ : t('launch.title.createSchedule');
const dialogDescription = isLaunchMode ? (
isRelaunch ? (
<>
- Stop the current run for
{effectiveTeamName} {' '}
- and start it again via local Claude CLI.
+ {t('launch.description.relaunchPrefix')}{' '}
+
{effectiveTeamName} {' '}
+ {t('launch.description.relaunchSuffix')}
>
) : (
<>
- Start team
{effectiveTeamName} via local
- Claude CLI.
+ {t('launch.description.launchPrefix')}{' '}
+
{effectiveTeamName} {' '}
+ {t('launch.description.launchSuffix')}
>
)
) : isEditing ? (
- `Editing schedule for team "${effectiveTeamName}"`
+ t('launch.description.editSchedule', { team: effectiveTeamName })
) : effectiveTeamName ? (
- `Schedule automatic runs for team "${effectiveTeamName}"`
+ t('launch.description.createScheduleForTeam', { team: effectiveTeamName })
) : (
- 'Schedule automatic Claude task execution'
+ t('launch.description.createSchedule')
);
const submitLabel = isLaunchMode
? isRelaunch
- ? 'Relaunch team'
- : 'Launch team'
+ ? t('launch.actions.relaunchTeam')
+ : t('launch.actions.launchTeam')
: isEditing
- ? 'Save Changes'
- : 'Create Schedule';
+ ? t('launch.actions.saveChanges')
+ : t('launch.actions.createSchedule');
const submittingLabel = isLaunchMode
? isRelaunch
- ? 'Relaunching...'
- : 'Launching...'
+ ? t('launch.actions.relaunching')
+ : t('launch.actions.launching')
: isEditing
- ? 'Saving...'
- : 'Creating...';
+ ? t('launch.actions.saving')
+ : t('launch.actions.creating');
// ---------------------------------------------------------------------------
// Render
@@ -2467,11 +2475,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
-
Relaunch will restart the current team run
-
- Saving these settings will stop the current team process, persist the updated
- roster, and launch the team again with the new runtime.
-
+
{t('launch.relaunchWarning.title')}
+
{t('launch.relaunchWarning.description')}
@@ -2491,15 +2496,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Another team “{conflictingTeam.displayName}” is already running for
- this working directory
-
-
- Running two teams in the same directory is risky — they may conflict editing the
- same files. Consider using a different directory or a git worktree for isolation.
+ {t('launch.conflict.title', { team: conflictingTeam.displayName })}
+
{t('launch.conflict.description')}
- Working directory: {effectiveCwd}
+ {t('launch.conflict.workingDirectory')}{' '}
+ {effectiveCwd}
- Team
+ {t('launch.schedule.team')}
{
@@ -2595,7 +2597,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
)}
- Schedule
+ {t('launch.schedule.title')}
{!schedExpanded && (schedLabel || cronExpression) ? (
@@ -2609,14 +2611,14 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{/* Label */}
- Label (optional)
+ {t('launch.schedule.labelOptional')}
setSchedLabel(e.target.value)}
- placeholder="e.g., Daily code review, Nightly tests..."
+ placeholder={t('launch.schedule.labelPlaceholder')}
/>
@@ -2655,11 +2657,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
═══════════════════════════════════════════════════════════════════ */}
{isLaunchMode ? (
@@ -2782,7 +2788,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Prompt for team lead (optional)
+ {t('launch.prompt.teamLeadOptional')}
Saved
+
+ {t('launch.prompt.saved')}
+
) : null
}
/>
@@ -2826,10 +2834,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- 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.
+ {t('launch.providerChanged', {
+ from: getProviderLabel(previousProviderId!),
+ to: getProviderLabel(selectedProviderId),
+ })}
@@ -2844,10 +2852,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
>
-
- Team relaunch starts a fresh lead session. Durable team state, task board,
- and member configuration are rehydrated into the launch prompt.
-
+
{t('launch.relaunchFreshSession')}
@@ -2867,7 +2872,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : (
<>
-
Prompt
+
{t('launch.prompt.label')}
Saved
+
+ {t('launch.prompt.saved')}
+
) : null
}
/>
- This prompt will be passed to claude -p for
- one-shot execution
+ {t('launch.prompt.oneShotPrefix')} claude -p{' '}
+ {t('launch.prompt.oneShotSuffix')}
{selectedProviderId === 'anthropic' ? (
@@ -3064,13 +3069,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{effectivePrepare.message ??
(effectivePrepare.state === 'idle'
- ? 'Checking selected providers...'
- : 'Preparing environment...')}
+ ? t('launch.prepare.checkingProviders')
+ : t('launch.prepare.preparingEnvironment'))}
- Pre-flight check to catch errors before{' '}
- {isRelaunch ? 'relaunch' : 'launch'}
+ {t('launch.prepare.preflight', {
+ action: isRelaunch
+ ? t('launch.prepare.action.relaunch')
+ : t('launch.prepare.action.launch'),
+ })}
@@ -3092,8 +3100,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
{prepareChecks.some((check) => check.status === 'notes') ||
prepareWarnings.length > 0
- ? 'Selected providers ready (with notes)'
- : 'Selected providers ready'}
+ ? t('launch.prepare.readyWithNotes')
+ : t('launch.prepare.ready')}
{effectivePrepare.message ? (
@@ -3126,14 +3134,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
- Runtime environment is not available - {isRelaunch ? 'relaunch' : 'launch'}{' '}
- is blocked
+ {t('launch.prepare.blocked', {
+ action: isRelaunch
+ ? t('launch.prepare.action.relaunch')
+ : t('launch.prepare.action.launch'),
+ })}
- {effectivePrepare.message ?? 'Failed to prepare environment'}
+ {effectivePrepare.message ?? t('launch.prepare.failed')}
- Pre-flight check to catch errors before {isRelaunch ? 'relaunch' : 'launch'}
+ {t('launch.prepare.preflight', {
+ action: isRelaunch
+ ? t('launch.prepare.action.relaunch')
+ : t('launch.prepare.action.launch'),
+ })}
@@ -3165,7 +3180,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
) : null}
- {getProvisioningFailureHint(effectivePrepare.message, prepareChecks)}
+ {getProvisioningFailureHint(effectivePrepare.message, prepareChecks, t)}
{(effectivePrepare.message ?? '').toLowerCase().includes('spawn ') ||
prepareChecks.some((check) =>
@@ -3179,7 +3194,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
openDashboard();
}}
>
- Go to Dashboard
+ {t('launch.actions.goToDashboard')}
) : null}
diff --git a/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx b/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx
index 0f72ca62..e3bd2882 100644
--- a/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx
+++ b/src/renderer/components/team/dialogs/LimitContextCheckbox.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { HoverTooltip } from '@renderer/components/ui/hover-tooltip';
import { Label } from '@renderer/components/ui/label';
@@ -19,35 +20,38 @@ export const LimitContextCheckbox: React.FC
= ({
onCheckedChange,
disabled = false,
scopeLabel,
-}) => (
-
-
onCheckedChange(value === true)}
- />
-
- Limit context to 200K tokens
- {scopeLabel ? (
- ({scopeLabel})
- ) : null}
- {disabled && (always 200K for this model) }
-
-
- {
+ const { t } = useAppTranslation('team');
+ return (
+
+ onCheckedChange(value === true)}
/>
-
-
-);
+
+ {t('contextLimit.limitTo200k')}
+ {scopeLabel ? (
+ ({scopeLabel})
+ ) : null}
+ {disabled && {t('contextLimit.always200k')} }
+
+
+
+
+
+ );
+};
diff --git a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx
index 006e3d26..31835a27 100644
--- a/src/renderer/components/team/dialogs/MembersJsonEditor.tsx
+++ b/src/renderer/components/team/dialogs/MembersJsonEditor.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
@@ -46,6 +47,7 @@ export const MembersJsonEditor = ({
error,
onClose,
}: MembersJsonEditorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const containerRef = useRef(null);
const viewRef = useRef(null);
const onChangeRef = useRef(onChange);
@@ -112,7 +114,7 @@ export const MembersJsonEditor = ({
- Hide JSON
+ {t('dialogs.membersJson.hide')}
diff --git a/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx b/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx
index b1c8c9d0..6eba627f 100644
--- a/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx
+++ b/src/renderer/components/team/dialogs/OpenCodeContextConfigHint.tsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { ChevronDown, ChevronRight, ExternalLink, Info } from 'lucide-react';
@@ -28,6 +29,7 @@ const OPENCODE_CONTEXT_CONFIG_EXAMPLE = `{
}`;
export const OpenCodeContextConfigHint = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
return (
@@ -46,27 +48,21 @@ export const OpenCodeContextConfigHint = (): React.JSX.Element => {
)}
-
- OpenCode local models can use an OpenCode context budget instead of prompt-only limits.
-
+ {t('openCodeContextConfigHint.summary')}
{expanded ? (
-
- Add matching limits to the OpenCode config for the provider and model used by this
- teammate. This helps OpenCode compact and prune before local models overflow their
- context window.
-
+
{t('openCodeContextConfigHint.description')}
{OPENCODE_CONTEXT_CONFIG_EXAMPLE}
- Replace local and{' '}
- your-model with the provider and model IDs from your
- OpenCode setup. Prompt instructions like{' '}
- stay below 10000 tokens are weaker because the
- request is assembled before the model reads them.
+ {t('openCodeContextConfigHint.replacePrefix')} local{' '}
+ {t('openCodeContextConfigHint.and')} your-model{' '}
+ {t('openCodeContextConfigHint.replaceSuffix')}{' '}
+ stay below 10000 tokens{' '}
+ {t('openCodeContextConfigHint.promptInstructionsSuffix')}
diff --git a/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx b/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
index 7e0355e6..b6649a77 100644
--- a/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
+++ b/src/renderer/components/team/dialogs/OptionalSettingsSection.tsx
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { ChevronRight, Settings2 } from 'lucide-react';
@@ -63,6 +64,7 @@ export const OptionalSettingsSection = ({
className,
children,
}: OptionalSettingsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [isOpen, setIsOpen] = useState(defaultOpen);
const { isLight } = useTheme();
@@ -137,7 +139,7 @@ export const OptionalSettingsSection = ({
className="shrink-0 rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] font-medium"
style={{ color: headerMutedColor }}
>
- Optional
+ {t('dialogs.optional.badge')}
{!isOpen && chips.length > 0 ? (
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
index 49d8af7c..66c5a583 100644
--- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
+++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx
@@ -6,6 +6,7 @@ import { Button } from '@renderer/components/ui/button';
import { Combobox } from '@renderer/components/ui/combobox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { Check, FolderOpen, FolderX } from 'lucide-react';
@@ -62,14 +63,17 @@ function isDeletedOption(option: ComboboxOption): boolean {
return (option.meta as ProjectPathOptionMeta | undefined)?.filesystemState === 'deleted';
}
-function getSourceLabel(source: DashboardRecentProjectSource): string {
+function getSourceLabel(
+ source: DashboardRecentProjectSource,
+ t: ReturnType
['t']
+): string {
switch (source) {
case 'claude':
- return 'Found by Claude';
+ return t('projectPath.source.claude');
case 'codex':
- return 'Found by Codex';
+ return t('projectPath.source.codex');
case 'mixed':
- return 'Found by Claude and Codex';
+ return t('projectPath.source.mixed');
}
}
@@ -78,6 +82,7 @@ const ProjectSourceBadge = ({
}: {
source?: DashboardRecentProjectSource;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
if (!source) {
return null;
}
@@ -92,7 +97,7 @@ const ProjectSourceBadge = ({
return (
{logos.map((providerId) => (
@@ -101,15 +106,18 @@ const ProjectSourceBadge = ({
);
};
-const ProjectDeletedBadge = (): React.JSX.Element => (
-
-
- Deleted
-
-);
+const ProjectDeletedBadge = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+ {t('projectPath.deleted.label')}
+
+ );
+};
export type CwdMode = 'project' | 'custom';
@@ -138,6 +146,7 @@ export const ProjectPathSelector = ({
projectsError,
fieldError,
}: ProjectPathSelectorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const projectOptions = React.useMemo(
() => buildProjectPathOptions(projects, selectedProjectPath),
[projects, selectedProjectPath]
@@ -145,7 +154,7 @@ export const ProjectPathSelector = ({
return (
-
Project
+
{t('projectPath.label')}
@@ -159,7 +168,7 @@ export const ProjectPathSelector = ({
)}
onClick={() => onCwdModeChange('project')}
>
- From project list
+ {t('projectPath.mode.projectList')}
onCwdModeChange('custom')}
>
- Custom path
+ {t('projectPath.mode.customPath')}
@@ -185,9 +194,13 @@ export const ProjectPathSelector = ({
options={projectOptions}
value={selectedProjectPath}
onValueChange={onSelectedProjectPathChange}
- placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
- searchPlaceholder="Search project by name or path"
- emptyMessage="Nothing found"
+ placeholder={
+ projectsLoading
+ ? t('projectPath.loadingProjects')
+ : t('projectPath.selectProject')
+ }
+ searchPlaceholder={t('projectPath.searchPlaceholder')}
+ emptyMessage={t('projectPath.empty')}
disabled={projectsLoading || projectOptions.length === 0}
renderTriggerLabel={(option) => (
@@ -229,13 +242,13 @@ export const ProjectPathSelector = ({
{!selectedProjectPath ? (
- Select a project from the list
+ {t('projectPath.selectFromList')}
) : null}
{projectsError ?
{projectsError}
: null}
{!projectsLoading && projectOptions.length === 0 ? (
- No projects found, switch to custom path.
+ {t('projectPath.noProjects')}
) : null}
@@ -246,7 +259,7 @@ export const ProjectPathSelector = ({
onCustomCwdChange(event.target.value)}
placeholder="/absolute/path/to/project"
/>
@@ -266,11 +279,11 @@ export const ProjectPathSelector = ({
})();
}}
>
- Browse
+ {t('projectPath.browse')}
- If the directory does not exist, it will be created automatically.
+ {t('projectPath.createAutomatically')}
)}
diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
index dcda6c15..fe6957e7 100644
--- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
+++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { formatProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
import {
@@ -14,6 +15,8 @@ import type {
TeamProvisioningSupportDiagnostic,
} from '@shared/types';
+type TeamTranslator = ReturnType['t'];
+
export type ProvisioningProviderCheckStatus = 'pending' | 'checking' | 'ready' | 'notes' | 'failed';
export type ProvisioningPrepareState = 'idle' | 'loading' | 'ready' | 'failed';
@@ -144,17 +147,26 @@ export function failIncompleteProviderChecks(
export function getProvisioningProviderProgressMessage(
providerIds: readonly TeamProviderId[],
- totalProviderCount: number
+ totalProviderCount: number,
+ t?: TeamTranslator
): string {
if (providerIds.length === 0 || providerIds.length === totalProviderCount) {
- return 'Checking selected providers in parallel...';
+ return t
+ ? t('provisioning.providerStatus.progress.checkingSelectedProviders')
+ : 'Checking selected providers in parallel...';
}
if (providerIds.length === 1) {
- return `Checking ${getProvisioningProviderLabel(providerIds[0])} provider...`;
+ const provider = getProvisioningProviderLabel(providerIds[0]);
+ return t
+ ? t('provisioning.providerStatus.progress.checkingProvider', { provider })
+ : `Checking ${provider} provider...`;
}
- return `Checking ${providerIds.map(getProvisioningProviderLabel).join(', ')} providers...`;
+ const providers = providerIds.map(getProvisioningProviderLabel).join(', ');
+ return t
+ ? t('provisioning.providerStatus.progress.checkingProviders', { providers })
+ : `Checking ${providers} providers...`;
}
type ProvisioningDetailSummary =
@@ -238,6 +250,25 @@ function getStatusLabel(status: ProvisioningProviderCheckStatus): string {
}
}
+function getLocalizedStatusLabel(
+ status: ProvisioningProviderCheckStatus,
+ t: TeamTranslator
+): string {
+ switch (status) {
+ case 'checking':
+ return t('provisioning.providerStatus.status.checking');
+ case 'ready':
+ return t('provisioning.providerStatus.status.ready');
+ case 'notes':
+ return t('provisioning.providerStatus.status.notes');
+ case 'failed':
+ return t('provisioning.providerStatus.status.failed');
+ case 'pending':
+ default:
+ return t('provisioning.providerStatus.status.pending');
+ }
+}
+
function summarizeDetail(
detail: string,
status: ProvisioningProviderCheckStatus,
@@ -349,7 +380,59 @@ function summarizeDetail(
return null;
}
-function getModelDetailSummary(details: string[]): string | null {
+function localizeProvisioningDetailSummary(
+ summary: ProvisioningDetailSummary,
+ t: TeamTranslator
+): string {
+ switch (summary) {
+ case 'CLI binary missing':
+ return t('provisioning.providerStatus.detailSummary.cliBinaryMissing');
+ case 'OpenCode runtime missing':
+ return t('provisioning.providerStatus.detailSummary.openCodeRuntimeMissing');
+ case 'OpenCode Windows access blocked':
+ return t('provisioning.providerStatus.detailSummary.openCodeWindowsAccessBlocked');
+ case 'OpenCode runtime check returned no output':
+ return t('provisioning.providerStatus.detailSummary.openCodeNoOutput');
+ case 'OpenCode app MCP unreachable':
+ return t('provisioning.providerStatus.detailSummary.openCodeMcpUnreachable');
+ case 'Working directory missing':
+ return t('provisioning.providerStatus.detailSummary.workingDirectoryMissing');
+ case 'CLI binary could not be started':
+ return t('provisioning.providerStatus.detailSummary.cliBinaryCouldNotStart');
+ case 'CLI preflight did not complete':
+ return t('provisioning.providerStatus.detailSummary.cliPreflightIncomplete');
+ case 'Authentication required':
+ return t('provisioning.providerStatus.detailSummary.authenticationRequired');
+ case 'Runtime provider is not configured':
+ return t('provisioning.providerStatus.detailSummary.runtimeProviderNotConfigured');
+ case 'CLI preflight failed':
+ return t('provisioning.providerStatus.detailSummary.cliPreflightFailed');
+ case 'Selected model compatible':
+ return t('provisioning.providerStatus.detailSummary.selectedModelCompatible');
+ case 'Selected model compatibility pending':
+ return t('provisioning.providerStatus.detailSummary.selectedModelCompatibilityPending');
+ case 'Selected model available':
+ return t('provisioning.providerStatus.detailSummary.selectedModelAvailable');
+ case 'Selected model verified':
+ return t('provisioning.providerStatus.detailSummary.selectedModelVerified');
+ case 'Selected model unavailable':
+ return t('provisioning.providerStatus.detailSummary.selectedModelUnavailable');
+ case 'Selected model verification timed out':
+ return t('provisioning.providerStatus.detailSummary.selectedModelTimedOut');
+ case 'Selected model check failed':
+ return t('provisioning.providerStatus.detailSummary.selectedModelCheckFailed');
+ case 'Selected model verification deferred':
+ return t('provisioning.providerStatus.detailSummary.selectedModelDeferred');
+ case 'Selected model ping not confirmed':
+ return t('provisioning.providerStatus.detailSummary.selectedModelPingNotConfirmed');
+ case 'Ready with notes':
+ return t('provisioning.providerStatus.detailSummary.readyWithNotes');
+ case 'Needs attention':
+ return t('provisioning.providerStatus.detailSummary.needsAttention');
+ }
+}
+
+function getModelDetailSummary(details: string[], t?: TeamTranslator): string | null {
let compatibilityPendingCount = 0;
let compatibleCount = 0;
let availableCount = 0;
@@ -428,37 +511,85 @@ function getModelDetailSummary(details: string[]): string | null {
const parts: string[] = [];
if (unavailableCount > 0) {
- parts.push(`${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.unavailable', { count: unavailableCount })
+ : `${unavailableCount} model${unavailableCount === 1 ? '' : 's'} unavailable`
+ );
}
if (checkFailedCount > 0) {
- parts.push(`${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.checkFailed', { count: checkFailedCount })
+ : `${checkFailedCount} model${checkFailedCount === 1 ? '' : 's'} check failed`
+ );
}
if (timedOutCount > 0) {
- parts.push(`${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.timedOut', { count: timedOutCount })
+ : `${timedOutCount} model${timedOutCount === 1 ? '' : 's'} timed out`
+ );
}
if (deferredCount > 0) {
- parts.push(`${deferredCount} verification deferred`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.deferred', { count: deferredCount })
+ : `${deferredCount} verification deferred`
+ );
}
if (pingNotConfirmedCount > 0) {
- parts.push(`${pingNotConfirmedCount} ping not confirmed`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.pingNotConfirmed', {
+ count: pingNotConfirmedCount,
+ })
+ : `${pingNotConfirmedCount} ping not confirmed`
+ );
}
if (compatibilityPendingCount > 0) {
- parts.push(`${compatibilityPendingCount} compatible, deep verification pending`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.compatibilityPending', {
+ count: compatibilityPendingCount,
+ })
+ : `${compatibilityPendingCount} compatible, deep verification pending`
+ );
}
if (compatibleCount > 0) {
- parts.push(`${compatibleCount} compatible`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.compatible', { count: compatibleCount })
+ : `${compatibleCount} compatible`
+ );
}
if (checkingCount > 0) {
- parts.push(`${checkingCount} checking`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.checking', { count: checkingCount })
+ : `${checkingCount} checking`
+ );
}
if (availableCount > 0) {
- parts.push(`${availableCount} available`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.available', { count: availableCount })
+ : `${availableCount} available`
+ );
}
if (verifiedCount > 0) {
- parts.push(`${verifiedCount} verified`);
+ parts.push(
+ t
+ ? t('provisioning.providerStatus.modelParts.verified', { count: verifiedCount })
+ : `${verifiedCount} verified`
+ );
}
- return parts.length > 0 ? `Selected model checks - ${parts.join(', ')}` : null;
+ return parts.length > 0
+ ? t
+ ? t('provisioning.providerStatus.modelChecksSummary', { details: parts.join(', ') })
+ : `Selected model checks - ${parts.join(', ')}`
+ : null;
}
function hasCompatibilityPendingDetails(checks: ProvisioningProviderCheck[]): boolean {
@@ -469,9 +600,9 @@ function hasCompatibilityPendingDetails(checks: ProvisioningProviderCheck[]): bo
);
}
-function getDisplayStatusText(check: ProvisioningProviderCheck): string {
+function getDisplayStatusText(check: ProvisioningProviderCheck, t?: TeamTranslator): string {
const publicDetails = getPublicProvisioningDetails(check.details);
- const modelSummary = getModelDetailSummary(publicDetails);
+ const modelSummary = getModelDetailSummary(publicDetails, t);
if (modelSummary) {
return modelSummary;
}
@@ -497,7 +628,10 @@ function getDisplayStatusText(check: ProvisioningProviderCheck): string {
summarizedDetails[0] ??
null)
: (summarizedDetails[0] ?? null);
- return summary ?? getStatusLabel(check.status);
+ if (summary) {
+ return t ? localizeProvisioningDetailSummary(summary, t) : summary;
+ }
+ return t ? getLocalizedStatusLabel(check.status, t) : getStatusLabel(check.status);
}
function getDetailTone(
@@ -614,6 +748,7 @@ export function deriveEffectiveProvisioningPrepareState(params: {
message: string | null;
warnings: string[];
checks: ProvisioningProviderCheck[];
+ t?: TeamTranslator;
}): { state: ProvisioningPrepareState; message: string | null } {
if (params.state !== 'loading') {
return {
@@ -637,6 +772,7 @@ export function deriveEffectiveProvisioningPrepareState(params: {
return {
state: params.state,
message:
+ params.t?.('provisioning.providerStatus.deepVerificationPending') ??
'Deep verification is still running. OpenCode free models may take around 20 seconds.',
};
}
@@ -652,6 +788,7 @@ export function deriveEffectiveProvisioningPrepareState(params: {
message:
getPrimaryProvisioningFailureDetail(params.checks) ??
params.message ??
+ params.t?.('create.prepare.someProvidersNeedAttention') ??
'Some selected providers need attention.',
};
}
@@ -662,8 +799,9 @@ export function deriveEffectiveProvisioningPrepareState(params: {
return {
state: 'ready',
message: hasNotes
- ? 'All selected providers are ready, with notes.'
- : 'All selected providers are ready.',
+ ? (params.t?.('create.prepare.readyWithNotes') ??
+ 'All selected providers are ready, with notes.')
+ : (params.t?.('create.prepare.ready') ?? 'All selected providers are ready.'),
};
}
@@ -720,7 +858,8 @@ const StatusIcon = ({ status }: { status: ProvisioningProviderCheckStatus }): Re
};
function getProvisioningProviderSettingsActionLabel(
- check: ProvisioningProviderCheck
+ check: ProvisioningProviderCheck,
+ t?: TeamTranslator
): string | null {
if (check.status !== 'notes' && check.status !== 'failed') {
return null;
@@ -748,10 +887,24 @@ function getProvisioningProviderSettingsActionLabel(
combined.includes('api key mode is selected');
return hasActionableProviderSetupDetail
- ? `Open ${getProvisioningProviderLabel(check.providerId)} settings`
+ ? t
+ ? t('provisioning.providerStatus.openProviderSettings', {
+ provider: getProvisioningProviderLabel(check.providerId),
+ })
+ : `Open ${getProvisioningProviderLabel(check.providerId)} settings`
: null;
}
+function getDisplayDetailText(
+ detail: string,
+ status: ProvisioningProviderCheckStatus,
+ providerId: TeamProviderId,
+ t: TeamTranslator
+): string {
+ const summary = summarizeDetail(detail, status, providerId);
+ return summary ? localizeProvisioningDetailSummary(summary, t) : detail;
+}
+
function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null {
if (check.providerId !== 'opencode') {
return null;
@@ -773,6 +926,7 @@ export const ProvisioningProviderStatusList = ({
suppressDetailsMatching?: string | null;
onOpenProviderSettings?: (providerId: TeamProviderId) => void;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [copiedDiagnosticsKey, setCopiedDiagnosticsKey] = React.useState(null);
if (checks.length === 0) {
@@ -804,7 +958,7 @@ export const ProvisioningProviderStatusList = ({
(detail) => detail.trim() !== suppressDetailsMatchingTrimmed
);
const settingsActionLabel = onOpenProviderSettings
- ? getProvisioningProviderSettingsActionLabel(check)
+ ? getProvisioningProviderSettingsActionLabel(check, t)
: null;
const supportDiagnosticsPayload = getSupportDiagnosticsPayload(check);
const supportDiagnosticsKey =
@@ -822,7 +976,7 @@ export const ProvisioningProviderStatusList = ({
{getProvisioningProviderLabel(check.providerId)}
{check.backendSummary ? ` (${check.backendSummary})` : ''}:{' '}
- {getDisplayStatusText(check)}
+ {getDisplayStatusText(check, t)}
{visibleDetails.length > 0 ? (
@@ -836,7 +990,7 @@ export const ProvisioningProviderStatusList = ({
check.providerId
)}`}
>
- {detail}
+ {getDisplayDetailText(detail, check.status, check.providerId, t)}
))}
@@ -871,7 +1025,9 @@ export const ProvisioningProviderStatusList = ({
}
>
{copiedDiagnostics ? : }
- {copiedDiagnostics ? 'Copied' : 'Copy diagnostics'}
+ {copiedDiagnostics
+ ? t('provisioning.providerStatus.copied')
+ : t('provisioning.providerStatus.copyDiagnostics')}
) : null}
@@ -884,7 +1040,8 @@ export const ProvisioningProviderStatusList = ({
export function getProvisioningFailureHint(
message: string | null | undefined,
- checks: ProvisioningProviderCheck[]
+ checks: ProvisioningProviderCheck[],
+ t?: TeamTranslator
): string {
const failedOpenCodeChecks = checks.filter(
(check) => check.providerId === 'opencode' && check.status === 'failed'
@@ -904,14 +1061,20 @@ export function getProvisioningFailureHint(
(normalizedMessage === OPENCODE_WINDOWS_ACCESS_DENIED_MESSAGE ||
(!hasFailedNonOpenCodeCheck && isOpenCodeWindowsAccessDeniedDiagnostic(normalizedMessage)));
if (hasOpenCodeAccessDeniedDetail || hasOpenCodeAccessDeniedMessage) {
- return 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeAccessDenied') ??
+ 'Fix folder permissions or move the project to a user-writable folder. Running as administrator is only a temporary workaround.'
+ );
}
const hasOpenCodeBridgeNoOutputMessage =
failedOpenCodeChecks.length > 0 &&
!hasFailedNonOpenCodeCheck &&
isOpenCodeBridgeNoOutputDiagnostic(normalizedMessage);
if (hasOpenCodeBridgeNoOutputDetail || hasOpenCodeBridgeNoOutputMessage) {
- return 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeBridgeNoOutput') ??
+ 'Restart the app and OpenCode runtime, then retry. If it repeats, copy diagnostics.'
+ );
}
const combined = [message ?? '', ...checks.flatMap((check) => check.details)]
@@ -919,27 +1082,42 @@ export function getProvisioningFailureHint(
.toLowerCase();
if (combined.includes('working directory does not exist:')) {
- return 'Choose an existing working directory, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.workingDirectoryMissing') ??
+ 'Choose an existing working directory, then reopen this dialog.'
+ );
}
if (combined.includes('not authenticated') || combined.includes('not logged in')) {
- return 'Authenticate the required provider in Claude CLI, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.authenticationRequired') ??
+ 'Authenticate the required provider in Claude CLI, then reopen this dialog.'
+ );
}
if (combined.includes('provider is not configured for runtime use')) {
- return 'Configure the selected provider runtime, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.runtimeProviderNotConfigured') ??
+ 'Configure the selected provider runtime, then reopen this dialog.'
+ );
}
if (
combined.includes('opencode cli not detected on path') ||
combined.includes('opencode cli not found') ||
combined.includes('opencode runtime binary is not installed')
) {
- return 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeRuntimeMissing') ??
+ 'Install or retry OpenCode runtime from the provider status card, then reopen this dialog.'
+ );
}
if (
combined.includes('opencode app mcp is unreachable') ||
(combined.includes('unable to connect') &&
(combined.includes('/experimental/tool') || combined.includes('mcp_unavailable')))
) {
- return 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.openCodeAppMcpUnreachable') ??
+ 'Retry launch to refresh the OpenCode app MCP bridge. If it repeats, restart the app and OpenCode runtime.'
+ );
}
if (
combined.includes('spawn ') ||
@@ -949,8 +1127,14 @@ export function getProvisioningFailureHint(
combined.includes('bad cpu type in executable') ||
combined.includes('image not found')
) {
- return 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.cliBinaryMissing') ??
+ 'Make sure the local Claude CLI binary exists and can be started, then reopen this dialog.'
+ );
}
- return 'Resolve the issue above, then reopen this dialog.';
+ return (
+ t?.('provisioning.providerStatus.failureHints.default') ??
+ 'Resolve the issue above, then reopen this dialog.'
+ );
}
diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx
index 9e843ef0..c32e3274 100644
--- a/src/renderer/components/team/dialogs/ReviewDialog.tsx
+++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
Dialog,
DialogContent,
@@ -41,6 +42,7 @@ export const ReviewDialog = ({
onCancel,
onSubmit,
}: ReviewDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const draft = useDraftPersistence({
@@ -81,7 +83,7 @@ export const ReviewDialog = ({
>
- Request Changes
+ {t('reviewDialog.title')}
Task #{taskId ? deriveTaskDisplayId(taskId) : ''}
@@ -90,7 +92,7 @@ export const ReviewDialog = ({
id="review-comment"
value={draft.value}
onValueChange={draft.setValue}
- placeholder="Describe what needs to change... (Enter to submit)"
+ placeholder={t('reviewDialog.placeholder')}
suggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
projectPath={projectPath}
@@ -105,7 +107,7 @@ export const ReviewDialog = ({
onClick={handleSubmit}
>
- Submit
+ {t('reviewDialog.submit')}
}
footerRight={
@@ -114,11 +116,13 @@ export const ReviewDialog = ({
- {remaining} chars left
+ {t('reviewDialog.charsLeft', { count: remaining })}
) : null}
{draft.isSaved ? (
- Saved
+
+ {t('reviewDialog.saved')}
+
) : null}
}
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
index 307efcda..b88c8068 100644
--- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx
+++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
@@ -113,6 +114,8 @@ export const SendMessageDialog = ({
onSend,
onClose,
}: SendMessageDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const { t: tCommon } = useAppTranslation('common');
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
const [quote, setQuote] = useState
(undefined);
@@ -167,13 +170,13 @@ export const SendMessageDialog = ({
const canAttach = supportsAttachments && canAddMore;
const attachmentRestrictionReason = !supportsAttachments
? !isTeamAlive
- ? 'Team must be online to attach files'
+ ? t('sendMessage.attachments.teamOnlineRequired')
: !showAttachmentControl
- ? 'Files can be sent to the team lead or OpenCode teammates'
+ ? t('sendMessage.attachments.recipientUnsupported')
: (memberAttachmentUnavailableReason ??
(isOpenCodeRecipient
- ? 'Team must be online to attach files for OpenCode teammates'
- : 'Team must be online to attach files'))
+ ? t('sendMessage.attachments.openCodeOnlineRequired')
+ : t('sendMessage.attachments.teamOnlineRequired')))
: undefined;
// Auto-switch to delegate when lead recipient is selected, but don't
@@ -334,7 +337,7 @@ export const SendMessageDialog = ({
setFileRestrictionError(
attachmentRestrictionReason ??
attachmentPayloadRestrictionReason ??
- 'Files can be sent to the team lead or OpenCode teammates'
+ t('sendMessage.attachments.recipientUnsupported')
);
window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => {
@@ -467,25 +470,25 @@ export const SendMessageDialog = ({
/>
- Send Message
- Send a direct message to a team member.
+ {t('sendMessage.title')}
+ {t('sendMessage.description')}
- Recipient
+ {t('sendMessage.recipientLabel')}
setMember(v ?? '')}
- placeholder="Select member..."
+ placeholder={t('sendMessage.selectMemberPlaceholder')}
size="sm"
/>
-
Message
+
{t('sendMessage.messageLabel')}
{showAttachmentControl ? (
<>
{canAttach
- ? 'Attach files (paste or drag & drop)'
- : (attachmentRestrictionReason ?? 'Attachments are unavailable')}
+ ? t('sendMessage.attachments.attachFiles')
+ : (attachmentRestrictionReason ?? t('sendMessage.attachments.unavailable'))}
>
@@ -530,7 +533,7 @@ export const SendMessageDialog = ({
disabledHint={
attachmentPayloadRestrictionReason ??
attachmentRestrictionReason ??
- 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
+ t('sendMessage.attachments.disabledHint')
}
/>
@@ -552,12 +555,12 @@ export const SendMessageDialog = ({
-
Remove quote
+
{t('sendMessage.quote.remove')}
- Replying to
+ {t('sendMessage.quote.replyingTo')}
@@ -576,7 +579,7 @@ export const SendMessageDialog = ({
className="mt-0.5 text-[10px] text-blue-500 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
onClick={() => setQuoteExpanded((v) => !v)}
>
- {quoteExpanded ? 'less' : 'more'}
+ {quoteExpanded ? tCommon('actions.showLess') : tCommon('actions.showMore')}
) : null}
@@ -584,7 +587,7 @@ export const SendMessageDialog = ({
- {sending ? 'Sending...' : 'Send'}
+ {sending ? t('sendMessage.sending') : t('sendMessage.send')}
}
footerRight={
@@ -635,11 +638,13 @@ export const SendMessageDialog = ({
- {remaining} chars left
+ {t('sendMessage.charsLeft', { count: remaining })}
) : null}
{textDraft.isSaved ? (
- Saved
+
+ {t('sendMessage.saved')}
+
) : null}
diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx
index 90be9c98..dd500e9d 100644
--- a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx
+++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { Info } from 'lucide-react';
@@ -14,52 +15,53 @@ export const SkipPermissionsCheckbox: React.FC = (
id,
checked,
onCheckedChange,
-}) => (
- <>
-
- onCheckedChange(value === true)}
- />
-
- Auto-approve all tools
-
-
- {checked ? (
-
-
-
-
- Autonomous mode: team tools execute without confirmation. Be cautious with untrusted
- code.
-
-
+}) => {
+ const { t } = useAppTranslation('team');
+
+ return (
+ <>
+
+ onCheckedChange(value === true)}
+ />
+
+ {t('permissions.autoApproveAllTools')}
+
- ) : (
-
-
-
-
Manual mode: you'll approve or deny each tool call in real time.
+ {checked ? (
+
+
+
+
{t('permissions.autonomousModeDescription')}
+
-
- )}
- >
-);
+ ) : (
+
+
+
+
{t('permissions.manualModeDescription')}
+
+
+ )}
+ >
+ );
+};
diff --git a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
index 25c56832..990b3f37 100644
--- a/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
+++ b/src/renderer/components/team/dialogs/StatusHistoryTimeline.tsx
@@ -1,5 +1,6 @@
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import {
REVIEW_STATE_DISPLAY,
@@ -36,12 +37,13 @@ export const WorkflowTimeline = ({
implementationDurationTask,
nowMs,
}: WorkflowTimelineProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const implementationNowMs = nowMs ?? 0;
if (events.length === 0) {
return (
- No workflow history recorded
+ {t('taskDetail.workflowTimeline.empty')}
);
}
@@ -80,11 +82,13 @@ export const WorkflowTimeline = ({
className="shrink-0 rounded bg-[var(--color-bg-secondary)] px-1.5 py-0.5 font-mono text-[10px] text-[var(--color-text-muted)]"
title={
implementationDuration.running
- ? 'Current implementation interval'
- : 'Implementation interval ended at this transition'
+ ? t('taskDetail.workflowTimeline.currentImplementationInterval')
+ : t('taskDetail.workflowTimeline.implementationIntervalEnded')
}
>
- {implementationDuration.running ? 'running ' : ''}
+ {implementationDuration.running
+ ? t('taskDetail.workflowTimeline.runningPrefix')
+ : ''}
{formatTaskImplementationDuration(implementationDuration.elapsedMs)}
) : null}
@@ -120,16 +124,19 @@ const EventContent = ({
event: TaskHistoryEvent;
memberColorMap?: Map
;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
switch (event.type) {
case 'task_created':
return (
- Created as
+ {t('taskDetail.workflowTimeline.createdAs')}
{event.actor ? (
<>
- by
+
+ {t('taskDetail.workflowTimeline.by')}
+
{event.from && event.to ? (
<>
- Reassigned
+ {t('taskDetail.workflowTimeline.reassigned')}
) : event.to ? (
<>
- Assigned to
+ {t('taskDetail.workflowTimeline.assignedTo')}
) : event.from ? (
<>
- Unassigned from
+ {t('taskDetail.workflowTimeline.unassignedFrom')}
>
) : (
- 'Owner changed'
+ t('taskDetail.workflowTimeline.ownerChanged')
)}
);
@@ -198,7 +205,7 @@ const EventContent = ({
return (
- Review requested
+ {t('taskDetail.workflowTimeline.reviewRequested')}
{event.reviewer ? (
- Review started
+ {t('taskDetail.workflowTimeline.reviewStarted')}
);
case 'review_changes_requested':
return (
- Changes requested
+ {t('taskDetail.workflowTimeline.changesRequested')}
);
@@ -228,18 +235,19 @@ const EventContent = ({
return (
- Approved
+ {t('taskDetail.workflowTimeline.approved')}
);
default:
- return Unknown event ;
+ return {t('taskDetail.workflowTimeline.unknownEvent')} ;
}
};
const StatusBadge = ({ status }: { status: TeamTaskStatus }): React.JSX.Element => {
const style = TASK_STATUS_STYLES[status] ?? TASK_STATUS_STYLES.pending;
- const label = TASK_STATUS_LABELS[status] ?? status;
+ const { t } = useAppTranslation('team');
+ const label = TASK_STATUS_LABELS[status] ? getStatusLabel(status, t) : status;
return (
{
+ const { t } = useAppTranslation('team');
if (state === 'none') return null;
const display = REVIEW_STATE_DISPLAY[state];
if (!display) return null;
@@ -257,11 +266,47 @@ const ReviewStateBadge = ({ state }: { state: TeamReviewState }): React.JSX.Elem
- {display.label}
+ {getReviewStateLabel(state, t)}
);
};
+function getStatusLabel(
+ status: TeamTaskStatus,
+ t: ReturnType['t']
+): string {
+ switch (status) {
+ case 'pending':
+ return t('tasks.status.pending');
+ case 'in_progress':
+ return t('tasks.status.inProgress');
+ case 'completed':
+ return t('tasks.status.completed');
+ case 'deleted':
+ return t('tasks.status.deleted');
+ default:
+ return status;
+ }
+}
+
+function getReviewStateLabel(
+ state: TeamReviewState,
+ t: ReturnType['t']
+): string {
+ switch (state) {
+ case 'approved':
+ return t('taskDetail.reviewStates.approved');
+ case 'needsFix':
+ return t('taskDetail.reviewStates.needsFix');
+ case 'review':
+ return t('taskDetail.reviewStates.inReview');
+ case 'none':
+ return '';
+ default:
+ return state;
+ }
+}
+
function dotColor(event: TaskHistoryEvent): string {
switch (event.type) {
case 'task_created':
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx
index 5c48993f..982de654 100644
--- a/src/renderer/components/team/dialogs/TaskAttachments.tsx
+++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
@@ -23,6 +24,7 @@ export const TaskAttachments = ({
taskId,
attachments,
}: TaskAttachmentsProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const saveTaskAttachment = useStore((s) => s.saveTaskAttachment);
const deleteTaskAttachment = useStore((s) => s.deleteTaskAttachment);
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
@@ -242,7 +244,7 @@ export const TaskAttachments = ({
{/* Drop zone indicator */}
{dragOver ? (
- Drop image here
+ {t('taskAttachments.dropImageHere')}
) : null}
@@ -264,9 +266,11 @@ export const TaskAttachments = ({
onClick={() => fileInputRef.current?.click()}
>
{uploading ? : }
- Attach image
+ {t('taskAttachments.attachImage')}
- or paste / drag-drop
+
+ {t('taskAttachments.pasteOrDragDrop')}
+
{error ?
{error}
: null}
diff --git a/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx b/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx
index 516a1cc9..45353ee1 100644
--- a/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentAwaitingReply.tsx
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { computeAwaitingReply } from '@renderer/utils/taskCommentPendingReply';
@@ -24,6 +25,7 @@ export const TaskCommentAwaitingReply = ({
taskCreatedBy,
members,
}: TaskCommentAwaitingReplyProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const result = useMemo(
() => computeAwaitingReply(comments, taskOwner, taskCreatedBy),
@@ -42,11 +44,17 @@ export const TaskCommentAwaitingReply = ({
-
Awaiting reply from
+
+ {t('taskComments.awaitingReplyFrom')}
+
{result.awaitingFrom.map((name, i) => (
- {i > 0 && or }
+ {i > 0 && (
+
+ {t('taskComments.or')}
+
+ )}
))}
diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
index 6991e4e3..f1c26b3e 100644
--- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
@@ -54,6 +55,8 @@ export const TaskCommentInput = ({
replyTo,
onClearReply,
}: TaskCommentInputProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ const { t: tCommon } = useAppTranslation('common');
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
@@ -245,11 +248,13 @@ export const TaskCommentInput = ({
-
Cancel reply
+
{t('taskComments.cancelReply')}
- Replying to
+
+ {t('taskComments.replyingTo')}
+
setQuoteExpanded((v) => !v)}
>
- {quoteExpanded ? 'less' : 'more'}
+ {quoteExpanded ? tCommon('actions.showLess') : tCommon('actions.showMore')}
) : null}
@@ -347,7 +352,7 @@ export const TaskCommentInput = ({
-
Attach file (or paste)
+
{t('taskComments.attachFile')}
@@ -387,7 +392,7 @@ export const TaskCommentInput = ({
- Voice to text
+ {t('taskComments.voiceToText')}
void handleSubmit()}
>
- Comment
+ {t('taskComments.comment')}
}
@@ -406,11 +411,13 @@ export const TaskCommentInput = ({
- {remaining} chars left
+ {t('taskComments.charsLeft', { count: remaining })}
) : null}
{draft.isSaved ? (
- Saved
+
+ {t('taskComments.saved')}
+
) : null}
}
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
index 1ebf3dc1..a2bae29c 100644
--- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
+++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx
@@ -10,6 +10,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { ExpandableContent } from '@renderer/components/ui/ExpandableContent';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
@@ -93,6 +94,7 @@ export const TaskCommentsSection = ({
focusCommentId,
registerCommentForViewport,
}: TaskCommentsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const addTaskComment = useStore((s) => s.addTaskComment);
const addingComment = useStore((s) => s.addingComment);
const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null);
@@ -222,7 +224,7 @@ export const TaskCommentsSection = ({
{!hideHeader ? (
- Comments
+ {t('taskDetail.sections.comments')}
{comments.length > 0 ? (
{comments.length}
@@ -235,8 +237,10 @@ export const TaskCommentsSection = ({
{comments.length > MAX_COMMENTS_TO_RENDER ? (
- Showing the most recent {MAX_COMMENTS_TO_RENDER.toLocaleString()} comments to keep the
- UI responsive.
+ {t('taskDetail.comments.renderLimit', {
+ count: MAX_COMMENTS_TO_RENDER,
+ formattedCount: MAX_COMMENTS_TO_RENDER.toLocaleString(),
+ })}
) : null}
@@ -285,19 +289,19 @@ export const TaskCommentsSection = ({
{comment.type === 'review_approved' ? (
- Approved
+ {t('taskDetail.comments.badges.approved')}
) : comment.type === 'review_request' ? (
- Review requested
+ {t('taskDetail.comments.badges.reviewRequested')}
) : null}
{(() => {
const date = new Date(comment.createdAt);
return isNaN(date.getTime())
- ? 'unknown time'
+ ? t('taskDetail.comments.unknownTime')
: formatDistanceToNow(date, { addSuffix: true });
})()}
@@ -318,10 +322,12 @@ export const TaskCommentsSection = ({
}}
>
- Reply
+ {t('taskDetail.comments.actions.reply')}
-
Reply to comment
+
+ {t('taskDetail.comments.actions.replyToComment')}
+
@@ -405,7 +411,10 @@ export const TaskCommentsSection = ({
setVisibleCount((v) => Math.min(sortedComments.length, v + VISIBLE_COMMENTS_STEP))
}
>
- Show more comments ({visibleComments.length}/{sortedComments.length})
+ {t('taskDetail.comments.actions.showMore', {
+ visible: visibleComments.length,
+ total: sortedComments.length,
+ })}
) : null}
@@ -418,7 +427,7 @@ export const TaskCommentsSection = ({
open
onClose={() => setPreviewImageUrl(null)}
src={previewImageUrl}
- alt="Attachment preview"
+ alt={t('taskDetail.comments.attachments.previewAlt')}
/>
) : null}
@@ -428,7 +437,7 @@ export const TaskCommentsSection = ({
- Replying to
+ {t('taskDetail.comments.replyingTo')}
@@ -445,7 +454,9 @@ export const TaskCommentsSection = ({
- Cancel reply
+
+ {t('taskDetail.comments.actions.cancelReply')}
+
) : null}
@@ -453,7 +464,7 @@ export const TaskCommentsSection = ({
}
@@ -518,6 +531,7 @@ const CommentAttachmentThumbnail = ({
taskId,
onPreview,
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState
(null);
const [downloading, setDownloading] = useState(false);
@@ -586,7 +600,11 @@ const CommentAttachmentThumbnail = ({
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
- setDownloadError(err instanceof Error ? err.message : 'Download failed');
+ setDownloadError(
+ err instanceof Error
+ ? err.message
+ : t('taskDetail.comments.attachments.downloadFailed')
+ );
} finally {
setDownloading(false);
}
diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
index bdc25007..f5d69c8b 100644
--- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
+++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
@@ -158,6 +159,7 @@ export const TaskDetailDialog = ({
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const avatarMap = useMemo(() => buildMemberAvatarMap(members), [members]);
const { isLight } = useTheme();
+ const { t } = useAppTranslation('team');
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
@@ -463,7 +465,7 @@ export const TaskDetailDialog = ({
setTaskChangesReviewability(null);
}
setTaskChangesError(
- error instanceof Error ? error.message : 'Failed to load task changes summary'
+ error instanceof Error ? error.message : t('taskDetail.changes.loadFailed')
);
} finally {
taskChangesLoadInFlightKeysRef.current.delete(requestKey);
@@ -472,7 +474,14 @@ export const TaskDetailDialog = ({
}
}
},
- [canShowTaskChanges, currentTask, loadTaskChangeSummary, syncTaskChangeSummaryResult, variant]
+ [
+ canShowTaskChanges,
+ currentTask,
+ loadTaskChangeSummary,
+ syncTaskChangeSummaryResult,
+ t,
+ variant,
+ ]
);
useEffect(() => {
@@ -617,9 +626,9 @@ export const TaskDetailDialog = ({
? taskChangesFiles.length
: taskChangesFiles && taskChangesWarnings.length > 0
? taskChangesReviewability === 'attention_required'
- ? 'attention'
+ ? t('taskDetail.changes.badges.attention')
: taskChangesReviewability === 'diagnostic_only'
- ? 'no safe diff'
+ ? t('taskDetail.changes.badges.noSafeDiff')
: undefined
: undefined
: undefined;
@@ -652,11 +661,11 @@ export const TaskDetailDialog = ({
!v && onClose()}>
- Loading task…
+ {t('taskDetail.loading.title')}
- Fetching team data
+ {t('taskDetail.loading.fetchingTeamData')}
@@ -668,7 +677,7 @@ export const TaskDetailDialog = ({
!v && handleClose()}>
- Task not found
+ {t('taskDetail.notFound')}
@@ -870,7 +879,9 @@ export const TaskDetailDialog = ({
size="md"
/>
) : (
- Unassigned
+
+ {t('taskDetail.unassigned')}
+
)}
{currentTask.createdBy ? (
@@ -903,7 +914,7 @@ export const TaskDetailDialog = ({
}}
>
- Delete
+ {t('taskDetail.actions.delete')}
) : null}
@@ -920,8 +931,8 @@ export const TaskDetailDialog = ({
{currentTask.needsClarification === 'user'
- ? 'Awaiting clarification from you'
- : 'Awaiting clarification from team lead'}
+ ? t('taskDetail.clarification.awaitingUser')
+ : t('taskDetail.clarification.awaitingLead')}
- Mark resolved
+ {t('taskDetail.actions.markResolved')}
) : null}
@@ -947,14 +958,16 @@ export const TaskDetailDialog = ({
{(relatedIds.length > 0 || relatedByIds.length > 0) && (
- Related tasks
+ {t('taskDetail.related.title')}
)}
{relatedIds.length > 0 ? (
-
Links
+
+ {t('taskDetail.related.links')}
+
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
@@ -982,7 +995,9 @@ export const TaskDetailDialog = ({
{relatedByIds.length > 0 ? (
-
Linked from
+
+ {t('taskDetail.related.linkedFrom')}
+
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
const label = depTask
@@ -1012,7 +1027,7 @@ export const TaskDetailDialog = ({
- Blocked by
+ {t('taskDetail.related.blockedBy')}
{blockedByIds.map((id) => {
const depTask = taskMap.get(id);
@@ -1050,7 +1065,7 @@ export const TaskDetailDialog = ({
- Blocks
+ {t('taskDetail.related.blocks')}
{blocksIds.map((id) => {
const depTask = taskMap.get(id);
@@ -1091,7 +1106,7 @@ export const TaskDetailDialog = ({
{/* Description */}
}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
@@ -1103,7 +1118,7 @@ export const TaskDetailDialog = ({
)}
- Save
+ {t('taskDetail.actions.save')}
setEditingDescription(false)}
>
- Cancel
+ {t('taskDetail.actions.cancel')}
@@ -1176,7 +1191,7 @@ export const TaskDetailDialog = ({
-
Edit description
+
{t('taskDetail.description.edit')}
) : (
@@ -1185,14 +1200,14 @@ export const TaskDetailDialog = ({
className="text-xs text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={startEditDescription}
>
- Click to add description...
+ {t('taskDetail.description.add')}
)}
{/* Attachments */}
}
badge={attachmentCount}
contentClassName="pl-2.5"
@@ -1225,7 +1240,7 @@ export const TaskDetailDialog = ({
{variant === 'team' && canShowTaskChanges ? (
}
badge={taskChangesBadge}
headerExtra={
@@ -1245,7 +1260,7 @@ export const TaskDetailDialog = ({
handleRefreshChanges();
}}
disabled={taskChangesLoading}
- aria-label="Refresh changes"
+ aria-label={t('taskDetail.changes.refresh')}
>
-
Refresh
+
+ {t('taskDetail.changes.refreshShort')}
+
) : null
}
@@ -1266,7 +1283,7 @@ export const TaskDetailDialog = ({
{taskChangesLoading && (!taskChangesFiles || taskChangesFiles.length === 0) ? (
- Loading changes...
+ {t('taskDetail.changes.loading')}
) : taskChangesError ? (
{taskChangesError}
@@ -1299,7 +1316,9 @@ export const TaskDetailDialog = ({
))}
{taskChangesWarnings.length > 2 ? (
- {taskChangesWarnings.length - 2} more diagnostics
+ {t('taskDetail.changes.moreDiagnostics', {
+ count: taskChangesWarnings.length - 2,
+ })}
) : null}
@@ -1363,7 +1382,9 @@ export const TaskDetailDialog = ({
-
Review diff
+
+ {t('taskDetail.changes.reviewDiff')}
+
) : null}
{onOpenInEditor ? (
@@ -1380,7 +1401,9 @@ export const TaskDetailDialog = ({
-
Open in editor
+
+ {t('taskDetail.changes.openInEditor')}
+
) : null}
@@ -1391,16 +1414,18 @@ export const TaskDetailDialog = ({
{taskChangesWarnings.length > 0
? taskChangesReviewability === 'attention_required'
- ? 'No reviewable file changes recovered'
+ ? t('taskDetail.changes.empty.noReviewableChangesRecovered')
: taskChangesReviewability === 'diagnostic_only'
- ? 'No safe diff available'
- : 'No file changes recorded yet'
- : 'No file changes recorded'}
+ ? t('taskDetail.changes.empty.noSafeDiffAvailable')
+ : t('taskDetail.changes.empty.noFileChangesRecordedYet')
+ : t('taskDetail.changes.empty.noFileChangesRecorded')}
) : null}
) : changesSectionOpen ? (
-
No file changes recorded
+
+ {t('taskDetail.changes.empty.noFileChangesRecorded')}
+
) : null}
) : null}
@@ -1409,12 +1434,12 @@ export const TaskDetailDialog = ({
{variant === 'team' ? (
}
badge={taskLogStreamCount}
headerExtra={
taskLogActivityActive ? (
-
+
) : null
}
contentClassName="pl-2.5 overflow-visible"
@@ -1449,7 +1474,7 @@ export const TaskDetailDialog = ({
{kanbanTaskState.reviewer ? (
- Reviewer: {kanbanTaskState.reviewer}
+ {t('taskDetail.review.reviewer', { reviewer: kanbanTaskState.reviewer })}
) : null}
{kanbanTaskState.errorDescription ? (
@@ -1462,7 +1487,7 @@ export const TaskDetailDialog = ({
{/* Workflow History */}
{currentTask.historyEvents && currentTask.historyEvents.length > 0 ? (
}
badge={currentTask.historyEvents.length}
contentClassName="pl-2.5"
@@ -1472,10 +1497,14 @@ export const TaskDetailDialog = ({
showTaskImplementationDuration ? (
- In progress time {taskImplementationDurationLabel}
+
+ {t('taskDetail.workflow.inProgressTime', {
+ duration: taskImplementationDurationLabel,
+ })}
+
) : undefined
}
@@ -1492,7 +1521,7 @@ export const TaskDetailDialog = ({
{/* Comments */}
}
badge={
(currentTask.comments?.length ?? 0) > 0
@@ -1562,6 +1591,7 @@ const CommentImagesGrid = ({
teamName: string;
taskId: string;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [previewUrl, setPreviewUrl] = useState
(null);
return (
@@ -1569,7 +1599,7 @@ const CommentImagesGrid = ({
- From comments
+ {t('taskDetail.attachments.fromComments')}
@@ -1588,7 +1618,7 @@ const CommentImagesGrid = ({
open
onClose={() => setPreviewUrl(null)}
src={previewUrl}
- alt="Comment attachment"
+ alt={t('taskDetail.attachments.commentAttachment')}
/>
) : null}
@@ -1606,6 +1636,7 @@ const CommentImageThumbnail = ({
taskId: string;
onPreview: (dataUrl: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState(null);
@@ -1641,7 +1672,7 @@ const CommentImageThumbnail = ({
type="button"
className="group relative flex size-16 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => thumbUrl && onPreview(thumbUrl)}
- aria-label={`Preview ${item.attachment.filename}`}
+ aria-label={t('taskDetail.attachments.preview', { filename: item.attachment.filename })}
>
{thumbUrl ? (
diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
index c00ffee4..9533d6f5 100644
--- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx
+++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { isOpenCodeCatalogHydrating } from '@renderer/components/runtime/providerConnectionUi';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -139,6 +140,8 @@ interface OpenCodeModelPricingInfo {
title: string | undefined;
}
+type TeamTranslator = ReturnType['t'];
+
const MODEL_GRID_MIN_CARD_WIDTH_PX = 140;
const MODEL_GRID_GAP_PX = 6;
const OPENCODE_MODEL_GRID_MAX_HEIGHT_PX = 400;
@@ -166,19 +169,24 @@ function getOpenCodeSourceInfo(model: string): OpenCodeSourceInfo | null {
}
function getOpenCodeRouteGroup(
- catalogModel: ProviderModelCatalogItem | null | undefined
+ catalogModel: ProviderModelCatalogItem | null | undefined,
+ t: TeamTranslator
): OpenCodeRouteGroupInfo {
const routeKind = catalogModel?.metadata?.opencode?.routeKind;
if (routeKind === 'configured_local') {
- return { id: 'opencode-config', label: 'OpenCode config', rank: 0 };
+ return { id: 'opencode-config', label: t('modelSelector.routeGroups.openCodeConfig'), rank: 0 };
}
if (routeKind === 'builtin_free') {
- return { id: 'builtin-free', label: 'Free built-in', rank: 1 };
+ return { id: 'builtin-free', label: t('modelSelector.routeGroups.builtinFree'), rank: 1 };
}
if (routeKind === 'connected_provider') {
- return { id: 'connected-providers', label: 'Connected providers', rank: 2 };
+ return {
+ id: 'connected-providers',
+ label: t('modelSelector.routeGroups.connectedProviders'),
+ rank: 2,
+ };
}
- return { id: 'catalog-provider', label: 'Other OpenCode catalog', rank: 3 };
+ return { id: 'catalog-provider', label: t('modelSelector.routeGroups.otherCatalog'), rank: 3 };
}
function isRecommendedTeamModelRecommendation(
@@ -322,9 +330,9 @@ function extractOpenCodeCostRates(cost: unknown): OpenCodeModelCostRates | null
return Object.values(rates).some((rate) => rate !== null) ? rates : null;
}
-function formatOpenCodeCostRate(rate: number): string {
+function formatOpenCodeCostRate(rate: number, t: TeamTranslator): string {
if (rate === 0) {
- return 'Free';
+ return t('modelSelector.pricing.free');
}
const formatted = rate.toLocaleString('en-US', {
@@ -334,41 +342,61 @@ function formatOpenCodeCostRate(rate: number): string {
return `$${formatted}`;
}
-function formatOpenCodeCostSummary(rates: OpenCodeModelCostRates): string | null {
+function formatOpenCodeCostSummary(
+ rates: OpenCodeModelCostRates,
+ t: TeamTranslator
+): string | null {
const summaryParts: string[] = [];
if (rates.input !== null) {
- summaryParts.push(`in ${formatOpenCodeCostRate(rates.input)}`);
+ summaryParts.push(
+ t('modelSelector.pricing.inputShort', { rate: formatOpenCodeCostRate(rates.input, t) })
+ );
}
if (rates.output !== null) {
- summaryParts.push(`out ${formatOpenCodeCostRate(rates.output)}`);
+ summaryParts.push(
+ t('modelSelector.pricing.outputShort', { rate: formatOpenCodeCostRate(rates.output, t) })
+ );
}
if (summaryParts.length === 0) {
return null;
}
- return `${summaryParts.join(' · ')} / 1M`;
+ return t('modelSelector.pricing.perMillionSummary', { summary: summaryParts.join(' · ') });
}
-function formatOpenCodeCostTitle(rates: OpenCodeModelCostRates): string {
+function formatOpenCodeCostTitle(rates: OpenCodeModelCostRates, t: TeamTranslator): string {
const titleParts: string[] = [];
if (rates.input !== null) {
- titleParts.push(`Input: ${formatOpenCodeCostRate(rates.input)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.inputTitle', { rate: formatOpenCodeCostRate(rates.input, t) })
+ );
}
if (rates.output !== null) {
- titleParts.push(`Output: ${formatOpenCodeCostRate(rates.output)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.outputTitle', { rate: formatOpenCodeCostRate(rates.output, t) })
+ );
}
if (rates.cacheRead !== null) {
- titleParts.push(`Cache read: ${formatOpenCodeCostRate(rates.cacheRead)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.cacheReadTitle', {
+ rate: formatOpenCodeCostRate(rates.cacheRead, t),
+ })
+ );
}
if (rates.cacheWrite !== null) {
- titleParts.push(`Cache write: ${formatOpenCodeCostRate(rates.cacheWrite)} per 1M tokens`);
+ titleParts.push(
+ t('modelSelector.pricing.cacheWriteTitle', {
+ rate: formatOpenCodeCostRate(rates.cacheWrite, t),
+ })
+ );
}
return titleParts.join('\n');
}
function getOpenCodeModelPricingInfo(
- catalogModel: ProviderModelCatalogItem | null | undefined
+ catalogModel: ProviderModelCatalogItem | null | undefined,
+ t: TeamTranslator
): OpenCodeModelPricingInfo | null {
const metadata = catalogModel?.metadata;
if (!metadata) {
@@ -378,8 +406,8 @@ function getOpenCodeModelPricingInfo(
const rates = extractOpenCodeCostRates(metadata.cost);
return {
free: metadata.free === true,
- summary: rates ? formatOpenCodeCostSummary(rates) : null,
- title: rates ? formatOpenCodeCostTitle(rates) : undefined,
+ summary: rates ? formatOpenCodeCostSummary(rates, t) : null,
+ title: rates ? formatOpenCodeCostTitle(rates, t) : undefined,
};
}
@@ -421,66 +449,75 @@ export const OPENCODE_ONE_SHOT_DISABLED_REASON =
export const OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only';
function getOpenCodeReadinessBadgeLabel(
- providerStatus: CliProviderStatus | null | undefined
+ providerStatus: CliProviderStatus | null | undefined,
+ t: TeamTranslator
): string {
if (!providerStatus) {
- return 'Check';
+ return t('modelSelector.openCodeStatus.badges.check');
}
if (!providerStatus.supported) {
- return 'Install';
+ return t('modelSelector.openCodeStatus.badges.install');
}
if (!providerStatus.authenticated) {
- return 'Free';
+ return t('modelSelector.openCodeStatus.badges.free');
}
- return 'Setup';
+ return t('modelSelector.openCodeStatus.badges.setup');
}
-function getOpenCodeReadinessSummary(providerStatus: CliProviderStatus | null | undefined): string {
+function getOpenCodeReadinessSummary(
+ providerStatus: CliProviderStatus | null | undefined,
+ t: TeamTranslator
+): string {
if (!providerStatus) {
- return 'OpenCode status: checking runtime';
+ return t('modelSelector.openCodeStatus.summary.checking');
}
const runtimeReady = providerStatus.supported;
const hasFreeModelRoute = hasFreeOpenCodeModelRoute(providerStatus);
- let readinessSummary = 'team launch blocked';
+ let readinessSummary = t('modelSelector.openCodeStatus.summaryParts.teamLaunchBlocked');
if (runtimeReady) {
if (!providerStatus.authenticated) {
readinessSummary = hasFreeModelRoute
- ? 'provider connection optional'
- : 'provider-backed models need setup';
+ ? t('modelSelector.openCodeStatus.summaryParts.providerOptional')
+ : t('modelSelector.openCodeStatus.summaryParts.providerModelsNeedSetup');
} else if (providerStatus.capabilities.teamLaunch) {
- readinessSummary = 'team launch ready';
+ readinessSummary = t('modelSelector.openCodeStatus.summaryParts.teamLaunchReady');
}
}
const parts = [
- runtimeReady ? 'runtime detected' : 'runtime missing',
+ runtimeReady
+ ? t('modelSelector.openCodeStatus.summaryParts.runtimeDetected')
+ : t('modelSelector.openCodeStatus.summaryParts.runtimeMissing'),
runtimeReady && !providerStatus.authenticated && hasFreeModelRoute
- ? 'free models available without auth'
+ ? t('modelSelector.openCodeStatus.summaryParts.freeWithoutAuth')
: providerStatus.authenticated
- ? 'provider connected'
- : 'provider not connected',
+ ? t('modelSelector.openCodeStatus.summaryParts.providerConnected')
+ : t('modelSelector.openCodeStatus.summaryParts.providerNotConnected'),
readinessSummary,
];
- return `OpenCode status: ${parts.join(' · ')}`;
+ return t('modelSelector.openCodeStatus.summary.status', { parts: parts.join(' · ') });
}
-function getOpenCodeReadinessMessage(providerStatus: CliProviderStatus | null | undefined): string {
+function getOpenCodeReadinessMessage(
+ providerStatus: CliProviderStatus | null | undefined,
+ t: TeamTranslator
+): string {
if (!providerStatus) {
- return 'The app is still checking the OpenCode runtime. Wait for provider status to finish, then try again.';
+ return t('modelSelector.openCodeStatus.messages.checking');
}
if (!providerStatus.supported) {
- return 'OpenCode is not installed, not found, or the detected runtime is not supported. Install or update OpenCode, then refresh provider status. You can also use the Install button on the home page.';
+ return t('modelSelector.openCodeStatus.messages.unsupported');
}
if (!providerStatus.authenticated) {
if (hasFreeOpenCodeModelRoute(providerStatus)) {
- return 'OpenCode is detected. You can use free OpenCode models such as Big Pickle without connecting a provider. Connect a provider only when you want provider-backed models.';
+ return t('modelSelector.openCodeStatus.messages.freeAvailable');
}
- return 'OpenCode is detected, but no free OpenCode model is listed yet. Refresh provider status, or connect a provider in OpenCode for provider-backed models.';
+ return t('modelSelector.openCodeStatus.messages.noFreeListed');
}
if (!providerStatus.capabilities.teamLaunch) {
- return 'OpenCode is installed and authenticated, but Agent Teams launch readiness is blocked.';
+ return t('modelSelector.openCodeStatus.messages.launchBlocked');
}
- return 'OpenCode is ready for team launch.';
+ return t('modelSelector.openCodeStatus.messages.ready');
}
export function getTeamModelLabel(model: string): string {
@@ -667,47 +704,50 @@ const OpenCodeVirtualizedModelGrid = ({
);
};
-const OpenCodeModelCatalogLoadingSkeleton = (): React.JSX.Element => (
-
-
-
-
- Loading OpenCode models...
-
-
+const OpenCodeModelCatalogLoadingSkeleton = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
- {[0, 1, 2].map((index) => (
-
+
+
+
+ {t('modelSelector.openCode.loadingModels')}
+
+
+
+ {[0, 1, 2].map((index) => (
-
-
- ))}
+ key={index}
+ className="min-h-[44px] rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-3 py-2"
+ >
+
+
+
+ ))}
+
-
-);
+ );
+};
export interface TeamModelSelectorProps {
providerId: TeamProviderId;
@@ -738,6 +778,7 @@ export const TeamModelSelector: React.FC
= ({
modelIssueReasonByValue,
modelUnavailableReasonByValue,
}) => {
+ const { t } = useAppTranslation('team');
const multimodelEnabled = useStore((s) => s.appConfig?.general?.multimodelEnabled ?? true);
const [recommendedOnly, setRecommendedOnly] = useState(false);
const [freeOnly, setFreeOnly] = useState(false);
@@ -773,8 +814,10 @@ export const TeamModelSelector: React.FC = ({
runtimeProviderStatus?.modelCatalog?.defaultModelId?.trim() ||
null;
return defaultCompatibleModel
- ? `Uses the Anthropic-compatible endpoint default model.\nCurrently resolves to ${defaultCompatibleModel}.`
- : 'Uses the Anthropic-compatible endpoint default model.';
+ ? t('modelSelector.defaultTooltip.anthropicCompatibleWithResolved', {
+ model: defaultCompatibleModel,
+ })
+ : t('modelSelector.defaultTooltip.anthropicCompatible');
}
const defaultLongContextModel =
@@ -790,7 +833,10 @@ export const TeamModelSelector: React.FC = ({
runtimeProviderStatus
) ?? 'Opus 4.7';
- return `Uses the Claude team default model.\nResolves to ${defaultLongContextModel} with 1M context, or ${defaultLimitedContextModel} with 200K context when Limit context is enabled.`;
+ return t('modelSelector.defaultTooltip.anthropic', {
+ longContextModel: defaultLongContextModel,
+ limitedContextModel: defaultLimitedContextModel,
+ });
}
if (effectiveProviderId === 'opencode') {
const defaultOpenCodeModel =
@@ -798,11 +844,11 @@ export const TeamModelSelector: React.FC = ({
runtimeProviderStatus?.modelCatalog?.defaultModelId ??
null;
return defaultOpenCodeModel
- ? `Uses the OpenCode default model.\nCurrently resolves to ${defaultOpenCodeModel}.`
- : 'Uses the OpenCode runtime default model.';
+ ? t('modelSelector.defaultTooltip.openCodeWithResolved', { model: defaultOpenCodeModel })
+ : t('modelSelector.defaultTooltip.openCode');
}
- return 'Uses the runtime default for the selected provider.';
- }, [effectiveProviderId, runtimeProviderStatus]);
+ return t('modelSelector.defaultTooltip.runtime');
+ }, [effectiveProviderId, runtimeProviderStatus, t]);
const getProviderOverrideDisabledReason = (candidateProviderId: string): string | null => {
if (!isTeamProviderId(candidateProviderId)) {
return null;
@@ -819,7 +865,7 @@ export const TeamModelSelector: React.FC = ({
if (candidateProviderId === 'opencode') {
const providerStatus = runtimeProviderStatusById.get('opencode') ?? null;
if (!providerStatus) {
- return 'OpenCode runtime status is still loading.';
+ return t('modelSelector.openCodeStatus.loadingRuntime');
}
if (!providerStatus.supported) {
return (
@@ -864,7 +910,7 @@ export const TeamModelSelector: React.FC = ({
if (candidateProviderId === 'opencode') {
return getProviderDisabledReason(candidateProviderId)
- ? getOpenCodeReadinessBadgeLabel(runtimeProviderStatusById.get('opencode'))
+ ? getOpenCodeReadinessBadgeLabel(runtimeProviderStatusById.get('opencode'), t)
: null;
}
@@ -874,14 +920,14 @@ export const TeamModelSelector: React.FC = ({
}
if (!isProviderSelectable(candidateProviderId)) {
- return 'Multimodel off';
+ return t('modelSelector.multimodelOff');
}
return null;
};
const getProviderStatusBadgeLabel = (statusBadge: string | null): string | null => {
- if (statusBadge === 'Multimodel off') {
- return 'Off';
+ if (statusBadge === t('modelSelector.multimodelOff')) {
+ return t('modelSelector.fastMode.off');
}
return statusBadge;
@@ -907,10 +953,16 @@ export const TeamModelSelector: React.FC = ({
const modelOptions = useMemo(() => {
if (shouldAwaitRuntimeModelList) {
- return [{ value: '', label: 'Default', badgeLabel: 'Default' }];
+ return [
+ {
+ value: '',
+ label: t('modelSelector.defaultModel'),
+ badgeLabel: t('modelSelector.defaultModel'),
+ },
+ ];
}
return getAvailableTeamProviderModelOptions(effectiveProviderId, runtimeProviderStatus);
- }, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList]);
+ }, [effectiveProviderId, runtimeProviderStatus, shouldAwaitRuntimeModelList, t]);
const showAnthropicCompatibleCustomModelInput =
effectiveProviderId === 'anthropic' &&
canUseCustomAnthropicCompatibleModel(runtimeProviderStatus);
@@ -957,8 +1009,8 @@ export const TeamModelSelector: React.FC = ({
const sourceInfo = getOpenCodeSourceInfo(option.value);
const recommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
const catalogModel = openCodeCatalogModelById.get(option.value);
- const pricingInfo = getOpenCodeModelPricingInfo(catalogModel);
- const routeGroup = getOpenCodeRouteGroup(catalogModel);
+ const pricingInfo = getOpenCodeModelPricingInfo(catalogModel, t);
+ const routeGroup = getOpenCodeRouteGroup(catalogModel, t);
const routeMetadata = catalogModel?.metadata?.opencode ?? null;
return {
@@ -981,7 +1033,7 @@ export const TeamModelSelector: React.FC = ({
isFree: isFreeOpenCodeModelOption({ option, routeMetadata, pricingInfo }),
};
});
- }, [effectiveProviderId, modelOptions, openCodeCatalogModelById]);
+ }, [effectiveProviderId, modelOptions, openCodeCatalogModelById, t]);
const openCodeModelMetadataByValue = useMemo(
() => new Map(openCodeModelMetadata.map((metadata) => [metadata.option.value, metadata])),
[openCodeModelMetadata]
@@ -1110,10 +1162,12 @@ export const TeamModelSelector: React.FC = ({
const openCodeSourceFilterLabel =
selectedOpenCodeSourceLabels.length === 0
- ? 'All OpenCode sources'
+ ? t('modelSelector.openCode.allSources')
: selectedOpenCodeSourceLabels.length === 1
? selectedOpenCodeSourceLabels[0]
- : `${selectedOpenCodeSourceLabels.length} OpenCode sources`;
+ : t('modelSelector.openCode.sourcesCount', {
+ count: selectedOpenCodeSourceLabels.length,
+ });
const toggleOpenCodeSourceFilter = (sourceId: string): void => {
setSelectedOpenCodeSourceIds((previous) => {
@@ -1257,14 +1311,14 @@ export const TeamModelSelector: React.FC = ({
!shouldShowOpenCodeCatalogLoading &&
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
const emptyModelListMessage = trimmedModelQuery
- ? 'No models match this search.'
+ ? t('modelSelector.empty.noSearchMatches')
: effectiveProviderId === 'opencode' && recommendedOnly && freeOnly
- ? 'No recommended free OpenCode models are available in the current runtime list.'
+ ? t('modelSelector.empty.recommendedFreeOpenCode')
: effectiveProviderId === 'opencode' && freeOnly
- ? 'No free OpenCode models are available in the current runtime list.'
+ ? t('modelSelector.empty.freeOpenCode')
: effectiveProviderId === 'opencode' && recommendedOnly
- ? 'No recommended OpenCode models are available in the current runtime list.'
- : 'No models are available in the current runtime list.';
+ ? t('modelSelector.empty.recommendedOpenCode')
+ : t('modelSelector.empty.noModels');
const activeProviderDisabledReason = activeProviderSelectable
? null
: getProviderDisabledReason(effectiveProviderId);
@@ -1274,9 +1328,9 @@ export const TeamModelSelector: React.FC = ({
activeProviderDisabledReason && effectiveProviderId === 'opencode'
? {
tone: 'warning' as const,
- title: 'OpenCode is not ready for team launch',
- summary: getOpenCodeReadinessSummary(runtimeProviderStatus),
- message: getOpenCodeReadinessMessage(runtimeProviderStatus),
+ title: t('modelSelector.openCodeStatus.notReadyTitle'),
+ summary: getOpenCodeReadinessSummary(runtimeProviderStatus, t),
+ message: getOpenCodeReadinessMessage(runtimeProviderStatus, t),
reason: activeProviderDisabledReason,
actionLabel: null,
}
@@ -1286,27 +1340,28 @@ export const TeamModelSelector: React.FC = ({
? {
tone: 'warning' as const,
title: hasFreeOpenCodeModelRoute(runtimeProviderStatus)
- ? 'OpenCode free models are available'
- : 'OpenCode provider is not connected',
- summary: getOpenCodeReadinessSummary(runtimeProviderStatus),
- message: getOpenCodeReadinessMessage(runtimeProviderStatus),
+ ? t('modelSelector.openCodeStatus.freeModelsAvailableTitle')
+ : t('modelSelector.openCodeStatus.providerNotConnectedTitle'),
+ summary: getOpenCodeReadinessSummary(runtimeProviderStatus, t),
+ message: getOpenCodeReadinessMessage(runtimeProviderStatus, t),
reason: null,
actionLabel: null,
}
: canActivateInspectedOpenCode
? {
tone: 'ready' as const,
- title: 'OpenCode is ready',
- summary: getOpenCodeReadinessSummary(runtimeProviderStatus),
- message:
- 'OpenCode passed provider readiness. Select it to use OpenCode models for this team.',
+ title: t('modelSelector.openCodeStatus.readyTitle'),
+ summary: getOpenCodeReadinessSummary(runtimeProviderStatus, t),
+ message: t('modelSelector.openCodeStatus.readyMessage'),
reason: null,
- actionLabel: 'Use OpenCode',
+ actionLabel: t('modelSelector.openCodeStatus.useOpenCode'),
}
: null;
const activeProviderNotice = providerNoticeById?.[effectiveProviderId] ?? null;
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
- reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
+ reason?.toLowerCase().includes('ping not confirmed')
+ ? t('modelSelector.advisory.pingNotConfirmed')
+ : t('modelSelector.advisory.note');
const renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => {
const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId,
@@ -1318,7 +1373,7 @@ export const TeamModelSelector: React.FC = ({
const availabilityReason = opt.value === '' ? null : (opt.availabilityReason ?? null);
const runtimeUnavailableReason =
opt.value !== '' && availabilityStatus === 'unavailable'
- ? (availabilityReason ?? 'Unavailable in current runtime')
+ ? (availabilityReason ?? t('modelSelector.unavailableInRuntime'))
: null;
const modelAdvisoryReason =
opt.value === '' ? null : (modelAdvisoryReasonByValue?.[opt.value] ?? null);
@@ -1418,39 +1473,39 @@ export const TeamModelSelector: React.FC = ({
- Free
+ {t('modelSelector.badges.free')}
) : null}
{openCodeRouteKind === 'configured_local' ? (
- Local
+ {t('modelSelector.badges.local')}
) : null}
{openCodeRouteKind === 'configured_local' ? (
- Configured
+ {t('modelSelector.badges.configured')}
) : null}
{openCodeRouteKind === 'connected_provider' ? (
- Connected
+ {t('modelSelector.badges.connected')}
) : null}
{openCodeProofState === 'verified' ? (
- Verified
+ {t('modelSelector.badges.verified')}
) : null}
{openCodeProofState === 'needs_probe' ? (
- Needs test
+ {t('modelSelector.badges.needsTest')}
) : null}
{openCodeProofState === 'failed' ? (
- Failed
+ {t('modelSelector.badges.failed')}
) : null}
{modelRecommendation ? (
@@ -1501,7 +1556,11 @@ export const TeamModelSelector: React.FC = ({
title={modelStatusMessage ?? undefined}
>
- {modelUnavailableReason ? 'Unavailable' : 'Issue'}
+
+ {modelUnavailableReason
+ ? t('modelSelector.badges.unavailable')
+ : t('modelSelector.badges.issue')}
+
{modelStatusMessage ? (
= ({
return (
- Model (optional)
+ {t('modelSelector.label')}
= ({
{!multimodelAvailable ? (
- Codex and Gemini require Multimodel mode.
+ {t('modelSelector.multimodelRequired')}
) : null}
@@ -1670,7 +1729,11 @@ export const TeamModelSelector: React.FC = ({
{activeProviderStatusPanel.summary}
{activeProviderStatusPanel.message}
{activeProviderStatusPanel.reason ? (
- Reason: {activeProviderStatusPanel.reason}
+
+ {t('modelSelector.reason', {
+ reason: activeProviderStatusPanel.reason,
+ })}
+
) : null}
{activeProviderStatusPanel.actionLabel ? (
= ({
) : null}
{shouldAwaitRuntimeModelList ? (
- Explicit models load from the current runtime. Default remains available while the
- list is syncing.
+ {t('modelSelector.runtimeModelsSyncing')}
) : null}
{showAnthropicCompatibleCustomModelInput ? (
@@ -1700,14 +1762,14 @@ export const TeamModelSelector: React.FC = ({
htmlFor="anthropic-compatible-custom-model"
className="mb-1 block text-[11px] font-medium text-[var(--color-text-secondary)]"
>
- Custom model id
+ {t('modelSelector.customModelId')}
onValueChange(event.currentTarget.value.trim())}
- placeholder="openai/gpt-oss-20b"
+ placeholder={t('modelSelector.placeholders.customModelId')}
className="h-8 text-xs"
disabled={isInspectingInactiveProvider || !activeProviderSelectable}
/>
@@ -1725,8 +1787,8 @@ export const TeamModelSelector: React.FC = ({
data-testid="team-model-selector-model-search"
value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)}
- placeholder="Search models"
- aria-label="Search models"
+ placeholder={t('modelSelector.searchModels')}
+ aria-label={t('modelSelector.searchModels')}
className="h-9 pr-3 text-sm"
style={{ paddingLeft: 40 }}
/>
@@ -1751,7 +1813,7 @@ export const TeamModelSelector: React.FC = ({
selectedOpenCodeSourceIds.size > 0 &&
'border-[var(--color-border-emphasis)] text-[var(--color-text)]'
)}
- aria-label="Filter OpenCode sources"
+ aria-label={t('modelSelector.openCode.filterSources')}
>
{openCodeSourceFilterLabel}
@@ -1767,13 +1829,13 @@ export const TeamModelSelector: React.FC = ({
- No sources found.
+ {t('modelSelector.openCode.noSourcesFound')}
{selectedOpenCodeSourceIds.size > 0 && !openCodeSourceQuery.trim() ? (
= ({
className="flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-[var(--color-text-muted)] outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
- All OpenCode sources
+ {t('modelSelector.openCode.allSources')}
) : null}
{filteredOpenCodeSourceOptions.map((source) => {
@@ -1799,7 +1861,9 @@ export const TeamModelSelector: React.FC = ({
onCheckedChange={() => toggleOpenCodeSourceFilter(source.id)}
onClick={(event) => event.stopPropagation()}
className="size-3.5"
- aria-label={`Filter ${source.label}`}
+ aria-label={t('modelSelector.openCode.filterSource', {
+ source: source.label,
+ })}
/>
{source.label}
@@ -1827,7 +1891,7 @@ export const TeamModelSelector: React.FC = ({
htmlFor="opencode-team-model-recommended-only"
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
>
- Recommended only
+ {t('modelSelector.openCode.recommendedOnly')}
) : null}
@@ -1843,7 +1907,7 @@ export const TeamModelSelector: React.FC = ({
htmlFor="opencode-team-model-free-only"
className="cursor-pointer text-[11px] font-normal text-[var(--color-text-secondary)]"
>
- Free only
+ {t('modelSelector.openCode.freeOnly')}
) : null}
diff --git a/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx b/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx
index 73e6b9e4..ca969bd0 100644
--- a/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx
+++ b/src/renderer/components/team/dialogs/TeammateRuntimeCompatibilityNotice.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle, Info } from 'lucide-react';
@@ -14,6 +15,8 @@ export const TeammateRuntimeCompatibilityNotice = ({
analysis,
onOpenDashboard,
}: TeammateRuntimeCompatibilityNoticeProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
+
if (!analysis.visible) {
return null;
}
@@ -50,7 +53,7 @@ export const TeammateRuntimeCompatibilityNotice = ({
className="mt-1 h-7 px-2 text-[11px]"
onClick={onOpenDashboard}
>
- Open Dashboard
+ {t('dialogs.actions.openDashboard')}
) : null}
diff --git a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx
index 38003e2f..6a837da4 100644
--- a/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx
+++ b/src/renderer/components/team/dialogs/ToolApprovalSettingsPanel.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
Select,
@@ -17,31 +18,36 @@ import type { ToolApprovalSettings, ToolApprovalTimeoutAction } from '@shared/ty
export const ToolApprovalSettingsToggle: React.FC<{ expanded: boolean; onToggle: () => void }> = ({
expanded,
onToggle,
-}) => (
-
{
- Object.assign(e.currentTarget.style, {
- backgroundColor: 'var(--color-surface-raised)',
- });
- }}
- onMouseLeave={(e) => {
- Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
- }}
- >
-
- Settings
- {expanded ? : }
-
-);
+}) => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
{
+ Object.assign(e.currentTarget.style, {
+ backgroundColor: 'var(--color-surface-raised)',
+ });
+ }}
+ onMouseLeave={(e) => {
+ Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
+ }}
+ >
+
+ {t('toolApproval.settings')}
+ {expanded ? : }
+
+ );
+};
export const ToolApprovalSettingsContent: React.FC<{
expanded: boolean;
teamName?: string;
}> = ({ expanded, teamName }) => {
+ const { t } = useAppTranslation('team');
const [localSeconds, setLocalSeconds] = useState
('');
const settings = useStore(useShallow((s) => s.toolApprovalSettings));
const rawUpdateSettings = useStore((s) => s.updateToolApprovalSettings);
@@ -69,7 +75,7 @@ export const ToolApprovalSettingsContent: React.FC<{
checked={settings.autoAllowAll}
onCheckedChange={(checked) => void updateSettings({ autoAllowAll: checked === true })}
/>
- Auto-allow all tools
+ {t('toolApproval.autoAllowAllTools')}
{/* Separator */}
@@ -90,7 +96,7 @@ export const ToolApprovalSettingsContent: React.FC<{
void updateSettings({ autoAllowFileEdits: checked === true })
}
/>
- Auto-allow file edits (Edit, Write, NotebookEdit)
+ {t('toolApproval.autoAllowFileEdits')}
{/* Auto-allow safe bash */}
@@ -108,7 +114,7 @@ export const ToolApprovalSettingsContent: React.FC<{
void updateSettings({ autoAllowSafeBash: checked === true })
}
/>
- Auto-allow safe commands (git, pnpm, npm, ls...)
+ {t('toolApproval.autoAllowSafeCommands')}
{/* Separator */}
@@ -119,7 +125,7 @@ export const ToolApprovalSettingsContent: React.FC<{
className="flex items-center gap-2 text-xs"
style={{ color: 'var(--color-text-secondary)' }}
>
- On timeout:
+ {t('toolApproval.onTimeout')}
@@ -130,15 +136,15 @@ export const ToolApprovalSettingsContent: React.FC<{
- Wait forever
- Allow
- Deny
+ {t('toolApproval.timeoutActions.wait')}
+ {t('toolApproval.timeoutActions.allow')}
+ {t('toolApproval.timeoutActions.deny')}
{settings.timeoutAction !== 'wait' && (
<>
- after
+ {t('toolApproval.after')}
- sec
+ {t('toolApproval.secondsShort')}
>
)}
diff --git a/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx
index d86f7b03..45173a54 100644
--- a/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx
+++ b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle, CheckCircle2, GitBranch, Loader2 } from 'lucide-react';
@@ -154,6 +155,7 @@ export const WorktreeGitReadinessBanner = ({
state: WorktreeGitReadinessState;
showReady?: boolean;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const { status, loading, actionLoading, error, initializeRepository, createInitialCommit } =
state;
@@ -161,7 +163,7 @@ export const WorktreeGitReadinessBanner = ({
return (
-
Checking Git repository status for teammate worktrees...
+
{t('worktreeGitReadiness.checking')}
);
}
@@ -185,8 +187,9 @@ export const WorktreeGitReadinessBanner = ({
- Git worktrees are ready
- {status.branch ? ` on branch ${status.branch}` : ''}.
+ {status.branch
+ ? t('worktreeGitReadiness.readyOnBranch', { branch: status.branch })
+ : t('worktreeGitReadiness.ready')}
);
@@ -197,15 +200,15 @@ export const WorktreeGitReadinessBanner = ({
-
Worktree isolation needs Git setup
+
{t('worktreeGitReadiness.needsSetup')}
{status.message ??
'Worktree isolation requires a Git repository with an initial commit.'}
{status.reason === 'missing_head' ? (
- The initial commit action stages and commits all current files with message{' '}
- chore: initial commit .
+ {t('worktreeGitReadiness.initialCommitNotice')}{' '}
+ {t('worktreeGitReadiness.initialCommitMessage')} .
) : null}
@@ -221,7 +224,7 @@ export const WorktreeGitReadinessBanner = ({
onClick={initializeRepository}
>
{actionLoading === 'init' ?
: null}
- Initialize Git repository
+ {t('worktreeGitReadiness.initializeRepository')}
) : null}
{status.reason === 'missing_head' ? (
@@ -234,7 +237,7 @@ export const WorktreeGitReadinessBanner = ({
onClick={createInitialCommit}
>
{actionLoading === 'commit' ?
: null}
- Create initial commit
+ {t('worktreeGitReadiness.createInitialCommit')}
) : null}
diff --git a/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx b/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx
index 3b19beea..097de296 100644
--- a/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx
+++ b/src/renderer/components/team/editor/EditorBinaryPlaceholder.tsx
@@ -2,6 +2,7 @@
* Placeholder for non-previewable binary files — shows file info and "Open in System Viewer" button.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { FileQuestion } from 'lucide-react';
@@ -17,6 +18,7 @@ export const EditorBinaryPlaceholder = ({
fileName,
size,
}: EditorBinaryPlaceholderProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const sizeFormatted =
size < 1024
@@ -33,9 +35,9 @@ export const EditorBinaryPlaceholder = ({
{fileName}
-
Binary file ({sizeFormatted})
+
{t('editor.binaryPlaceholder.file', { size: sizeFormatted })}
- Open in System Viewer
+ {t('editor.imagePreview.openSystemViewer')}
);
diff --git a/src/renderer/components/team/editor/EditorEmptyState.tsx b/src/renderer/components/team/editor/EditorEmptyState.tsx
index 0e657b10..79d89c2f 100644
--- a/src/renderer/components/team/editor/EditorEmptyState.tsx
+++ b/src/renderer/components/team/editor/EditorEmptyState.tsx
@@ -3,25 +3,30 @@
* Shows keyboard shortcuts cheatsheet.
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { FileCode } from 'lucide-react';
-const SHORTCUTS = [
- { keys: shortcutLabel('⌘ P', 'Ctrl+P'), label: 'Quick Open' },
- { keys: shortcutLabel('⌘ ⇧ F', 'Ctrl+Shift+F'), label: 'Search in Files' },
- { keys: shortcutLabel('⌘ S', 'Ctrl+S'), label: 'Save' },
- { keys: shortcutLabel('⌘ B', 'Ctrl+B'), label: 'Toggle Sidebar' },
- { keys: shortcutLabel('⌘ G', 'Ctrl+G'), label: 'Go to Line' },
- { keys: 'Esc', label: 'Close Editor' },
-];
-
export const EditorEmptyState = (): React.ReactElement => {
+ const { t } = useAppTranslation('team');
+ const shortcuts = [
+ { keys: shortcutLabel('⌘ P', 'Ctrl+P'), label: t('editor.shortcuts.actions.quickOpen') },
+ {
+ keys: shortcutLabel('⌘ ⇧ F', 'Ctrl+Shift+F'),
+ label: t('editor.shortcuts.actions.searchInFiles'),
+ },
+ { keys: shortcutLabel('⌘ S', 'Ctrl+S'), label: t('editor.shortcuts.actions.save') },
+ { keys: shortcutLabel('⌘ B', 'Ctrl+B'), label: t('editor.shortcuts.actions.toggleSidebar') },
+ { keys: shortcutLabel('⌘ G', 'Ctrl+G'), label: t('editor.shortcuts.actions.goToLine') },
+ { keys: 'Esc', label: t('editor.shortcuts.actions.closeEditor') },
+ ];
+
return (
-
Select a file from the tree to edit
+
{t('editor.empty.selectFile')}
- {SHORTCUTS.map((s) => (
+ {shortcuts.map((s) => (
{s.label}
diff --git a/src/renderer/components/team/editor/EditorErrorBoundary.tsx b/src/renderer/components/team/editor/EditorErrorBoundary.tsx
index 67fd4db2..6c0b9753 100644
--- a/src/renderer/components/team/editor/EditorErrorBoundary.tsx
+++ b/src/renderer/components/team/editor/EditorErrorBoundary.tsx
@@ -7,12 +7,18 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle } from 'lucide-react';
interface Props {
filePath: string;
onRetry?: () => void;
children: React.ReactNode;
+ labels?: {
+ crashed: string;
+ unknownError: string;
+ retry: string;
+ };
}
interface State {
@@ -20,7 +26,7 @@ interface State {
error: string | null;
}
-export class EditorErrorBoundary extends React.Component {
+class EditorErrorBoundaryInner extends React.Component {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
@@ -46,14 +52,14 @@ export class EditorErrorBoundary extends React.Component {
>
- Editor crashed: {this.state.error ?? 'Unknown error'}
+ {this.props.labels?.crashed}: {this.state.error ?? this.props.labels?.unknownError}
- Retry
+ {this.props.labels?.retry}
);
@@ -61,3 +67,22 @@ export class EditorErrorBoundary extends React.Component
{
return <>{this.props.children}>;
}
}
+
+export const EditorErrorBoundary = ({
+ children,
+ ...props
+}: Omit): React.ReactElement => {
+ const { t } = useAppTranslation('team');
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/renderer/components/team/editor/EditorErrorState.tsx b/src/renderer/components/team/editor/EditorErrorState.tsx
index c0b64d2b..83b74dd9 100644
--- a/src/renderer/components/team/editor/EditorErrorState.tsx
+++ b/src/renderer/components/team/editor/EditorErrorState.tsx
@@ -2,6 +2,7 @@
* Error state for file read failures (EACCES, ENOENT, etc.).
*/
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { AlertTriangle } from 'lucide-react';
@@ -16,6 +17,7 @@ export const EditorErrorState = ({
onRetry,
onClose,
}: EditorErrorStateProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
return (
{onRetry && (
- Retry
+ {t('editor.actions.retry')}
)}
{onClose && (
- Close Tab
+ {t('editor.actions.closeTab')}
)}
diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx
index b637abe9..092064da 100644
--- a/src/renderer/components/team/editor/EditorFileTree.tsx
+++ b/src/renderer/components/team/editor/EditorFileTree.tsx
@@ -16,6 +16,7 @@ import {
useSensor,
useSensors,
} from '@dnd-kit/core';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -106,6 +107,7 @@ export const EditorFileTree = ({
onCreateTask,
onSendMessage,
}: EditorFileTreeProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
fileTreeRenderCount++;
if (fileTreeRenderCount % 5 === 0) {
console.debug(`[perf] EditorFileTree render #${fileTreeRenderCount}`);
@@ -446,15 +448,19 @@ export const EditorFileTree = ({
// ─── Early returns ─────────────────────────────────────────────────────────
if (error) {
- return Failed to load files: {error}
;
+ return (
+
+ {t('editor.fileTree.failedToLoadFiles', { error })}
+
+ );
}
if (loading && !fileTree) {
- return Loading files...
;
+ return {t('editor.fileTree.loading')}
;
}
if (treeNodes.length === 0) {
- return No files found
;
+ return {t('editor.fileTree.empty')}
;
}
return (
@@ -546,7 +552,10 @@ export const EditorFileTree = ({
{/* Spacer at bottom — drop here to move to project root */}
{draggedItem && (
-
+
)}
@@ -558,17 +567,19 @@ export const EditorFileTree = ({
!open && handleCancelDelete()}>
- Move to Trash
+ {t('editor.fileTree.moveToTrash')}
- Move “{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}” to Trash?
+ {t('editor.fileTree.moveToTrashConfirm', {
+ name: deleteConfirmPath ? getBasename(deleteConfirmPath) : '',
+ })}
- Cancel
+ {t('editor.fileTree.cancel')}
void handleConfirmDelete()}>
- Move to Trash
+ {t('editor.fileTree.moveToTrash')}
diff --git a/src/renderer/components/team/editor/EditorImagePreview.tsx b/src/renderer/components/team/editor/EditorImagePreview.tsx
index 44c74435..41586973 100644
--- a/src/renderer/components/team/editor/EditorImagePreview.tsx
+++ b/src/renderer/components/team/editor/EditorImagePreview.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
@@ -25,6 +26,7 @@ export const EditorImagePreview = ({
fileName,
size,
}: EditorImagePreviewProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const [dataUrl, setDataUrl] = useState(null);
const [loading, setLoading] = useState(true);
@@ -87,7 +89,7 @@ export const EditorImagePreview = ({
return (
-
Loading preview…
+
{t('editor.imagePreview.loading')}
);
}
@@ -102,7 +104,7 @@ export const EditorImagePreview = ({
type="button"
className="checkerboard-bg flex max-h-[60vh] max-w-[80%] cursor-zoom-in items-center justify-center overflow-hidden rounded-lg border border-border-subtle p-1"
onClick={() => setLightboxOpen(true)}
- aria-label="Open full-size preview"
+ aria-label={t('editor.imagePreview.openFullSize')}
>
- Open in System Viewer
+ {t('editor.imagePreview.openSystemViewer')}
diff --git a/src/renderer/components/team/editor/EditorSearchPanel.tsx b/src/renderer/components/team/editor/EditorSearchPanel.tsx
index 4af3a3ab..f9fdbd08 100644
--- a/src/renderer/components/team/editor/EditorSearchPanel.tsx
+++ b/src/renderer/components/team/editor/EditorSearchPanel.tsx
@@ -20,6 +20,7 @@ import {
setSearchQuery,
} from '@codemirror/search';
import { EditorView, type Panel } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import {
@@ -48,6 +49,9 @@ import type { ViewUpdate } from '@codemirror/view';
// =============================================================================
const MAX_MATCH_COUNT = 999;
+const SHORTCUT_PREVIOUS_MATCH = '⇧Enter';
+const SHORTCUT_NEXT_MATCH = 'Enter';
+const SHORTCUT_CLOSE = 'Esc';
// =============================================================================
// SearchToggleButton
@@ -136,6 +140,7 @@ const EditorSearchPanelContent = ({
initialWholeWord,
registerUpdateNotifier,
}: EditorSearchPanelContentProps) => {
+ const { t } = useAppTranslation('team');
const [searchText, setSearchText] = useState(initialSearch);
const [replaceText, setReplaceText] = useState(initialReplace);
const [caseSensitive, setCaseSensitive] = useState(initialCaseSensitive);
@@ -270,14 +275,14 @@ const EditorSearchPanelContent = ({
)}
- Toggle Replace
+ {t('editor.search.toggleReplace')}
{/* Search input */}
setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
@@ -339,7 +344,8 @@ const EditorSearchPanelContent = ({
- Previous Match ⇧Enter
+ {t('editor.searchPanel.previousMatch')}{' '}
+ {SHORTCUT_PREVIOUS_MATCH}
@@ -357,7 +363,8 @@ const EditorSearchPanelContent = ({
- Next Match Enter
+ {t('editor.searchPanel.nextMatch')}{' '}
+ {SHORTCUT_NEXT_MATCH}
@@ -375,7 +382,8 @@ const EditorSearchPanelContent = ({
- Close Esc
+ {t('editor.searchPanel.close')}{' '}
+ {SHORTCUT_CLOSE}
@@ -388,7 +396,7 @@ const EditorSearchPanelContent = ({
setReplaceText(e.target.value)}
onKeyDown={handleReplaceKeyDown}
@@ -405,10 +413,10 @@ const EditorSearchPanelContent = ({
disabled={matchCount === 0}
tabIndex={-1}
>
- Replace
+ {t('editor.searchPanel.replace')}
- Replace Next
+ {t('editor.searchPanel.replaceNext')}
@@ -421,10 +429,10 @@ const EditorSearchPanelContent = ({
disabled={matchCount === 0}
tabIndex={-1}
>
- All
+ {t('editor.searchPanel.all')}
- Replace All
+ {t('editor.searchPanel.replaceAll')}
)}
diff --git a/src/renderer/components/team/editor/EditorShortcutsHelp.tsx b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx
index eb79fa90..e1fff362 100644
--- a/src/renderer/components/team/editor/EditorShortcutsHelp.tsx
+++ b/src/renderer/components/team/editor/EditorShortcutsHelp.tsx
@@ -5,8 +5,7 @@
* the appropriate modifier symbols.
*/
-import { useMemo } from 'react';
-
+import { useAppTranslation } from '@features/localization/renderer';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
import { IS_MAC } from '@renderer/utils/platformKeys';
@@ -24,82 +23,106 @@ interface ShortcutDef {
description: string;
}
-// =============================================================================
-// Shortcuts data
-// =============================================================================
-
-const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
- {
- title: 'File Operations',
- shortcuts: [
- { mac: '⌘ P', other: 'Ctrl+P', description: 'Quick Open' },
- { mac: '⌘ S', other: 'Ctrl+S', description: 'Save' },
- { mac: '⌘ ⇧ S', other: 'Ctrl+Shift+S', description: 'Save All' },
- { mac: '⌘ W', other: 'Ctrl+W', description: 'Close Tab' },
- ],
- },
- {
- title: 'Search',
- shortcuts: [
- { mac: '⌘ F', other: 'Ctrl+F', description: 'Find in File' },
- { mac: '⌘ ⇧ F', other: 'Ctrl+Shift+F', description: 'Search in Files' },
- { mac: '⌘ G', other: 'Ctrl+G', description: 'Go to Line' },
- ],
- },
- {
- title: 'Navigation',
- shortcuts: [
- { mac: '⌘ ⇧ ]', other: 'Ctrl+Shift+]', description: 'Next Tab' },
- { mac: '⌘ ⇧ [', other: 'Ctrl+Shift+[', description: 'Previous Tab' },
- { mac: '⌃ Tab', other: 'Ctrl+Tab', description: 'Cycle Tabs' },
- { mac: '⌘ B', other: 'Ctrl+B', description: 'Toggle Sidebar' },
- ],
- },
- {
- title: 'Editing',
- shortcuts: [
- { mac: '⌘ Z', other: 'Ctrl+Z', description: 'Undo' },
- { mac: '⌘ ⇧ Z', other: 'Ctrl+Y', description: 'Redo' },
- { mac: '⌘ D', other: 'Ctrl+D', description: 'Select Next Match' },
- { mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
- ],
- },
- {
- title: 'Markdown',
- shortcuts: [
- { mac: '⌘ ⇧ M', other: 'Ctrl+Shift+M', description: 'Split Preview' },
- { mac: '⌘ ⇧ V', other: 'Ctrl+Shift+V', description: 'Full Preview' },
- ],
- },
- {
- title: 'General',
- shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
- },
-];
-
// =============================================================================
// Component
// =============================================================================
export const EditorShortcutsHelp = ({ onClose }: EditorShortcutsHelpProps): React.ReactElement => {
- // Resolve platform-specific keys once
- const resolvedGroups = useMemo(
- () =>
- SHORTCUT_GROUPS.map((group) => ({
- ...group,
- shortcuts: group.shortcuts.map((s) => ({
- keys: IS_MAC ? s.mac : s.other,
- description: s.description,
- })),
+ const { t } = useAppTranslation('team');
+ const resolvedGroups: { title: string; shortcuts: { keys: string; description: string }[] }[] = [
+ {
+ title: t('editor.shortcuts.groups.fileOperations'),
+ shortcuts: [
+ { mac: '⌘ P', other: 'Ctrl+P', description: t('editor.shortcuts.actions.quickOpen') },
+ { mac: '⌘ S', other: 'Ctrl+S', description: t('editor.shortcuts.actions.save') },
+ { mac: '⌘ ⇧ S', other: 'Ctrl+Shift+S', description: t('editor.shortcuts.actions.saveAll') },
+ { mac: '⌘ W', other: 'Ctrl+W', description: t('editor.shortcuts.actions.closeTab') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
})),
- []
- );
+ },
+ {
+ title: t('editor.shortcuts.groups.search'),
+ shortcuts: [
+ { mac: '⌘ F', other: 'Ctrl+F', description: t('editor.shortcuts.actions.findInFile') },
+ {
+ mac: '⌘ ⇧ F',
+ other: 'Ctrl+Shift+F',
+ description: t('editor.shortcuts.actions.searchInFiles'),
+ },
+ { mac: '⌘ G', other: 'Ctrl+G', description: t('editor.shortcuts.actions.goToLine') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.navigation'),
+ shortcuts: [
+ { mac: '⌘ ⇧ ]', other: 'Ctrl+Shift+]', description: t('editor.shortcuts.actions.nextTab') },
+ {
+ mac: '⌘ ⇧ [',
+ other: 'Ctrl+Shift+[',
+ description: t('editor.shortcuts.actions.previousTab'),
+ },
+ { mac: '⌃ Tab', other: 'Ctrl+Tab', description: t('editor.shortcuts.actions.cycleTabs') },
+ { mac: '⌘ B', other: 'Ctrl+B', description: t('editor.shortcuts.actions.toggleSidebar') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.editing'),
+ shortcuts: [
+ { mac: '⌘ Z', other: 'Ctrl+Z', description: t('editor.shortcuts.actions.undo') },
+ { mac: '⌘ ⇧ Z', other: 'Ctrl+Y', description: t('editor.shortcuts.actions.redo') },
+ {
+ mac: '⌘ D',
+ other: 'Ctrl+D',
+ description: t('editor.shortcuts.actions.selectNextMatch'),
+ },
+ { mac: '⌘ /', other: 'Ctrl+/', description: t('editor.shortcuts.actions.toggleComment') },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.markdown'),
+ shortcuts: [
+ {
+ mac: '⌘ ⇧ M',
+ other: 'Ctrl+Shift+M',
+ description: t('editor.shortcuts.actions.splitPreview'),
+ },
+ {
+ mac: '⌘ ⇧ V',
+ other: 'Ctrl+Shift+V',
+ description: t('editor.shortcuts.actions.fullPreview'),
+ },
+ ].map((shortcut: ShortcutDef) => ({
+ keys: IS_MAC ? shortcut.mac : shortcut.other,
+ description: shortcut.description,
+ })),
+ },
+ {
+ title: t('editor.shortcuts.groups.general'),
+ shortcuts: [
+ {
+ keys: 'Esc',
+ description: t('editor.shortcuts.actions.closeEditor'),
+ },
+ ],
+ },
+ ];
return (
!open && onClose()}>
- Keyboard Shortcuts
+ {t('editor.shortcuts.title')}
diff --git a/src/renderer/components/team/editor/EditorStatusBar.tsx b/src/renderer/components/team/editor/EditorStatusBar.tsx
index 7637e234..0dc038ce 100644
--- a/src/renderer/components/team/editor/EditorStatusBar.tsx
+++ b/src/renderer/components/team/editor/EditorStatusBar.tsx
@@ -4,6 +4,7 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { GitBranch } from 'lucide-react';
@@ -20,6 +21,7 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
col,
language,
}: EditorStatusBarProps): React.ReactElement {
+ const { t } = useAppTranslation('team');
const { gitBranch, isGitRepo, watcherEnabled } = useStore(
useShallow((s) => ({
gitBranch: s.editorGitBranch,
@@ -32,9 +34,7 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
return (
-
- Ln {line}, Col {col}
-
+ {t('editor.statusBar.position', { line, col })}
{isGitRepo && gitBranch && (
@@ -53,19 +53,25 @@ export const EditorStatusBar = React.memo(function EditorStatusBar({
? 'bg-green-500/15 text-green-400 hover:bg-green-500/20'
: 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'
}`}
- aria-label={watcherEnabled ? 'Disable file watcher' : 'Enable file watcher'}
+ aria-label={
+ watcherEnabled
+ ? t('editor.statusBar.disableWatcher')
+ : t('editor.statusBar.enableWatcher')
+ }
aria-pressed={watcherEnabled}
>
- {watcherEnabled ? 'watching' : 'watch'}
+ {watcherEnabled ? t('editor.statusBar.watching') : t('editor.statusBar.watch')}
- {watcherEnabled ? 'Disable external change watcher' : 'Watch for external changes'}
+ {watcherEnabled
+ ? t('editor.statusBar.disableExternalWatcher')
+ : t('editor.statusBar.watchExternalChanges')}
{language}
- UTF-8
- Spaces: 2
+ {t('editor.statusBar.encodingUtf8')}
+ {t('editor.statusBar.spaces', { count: 2 })}
);
diff --git a/src/renderer/components/team/editor/EditorTabBar.tsx b/src/renderer/components/team/editor/EditorTabBar.tsx
index b624e02f..58bd343f 100644
--- a/src/renderer/components/team/editor/EditorTabBar.tsx
+++ b/src/renderer/components/team/editor/EditorTabBar.tsx
@@ -7,6 +7,7 @@
import { useCallback, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
closestCenter,
DndContext,
@@ -163,6 +164,7 @@ const SortableEditorTab = ({
onCloseToRight,
onCloseAll,
}: SortableEditorTabProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
});
@@ -224,7 +226,7 @@ const SortableEditorTab = ({
{isModified && (
)}
diff --git a/src/renderer/components/team/editor/EditorToolbar.tsx b/src/renderer/components/team/editor/EditorToolbar.tsx
index bc0123ec..6a63c167 100644
--- a/src/renderer/components/team/editor/EditorToolbar.tsx
+++ b/src/renderer/components/team/editor/EditorToolbar.tsx
@@ -5,6 +5,7 @@
import React from 'react';
import { redo, undo } from '@codemirror/commands';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
@@ -36,6 +37,7 @@ export const EditorToolbar = ({
onToggleSplit,
onToggleFullPreview,
}: EditorToolbarProps): React.ReactElement | null => {
+ const { t } = useAppTranslation('team');
const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
useShallow((s) => ({
activeTabId: s.editorActiveTabId,
@@ -70,27 +72,27 @@ export const EditorToolbar = ({
}
- label="Save"
+ label={t('editor.shortcuts.actions.save')}
shortcut={shortcutLabel('⌘ S', 'Ctrl+S')}
onClick={handleSave}
disabled={!isDirty || isSaving}
/>
}
- label="Undo"
+ label={t('editor.shortcuts.actions.undo')}
shortcut={shortcutLabel('⌘ Z', 'Ctrl+Z')}
onClick={handleUndo}
/>
}
- label="Redo"
+ label={t('editor.shortcuts.actions.redo')}
shortcut={shortcutLabel('⌘ ⇧ Z', 'Ctrl+Y')}
onClick={handleRedo}
/>
}
- label={lineWrap ? 'Disable word wrap' : 'Enable word wrap'}
+ label={lineWrap ? t('editor.toolbar.disableWordWrap') : t('editor.toolbar.enableWordWrap')}
shortcut={shortcutLabel('⌘ ⇧ W', 'Ctrl+Shift+W')}
onClick={toggleLineWrap}
active={lineWrap}
@@ -100,14 +102,22 @@ export const EditorToolbar = ({
}
- label={mdPreviewMode === 'split' ? 'Close split preview' : 'Split preview'}
+ label={
+ mdPreviewMode === 'split'
+ ? t('editor.toolbar.closeSplitPreview')
+ : t('editor.shortcuts.actions.splitPreview')
+ }
shortcut={shortcutLabel('⌘ ⇧ M', 'Ctrl+Shift+M')}
onClick={onToggleSplit ?? (() => {})}
active={mdPreviewMode === 'split'}
/>
}
- label={mdPreviewMode === 'preview' ? 'Close preview' : 'Full preview'}
+ label={
+ mdPreviewMode === 'preview'
+ ? t('editor.toolbar.closePreview')
+ : t('editor.shortcuts.actions.fullPreview')
+ }
shortcut={shortcutLabel('⌘ ⇧ V', 'Ctrl+Shift+V')}
onClick={onToggleFullPreview ?? (() => {})}
active={mdPreviewMode === 'preview'}
diff --git a/src/renderer/components/team/editor/GoToLineDialog.tsx b/src/renderer/components/team/editor/GoToLineDialog.tsx
index dd1c9ef8..68b62318 100644
--- a/src/renderer/components/team/editor/GoToLineDialog.tsx
+++ b/src/renderer/components/team/editor/GoToLineDialog.tsx
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { EditorView } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { editorBridge } from '@renderer/utils/editorBridge';
@@ -73,6 +74,7 @@ function parseLineInput(input: string, view: EditorView): ParsedTarget | null {
// =============================================================================
export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const [value, setValue] = useState('');
const inputRef = useRef
(null);
@@ -145,15 +147,15 @@ export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactEle
- Go to Line{' '}
+ {t('editor.goToLine.title')}{' '}
- (current: {currentLine}, total: {totalLines})
+ {t('editor.goToLine.position', { current: currentLine, total: totalLines })}
@@ -162,7 +164,7 @@ export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactEle
setValue(e.target.value)}
onKeyDown={handleKeyDown}
@@ -176,7 +178,7 @@ export const GoToLineDialog = ({ onClose }: GoToLineDialogProps): React.ReactEle
onClick={handleGo}
disabled={!value.trim()}
>
- Go
+ {t('editor.goToLine.go')}
diff --git a/src/renderer/components/team/editor/NewFileDialog.tsx b/src/renderer/components/team/editor/NewFileDialog.tsx
index 8e5ad5a9..5b68f2d6 100644
--- a/src/renderer/components/team/editor/NewFileDialog.tsx
+++ b/src/renderer/components/team/editor/NewFileDialog.tsx
@@ -9,6 +9,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { FilePlus, FolderPlus } from 'lucide-react';
// =============================================================================
@@ -29,12 +30,12 @@ interface NewFileDialogProps {
// eslint-disable-next-line no-control-regex, sonarjs/no-control-regex -- Intentional: validating filenames against control characters
const INVALID_CHARS = /[\x00-\x1f/\\:*?"<>|]/;
-function validateName(name: string): string | null {
+function validateName(name: string, t: ReturnType['t']): string | null {
const trimmed = name.trim();
- if (trimmed.length === 0) return 'Name cannot be empty';
- if (trimmed === '.' || trimmed === '..') return 'Invalid name';
- if (INVALID_CHARS.test(trimmed)) return 'Name contains invalid characters';
- if (trimmed.length > 255) return 'Name is too long';
+ if (trimmed.length === 0) return t('editor.newFile.validation.nameRequired');
+ if (trimmed === '.' || trimmed === '..') return t('editor.newFile.validation.invalidName');
+ if (INVALID_CHARS.test(trimmed)) return t('editor.newFile.validation.invalidCharacters');
+ if (trimmed.length > 255) return t('editor.newFile.validation.nameTooLong');
return null;
}
@@ -48,6 +49,7 @@ export const NewFileDialog = ({
onSubmit,
onCancel,
}: NewFileDialogProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const [value, setValue] = useState('');
const [error, setError] = useState(null);
const inputRef = useRef(null);
@@ -80,13 +82,13 @@ export const NewFileDialog = ({
const handleSubmit = useCallback(() => {
const trimmed = value.trim();
- const validationError = validateName(trimmed);
+ const validationError = validateName(trimmed, t);
if (validationError) {
setError(validationError);
return;
}
onSubmit(trimmed);
- }, [value, onSubmit]);
+ }, [value, onSubmit, t]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -120,9 +122,17 @@ export const NewFileDialog = ({
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
- placeholder={type === 'file' ? 'File name...' : 'Folder name...'}
+ placeholder={
+ type === 'file'
+ ? t('editor.newFile.placeholders.fileName')
+ : t('editor.newFile.placeholders.folderName')
+ }
className="min-w-0 flex-1 rounded border border-border-emphasis bg-surface px-1.5 py-0.5 text-xs text-text outline-none focus:border-blue-500"
- aria-label={type === 'file' ? 'New file name' : 'New folder name'}
+ aria-label={
+ type === 'file'
+ ? t('editor.newFile.aria.newFileName')
+ : t('editor.newFile.aria.newFolderName')
+ }
/>
{error &&
{error} }
diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx
index 47aa0b5f..1c5eba41 100644
--- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx
+++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -77,6 +78,7 @@ export const ProjectEditorOverlay = ({
onClose,
onEditorAction,
}: ProjectEditorOverlayProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
// Data selectors — grouped with useShallow to prevent unnecessary re-renders
const { activeTabId, openTabs, modifiedFiles, saveErrors, externalChanges, conflictFile } =
useStore(
@@ -529,7 +531,7 @@ export const ProjectEditorOverlay = ({
tabIndex={-1}
role="dialog"
aria-modal="true"
- aria-label="Project Editor"
+ aria-label={t('editor.ariaLabel')}
>
{/* Header */}
- Refresh git status (F5)
+ {t('editor.actions.refreshTooltip')}
@@ -562,12 +564,12 @@ export const ProjectEditorOverlay = ({
size="icon"
className="size-7 text-text-muted"
onClick={() => setShortcutsHelpVisible(true)}
- aria-label="Keyboard shortcuts"
+ aria-label={t('editor.actions.keyboardShortcuts')}
>
- Keyboard shortcuts
+ {t('editor.actions.keyboardShortcuts')}
@@ -576,12 +578,12 @@ export const ProjectEditorOverlay = ({
size="icon"
className="size-7 text-text-muted"
onClick={handleCloseRequest}
- aria-label="Close editor"
+ aria-label={t('editor.actions.closeEditor')}
>
- Close editor (Esc)
+ {t('editor.actions.closeTooltip')}
@@ -604,7 +606,7 @@ export const ProjectEditorOverlay = ({
- Explorer
+ {t('editor.sidebar.explorer')}
@@ -613,13 +615,15 @@ export const ProjectEditorOverlay = ({
size="icon"
className="size-6 text-text-muted"
onClick={toggleSidebar}
- aria-label="Hide sidebar"
+ aria-label={t('editor.sidebar.hide')}
>
- Hide sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
+ {t('editor.sidebar.hideWithShortcut', {
+ shortcut: shortcutLabel('⌘ B', 'Ctrl+B'),
+ })}
@@ -652,13 +656,15 @@ export const ProjectEditorOverlay = ({
variant="ghost"
className="flex h-full w-6 shrink-0 items-start justify-center rounded-none border-r border-border bg-surface-sidebar pt-2 text-text-muted"
onClick={toggleSidebar}
- aria-label="Show sidebar"
+ aria-label={t('editor.sidebar.show')}
>
- Show sidebar ({shortcutLabel('⌘ B', 'Ctrl+B')})
+ {t('editor.sidebar.showWithShortcut', {
+ shortcut: shortcutLabel('⌘ B', 'Ctrl+B'),
+ })}
)}
@@ -687,14 +693,14 @@ export const ProjectEditorOverlay = ({
}}
>
-
Recovered unsaved changes from a previous session.
+
{t('editor.draftRecovered')}
- Keep
+ {t('editor.actions.keep')}
- Discard
+ {t('editor.actions.discard')}
)}
@@ -711,14 +717,14 @@ export const ProjectEditorOverlay = ({
{activeSaveError && (
-
Save failed: {activeSaveError}
+
{t('editor.saveFailed', { error: activeSaveError })}
activeTabId && void saveFile(activeTabId)}
>
- Retry
+ {t('editor.actions.retry')}
)}
@@ -729,8 +735,8 @@ export const ProjectEditorOverlay = ({
{externalChanges[activeTabId] === 'delete'
- ? 'File no longer exists on disk.'
- : 'File changed on disk.'}
+ ? t('editor.externalChange.deleted')
+ : t('editor.externalChange.changed')}
{externalChanges[activeTabId] === 'delete' ? (
closeEditorTab(activeTabId)}
>
- Close tab
+ {t('editor.actions.closeTab')}
) : (
<>
@@ -749,7 +755,7 @@ export const ProjectEditorOverlay = ({
className="ml-auto h-auto px-2 py-0.5"
onClick={handleReloadExternalChange}
>
- Reload
+ {t('editor.actions.reload')}
- Keep mine
+ {t('editor.actions.keepMine')}
>
)}
@@ -863,20 +869,18 @@ export const ProjectEditorOverlay = ({
!open && handleCancelClose()}>
- Unsaved Changes
-
- You have unsaved changes. What would you like to do?
-
+ {t('editor.dialogs.unsavedTitle')}
+ {t('editor.dialogs.unsavedDescription')}
- Cancel
+ {t('editor.actions.cancel')}
- Discard & Close
+ {t('editor.actions.discardAndClose')}
void handleSaveAndClose()}>
- Save All & Close
+ {t('editor.actions.saveAllAndClose')}
@@ -886,18 +890,15 @@ export const ProjectEditorOverlay = ({
!open && handleCancelConflict()}>
- Save Conflict
-
- The file has been modified externally since you opened it. Overwrite with your
- changes?
-
+ {t('editor.dialogs.conflictTitle')}
+ {t('editor.dialogs.conflictDescription')}
- Cancel
+ {t('editor.actions.cancel')}
- Overwrite
+ {t('editor.actions.overwrite')}
@@ -907,20 +908,18 @@ export const ProjectEditorOverlay = ({
!open && handleCancelCloseTab()}>
- Unsaved Changes
-
- This file has unsaved changes. What would you like to do?
-
+ {t('editor.dialogs.unsavedTitle')}
+ {t('editor.dialogs.unsavedFileDescription')}
- Cancel
+ {t('editor.actions.cancel')}
- Discard
+ {t('editor.actions.discard')}
void handleSaveAndCloseTab()}>
- Save
+ {t('editor.actions.save')}
diff --git a/src/renderer/components/team/editor/QuickOpenDialog.tsx b/src/renderer/components/team/editor/QuickOpenDialog.tsx
index f8c8082b..759f5aa1 100644
--- a/src/renderer/components/team/editor/QuickOpenDialog.tsx
+++ b/src/renderer/components/team/editor/QuickOpenDialog.tsx
@@ -7,6 +7,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import { Command } from 'cmdk';
import { Loader2 } from 'lucide-react';
@@ -32,6 +33,7 @@ export const QuickOpenDialog = ({
onClose,
onSelectFile,
}: QuickOpenDialogProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const projectPath = useStore((s) => s.editorProjectPath);
const dialogRef = useRef(null);
const [allFiles, setAllFiles] = useState([]);
@@ -116,12 +118,12 @@ export const QuickOpenDialog = ({
ref={dialogRef}
role="dialog"
aria-modal="true"
- aria-label="Quick Open"
+ aria-label={t('editor.quickOpen.title')}
className="relative z-10 w-[520px] overflow-hidden rounded-lg border border-border-emphasis bg-surface shadow-2xl"
>
-
+
@@ -129,12 +131,12 @@ export const QuickOpenDialog = ({
{loading && (
- Loading files...
+ {t('editor.quickOpen.loading')}
)}
{!loading && (
- No files found
+ {t('editor.quickOpen.empty')}
)}
{fileItems.map((file) => {
diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
index 81d57b58..d7fa6e6e 100644
--- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx
+++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx
@@ -7,6 +7,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -46,6 +47,7 @@ export const SearchInFilesPanel = ({
onClose,
onSelectMatch,
}: SearchInFilesPanelProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const [query, setQuery] = useState('');
const [caseSensitive, setCaseSensitive] = useState(false);
const [results, setResults] = useState(null);
@@ -159,7 +161,9 @@ export const SearchInFilesPanel = ({
{/* Header */}
- Search in Files
+
+ {t('editor.searchInFiles.title')}
+
- Close search (Esc)
+
+ {t('editor.searchInFiles.closeSearchShortcut')}
+
@@ -185,7 +191,7 @@ export const SearchInFilesPanel = ({
type="text"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
- placeholder="Search..."
+ placeholder={t('editor.searchInFiles.searchPlaceholder')}
className="flex-1 bg-transparent text-xs text-text outline-none placeholder:text-text-muted"
/>
{searching &&
}
@@ -200,13 +206,13 @@ export const SearchInFilesPanel = ({
? 'bg-blue-500/20 text-blue-400'
: 'text-text-muted hover:bg-surface-raised'
}`}
- aria-label="Match Case"
+ aria-label={t('editor.searchInFiles.matchCase')}
aria-pressed={caseSensitive}
>
- Aa
+ {t('editor.searchInFiles.matchCaseToggle')}
-
Match Case
+
{t('editor.searchInFiles.matchCase')}
@@ -216,15 +222,19 @@ export const SearchInFilesPanel = ({
{error && {error}
}
{results?.totalMatches === 0 && query.trim() && (
- No results found
+
+ {t('editor.searchInFiles.noResults')}
+
)}
{results && results.totalMatches > 0 && (
<>
- {results.totalMatches} match{results.totalMatches !== 1 ? 'es' : ''} in{' '}
- {results.results.length} file{results.results.length !== 1 ? 's' : ''}
- {results.truncated && ' (truncated)'}
+ {t('editor.searchInFiles.resultsSummary', {
+ count: results.totalMatches,
+ fileCount: results.results.length,
+ })}
+ {results.truncated && ` ${t('editor.searchInFiles.truncated')}`}
{results.results.map((fileResult) => (
(null);
const scrollRestoreTimeoutsRef = useRef([]);
const [viewMode, setViewMode] = useState('grid');
@@ -538,7 +540,7 @@ export const KanbanBoard = memo(function KanbanBoard({
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
- Add task
+ {t('kanban.board.addTask')}
) : null;
@@ -546,7 +548,7 @@ export const KanbanBoard = memo(function KanbanBoard({
return (
addButton ?? (
- No tasks
+ {t('kanban.board.noTasks')}
)
);
@@ -562,9 +564,9 @@ export const KanbanBoard = memo(function KanbanBoard({
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
- Show {nextRevealCount} more
+ {t('kanban.board.showMore', { count: nextRevealCount })}
- {hiddenTaskCount} hidden
+ {t('kanban.board.hiddenCount', { count: hiddenTaskCount })}
) : null;
@@ -741,7 +743,7 @@ export const KanbanBoard = memo(function KanbanBoard({
const accent = COLUMN_ACCENTS[column.id];
return {
id: column.id,
- title: column.title,
+ title: t(column.titleKey),
count: columnTasks.length,
icon: accent.icon,
headerBg: accent.headerBg,
@@ -762,6 +764,7 @@ export const KanbanBoard = memo(function KanbanBoard({
renderableColumnTasks,
kanbanState,
hasReviewers,
+ t,
]
);
@@ -804,7 +807,7 @@ export const KanbanBoard = memo(function KanbanBoard({
{deletedTaskCount}
- Trash
+ {t('kanban.board.trash')}
) : null}
@@ -820,12 +823,12 @@ export const KanbanBoard = memo(function KanbanBoard({
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('grid')}
- aria-label="Grid view"
+ aria-label={t('kanban.board.gridView')}
>
- Grid view
+ {t('kanban.board.gridView')}
@@ -839,12 +842,12 @@ export const KanbanBoard = memo(function KanbanBoard({
: 'text-[var(--color-text-muted)]'
)}
onClick={() => switchViewMode('columns')}
- aria-label="Columns view"
+ aria-label={t('kanban.board.columnsView')}
>
- Columns view
+ {t('kanban.board.columnsView')}
@@ -870,7 +873,7 @@ export const KanbanBoard = memo(function KanbanBoard({
{
+ const { t } = useAppTranslation('team');
const activeCount = useMemo(() => {
let count = 0;
if (filter.sessionId !== null) count += 1;
@@ -89,7 +91,7 @@ export const KanbanFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter tasks"
+ aria-label={t('kanban.filter.title')}
>
{activeCount > 0 && (
@@ -100,13 +102,13 @@ export const KanbanFilterPopover = ({
- Filter tasks
+ {t('kanban.filter.title')}
{/* Session section */}
- Session
+ {t('kanban.filter.session')}
handleSessionSelect(null)}
>
- All sessions
+ {t('kanban.filter.allSessions')}
{sessions.map((session) => {
const isLead = session.id === leadSessionId;
@@ -146,7 +148,7 @@ export const KanbanFilterPopover = ({
{/* Teammate section */}
- Teammate
+ {t('kanban.filter.teammate')}
{members.map((member) => (
@@ -167,7 +169,7 @@ export const KanbanFilterPopover = ({
checked={filter.selectedOwners.has(UNASSIGNED_OWNER)}
onCheckedChange={() => handleOwnerToggle(UNASSIGNED_OWNER)}
/>
- (unassigned)
+ {t('kanban.filter.unassigned')}
@@ -175,7 +177,7 @@ export const KanbanFilterPopover = ({
{/* Column section */}
- Column
+ {t('kanban.filter.column')}
{KANBAN_COLUMNS.map((col) => (
@@ -188,7 +190,7 @@ export const KanbanFilterPopover = ({
checked={filter.columns.has(col.id)}
onCheckedChange={() => handleColumnToggle(col.id)}
/>
- {col.label}
+ {t(col.labelKey)}
))}
@@ -203,7 +205,7 @@ export const KanbanFilterPopover = ({
disabled={activeCount === 0}
onClick={handleClearAll}
>
- Clear all
+ {t('kanban.filter.clearAll')}
diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx
index 2bc527b5..43640be9 100644
--- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx
+++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx
@@ -2,6 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
+import { useAppTranslation } from '@features/localization/renderer';
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
import { cn } from '@renderer/lib/utils';
import { browserGridLayoutRepository } from '@renderer/services/layout-system/BrowserGridLayoutRepository';
@@ -164,6 +165,7 @@ const LoadingKanbanGridLayout = ({
onPrimaryColumnWidthChange,
className,
}: Readonly
): ReactElement => {
+ const { t } = useAppTranslation('team');
const columnMap = new Map(columns.map((column) => [column.id, column]));
const loadingItems =
visibleItems.length > 0
@@ -253,17 +255,17 @@ const LoadingKanbanGridLayout = ({
))}
{showAddButton ? (
- Add task
+ {t('kanban.grid.addTask')}
) : null}
>
) : showAddButton ? (
- Add task
+ {t('kanban.grid.addTask')}
) : (
- No tasks
+ {t('kanban.grid.noTasks')}
)}
diff --git a/src/renderer/components/team/kanban/KanbanSearchInput.tsx b/src/renderer/components/team/kanban/KanbanSearchInput.tsx
index bfc0586d..ae441bbf 100644
--- a/src/renderer/components/team/kanban/KanbanSearchInput.tsx
+++ b/src/renderer/components/team/kanban/KanbanSearchInput.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { TASK_STATUS_LABELS } from '@renderer/utils/memberHelpers';
@@ -29,6 +30,7 @@ export const KanbanSearchInput = ({
tasks,
members,
}: KanbanSearchInputProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [showDropdown, setShowDropdown] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const containerRef = useRef(null);
@@ -130,7 +132,7 @@ export const KanbanSearchInput = ({
onChange(e.target.value)}
onKeyDown={handleKeyDown}
@@ -147,7 +149,7 @@ export const KanbanSearchInput = ({
- Clear search
+ {t('kanban.search.clearSearch')}
)}
@@ -160,7 +162,7 @@ export const KanbanSearchInput = ({
- Tasks
+ {t('kanban.search.tasks')}
{suggestions.map((task, index) => (
@@ -201,6 +203,7 @@ const TaskSuggestionItem = React.memo(function TaskSuggestionItem({
onSelect,
onHover,
}: TaskSuggestionItemProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const displayId = getTaskDisplayId(task);
const statusLabel = TASK_STATUS_LABELS[task.status] ?? task.status;
const memberColorMap = useMemo(() => {
@@ -272,10 +275,14 @@ const TaskSuggestionItem = React.memo(function TaskSuggestionItem({
/>
)}
{createdAgo && (
- created {createdAgo}
+
+ {t('kanban.search.createdAgo', { time: createdAgo })}
+
)}
{updatedAgo && updatedAgo !== createdAgo && (
- updated {updatedAgo}
+
+ {t('kanban.search.updatedAgo', { time: updatedAgo })}
+
)}
diff --git a/src/renderer/components/team/kanban/KanbanSortPopover.tsx b/src/renderer/components/team/kanban/KanbanSortPopover.tsx
index f05c2491..2178a90e 100644
--- a/src/renderer/components/team/kanban/KanbanSortPopover.tsx
+++ b/src/renderer/components/team/kanban/KanbanSortPopover.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -10,37 +11,37 @@ export interface KanbanSortState {
field: KanbanSortField;
}
-const SORT_OPTIONS: {
- field: KanbanSortField;
- label: string;
- description: string;
- icon: React.ReactNode;
-}[] = [
+const SORT_OPTIONS = [
{
field: 'updatedAt',
- label: 'Last updated',
- description: 'Recently updated first',
+ labelKey: 'kanban.sort.options.updatedAt.label',
+ descriptionKey: 'kanban.sort.options.updatedAt.description',
icon: ,
},
{
field: 'createdAt',
- label: 'Created',
- description: 'Newest first',
+ labelKey: 'kanban.sort.options.createdAt.label',
+ descriptionKey: 'kanban.sort.options.createdAt.description',
icon: ,
},
{
field: 'owner',
- label: 'Owner',
- description: 'Alphabetically by assignee',
+ labelKey: 'kanban.sort.options.owner.label',
+ descriptionKey: 'kanban.sort.options.owner.description',
icon: ,
},
{
field: 'manual',
- label: 'Manual',
- description: 'Drag-and-drop order',
+ labelKey: 'kanban.sort.options.manual.label',
+ descriptionKey: 'kanban.sort.options.manual.description',
icon: ,
},
-];
+] as const satisfies readonly {
+ field: KanbanSortField;
+ labelKey: string;
+ descriptionKey: string;
+ icon: React.ReactNode;
+}[];
interface KanbanSortPopoverProps {
sort: KanbanSortState;
@@ -51,6 +52,7 @@ export const KanbanSortPopover = ({
sort,
onSortChange,
}: KanbanSortPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const isNonDefault = sort.field !== 'updatedAt';
return (
@@ -62,7 +64,7 @@ export const KanbanSortPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Sort tasks"
+ aria-label={t('kanban.sort.title')}
>
{isNonDefault && (
@@ -73,12 +75,12 @@ export const KanbanSortPopover = ({
- Sort tasks
+ {t('kanban.sort.title')}
- Sort by
+ {t('kanban.sort.sortBy')}
{SORT_OPTIONS.map((option) => {
@@ -104,14 +106,14 @@ export const KanbanSortPopover = ({
{option.icon}
-
{option.label}
+
{t(option.labelKey)}
- {option.description}
+ {t(option.descriptionKey)}
{isSelected && (
@@ -130,7 +132,7 @@ export const KanbanSortPopover = ({
className="h-6 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => onSortChange({ field: 'updatedAt' })}
>
- Reset
+ {t('kanban.sort.reset')}
)}
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
index b46cdc79..273d2917 100644
--- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx
+++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { OngoingIndicator } from '@renderer/components/common/OngoingIndicator';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
@@ -204,6 +205,7 @@ const CancelTaskButton = ({
taskId: string;
onConfirm: (taskId: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [open, setOpen] = useState(false);
return (
@@ -215,14 +217,14 @@ const CancelTaskButton = ({
variant="destructive"
size="icon"
className="size-6 rounded-full shadow-sm"
- aria-label={`Cancel task ${taskId}`}
+ aria-label={t('kanban.taskCard.cancelTask', { taskId })}
onClick={(e) => e.stopPropagation()}
>
-
Cancel
+
{t('kanban.taskCard.cancel')}
e.stopPropagation()}
>
- Move this task back to TODO and notify the team?
+ {t('kanban.taskCard.moveBackToTodoConfirm')}
- Confirm
+ {t('kanban.taskCard.confirm')}
setOpen(false)}>
- Keep
+ {t('kanban.taskCard.keep')}
@@ -311,6 +313,7 @@ export const KanbanTaskCard = memo(
onViewChanges,
onDeleteTask,
}: KanbanTaskCardProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
const commentPulseTaskKey = `${teamName}/${task.id}`;
@@ -351,7 +354,11 @@ export const KanbanTaskCard = memo(
<>
{canOpenChanges ? (
}
variant="ghost"
className={
@@ -372,7 +379,7 @@ export const KanbanTaskCard = memo(
/>
{onDeleteTask ? (
}
variant="ghost"
className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
@@ -406,8 +413,8 @@ export const KanbanTaskCard = memo(
{formatTaskDisplayLabel(task)}
{hasLiveTaskLogs ? (
-
-
+
+
) : null}
@@ -427,7 +434,9 @@ export const KanbanTaskCard = memo(
}`}
>
- {task.needsClarification === 'user' ? 'Awaiting user' : 'Awaiting lead'}
+ {task.needsClarification === 'user'
+ ? t('kanban.taskCard.awaitingUser')
+ : t('kanban.taskCard.awaitingLead')}
) : null}
{isTeamTaskNeedsFixActionable(task) ? (
@@ -444,7 +453,7 @@ export const KanbanTaskCard = memo(
- Blocked by
+ {t('kanban.taskCard.blockedBy')}
{blockedByIds.map((id) => (
- Blocks
+ {t('kanban.taskCard.blocks')}
{blocksIds.map((id) => (
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -488,7 +497,7 @@ export const KanbanTaskCard = memo(
}}
/>
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -502,7 +511,7 @@ export const KanbanTaskCard = memo(
{columnId === 'in_progress' ? (
<>
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -517,7 +526,7 @@ export const KanbanTaskCard = memo(
{columnId === 'done' ? (
<>
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -526,7 +535,7 @@ export const KanbanTaskCard = memo(
}}
/>
}
className="border-violet-500/40 text-violet-400 hover:bg-violet-500/10 hover:text-violet-300"
onClick={(e) => {
@@ -541,12 +550,12 @@ export const KanbanTaskCard = memo(
{isReviewManual ? (
- Manual review
+ {t('kanban.taskCard.manualReview')}
) : null}
}
className="border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={(e) => {
@@ -555,7 +564,7 @@ export const KanbanTaskCard = memo(
}}
/>
}
variant="destructive"
className="bg-red-500/90 text-white hover:bg-red-500"
diff --git a/src/renderer/components/team/kanban/TrashDialog.tsx b/src/renderer/components/team/kanban/TrashDialog.tsx
index 064b8b94..9eef2880 100644
--- a/src/renderer/components/team/kanban/TrashDialog.tsx
+++ b/src/renderer/components/team/kanban/TrashDialog.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@@ -31,19 +32,21 @@ export const TrashDialog = ({
onClose,
onRestore,
}: TrashDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
return (
!v && onClose()}>
- Trash
+ {t('kanban.trash.title')}
{tasks.length === 0 ? (
- No deleted tasks
+ {t('kanban.trash.empty')}
) : (
@@ -52,9 +55,9 @@ export const TrashDialog = ({
#
- Subject
- Owner
- Deleted
+ {t('kanban.trash.subject')}
+ {t('kanban.trash.owner')}
+ {t('kanban.trash.deleted')}
{onRestore ? : null}
@@ -69,7 +72,7 @@ export const TrashDialog = ({
{task.subject}
- {task.owner ?? 'Unassigned'}
+ {task.owner ?? t('kanban.trash.unassigned')}
{task.deletedAt
@@ -84,12 +87,12 @@ export const TrashDialog = ({
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={() => onRestore(task.id)}
- aria-label="Restore task"
+ aria-label={t('kanban.trash.restoreTask')}
>
- Restore
+ {t('kanban.trash.restore')}
) : null}
@@ -103,7 +106,7 @@ export const TrashDialog = ({
- Close
+ {t('kanban.trash.close')}
diff --git a/src/renderer/components/team/lead-load-guards.ts b/src/renderer/components/team/lead-load-guards.ts
new file mode 100644
index 00000000..8e5d8523
--- /dev/null
+++ b/src/renderer/components/team/lead-load-guards.ts
@@ -0,0 +1 @@
+export { deriveLeadContextButtonLabel as deriveLeadLoadButtonLabel } from './leadContextLoadGuards';
diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx
index 5c7d7255..9fe5de3f 100644
--- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx
+++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx
@@ -1,5 +1,6 @@
import { memo, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import {
formatMemberActivityElapsed,
@@ -95,6 +96,7 @@ export const CurrentTaskIndicator = memo(
isTimerRunning = true,
onOpenTask,
}: CurrentTaskIndicatorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const timerLabel = useActivityTimerLabel(activityTimer, isTimerRunning);
const subjectText =
typeof maxSubjectLength === 'number' &&
@@ -114,7 +116,7 @@ export const CurrentTaskIndicator = memo(
{
e.stopPropagation();
onOpenTask?.();
diff --git a/src/renderer/components/team/members/LeadModelRow.tsx b/src/renderer/components/team/members/LeadModelRow.tsx
index e68a5549..3b4e5c7d 100644
--- a/src/renderer/components/team/members/LeadModelRow.tsx
+++ b/src/renderer/components/team/members/LeadModelRow.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import {
ANTHROPIC_LONG_CONTEXT_PRICING_URL,
@@ -76,14 +77,18 @@ export const LeadModelRow = ({
showAnthropicContextLimit = providerId === 'anthropic',
disableAnthropicContextLimit,
}: LeadModelRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const hasActiveProviderNotice = Boolean(providerNoticeById?.[providerId]);
const [modelExpanded, setModelExpanded] = useState(hasActiveProviderNotice);
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim())
- : 'Default';
- const modelButtonAriaLabel = `${getTeamProviderLabel(providerId)} provider, ${modelButtonLabel}`;
+ : t('members.leadModel.defaultModel');
+ const modelButtonAriaLabel = t('members.leadModel.providerModelAria', {
+ provider: getTeamProviderLabel(providerId),
+ model: modelButtonLabel,
+ });
const selectedModelIssueText =
model.trim() && modelIssueReasonByValue?.[model.trim()]
? modelIssueReasonByValue[model.trim()]
@@ -143,8 +148,12 @@ export const LeadModelRow = ({
loading="lazy"
/>
- lead
- Team Lead
+
+ {t('members.leadModel.leadShort')}
+
+
+ {t('members.leadModel.teamLead')}
+
@@ -160,7 +169,7 @@ export const LeadModelRow = ({
htmlFor="sync-models-with-lead"
className="cursor-pointer truncate text-xs font-normal text-text-secondary"
>
- Sync model with teammates
+ {t('members.leadModel.syncWithTeammates')}
@@ -237,15 +246,17 @@ export const LeadModelRow = ({
checked={limitContext}
onCheckedChange={onLimitContextChange}
disabled={contextLimitDisabled}
- scopeLabel={providerId === 'anthropic' ? undefined : 'Anthropic team-wide'}
+ scopeLabel={
+ providerId === 'anthropic' ? undefined : t('members.leadModel.anthropicTeamWide')
+ }
/>
) : null}
- Lead runtime applies to teammates unless they set their own provider or model.
+ {t('members.leadModel.runtimeInheritance')}
{showAnthropicContextLimit
- ? ' The 200K context limit is team-wide for Anthropic runtimes in this launch, including custom Anthropic teammates.'
+ ? ` ${t('members.leadModel.anthropicContextLimit')}`
: null}
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx
index 361664e2..9cfd5332 100644
--- a/src/renderer/components/team/members/MemberCard.tsx
+++ b/src/renderer/components/team/members/MemberCard.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -350,6 +351,7 @@ const RuntimeTelemetryTooltipContent = ({
}: Readonly<{
runtimeEntry: TeamAgentRuntimeEntry | undefined;
}>): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
if (!runtimeEntry) {
return null;
}
@@ -377,10 +379,10 @@ const RuntimeTelemetryTooltipContent = ({
- Local runtime load
+ {t('members.runtimeTelemetry.title')}
- Parent and child processes only. Remote LLM inference is not included.
+ {t('members.runtimeTelemetry.description')}
@@ -389,7 +391,7 @@ const RuntimeTelemetryTooltipContent = ({
- CPU
+ {t('members.runtimeTelemetry.cpu')}
{aggregateCpuLabel ?? 'unknown'}
@@ -404,12 +406,14 @@ const RuntimeTelemetryTooltipContent = ({
- Memory
+ {t('members.runtimeTelemetry.memory')}
{rssLabel ?? 'unknown'}
-
summed RSS
+
+ {t('members.runtimeTelemetry.summedRss')}
+
@@ -430,20 +434,20 @@ const RuntimeTelemetryTooltipContent = ({
{runtimeEntry.runtimeLoadScope === 'shared-host' ? (
- Shared OpenCode host metric. It is not exclusive to this member.
+ {t('members.runtimeTelemetry.sharedHost')}
) : null}
{runtimeEntry.runtimeLoadTruncated ? (
- Process tree was capped for this sample.
+ {t('members.runtimeTelemetry.processTreeCapped')}
) : null}
- RSS can include shared pages, so it is best read as a load signal, not exclusive memory.
+ {t('members.runtimeTelemetry.rssHint')}
);
@@ -640,6 +644,7 @@ export const MemberCard = memo(function MemberCard({
onSkipMemberForLaunch,
onRestoreMember,
}: MemberCardProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
// NOTE: lead context display disabled — usage formula is inaccurate
// const teamName = useStore((s) => s.selectedTeamName);
// const leadContext = useStore((s) =>
@@ -1079,7 +1084,7 @@ export const MemberCard = memo(function MemberCard({
className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300"
data-runtime-telemetry-exempt="true"
>
- worktree
+ {t('members.badges.worktree')}
@@ -1488,7 +1493,7 @@ export const MemberCard = memo(function MemberCard({
- Send message
+ {t('members.actions.sendMessage')}
@@ -1503,7 +1508,7 @@ export const MemberCard = memo(function MemberCard({
- Assign task
+ {t('members.actions.assignTask')}
)}
diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx
index 531e6656..3222cbdf 100644
--- a/src/renderer/components/team/members/MemberDetailDialog.tsx
+++ b/src/renderer/components/team/members/MemberDetailDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
isMemberLogStreamUiEnabled,
MemberLogStreamSection,
@@ -143,6 +144,7 @@ export const MemberDetailDialog = ({
updatingRole,
onViewMemberChanges,
}: MemberDetailDialogProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const memberTasks = useMemo(
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
[tasks, member]
@@ -248,7 +250,9 @@ export const MemberDetailDialog = ({
? OPENCODE_BOOTSTRAP_STALLED_MESSAGE
: undefined;
const isOpenCodeMember = member?.providerId === 'opencode';
- const restartButtonLabel = isOpenCodeMember ? 'Relaunch OpenCode' : 'Restart';
+ const restartButtonLabel = isOpenCodeMember
+ ? t('members.detail.relaunchOpenCode')
+ : t('members.detail.restart');
const hasLiveRestartContext = isTeamAlive === true || isTeamProvisioning === true;
const canControlledOpenCodeRelaunch =
member == null
@@ -406,7 +410,7 @@ export const MemberDetailDialog = ({
{showLegacyLogsFallback ? (
- Legacy Logs Fallback
+ {t('members.detail.legacyLogsFallback')}
@@ -429,7 +433,7 @@ export const MemberDetailDialog = ({
{launchDiagnosticsPayload && showCopyDiagnostics ? (
) : null}
@@ -442,7 +446,7 @@ export const MemberDetailDialog = ({
) : runtimeEntry?.pid ? (
- PID {runtimeEntry.pid}
+ {t('members.detail.pid', { pid: runtimeEntry.pid })}
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
) : (
@@ -450,7 +454,9 @@ export const MemberDetailDialog = ({
)}
{member.removedAt ? (
- Removed {new Date(member.removedAt).toLocaleDateString()}
+ {t('members.detail.removedAt', {
+ date: new Date(member.removedAt).toLocaleDateString(),
+ })}
) : (
<>
@@ -468,7 +474,9 @@ export const MemberDetailDialog = ({
await onRestartMember(member.name);
} catch (error) {
setRestartError(
- error instanceof Error ? error.message : 'Failed to restart member'
+ error instanceof Error
+ ? error.message
+ : t('members.detail.failedToRestartMember')
);
} finally {
setRestarting(false);
@@ -485,11 +493,11 @@ export const MemberDetailDialog = ({
)}
- Send Message
+ {t('members.detail.sendMessage')}
- Assign Task
+ {t('members.detail.assignTask')}
{onRemoveMember && !isLeadMember(member) && (
- Remove
+ {t('members.detail.remove')}
)}
>
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx
index 14624594..b1c9acb7 100644
--- a/src/renderer/components/team/members/MemberDetailHeader.tsx
+++ b/src/renderer/components/team/members/MemberDetailHeader.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors';
@@ -72,6 +73,7 @@ export const MemberDetailHeader = ({
onUpdateRole,
updatingRole,
}: MemberDetailHeaderProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [editing, setEditing] = useState(false);
const selectedTeamName = useStore((s) => s.selectedTeamName);
const teamMembers = useStore((s) =>
@@ -170,7 +172,7 @@ export const MemberDetailHeader = ({
type="button"
className="inline-flex items-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setEditing(true)}
- aria-label="Edit role"
+ aria-label={t('members.actions.editRole')}
>
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx
index 83702416..d7f868b8 100644
--- a/src/renderer/components/team/members/MemberDraftRow.tsx
+++ b/src/renderer/components/team/members/MemberDraftRow.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { AnthropicExtraUsageWarning } from '@renderer/components/team/dialogs/AnthropicExtraUsageWarning';
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
@@ -168,6 +169,7 @@ export const MemberDraftRow = ({
agentTeamsMcpLocked = false,
lockedModelAction,
}: MemberDraftRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const memberColorSet = getTeamColorSet(
resolvedColor ??
@@ -244,8 +246,8 @@ export const MemberDraftRow = ({
: mcpMode === 'strictAllowlist'
? `MCP ${mcpServerNames.length || 'strict'}`
: mcpMode === 'inheritScopes'
- ? 'MCP scopes'
- : 'MCP inherit';
+ ? t('memberDraft.mcp.buttonScopes')
+ : t('memberDraft.mcp.buttonInherit');
const updateMcpPolicy = useCallback(
(policy: TeamMemberMcpPolicy | undefined) => {
if (agentTeamsMcpLocked) {
@@ -307,6 +309,17 @@ export const MemberDraftRow = ({
[mcpScopes, updateMcpPolicy]
);
+ const getMcpScopeLabel = (scope: 'user' | 'project' | 'local'): string => {
+ switch (scope) {
+ case 'user':
+ return t('memberDraft.mcp.scopes.user');
+ case 'project':
+ return t('memberDraft.mcp.scopes.project');
+ case 'local':
+ return t('memberDraft.mcp.scopes.local');
+ }
+ };
+
useEffect(() => {
if (
onWorkflowChange &&
@@ -331,14 +344,17 @@ export const MemberDraftRow = ({
: (member.effort ?? inheritedEffort);
const modelButtonLabelBase = effectiveModel?.trim()
? getProviderScopedTeamModelLabel(effectiveProviderId, effectiveModel.trim())
- : 'Default';
+ : t('memberDraft.model.default');
const modelButtonLabel = forceInheritedModelSettings
- ? `${modelButtonLabelBase} (lead)`
+ ? t('memberDraft.model.leadSuffix', { label: modelButtonLabelBase })
: modelButtonLabelBase;
- const modelButtonAriaLabel = `${getTeamProviderLabel(effectiveProviderId)} provider, ${modelButtonLabel}`;
+ const modelButtonAriaLabel = t('memberDraft.model.ariaLabel', {
+ provider: getTeamProviderLabel(effectiveProviderId),
+ model: modelButtonLabel,
+ });
const canOpenLockedModelPanel = lockProviderModel && !isRemoved && Boolean(lockedModelAction);
const modelTooltipText = forceInheritedModelSettings
- ? 'Provider, model, and effort are inherited from the lead while sync is enabled.'
+ ? t('memberDraft.model.inheritedTooltip')
: lockProviderModel
? (lockedModelAction?.description ?? modelLockReason)
: undefined;
@@ -347,7 +363,7 @@ export const MemberDraftRow = ({
const worktreeIsolationDescription =
worktreeIsolationDisabledReason && member.isolation !== 'worktree'
? worktreeIsolationDisabledReason
- : 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.';
+ : t('memberDraft.worktree.description');
const worktreeIsolationDescriptionId = showWorktreeIsolationControls
? `member-${member.id}-worktree-isolation-description`
: undefined;
@@ -413,16 +429,17 @@ export const MemberDraftRow = ({
Boolean(message)
);
const hasWarnings = warningMessages.length > 0 || showSonnetExtraUsageWarning;
- const anthropicContextModeLabel = limitContext ? '200K limit enabled' : 'default context setting';
+ const anthropicContextModeLabel = limitContext
+ ? t('memberDraft.anthropicContext.limitEnabled')
+ : t('memberDraft.anthropicContext.defaultSetting');
const workflowTooltipText = workflowDraft.value.trim()
- ? 'Edit teammate workflow'
- : 'Add teammate workflow';
- const mcpTooltipText = `${mcpButtonLabel}: Control this member's MCP inheritance policy`;
- const mcpLockedInfoText =
- 'Agent Teams MCP only is enabled for all teammates. This teammate will launch with only the Agent Teams server.';
+ ? t('memberDraft.workflow.editTooltip')
+ : t('memberDraft.workflow.addTooltip');
+ const mcpTooltipText = t('memberDraft.mcp.tooltip', { label: mcpButtonLabel });
+ const mcpLockedInfoText = t('memberDraft.mcp.lockedInfo');
const mcpSettingInfoText = agentTeamsMcpLocked
? mcpLockedInfoText
- : 'Agent Teams MCP launches this teammate with only the Agent Teams server. Scope and allowlist modes apply only to this teammate launch.';
+ : t('memberDraft.mcp.settingInfo');
const runtimeSummary = formatTeamModelSummary(
effectiveProviderId,
effectiveModel?.trim() ?? '',
@@ -457,11 +474,11 @@ export const MemberDraftRow = ({
onNameChange(member.id, event.target.value)}
- placeholder="member-name"
+ placeholder={t('memberDraft.placeholders.name')}
/>
{nameError ?
{nameError}
: null}
@@ -469,7 +486,10 @@ export const MemberDraftRow = ({
{lockRole ? (
- {lockedRoleLabel || member.customRole || member.roleSelection || 'No role'}
+ {lockedRoleLabel ||
+ member.customRole ||
+ member.roleSelection ||
+ t('memberDraft.noRole')}
) : (
- Worktree
+ {t('memberDraft.worktree.label')}
@@ -654,8 +674,10 @@ export const MemberDraftRow = ({
variant="outline"
size="sm"
className="size-8 shrink-0 px-0"
- aria-label={`Restore ${member.name || `member ${index + 1}`}`}
- title="Restore member"
+ aria-label={t('memberDraft.actions.restoreAria', {
+ name: member.name || t('memberDraft.nameFallback', { index: index + 1 }),
+ })}
+ title={t('memberDraft.actions.restore')}
onClick={() => onRestore?.(member.id)}
>
@@ -665,8 +687,10 @@ export const MemberDraftRow = ({
variant="outline"
size="sm"
className="size-8 shrink-0 border-red-500/40 px-0 text-red-300 hover:bg-red-500/10 hover:text-red-200"
- aria-label={`Remove ${member.name || `member ${index + 1}`}`}
- title="Remove member"
+ aria-label={t('memberDraft.actions.removeAria', {
+ name: member.name || t('memberDraft.nameFallback', { index: index + 1 }),
+ })}
+ title={t('memberDraft.actions.remove')}
onClick={() => onRemove(member.id)}
>
@@ -674,7 +698,9 @@ export const MemberDraftRow = ({
)}
{isRemoved ? (
- Removed
+
+ {t('memberDraft.removed')}
+
) : null}
{!isRemoved && hasWarnings ? (
@@ -707,7 +733,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-mcp-mode`}
className="text-[10px] text-[var(--color-text-muted)]"
>
- MCP mode
+ {t('memberDraft.mcp.mode')}
- Inherit lead
- Choose scopes
- Strict allowlist
- Agent Teams MCP
+ {t('memberDraft.mcp.inheritLead')}
+
+ {t('memberDraft.mcp.chooseScopes')}
+
+
+ {t('memberDraft.mcp.strictAllowlist')}
+
+ {t('memberDraft.mcp.agentTeamsMcp')}
@@ -749,7 +779,7 @@ export const MemberDraftRow = ({
}
onCheckedChange={(checked) => updateMcpScope(scope, checked === true)}
/>
- {scope}
+ {getMcpScopeLabel(scope)}
))}
@@ -759,7 +789,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-mcp-servers`}
className="text-[10px] text-[var(--color-text-muted)]"
>
- Server names
+ {t('memberDraft.mcp.serverNames')}
updateMcpServerNames(event.target.value)}
- placeholder="github, sentry"
+ placeholder={t('memberDraft.placeholders.mcpServers')}
/>
) : null}
@@ -785,7 +815,7 @@ export const MemberDraftRow = ({
htmlFor={`member-${member.id}-workflow`}
className="block text-[10px] font-medium text-[var(--color-text-muted)]"
>
- Workflow (optional)
+ {t('memberDraft.workflow.label')}
Saved
+
+ {t('memberDraft.workflow.saved')}
+
) : null
}
/>
@@ -816,16 +848,15 @@ export const MemberDraftRow = ({
- Current lead runtime
+ {t('memberDraft.model.currentLeadRuntime')}
{runtimeSummary}
- {lockedModelAction.description ??
- 'Lead runtime changes open Relaunch Team, where provider, model, and effort can be updated.'}
+ {lockedModelAction.description ?? t('memberDraft.model.lockedActionFallback')}
- Saving those runtime changes restarts the whole team.
+ {t('memberDraft.model.restartWholeTeam')}
- Anthropic context is team-wide for this launch: {anthropicContextModeLabel}. Use
- the lead runtime panel's Limit context checkbox to change it.
+ {t('memberDraft.anthropicContext.description', {
+ mode: anthropicContextModeLabel,
+ })}
) : null}
{lockProviderModel && (
- {modelLockReason ??
- 'Provider, model, and effort changes are disabled while the team is live. Reconnect the team to apply them safely.'}
+ {modelLockReason ?? t('memberDraft.model.liveDisabled')}
)}
>
diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx
index 734a7f94..3f7b0dc3 100644
--- a/src/renderer/components/team/members/MemberExecutionLog.tsx
+++ b/src/renderer/components/team/members/MemberExecutionLog.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
@@ -34,6 +35,7 @@ export const MemberExecutionLog = ({
teamName,
hideMemberHeading,
}: MemberExecutionLogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
// Show newest groups first — most recent activity is most relevant in execution logs.
@@ -49,7 +51,7 @@ export const MemberExecutionLog = ({
if (!orderedItems.length) {
return (
- Nothing to display
+ {t('members.executionLog.empty')}
);
}
@@ -113,6 +115,7 @@ function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[]
}
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const text = group.content.rawText ?? group.content.text ?? '';
const { humanText, agentInfo } = useMemo(() => splitAgentBlocks(text), [text]);
const [agentInfoOpen, setAgentInfoOpen] = useState(false);
@@ -120,7 +123,9 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
if (!humanText && agentInfo.length === 0) {
return (
- {format(group.timestamp, 'h:mm:ss a')} — (empty)
+ {t('members.executionLog.emptyUserMessage', {
+ time: format(group.timestamp, 'h:mm:ss a'),
+ })}
);
}
@@ -147,7 +152,7 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
className={`shrink-0 transition-transform ${agentInfoOpen ? 'rotate-90' : ''}`}
/>
- Agent instructions
+ {t('members.executionLog.agentInstructions')}
{agentInfoOpen && (
@@ -183,6 +188,7 @@ const AIExecutionGroup = ({
onToggleExpanded,
onToggleItem,
}: AIExecutionGroupProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const enhanced = useMemo(() => {
if (!memberName) {
@@ -195,7 +201,9 @@ const AIExecutionGroup = ({
return enhanceAIGroup({ ...group, processes: filteredProcesses });
}, [group, memberName]);
const normalizedMemberName = memberName?.trim();
- const groupLabel = normalizedMemberName ? `${normalizedMemberName} turn` : 'Agent turn';
+ const groupLabel = normalizedMemberName
+ ? t('members.executionLog.memberTurn', { member: normalizedMemberName })
+ : t('members.executionLog.agentTurn');
const hasToggleContent = enhanced.displayItems.length > 0;
const visibleLastOutput =
enhanced.lastOutput?.type === 'tool_result' && hasToggleContent ? null : enhanced.lastOutput;
@@ -225,12 +233,12 @@ const AIExecutionGroup = ({
disableHoverCard
/>
- turn
+ {t('members.executionLog.turn')}
>
) : hideMemberHeading ? (
- turn
+ {t('members.executionLog.turn')}
) : (
<>
diff --git a/src/renderer/components/team/members/MemberHoverCard.tsx b/src/renderer/components/team/members/MemberHoverCard.tsx
index c2cde842..f5a47c13 100644
--- a/src/renderer/components/team/members/MemberHoverCard.tsx
+++ b/src/renderer/components/team/members/MemberHoverCard.tsx
@@ -1,5 +1,6 @@
import { memo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@renderer/components/ui/hover-card';
import {
@@ -69,6 +70,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
onOpenTask,
children,
}: MemberHoverCardProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const { isLight } = useTheme();
const selectedTeamName = useStore((s) => s.selectedTeamName);
const effectiveTeamName = teamName ?? selectedTeamName;
@@ -326,7 +328,7 @@ export const MemberHoverCard = memo(function MemberHoverCard({
}}
>
- Open profile
+ {t('members.actions.openProfile')}
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx
index 443e9831..5e3732a9 100644
--- a/src/renderer/components/team/members/MemberList.tsx
+++ b/src/renderer/components/team/members/MemberList.tsx
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useTheme } from '@renderer/hooks/useTheme';
import {
deriveReviewActivityTimerAnchor,
@@ -614,6 +615,7 @@ const MemberListLoadingSkeleton = ({
}: Readonly<{
expectedTeammateCount?: number;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const skeletonCount = getMemberLoadingSkeletonCount(expectedTeammateCount);
const { isLight } = useTheme();
@@ -621,7 +623,7 @@ const MemberListLoadingSkeleton = ({
{Array.from({ length: skeletonCount }, (_, index) => {
@@ -681,17 +683,14 @@ const MemberRosterUnavailableState = ({
}: Readonly<{
expectedTeammateCount?: number;
}>): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const count = Number.isFinite(expectedTeammateCount)
? Math.max(0, Math.floor(expectedTeammateCount ?? 0))
: 0;
- const teammateLabel = count === 1 ? '1 teammate is' : `${count || 'Some'} teammates are`;
-
return (
-
Member roster unavailable
-
- {teammateLabel} known from team metadata, but roster details are missing.
-
+
{t('members.list.unavailable')}
+
{t('members.list.unavailableDescription', { count })}
);
};
@@ -720,6 +719,7 @@ export const MemberList = memo(function MemberList({
onSkipMemberForLaunch,
onRestoreMember,
}: MemberListProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const containerRef = useRef
(null);
const [isWide, setIsWide] = useState(false);
@@ -909,7 +909,7 @@ export const MemberList = memo(function MemberList({
return (
- Solo team - lead only
+ {t('members.list.soloLeadOnly')}
);
}
@@ -1020,7 +1020,7 @@ export const MemberList = memo(function MemberList({
{removedMembers.length > 0 && (
<>
- Removed ({removedMembers.length})
+ {t('members.list.removedCount', { count: removedMembers.length })}
{removedMembers.map((member) => (
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx
index 32a21a95..53b8ae9c 100644
--- a/src/renderer/components/team/members/MemberLogsTab.tsx
+++ b/src/renderer/components/team/members/MemberLogsTab.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import {
@@ -130,6 +131,7 @@ export const MemberLogsTab = ({
showLeadPreview = false,
onPreviewOnlineChange,
}: MemberLogsTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
// Visibility check: skip polling when tab is hidden (display:none) to avoid OOM
const tabId = useTabIdOptional();
const activeTabId = useStore((s) => s.activeTabId);
@@ -718,7 +720,7 @@ export const MemberLogsTab = ({
return (
- Searching logs...
+ {t('members.logs.searching')}
);
}
@@ -736,13 +738,13 @@ export const MemberLogsTab = ({
return (
- No logs found
+ {t('members.logs.empty')}
{taskId != null
? taskStatus === 'in_progress'
- ? 'Task is in progress — waiting for session activity (auto-refreshing)...'
- : 'No session activity for this task yet'
- : 'This member has no recorded session activity yet'}
+ ? t('members.logs.waitingForTaskActivity')
+ : t('members.logs.noTaskActivity')
+ : t('members.logs.noMemberActivity')}
);
@@ -787,6 +789,7 @@ const LogCard = ({
detailLoading,
onToggle,
}: LogCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const createdAgo = formatRelativeTime(log.startTime);
const lastActivityTime = useMemo(() => {
const startMs = new Date(log.startTime).getTime();
@@ -837,8 +840,8 @@ const LogCard = ({
{log.kind === 'lead_session'
- ? 'Full team lead session logs - useful for global orchestration context, not specific to this agent'
- : 'Full persistent teammate session logs - useful when work runs in a root member session instead of a subagent file'}
+ ? t('members.logs.leadSessionTooltip')
+ : t('members.logs.memberSessionTooltip')}
)}
@@ -850,7 +853,9 @@ const LogCard = ({
{updatedAgo}
-
started {createdAgo}
+
+ {t('members.logs.startedAt', { time: createdAgo })}
+
>
) : (
@@ -864,7 +869,9 @@ const LogCard = ({
{log.messageCount}
{log.isOngoing && (
-
active
+
+ {t('members.logs.active')}
+
)}
{log.lastOutputPreview && !expanded && (
@@ -878,7 +885,9 @@ const LogCard = ({
-
{expanded ? 'Hide details' : 'Show details'}
+
+ {expanded ? t('members.logs.hideDetails') : t('members.logs.showDetails')}
+
{expanded && (
@@ -886,12 +895,12 @@ const LogCard = ({
{detailLoading && (
- Loading details...
+ {t('members.logs.loadingDetails')}
)}
{!detailLoading && !detailChunks && (
- Failed to load details
+ {t('members.logs.failedToLoadDetails')}
)}
{!detailLoading && detailChunks && (
diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx
index 6e5c9eac..98119816 100644
--- a/src/renderer/components/team/members/MemberMessagesTab.tsx
+++ b/src/renderer/components/team/members/MemberMessagesTab.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ActivityItem } from '@renderer/components/team/activity/ActivityItem';
import {
buildMessageContext,
@@ -30,11 +31,11 @@ interface MemberMessagesTabProps {
}
const MAX_MESSAGES = 100;
-const FILTER_OPTIONS: readonly { value: MemberActivityFilter; label: string }[] = [
- { value: 'all', label: 'All' },
- { value: 'messages', label: 'Messages' },
- { value: 'comments', label: 'Comments' },
-];
+const FILTER_OPTIONS = [
+ { value: 'all', labelKey: 'members.messages.filters.all' },
+ { value: 'messages', labelKey: 'members.messages.filters.messages' },
+ { value: 'comments', labelKey: 'members.messages.filters.comments' },
+] as const satisfies readonly { value: MemberActivityFilter; labelKey: string }[];
export const MemberMessagesTab = ({
teamName,
@@ -45,6 +46,7 @@ export const MemberMessagesTab = ({
onCreateTask,
onTaskClick,
}: MemberMessagesTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [activityFilter, setActivityFilter] = useState
(initialFilter);
const [expandedItem, setExpandedItem] = useState(null);
const { messages, messagesState, loadOlderTeamMessages } = useStore(
@@ -132,16 +134,16 @@ export const MemberMessagesTab = ({
const initialPageLoading = loading && activityEntries.length === 0;
const emptyStateText = initialPageLoading
- ? 'Loading activity...'
+ ? t('members.messages.empty.loading')
: activityFilter === 'comments'
- ? 'No comments for this member'
+ ? t('members.messages.empty.noComments')
: activityFilter === 'messages'
? hasMore
- ? 'No loaded messages for this member yet'
- : 'No messages with this member'
+ ? t('members.messages.empty.noLoadedMessages')
+ : t('members.messages.empty.noMessages')
: hasMore
- ? 'No loaded activity for this member yet'
- : 'No activity with this member';
+ ? t('members.messages.empty.noLoadedActivity')
+ : t('members.messages.empty.noActivity');
const canLoadOlderMessages = hasMore && activityFilter !== 'comments';
return (
@@ -161,7 +163,7 @@ export const MemberMessagesTab = ({
].join(' ')}
onClick={() => setActivityFilter(option.value)}
>
- {option.label}
+ {t(option.labelKey)}
);
})}
@@ -227,7 +229,7 @@ export const MemberMessagesTab = ({
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
- Load older messages
+ {t('members.messages.loadOlder')}
)}
diff --git a/src/renderer/components/team/members/MemberStatsTab.tsx b/src/renderer/components/team/members/MemberStatsTab.tsx
index 97a93e49..d64e5cd6 100644
--- a/src/renderer/components/team/members/MemberStatsTab.tsx
+++ b/src/renderer/components/team/members/MemberStatsTab.tsx
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { cn } from '@renderer/lib/utils';
import { formatRelativeTime } from '@renderer/utils/formatters';
@@ -36,6 +37,7 @@ export const MemberStatsTab = ({
onFileClick,
onShowAllFiles,
}: MemberStatsTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const usePrefetched = prefetchedStats !== undefined;
const [localStats, setLocalStats] = useState(null);
@@ -73,7 +75,7 @@ export const MemberStatsTab = ({
return (
- Computing stats...
+ {t('members.stats.computing')}
);
}
@@ -91,7 +93,7 @@ export const MemberStatsTab = ({
return (
- No stats available
+ {t('members.stats.empty')}
);
}
@@ -153,25 +155,30 @@ const SummaryCards = ({
stats: MemberFullStats;
totalTokens: number;
totalToolCalls: number;
-}): React.JSX.Element => (
-
- 0 ? `-${stats.linesRemoved}` : undefined}
- info="Approximate. Accurate for Edit and Write tools. Bash file writes are estimated from command patterns (heredoc, echo, sed) and may be underreported."
- />
-
-
-
-
-);
+}): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
+ return (
+
+ 0 ? `-${stats.linesRemoved}` : undefined}
+ info={t('members.stats.linesInfo')}
+ />
+
+
+
+
+ );
+};
const ToolUsageBars = ({
toolUsage,
}: {
toolUsage: Record;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const entries = Object.entries(toolUsage).sort(([, a], [, b]) => b - a);
if (entries.length === 0) return null;
@@ -179,7 +186,9 @@ const ToolUsageBars = ({
return (
-
Tool Usage
+
+ {t('members.stats.toolUsage')}
+
{entries.map(([name, count]) => (
@@ -235,6 +244,7 @@ const FilesTouchedSection = ({
onFileClick?: (filePath: string) => void;
onShowAll?: () => void;
}): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
const validFiles = files.filter((f) => !isInvalidPath(f));
@@ -248,11 +258,11 @@ const FilesTouchedSection = ({
- Files Touched ({validFiles.length})
+ {t('members.stats.filesTouched', { count: validFiles.length })}
{onShowAll && (
- View All Changes
+ {t('members.stats.viewAllChanges')}
)}
@@ -291,7 +301,9 @@ const FilesTouchedSection = ({
onClick={() => setExpanded(!expanded)}
>
{expanded ?
:
}
- {expanded ? 'Show less' : `+${hiddenCount} more`}
+ {expanded
+ ? t('members.stats.showLess')
+ : t('members.stats.moreFiles', { count: hiddenCount })}
)}
@@ -299,11 +311,12 @@ const FilesTouchedSection = ({
};
const StatsFooter = ({ stats }: { stats: MemberFullStats }): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const computedAgo = formatRelativeTime(stats.computedAt);
return (
- {stats.sessionCount} session{stats.sessionCount !== 1 ? 's' : ''} · computed {computedAgo}
+ {t('members.stats.footer', { count: stats.sessionCount, computedAgo })}
);
};
diff --git a/src/renderer/components/team/members/MemberTasksTab.tsx b/src/renderer/components/team/members/MemberTasksTab.tsx
index 15d90066..75fb3232 100644
--- a/src/renderer/components/team/members/MemberTasksTab.tsx
+++ b/src/renderer/components/team/members/MemberTasksTab.tsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import {
KANBAN_COLUMN_DISPLAY,
@@ -27,6 +28,7 @@ const STATUS_ORDER: Record
= {
};
export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const visibleTasks = useMemo(
() =>
tasks
@@ -38,7 +40,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
if (visibleTasks.length === 0) {
return (
- No tasks assigned to this member
+ {t('members.tasks.empty')}
);
}
diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx
index 6c1ad20e..f84b65f0 100644
--- a/src/renderer/components/team/members/MembersEditorSection.tsx
+++ b/src/renderer/components/team/members/MembersEditorSection.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
@@ -196,6 +197,7 @@ export const MembersEditorSection = ({
worktreeIsolationDisabledReason,
onTeammateWorktreeDefaultChange,
}: MembersEditorSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState(null);
@@ -439,7 +441,7 @@ export const MembersEditorSection = ({
return (
-
Members
+
{t('members.editor.title')}
{!hideContent && (
- Add member
+ {t('members.editor.addMember')}
{showJsonEditor && !jsonEditorOpen ? (
- Edit as JSON
+ {t('members.editor.editAsJson')}
) : null}
@@ -499,7 +501,7 @@ export const MembersEditorSection = ({
className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
>
-
Run teammates in separate worktrees
+
{t('members.editor.runInSeparateWorktrees')}
@@ -513,7 +515,9 @@ export const MembersEditorSection = ({
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
>
-
Agent Teams MCP only
+
+ {t('members.editor.agentTeamsMcpOnly')}
+
@@ -568,7 +572,7 @@ export const MembersEditorSection = ({
{softDeleteMembers && removedMembers.length > 0 ? (
- Removed ({removedMembers.length})
+ {t('members.editor.removedCount', { count: removedMembers.length })}
{removedMembers.map((member, index) => (
@@ -605,7 +609,7 @@ export const MembersEditorSection = ({
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel
- modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
+ modelLockReason={t('members.editor.removedModelLockReason')}
isRemoved
warningText={null}
disableGeminiOption={disableGeminiOption}
@@ -619,7 +623,7 @@ export const MembersEditorSection = ({
{hasDuplicates ? (
- Member names must be unique
+ {t('members.editor.memberNamesUnique')}
) : fieldError ? (
diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx
index b4611407..7db8cb87 100644
--- a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx
+++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
@@ -38,6 +39,7 @@ export const SubagentRecentMessagesPreview = ({
hasMore = false,
onLoadMore,
}: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [expandedAll, setExpandedAll] = useState(false);
// Strip agent-only blocks from message content before display
@@ -58,7 +60,9 @@ export const SubagentRecentMessagesPreview = ({
- Latest messages{memberName ? ` — ${displayMemberName(memberName)}` : ''}
+ {memberName
+ ? t('members.recentMessages.latestForMember', { member: displayMemberName(memberName) })
+ : t('members.recentMessages.latest')}
@@ -93,7 +97,7 @@ export const SubagentRecentMessagesPreview = ({
onClick={onLoadMore}
>
- Load more
+ {t('members.recentMessages.loadMore')}
) : null}
@@ -107,7 +111,7 @@ export const SubagentRecentMessagesPreview = ({
onClick={() => setExpandedAll(true)}
>
- Expand
+ {t('members.recentMessages.expand')}
) : (
setExpandedAll(false)}
>
- Collapse
+ {t('members.recentMessages.collapse')}
)}
diff --git a/src/renderer/components/team/messages/ActionModeSelector.tsx b/src/renderer/components/team/messages/ActionModeSelector.tsx
index d5184e12..fccfc6d0 100644
--- a/src/renderer/components/team/messages/ActionModeSelector.tsx
+++ b/src/renderer/components/team/messages/ActionModeSelector.tsx
@@ -4,6 +4,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import type { AgentActionMode } from '@shared/types';
@@ -53,6 +54,7 @@ export const ActionModeSelector = ({
showDelegate,
disabled = false,
}: ActionModeSelectorProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const modes = showDelegate ? MODE_CONFIG : MODE_CONFIG.filter((m) => m.mode !== 'delegate');
return (
@@ -60,7 +62,7 @@ export const ActionModeSelector = ({
{modes.map((cfg, idx) => {
const isActive = value === cfg.mode;
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index d6e383d6..06c41b15 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -10,6 +10,7 @@ import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
+import { useAppTranslation } from '@features/localization/renderer';
import { useComposerDraft } from '@renderer/hooks/useComposerDraft';
import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useTeamSuggestions } from '@renderer/hooks/useTeamSuggestions';
@@ -123,6 +124,7 @@ export const MessageComposer = ({
onSend,
onCrossTeamSend,
}: MessageComposerProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const internalTextareaRef = useRef(null);
const textareaRef = useMemo(() => {
// Merge internal and external refs into a single callback ref
@@ -217,9 +219,7 @@ export const MessageComposer = ({
const isCrossTeam = selectedTeam !== null;
const selectedTarget = sortedCrossTeamTargets.find((t) => t.teamName === selectedTeam);
const targetDisplayName = selectedTarget?.displayName ?? selectedTeam;
- const crossTeamHintText = isCrossTeam
- ? 'Tip: Cross-team messages go to the target team lead. If you want the reply to come back to your team lead instead of you, say that explicitly in the message.'
- : undefined;
+ const crossTeamHintText = isCrossTeam ? t('messageComposer.crossTeam.hint') : undefined;
// Members load async with team data; keep recipient stable if valid, otherwise default to lead/first.
useEffect(() => {
@@ -370,19 +370,19 @@ export const MessageComposer = ({
const canAttach = supportsAttachments && draft.canAddMore && !sending;
const attachmentRestrictionReason = !supportsAttachments
? isCrossTeam
- ? 'File attachments are not supported for cross-team messages'
+ ? t('messageComposer.attachments.restrictions.crossTeam')
: !isTeamAlive
- ? 'Team must be online to attach files'
+ ? t('messageComposer.attachments.restrictions.teamOffline')
: !showAttachmentControl
- ? 'Files can be sent to the team lead or OpenCode teammates'
+ ? t('messageComposer.attachments.restrictions.unsupportedRecipient')
: (memberAttachmentUnavailableReason ??
(isOpenCodeRecipient
- ? 'Team must be online to attach files for OpenCode teammates'
- : 'Team must be online to attach files'))
+ ? t('messageComposer.attachments.restrictions.openCodeOffline')
+ : t('messageComposer.attachments.restrictions.teamOffline')))
: sending
- ? 'Wait for current message to finish sending before adding files'
+ ? t('messageComposer.attachments.restrictions.sending')
: !draft.canAddMore
- ? 'Maximum attachments reached'
+ ? t('messageComposer.attachments.restrictions.maximumReached')
: undefined;
const attachmentPayloadRestrictionReason = validateAttachmentPayloadsForMember({
member: selectedMember,
@@ -393,13 +393,13 @@ export const MessageComposer = ({
(!supportsAttachments || attachmentPayloadRestrictionReason != null);
const slashCommandRestrictionReason = standaloneSlashCommand
? draft.attachments.length > 0
- ? 'Slash commands require a live team lead and cannot be sent with attachments'
+ ? t('messageComposer.slash.restrictions.attachments')
: isCrossTeam
- ? 'Slash commands can only be run on the current team lead'
+ ? t('messageComposer.slash.restrictions.crossTeam')
: !isLeadRecipient
- ? 'Slash commands can only be sent to the team lead'
+ ? t('messageComposer.slash.restrictions.notLead')
: !isTeamAlive
- ? 'Slash commands require the team lead to be online'
+ ? t('messageComposer.slash.restrictions.leadOffline')
: null
: null;
const canSend =
@@ -532,7 +532,7 @@ export const MessageComposer = ({
setFileRestrictionError(
attachmentRestrictionReason ??
attachmentPayloadRestrictionReason ??
- 'Files can only be sent to the team lead'
+ t('messageComposer.attachments.restrictions.leadOnly')
);
window.clearTimeout(fileRestrictionTimerRef.current);
fileRestrictionTimerRef.current = window.setTimeout(() => {
@@ -735,7 +735,7 @@ export const MessageComposer = ({
) : lastResult?.deduplicated ? (
- Reused recent cross-team request
+ {t('messageComposer.status.reusedCrossTeamRequest')}
) : null;
const shouldShowFooterCharCount = remaining < 200;
@@ -750,11 +750,13 @@ export const MessageComposer = ({
- {remaining} chars left
+ {t('messageComposer.input.charsLeft', { count: remaining })}
) : null}
{shouldShowSavedIndicator ? (
- Saved
+
+ {t('tasks.createTask.saved')}
+
) : null}
) : null}
@@ -808,8 +810,8 @@ export const MessageComposer = ({
{canAttach
- ? 'Attach files (paste or drag & drop)'
- : (attachmentRestrictionReason ?? 'Attachments are unavailable')}
+ ? t('messageComposer.attachments.attachFiles')
+ : (attachmentRestrictionReason ?? t('messageComposer.attachments.unavailable'))}
>
@@ -818,7 +820,7 @@ export const MessageComposer = ({
{!isTeamAlive && !isLaunchBlocking && (
- Team offline
+ {t('messageComposer.status.teamOffline')}
)}
@@ -873,7 +875,9 @@ export const MessageComposer = ({
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
-
This team
+
+ {t('messageComposer.teamSelector.thisTeam')}
+
>
)}
@@ -900,9 +904,11 @@ export const MessageComposer = ({
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
-
This team
+
+ {t('messageComposer.teamSelector.thisTeam')}
+
- current
+ {t('messageComposer.teamSelector.current')}
{!isCrossTeam ? (
@@ -942,7 +948,11 @@ export const MessageComposer = ({
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
- title={target.isOnline ? 'Online' : 'Offline'}
+ title={
+ target.isOnline
+ ? t('messageComposer.teamSelector.onlineTitle')
+ : t('messageComposer.teamSelector.offlineTitle')
+ }
/>
@@ -957,7 +967,9 @@ export const MessageComposer = ({
: 'text-[var(--color-text-muted)]'
)}
>
- {target.isOnline ? 'online' : 'offline'}
+ {target.isOnline
+ ? t('messageComposer.teamSelector.online')
+ : t('messageComposer.teamSelector.offline')}
{target.description ? (
@@ -1005,7 +1017,9 @@ export const MessageComposer = ({
disableHoverCard
/>
) : (
-
Select...
+
+ {t('messageComposer.recipient.select')}
+
)}
@@ -1029,7 +1043,7 @@ export const MessageComposer = ({
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
- placeholder="Search..."
+ placeholder={t('messageComposer.recipient.searchPlaceholder')}
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
@@ -1045,7 +1059,7 @@ export const MessageComposer = ({
if (filtered.length === 0) {
return (
- No results
+ {t('messageComposer.recipient.noResults')}
);
}
@@ -1111,7 +1125,7 @@ export const MessageComposer = ({
disabledHint={
attachmentPayloadRestrictionReason ??
attachmentRestrictionReason ??
- 'File attachments are supported for the online team lead and online OpenCode teammates. Remove attachments or switch recipient.'
+ t('messageComposer.attachments.disabledHint')
}
/>
) : null}
@@ -1128,10 +1142,12 @@ export const MessageComposer = ({
id={`compose-${teamName}`}
placeholder={
isLaunchBlocking
- ? 'Team is launching... message will be queued for inbox delivery.'
+ ? t('messageComposer.input.teamLaunchingPlaceholder')
: isCrossTeam
- ? `Cross-team message to ${targetDisplayName ?? 'team'}...`
- : 'Write a message... (Enter to send, Shift+Enter for new line)'
+ ? t('messageComposer.input.crossTeamPlaceholder', {
+ team: targetDisplayName ?? t('messageComposer.input.teamFallback'),
+ })
+ : t('messageComposer.input.placeholder')
}
value={draft.text}
onValueChange={draft.setText}
@@ -1148,7 +1164,7 @@ export const MessageComposer = ({
onModEnter={handleSend}
onShiftTab={handleCycleActionMode}
dismissMentionsRef={dismissMentionsRef}
- extraTips={['Tip: You can use "/" to run any Claude commands.']}
+ extraTips={[t('messageComposer.input.slashTip')]}
surfaceClassName="message-composer-shell message-composer-orbit-surface border border-transparent bg-[var(--color-surface-raised)] shadow-[0_8px_24px_rgba(0,0,0,0.18),inset_0_1px_0_rgba(255,255,255,0.03)]"
surfaceDecoration="orbit-border"
surfaceFadeColor="var(--color-surface-raised)"
@@ -1181,7 +1197,9 @@ export const MessageComposer = ({
-
Voice to text
+
+ {t('messageComposer.actions.voiceToText')}
+
@@ -1193,7 +1211,7 @@ export const MessageComposer = ({
onClick={handleSend}
>
- Send
+ {t('messageComposer.actions.send')}
@@ -1201,7 +1219,7 @@ export const MessageComposer = ({
{slashCommandRestrictionReason}
) : isLaunchBlocking && !sending ? (
- Sending unavailable while team is launching
+ {t('messageComposer.actions.sendingUnavailableLaunching')}
) : null}
diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx
index 83daddc6..2ad14803 100644
--- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx
+++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
@@ -54,6 +55,7 @@ export const MessagesFilterPopover = ({
onOpenChange,
onApply,
}: MessagesFilterPopoverProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [draft, setDraft] = useState
({
from: new Set(),
to: new Set(),
@@ -118,7 +120,7 @@ export const MessagesFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter messages"
+ aria-label={t('messages.filter.ariaLabel')}
>
{activeCount > 0 && (
@@ -129,18 +131,20 @@ export const MessagesFilterPopover = ({
- Filter messages
+ {t('messages.filter.tooltip')}
{/* Scrollable filter sections */}
- From
+ {t('messages.filter.from')}
{fromOptions.length === 0 ? (
-
No data
+
+ {t('messages.filter.noData')}
+
) : (
fromOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
@@ -166,11 +170,13 @@ export const MessagesFilterPopover = ({
- To
+ {t('messages.filter.to')}
{toOptions.length === 0 ? (
-
No data
+
+ {t('messages.filter.noData')}
+
) : (
toOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
@@ -204,7 +210,7 @@ export const MessagesFilterPopover = ({
setDraft((prev) => ({ ...prev, showNoise: !prev.showNoise }))
}
/>
-
Show status updates (idle/shutdown)
+
{t('messages.filter.showStatusUpdates')}
@@ -215,10 +221,10 @@ export const MessagesFilterPopover = ({
disabled={draftCount === 0 && !draft.showNoise}
onClick={handleReset}
>
- Reset
+ {t('messages.filter.actions.reset')}
- Save
+ {t('messages.filter.actions.save')}
diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx
index 5a438df4..8c17e5a1 100644
--- a/src/renderer/components/team/messages/MessagesPanel.tsx
+++ b/src/renderer/components/team/messages/MessagesPanel.tsx
@@ -11,6 +11,7 @@ import {
} from 'react';
import { Sheet, type SheetRef } from 'react-modal-sheet';
+import { useAppTranslation } from '@features/localization/renderer';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@@ -279,6 +280,7 @@ const MessagesTimelineSection = memo(function MessagesTimelineSection({
onExpandContent,
viewport,
}: MessagesTimelineSectionProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
return (
<>
- Load older messages
+ {t('messages.actions.loadOlder')}
)}
@@ -363,6 +365,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onFloatingComposerHeightChange,
inlineScrollContainerRef,
}: MessagesPanelProps): React.JSX.Element {
+ const { t } = useAppTranslation('team');
const {
sendTeamMessage,
sendCrossTeamMessage,
@@ -916,26 +919,26 @@ export const MessagesPanel = memo(function MessagesPanel({
variant="ghost"
size="sm"
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
- aria-label="Message panel mode"
+ aria-label={t('messages.panelMode')}
>
-
Message panel mode
+
{t('messages.panelMode')}
- Move to inline
+ {t('messages.actions.moveToInline')}
- Move to bottom sheet
+ {t('messages.actions.moveToBottomSheet')}
- Move to sidebar
+ {t('messages.actions.moveToSidebar')}
@@ -1046,7 +1049,7 @@ export const MessagesPanel = memo(function MessagesPanel({
setMessagesSearchQuery(e.target.value)}
onPointerDown={(e) => e.stopPropagation()}
@@ -1114,7 +1117,9 @@ export const MessagesPanel = memo(function MessagesPanel({
{/* Header */}
-
Messages
+
+ {t('messages.title')}
+
{filteredMessages.length > 0 && (
- {messagesUnreadCount} new
+ {t('messages.unread.new', { count: messagesUnreadCount })}
-
{messagesUnreadCount} unread
+
+ {t('messages.unread.unread', { count: messagesUnreadCount })}
+
)}
{messagesUnreadCount > 0 && (
@@ -1147,7 +1154,7 @@ export const MessagesPanel = memo(function MessagesPanel({
-
Mark all as read
+
{t('messages.actions.markAllRead')}
)}
@@ -1159,13 +1166,15 @@ export const MessagesPanel = memo(function MessagesPanel({
variant="ghost"
size="sm"
className="size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
- aria-label="Message panel actions"
+ aria-label={t('messages.actions.panelActions')}
>
-
Message actions
+
+ {t('messages.actions.messageActions')}
+
setMessagesCollapsed((v) => !v)}>
@@ -1174,7 +1183,11 @@ export const MessagesPanel = memo(function MessagesPanel({
) : (
)}
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+
+ {messagesCollapsed
+ ? t('messages.actions.expandAll')
+ : t('messages.actions.collapseAll')}
+
setMessagesSearchBarVisible((v) => !v)}>
{messagesSearchBarVisible ? (
@@ -1182,19 +1195,23 @@ export const MessagesPanel = memo(function MessagesPanel({
) : (
)}
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+ {messagesSearchBarVisible
+ ? t('messages.actions.hideSearch')
+ : t('messages.actions.searchMessages')}
+
- Move to inline
+ {t('messages.actions.moveToInline')}
- Move to bottom sheet
+ {t('messages.actions.moveToBottomSheet')}
- Float composer
+ {t('messages.actions.floatComposer')}
@@ -1278,7 +1295,9 @@ export const MessagesPanel = memo(function MessagesPanel({
-
Messages
+
+ {t('messages.title')}
+
{filteredMessages.length > 0 && (
- {messagesUnreadCount} new
+ {t('messages.unread.new', { count: messagesUnreadCount })}
-
{messagesUnreadCount} unread
+
+ {t('messages.unread.unread', { count: messagesUnreadCount })}
+
)}
-
Message actions
+
+ {t('messages.actions.messageActions')}
+
{messagesUnreadCount > 0 && (
@@ -1327,7 +1350,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onSelect={handleMarkAllRead}
>
- Mark all as read
+ {t('messages.actions.markAllRead')}
)}
setMessagesCollapsed((value) => !value)}>
@@ -1337,7 +1360,9 @@ export const MessagesPanel = memo(function MessagesPanel({
)}
- {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
+ {messagesCollapsed
+ ? t('messages.actions.expandAll')
+ : t('messages.actions.collapseAll')}
)}
- {messagesSearchBarVisible ? 'Hide search' : 'Search messages'}
+
+ {messagesSearchBarVisible
+ ? t('messages.actions.hideSearch')
+ : t('messages.actions.searchMessages')}
+
{isBottomSheetCollapsed ? (
@@ -1356,19 +1385,23 @@ export const MessagesPanel = memo(function MessagesPanel({
) : (
)}
- {isBottomSheetCollapsed ? 'Expand sheet' : 'Collapse sheet'}
+
+ {isBottomSheetCollapsed
+ ? t('messages.actions.expandSheet')
+ : t('messages.actions.collapseSheet')}
+
- Move to inline
+ {t('messages.actions.moveToInline')}
- Move to sidebar
+ {t('messages.actions.moveToSidebar')}
- Float composer
+ {t('messages.actions.floatComposer')}
@@ -1410,7 +1443,7 @@ export const MessagesPanel = memo(function MessagesPanel({
return (
}
badge={filteredMessages.length}
secondaryBadge={
@@ -1431,7 +1464,7 @@ export const MessagesPanel = memo(function MessagesPanel({
-
Mark all as read
+
{t('messages.actions.markAllRead')}
) : undefined
}
@@ -1447,12 +1480,12 @@ export const MessagesPanel = memo(function MessagesPanel({
e.stopPropagation();
moveToBottomSheet();
}}
- aria-label="Move messages to bottom sheet"
+ aria-label={t('messages.actions.moveMessagesToBottomSheet')}
>
-
Move to bottom sheet
+
{t('messages.actions.moveToBottomSheet')}
@@ -1464,12 +1497,12 @@ export const MessagesPanel = memo(function MessagesPanel({
e.stopPropagation();
moveToFloatingComposer();
}}
- aria-label="Float messages composer"
+ aria-label={t('messages.actions.floatMessagesComposer')}
>
- Float composer
+ {t('messages.actions.floatComposer')}
@@ -1481,12 +1514,12 @@ export const MessagesPanel = memo(function MessagesPanel({
e.stopPropagation();
moveToSidebar();
}}
- aria-label="Move messages to sidebar"
+ aria-label={t('messages.actions.moveMessagesToSidebar')}
>
- Move to sidebar
+ {t('messages.actions.moveToSidebar')}
}
diff --git a/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
index 18a304b8..6926ea5a 100644
--- a/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
+++ b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
formatOpenCodeRuntimeDeliveryDebugDetails,
type OpenCodeRuntimeDeliveryDebugDetails,
@@ -19,6 +20,7 @@ export const OpenCodeDeliveryWarning = ({
debugDetails,
pendingDelayMs = 10_000,
}: OpenCodeDeliveryWarningProps): JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}:${debugDetails?.statusMessageId ?? ''}:${debugDetails?.userVisibleState ?? ''}`;
const delayPendingWarning =
debugDetails?.userVisibleState === 'checking' ||
@@ -112,46 +114,80 @@ export const OpenCodeDeliveryWarning = ({
setExpandedKey((currentKey) => (currentKey === detailsKey ? null : detailsKey))
}
>
- Details
+ {t('messages.delivery.details')}
) : null}
{expanded && debugDetails ? (
- messageId
+
+ {t('messages.delivery.fields.messageId')}
+
{debugDetails.messageId}
- statusMessageId
+
+ {t('messages.delivery.fields.statusMessageId')}
+
{debugDetails.statusMessageId}
- providerId
+
+ {t('messages.delivery.fields.providerId')}
+
{debugDetails.providerId}
- delivered
+
+ {t('messages.delivery.fields.delivered')}
+
{String(debugDetails.delivered)}
- responsePending
+
+ {t('messages.delivery.fields.responsePending')}
+
{String(debugDetails.responsePending)}
- responseState
+
+ {t('messages.delivery.fields.responseState')}
+
{debugDetails.responseState ?? 'null'}
- ledgerStatus
+
+ {t('messages.delivery.fields.ledgerStatus')}
+
{debugDetails.ledgerStatus ?? 'null'}
- visibleReplyMessageId
+
+ {t('messages.delivery.fields.visibleReplyMessageId')}
+
{debugDetails.visibleReplyMessageId ?? 'null'}
- visibleReplyCorrelation
+
+ {t('messages.delivery.fields.visibleReplyCorrelation')}
+
{debugDetails.visibleReplyCorrelation ?? 'null'}
- queuedBehindMessageId
+
+ {t('messages.delivery.fields.queuedBehindMessageId')}
+
{debugDetails.queuedBehindMessageId ?? 'null'}
- acceptanceUnknown
+
+ {t('messages.delivery.fields.acceptanceUnknown')}
+
{String(debugDetails.acceptanceUnknown)}
- userVisibleState
+
+ {t('messages.delivery.fields.userVisibleState')}
+
{debugDetails.userVisibleState ?? 'null'}
- userVisibleReasonCode
+
+ {t('messages.delivery.fields.userVisibleReasonCode')}
+
{debugDetails.userVisibleReasonCode ?? 'null'}
- userVisibleNextReviewAt
+
+ {t('messages.delivery.fields.userVisibleNextReviewAt')}
+
{debugDetails.userVisibleNextReviewAt ?? 'null'}
- userVisibleMessage
+
+ {t('messages.delivery.fields.userVisibleMessage')}
+
{debugDetails.userVisibleMessage ?? 'null'}
- reason
+
+ {t('messages.delivery.fields.reason')}
+
{debugDetails.reason ?? 'null'}
- diagnostics
+
+ {t('messages.delivery.fields.diagnostics')}
+
{debugDetails.diagnostics.length ? debugDetails.diagnostics.join('; ') : '[]'}
@@ -161,7 +197,7 @@ export const OpenCodeDeliveryWarning = ({
className="mt-2 rounded border border-amber-500/20 px-2 py-1 text-[10px] text-amber-200 hover:border-amber-400/40 hover:text-amber-100"
onClick={() => void handleCopy()}
>
- {copied ? 'Copied' : 'Copy debug details'}
+ {copied ? t('messages.delivery.copied') : t('messages.delivery.copyDebugDetails')}
) : null}
diff --git a/src/renderer/components/team/messages/StatusBlock.tsx b/src/renderer/components/team/messages/StatusBlock.tsx
index 116a0dee..b17cbf4b 100644
--- a/src/renderer/components/team/messages/StatusBlock.tsx
+++ b/src/renderer/components/team/messages/StatusBlock.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { computePendingCrossTeamReplies } from '@renderer/utils/crossTeamPendingReplies';
import { isDisplayableCurrentTask } from '@renderer/utils/teamTaskDisplayState';
import { ChevronRight } from 'lucide-react';
@@ -38,6 +39,7 @@ export const StatusBlock = ({
onMemberClick,
onTaskClick,
}: StatusBlockProps): React.JSX.Element | null => {
+ const { t } = useAppTranslation('team');
const [collapsed, setCollapsed] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
@@ -86,7 +88,7 @@ export const StatusBlock = ({
size={12}
className={`shrink-0 transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
/>
- Status
+ {t('messages.status.title')}
);
const flowInlineToggle = layout === 'flow' && !collapsed ? toggleButton : null;
diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts
index 776eaecc..8d76f810 100644
--- a/src/renderer/components/team/provisioningSteps.ts
+++ b/src/renderer/components/team/provisioningSteps.ts
@@ -13,10 +13,10 @@ interface LaunchJoinMemberLike {
/** Display steps for the provisioning stepper (0-indexed). */
export const DISPLAY_STEPS = [
- { key: 'starting', label: 'Starting' },
- { key: 'configuring', label: 'Team setup' },
- { key: 'assembling', label: 'Members joining' },
- { key: 'finalizing', label: 'Finalizing' },
+ { key: 'starting', labelKey: 'provisioning.steps.starting' },
+ { key: 'configuring', labelKey: 'provisioning.steps.configuring' },
+ { key: 'assembling', labelKey: 'provisioning.steps.assembling' },
+ { key: 'finalizing', labelKey: 'provisioning.steps.finalizing' },
] as const;
export const DISPLAY_COMPLETE_STEP_INDEX = DISPLAY_STEPS.length;
diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx
index 16649e96..25d5cf62 100644
--- a/src/renderer/components/team/review/ChangeReviewDialog.tsx
+++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { undo } from '@codemirror/commands';
import { rejectChunk } from '@codemirror/merge';
+import { useAppTranslation } from '@features/localization/renderer';
import { api, isElectronMode } from '@renderer/api';
import { EditorSelectionMenu } from '@renderer/components/team/editor/EditorSelectionMenu';
import { useContinuousScrollNav } from '@renderer/hooks/useContinuousScrollNav';
@@ -77,6 +78,7 @@ const TaskChangesEmptyState = ({
}: {
changeSet: TaskChangeSetV2 | null;
}): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const status = changeSet ? classifyTaskChangeReviewability(changeSet) : null;
const diagnosticMessages =
status && status.diagnostics.length > 0
@@ -91,17 +93,17 @@ const TaskChangesEmptyState = ({
const hasDiagnosticContext = uniqueMessages.length > 0;
const Icon = isAttention ? AlertTriangle : hasDiagnosticContext ? Info : FileSearch;
const title = isDiagnosticOnly
- ? 'No safe diff available'
+ ? t('review.empty.noSafeDiff')
: isAttention
- ? 'No reviewable file changes'
- : 'No file changes recorded';
+ ? t('review.continuousScroll.empty')
+ : t('review.empty.noFileChangesRecorded');
const description = isNoSafeDiff
? isDiagnosticOnly
- ? 'The task ledger did not expose a safe file diff for this task.'
- : 'The task ledger did not expose a safe file diff for this task. The diagnostics below explain why.'
+ ? t('review.empty.noSafeDiffDescription')
+ : t('review.empty.noSafeDiffDiagnosticsDescription')
: hasDiagnosticContext
- ? 'The task ledger has no file events for this task yet.'
- : 'The task ledger has no file events for this task.';
+ ? t('review.empty.noFileEventsYet')
+ : t('review.empty.noFileEvents');
return (
@@ -142,6 +144,7 @@ export const ChangeReviewDialog = ({
projectPath,
onEditorAction,
}: ChangeReviewDialogProps): React.ReactElement | null => {
+ const { t } = useAppTranslation('team');
const {
activeChangeSet,
changeSetLoading,
@@ -1345,7 +1348,11 @@ export const ChangeReviewDialog = ({
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs text-text-secondary hover:text-text"
>
- Edit Timeline ({activeFile.timeline.events.length})
+
+ {t('review.timeline.titleWithCount', {
+ count: activeFile.timeline.events.length,
+ })}
+
{
/* Component */
export const ChangesLoadingAnimation = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [phaseIdx, setPhaseIdx] = useState(0);
const [phaseFading, setPhaseFading] = useState(false);
const [visibleLines, setVisibleLines] = useState([]);
@@ -228,7 +246,7 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => {
- DIFF
+ {t('review.loading.diff')}
@@ -306,10 +324,10 @@ export const ChangesLoadingAnimation = (): React.JSX.Element => {
}}
>
- {phase.label}
+ {t(phase.labelKey)}
- {fileCount} ledger objects processed
+ {t('review.loading.ledgerObjectsProcessed', { count: fileCount })}
diff --git a/src/renderer/components/team/review/CodeMirrorDiffView.tsx b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
index c098d4f9..1f39d8a8 100644
--- a/src/renderer/components/team/review/CodeMirrorDiffView.tsx
+++ b/src/renderer/components/team/review/CodeMirrorDiffView.tsx
@@ -6,6 +6,7 @@ import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import {
getAsyncLanguageDesc,
getSyncLanguageExtension,
@@ -207,6 +208,7 @@ export const CodeMirrorDiffView = ({
globalHunkOffset = 0,
totalReviewHunks,
}: CodeMirrorDiffViewProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const rootRef = useRef
(null);
const containerRef = useRef(null);
const viewRef = useRef(null);
@@ -800,7 +802,7 @@ export const CodeMirrorDiffView = ({
{
e.preventDefault();
moveBetweenChunks('prev');
@@ -815,7 +817,7 @@ export const CodeMirrorDiffView = ({
{
e.preventDefault();
moveBetweenChunks('next');
@@ -832,14 +834,16 @@ export const CodeMirrorDiffView = ({
backgroundColor: 'var(--diff-merge-undo-bg)',
border: '1px solid var(--diff-merge-undo-border)',
}}
- title="Reject change (⌘N)"
+ title={t('review.diffControls.rejectChange')}
onMouseDown={(e) => {
e.preventDefault();
actOnActiveChunk('reject');
}}
>
- {'Undo '}
- {'\u2318N'}
+ {t('review.diffControls.undo')}{' '}
+
+ {t('review.diffControls.rejectShortcut')}
+
{
e.preventDefault();
actOnActiveChunk('accept');
}}
>
- {'Keep '}
- {'\u2318Y'}
+ {t('review.diffControls.keep')}{' '}
+
+ {t('review.diffControls.acceptShortcut')}
+
)}
diff --git a/src/renderer/components/team/review/ConfidenceBadge.tsx b/src/renderer/components/team/review/ConfidenceBadge.tsx
index a4578061..b9ac54ab 100644
--- a/src/renderer/components/team/review/ConfidenceBadge.tsx
+++ b/src/renderer/components/team/review/ConfidenceBadge.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
import type { TaskScopeConfidence } from '@shared/types';
interface ConfidenceBadgeProps {
@@ -13,24 +15,27 @@ const TIER_COLORS: Record = {
4: 'bg-red-500/20 text-red-400 border-red-500/30',
};
-const TIER_LABELS: Record = {
- 1: 'High confidence',
- 2: 'Medium confidence',
- 3: 'Low confidence',
- 4: 'Best effort',
-};
-
export const ConfidenceBadge = ({
confidence,
showTooltip = true,
label,
}: ConfidenceBadgeProps) => {
+ const { t } = useAppTranslation('team');
+ const fallbackLabel =
+ confidence.tier === 1
+ ? t('review.scope.confidence.high')
+ : confidence.tier === 2
+ ? t('review.scope.confidence.medium')
+ : confidence.tier === 3
+ ? t('review.scope.confidence.low')
+ : t('review.scope.confidence.bestEffort');
+
return (
- {label ?? TIER_LABELS[confidence.tier] ?? TIER_LABELS[4]}
+ {label ?? fallbackLabel}
);
};
diff --git a/src/renderer/components/team/review/ConflictDialog.tsx b/src/renderer/components/team/review/ConflictDialog.tsx
index 4698b3eb..06c11320 100644
--- a/src/renderer/components/team/review/ConflictDialog.tsx
+++ b/src/renderer/components/team/review/ConflictDialog.tsx
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, X } from 'lucide-react';
@@ -22,6 +23,7 @@ export const ConflictDialog = ({
onResolveUseOriginal,
onResolveManual,
}: ConflictDialogProps) => {
+ const { t } = useAppTranslation('team');
const [editMode, setEditMode] = useState(false);
const [editContent, setEditContent] = useState(conflictContent);
@@ -39,10 +41,8 @@ export const ConflictDialog = ({
-
Conflict Detected
-
- This file has been modified since the agent's changes
-
+
{t('review.conflict.title')}
+
{t('review.conflict.description')}
onOpenChange(false)}
@@ -98,13 +98,13 @@ export const ConflictDialog = ({
onClick={() => setEditMode(false)}
className="rounded px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
- Cancel
+ {t('review.conflict.cancel')}
- Save Resolution
+ {t('review.conflict.saveResolution')}
>
) : (
@@ -116,7 +116,7 @@ export const ConflictDialog = ({
}}
className="rounded px-3 py-1.5 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
>
- Edit Manually
+ {t('review.conflict.editManually')}
- Use Original
+ {t('review.conflict.useOriginal')}
- Keep Current
+ {t('review.conflict.keepCurrent')}
>
)}
diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx
index ca933049..602c3cf8 100644
--- a/src/renderer/components/team/review/ContinuousScrollView.tsx
+++ b/src/renderer/components/team/review/ContinuousScrollView.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
import { useStore } from '@renderer/store';
@@ -116,6 +117,7 @@ export const ContinuousScrollView = ({
globalHunkOffsets,
totalReviewHunks,
}: ContinuousScrollViewProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const setFileChunkCount = useStore((s) => s.setFileChunkCount);
const [localCollapsedFiles, setLocalCollapsedFiles] = useState
>(() => new Set());
const collapsedFiles = collapsedFilesProp ?? localCollapsedFiles;
@@ -240,7 +242,7 @@ export const ContinuousScrollView = ({
if (files.length === 0) {
return (
- No reviewable file changes
+ {t('review.continuousScroll.empty')}
);
}
diff --git a/src/renderer/components/team/review/DiffErrorBoundary.tsx b/src/renderer/components/team/review/DiffErrorBoundary.tsx
index ff51aa78..5b5159f7 100644
--- a/src/renderer/components/team/review/DiffErrorBoundary.tsx
+++ b/src/renderer/components/team/review/DiffErrorBoundary.tsx
@@ -1,5 +1,6 @@
import { Component, type JSX, type ReactNode } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { AlertTriangle } from 'lucide-react';
interface DiffErrorBoundaryProps {
@@ -15,8 +16,17 @@ interface DiffErrorBoundaryState {
error: Error | null;
}
-export class DiffErrorBoundary extends Component {
- constructor(props: DiffErrorBoundaryProps) {
+type ReviewT = ReturnType['t'];
+
+interface DiffErrorBoundaryInnerProps extends DiffErrorBoundaryProps {
+ t: ReviewT;
+}
+
+class DiffErrorBoundaryInner extends Component<
+ DiffErrorBoundaryInnerProps,
+ DiffErrorBoundaryState
+> {
+ constructor(props: DiffErrorBoundaryInnerProps) {
super(props);
this.state = { hasError: false, error: null };
}
@@ -39,18 +49,18 @@ export class DiffErrorBoundary extends Component{this.props.children}>;
}
- const { filePath, oldString, newString, onRetry } = this.props;
+ const { filePath, oldString, newString, onRetry, t } = this.props;
const { error } = this.state;
return (
-
Failed to render diff view
+
{t('review.diffError.title')}
- {error?.message ?? 'An unexpected error occurred while rendering the diff.'}
+ {error?.message ?? t('review.diffError.unexpected')}
@@ -62,7 +72,7 @@ export class DiffErrorBoundary extends Component
- Retry
+ {t('review.diffError.actions.retry')}
)}
@@ -70,25 +80,31 @@ export class DiffErrorBoundary extends Component
- Show raw diff data
+ {t('review.diffError.raw.show')}
-
File: {filePath}
+
+ {t('review.diffError.raw.file', { file: filePath })}
+
{oldString && (
-
--- Original
+
{t('review.diffError.raw.original')}
{oldString.slice(0, 2000)}
{oldString.length > 2000 && (
-
... ({oldString.length} chars total)
+
+ {t('review.diffError.raw.charsTotal', { count: oldString.length })}
+
)}
)}
{newString && (
-
+++ Modified
+
{t('review.diffError.raw.modified')}
{newString.slice(0, 2000)}
{newString.length > 2000 && (
-
... ({newString.length} chars total)
+
+ {t('review.diffError.raw.charsTotal', { count: newString.length })}
+
)}
)}
@@ -99,3 +115,8 @@ export class DiffErrorBoundary extends Component
;
+}
diff --git a/src/renderer/components/team/review/FileEditTimeline.tsx b/src/renderer/components/team/review/FileEditTimeline.tsx
index 51838f62..06bfc266 100644
--- a/src/renderer/components/team/review/FileEditTimeline.tsx
+++ b/src/renderer/components/team/review/FileEditTimeline.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import type { FileEditTimeline as FileEditTimelineType } from '@shared/types/review';
@@ -13,8 +14,10 @@ export const FileEditTimeline = ({
onEventClick,
activeSnippetIndex,
}: FileEditTimelineProps) => {
+ const { t } = useAppTranslation('team');
+
if (timeline.events.length === 0) {
- return
No edit events
;
+ return
{t('review.timeline.empty')}
;
}
return (
diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx
index 80a24946..bce6fdfd 100644
--- a/src/renderer/components/team/review/FileSectionDiff.tsx
+++ b/src/renderer/components/team/review/FileSectionDiff.tsx
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { CodeMirrorDiffView } from './CodeMirrorDiffView';
import { DiffErrorBoundary } from './DiffErrorBoundary';
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
@@ -56,6 +57,7 @@ export const FileSectionDiff = ({
globalHunkOffset = 0,
totalReviewHunks,
}: FileSectionDiffProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const localEditorViewRef = useRef
(null);
const sentinelRef = useRef(null);
const hasSnippetText = hasReviewSnippetText(file);
@@ -206,8 +208,8 @@ export const FileSectionDiff = ({
className="border-b border-border bg-red-500/10 px-4 py-2 text-xs"
style={{ color: 'var(--diff-removed-text)' }}
>
- File is missing on disk. This diff may be only a preview from agent logs. Use{' '}
- Restore to create the file on disk.
+ {t('review.fileMissingPrefix')} {t('review.restore')} {' '}
+ {t('review.fileMissingSuffix')}
)}
= {
- 'ledger-exact': 'Task Ledger',
- 'ledger-snapshot': 'Ledger Snapshot',
- 'file-history': 'File History',
- 'snippet-reconstruction': 'Reconstructed',
- 'disk-current': 'Current Disk',
- 'git-fallback': 'Git Fallback',
- unavailable: 'Content unavailable',
-};
-
interface FileSectionHeaderProps {
file: FileChangeSummary;
fileContent: FileChangeWithContent | null;
@@ -65,6 +56,7 @@ export const FileSectionHeader = ({
onAcceptFile,
onRejectFile,
}: FileSectionHeaderProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const restoreContent = getResolvedReviewModifiedContent(file, fileContent);
const isMissingOnDisk = isReviewFileMissingOnDisk(fileContent);
const isContentUnavailable = isReviewTextContentUnavailable(file, fileContent);
@@ -76,12 +68,18 @@ export const FileSectionHeader = ({
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent != null;
const externalChangeLabel =
externalChange?.type === 'unlink'
- ? 'Deleted on disk'
+ ? t('review.fileHeader.externalChange.deletedOnDisk')
: externalChange?.type === 'add'
- ? 'Recreated on disk'
+ ? t('review.fileHeader.externalChange.recreatedOnDisk')
: externalChange?.type === 'change'
- ? 'Changed on disk'
+ ? t('review.fileHeader.externalChange.changedOnDisk')
: null;
+ const contentSourceLabel =
+ fileContent?.contentSource != null
+ ? t(`review.fileHeader.contentSource.${fileContent.contentSource}`, {
+ defaultValue: fileContent.contentSource,
+ })
+ : null;
const handleHeaderClick = (e: React.MouseEvent): void => {
// Don't collapse when clicking action buttons
@@ -112,13 +110,13 @@ export const FileSectionHeader = ({
{file.isNewFile && (
- NEW
+ {t('review.fileHeader.badges.new')}
)}
{pathChangeLabel?.kind === 'deleted' && (
- DELETED
+ {t('review.fileHeader.badges.deleted')}
)}
@@ -130,7 +128,9 @@ export const FileSectionHeader = ({
- {pathChangeLabel.direction === 'from' ? 'From' : 'To'} {pathChangeLabel.otherPath}
+ {pathChangeLabel.direction === 'from'
+ ? t('review.fileHeader.pathChange.from', { path: pathChangeLabel.otherPath })
+ : t('review.fileHeader.pathChange.to', { path: pathChangeLabel.otherPath })}
)}
@@ -145,45 +145,49 @@ export const FileSectionHeader = ({
].join(' ')}
>
{isContentUnavailable
- ? 'Content unavailable'
+ ? t('review.fileHeader.contentUnavailable.badge')
: isMissingOnDisk
- ? 'Missing on disk'
- : (CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource)}
+ ? t('review.fileHeader.missingOnDisk.badge')
+ : contentSourceLabel}
{isContentUnavailable ? (
-
Text content is unavailable
-
- The ledger recorded metadata for this change, but full text content is not
- available. This usually means binary, large, or hash-only content.
+
+ {t('review.fileHeader.contentUnavailable.title')}
- Automatic accept/reject is disabled for this file to avoid unsafe disk writes.
+ {t('review.fileHeader.contentUnavailable.description')}
+
+
+ {t('review.fileHeader.contentUnavailable.safety')}
) : isMissingOnDisk ? (
-
File is missing on disk
+
+ {t('review.fileHeader.missingOnDisk.title')}
+
- We can still show a preview from agent logs, but your filesystem is out of sync.
+ {t('review.fileHeader.missingOnDisk.description')}
{restoreContent != null ? (
- Use Restore to write the preview
- content back to disk.
+ {t('review.fileHeader.missingOnDisk.restorePrefix')}{' '}
+
+ {t('review.fileHeader.actions.restore')}
+ {' '}
+ {t('review.fileHeader.missingOnDisk.restoreSuffix')}
) : (
- Full file content is not available to restore automatically.
+ {t('review.fileHeader.missingOnDisk.restoreUnavailable')}
)}
) : (
-
- {CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
-
+
{contentSourceLabel}
)}
@@ -194,13 +198,13 @@ export const FileSectionHeader = ({
- WORKTREE
+ {t('review.fileHeader.badges.worktree')}
- {file.ledgerSummary.worktreeBranch ?? 'Isolated worktree'}
+ {file.ledgerSummary.worktreeBranch ?? t('review.fileHeader.worktree.isolated')}
{file.ledgerSummary.worktreePath}
{file.ledgerSummary.dirtyLeaderWarning && (
@@ -233,7 +237,7 @@ export const FileSectionHeader = ({
{manualLedgerReviewRequired && (
- MANUAL REVIEW
+ {t('review.fileHeader.badges.manualReview')}
)}
@@ -245,14 +249,14 @@ export const FileSectionHeader = ({
disabled={applying}
className="rounded bg-blue-500/15 px-2 py-1 text-xs font-medium text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
- Reload from disk
+ {t('review.fileHeader.actions.reloadFromDisk')}
onKeepDraft(file.filePath)}
disabled={applying}
className="rounded bg-amber-500/15 px-2 py-1 text-xs font-medium text-amber-300 transition-colors hover:bg-amber-500/25 disabled:opacity-50"
>
- Keep my draft
+ {t('review.fileHeader.actions.keepMyDraft')}
)}
@@ -273,15 +277,15 @@ export const FileSectionHeader = ({
: 'bg-green-500/15 text-green-400 hover:bg-green-500/25',
].join(' ')}
>
- Accept
+ {t('review.fileHeader.actions.accept')}
{isPreviewOnly && (
{isContentUnavailable
- ? 'Accept/Reject is disabled because full text content is unavailable.'
- : 'Accept/Reject is disabled while the file is missing on disk.'}
+ ? t('review.fileHeader.disabled.acceptRejectContentUnavailable')
+ : t('review.fileHeader.disabled.acceptRejectMissingOnDisk')}
)}
@@ -300,19 +304,19 @@ export const FileSectionHeader = ({
: 'bg-red-500/15 text-red-400 hover:bg-red-500/25',
].join(' ')}
>
- Reject
+ {t('review.fileHeader.actions.reject')}
{rejectDisabled && (
{rejectBlockReason === 'manual-ledger-review'
- ? 'Reject is disabled because this ledger change has binary, large, or unavailable content.'
+ ? t('review.fileHeader.disabled.rejectManualLedgerReview')
: rejectBlockReason === 'content-unavailable'
- ? 'Reject is disabled because full text content is unavailable.'
+ ? t('review.fileHeader.disabled.rejectContentUnavailable')
: rejectBlockReason === 'missing-on-disk'
- ? 'Accept/Reject is disabled while the file is missing on disk.'
- : 'Reject is disabled because the original baseline is unavailable.'}
+ ? t('review.fileHeader.disabled.acceptRejectMissingOnDisk')
+ : t('review.fileHeader.disabled.rejectBaselineUnavailable')}
)}
@@ -328,11 +332,11 @@ export const FileSectionHeader = ({
className="flex items-center gap-1 rounded bg-blue-500/15 px-2 py-1 text-xs text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
>
- Restore
+ {t('review.fileHeader.actions.restore')}
- Create/restore this file on disk from the preview
+ {t('review.fileHeader.actions.restoreTooltip')}
)}
@@ -345,10 +349,12 @@ export const FileSectionHeader = ({
className="flex items-center gap-1 rounded bg-orange-500/15 px-2 py-1 text-xs text-orange-400 transition-colors hover:bg-orange-500/25"
>
- Discard
+ {t('review.fileHeader.actions.discard')}
- Discard all edits for this file
+
+ {t('review.fileHeader.actions.discardTooltip')}
+
@@ -362,11 +368,11 @@ export const FileSectionHeader = ({
) : (
)}
- Save File
+ {t('review.fileHeader.actions.saveFile')}
- Save file to disk
+ {t('review.fileHeader.actions.saveFileTooltip')}
{shortcutLabel('⌘ S', 'Ctrl+S')}
diff --git a/src/renderer/components/team/review/FileSectionPlaceholder.tsx b/src/renderer/components/team/review/FileSectionPlaceholder.tsx
index 3c7dac55..b0de8c40 100644
--- a/src/renderer/components/team/review/FileSectionPlaceholder.tsx
+++ b/src/renderer/components/team/review/FileSectionPlaceholder.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { FileDiff, LoaderCircle } from 'lucide-react';
interface FileSectionPlaceholderProps {
@@ -8,32 +9,35 @@ interface FileSectionPlaceholderProps {
export const FileSectionPlaceholder = ({
fileName,
-}: FileSectionPlaceholderProps): React.ReactElement => (
-
-
-
-
-
-
-
-
{fileName}
-
-
- Loading
-
+}: FileSectionPlaceholderProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+
+
+
+
+
+ {fileName}
+
+
+ {t('review.filePlaceholder.loading')}
+
+
+
{t('review.filePlaceholder.description')}
-
Preparing a full editor diff for this file.
-
-
-);
+ );
+};
diff --git a/src/renderer/components/team/review/FullDiffLoadingBanner.tsx b/src/renderer/components/team/review/FullDiffLoadingBanner.tsx
index 9cbca533..758ba643 100644
--- a/src/renderer/components/team/review/FullDiffLoadingBanner.tsx
+++ b/src/renderer/components/team/review/FullDiffLoadingBanner.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Clock3, FileDiff, LoaderCircle, Sparkles } from 'lucide-react';
interface FullDiffLoadingBannerProps {
@@ -17,14 +18,17 @@ export const FullDiffLoadingBanner = ({
snippetCount,
activeFileName,
}: FullDiffLoadingBannerProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const title =
- loadingFilesCount === 1 ? 'Preparing Full Diff' : `Preparing ${loadingFilesCount} Full Diffs`;
+ loadingFilesCount === 1
+ ? t('review.fullDiffLoading.titleOne')
+ : t('review.fullDiffLoading.titleMany', { count: loadingFilesCount });
const subtitle =
loadingFilesCount === 1
? activeFileName
- ? `Finalizing the exact editor diff for ${activeFileName}.`
- : 'Finalizing the exact editor diff for the current file.'
- : 'Resolving exact before/after baselines for the files currently loading.';
+ ? t('review.fullDiffLoading.subtitleForFile', { file: activeFileName })
+ : t('review.fullDiffLoading.subtitleCurrentFile')
+ : t('review.fullDiffLoading.subtitleMany');
const showFileProgress = totalFilesCount > 1;
const progressPercent =
totalFilesCount > 0 ? Math.max(0, Math.min(100, (readyFilesCount / totalFilesCount) * 100)) : 0;
@@ -57,20 +61,23 @@ export const FullDiffLoadingBanner = ({
- {snippetCount} preview{snippetCount === 1 ? '' : 's'} ready
+ {t('review.fullDiffLoading.previewsReady', { count: snippetCount })}
- Editor view loading
+ {t('review.fullDiffLoading.editorViewLoading')}
- {loadingFilesCount} file{loadingFilesCount === 1 ? '' : 's'} in progress
+ {t('review.fullDiffLoading.filesInProgress', { count: loadingFilesCount })}
{showFileProgress ? (
- {readyFilesCount}/{totalFilesCount} files ready
+ {t('review.fullDiffLoading.filesReady', {
+ ready: readyFilesCount,
+ total: totalFilesCount,
+ })}
) : null}
@@ -91,8 +98,11 @@ export const FullDiffLoadingBanner = ({
{showFileProgress
- ? `${readyFilesCount} ready, ${loadingFilesCount} still loading. Preview diffs stay visible below while the remaining baselines are resolved.`
- : 'Preview diffs stay visible below while the exact baseline is resolved.'}
+ ? t('review.fullDiffLoading.progressDescription', {
+ ready: readyFilesCount,
+ loading: loadingFilesCount,
+ })
+ : t('review.fullDiffLoading.singleDescription')}
diff --git a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
index 1bb9fc06..55b5d785 100644
--- a/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
+++ b/src/renderer/components/team/review/KeyboardShortcutsHelp.tsx
@@ -1,5 +1,6 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { IS_MAC } from '@renderer/utils/platformKeys';
import { X } from 'lucide-react';
@@ -13,29 +14,31 @@ const alt = IS_MAC ? '\u2325' : 'Alt';
const shift = IS_MAC ? '\u21E7' : 'Shift';
const shortcuts = [
- { keys: [`${alt}+J`], action: 'Next change' },
- { keys: [`${alt}+K`], action: 'Previous change' },
- { keys: [`${alt}+\u2193`], action: 'Next file' },
- { keys: [`${alt}+\u2191`], action: 'Previous file' },
- { keys: [`${mod}+Y`], action: 'Accept change' },
- { keys: [`${mod}+N`], action: 'Reject change' },
- { keys: [`${mod}+S`], action: 'Save file' },
- { keys: [`${mod}+Z`], action: 'Undo' },
- { keys: [`${mod}+${shift}+Z`], action: 'Redo' },
- { keys: ['?'], action: 'Toggle shortcuts' },
- { keys: ['Esc'], action: 'Close dialog' },
-];
+ { keys: [`${alt}+J`], actionKey: 'nextChange' },
+ { keys: [`${alt}+K`], actionKey: 'previousChange' },
+ { keys: [`${alt}+\u2193`], actionKey: 'nextFile' },
+ { keys: [`${alt}+\u2191`], actionKey: 'previousFile' },
+ { keys: [`${mod}+Y`], actionKey: 'acceptChange' },
+ { keys: [`${mod}+N`], actionKey: 'rejectChange' },
+ { keys: [`${mod}+S`], actionKey: 'saveFile' },
+ { keys: [`${mod}+Z`], actionKey: 'undo' },
+ { keys: [`${mod}+${shift}+Z`], actionKey: 'redo' },
+ { keys: ['?'], actionKey: 'toggleShortcuts' },
+ { keys: ['Esc'], actionKey: 'closeDialog' },
+] as const;
export const KeyboardShortcutsHelp = ({
open,
onOpenChange,
}: KeyboardShortcutsHelpProps): React.ReactElement | null => {
+ const { t } = useAppTranslation('team');
+
if (!open) return null;
return (
- Keyboard Shortcuts
+ {t('review.shortcuts.title')}
onOpenChange(false)}
className="rounded p-0.5 text-text-muted hover:bg-surface-raised hover:text-text"
@@ -44,9 +47,11 @@ export const KeyboardShortcutsHelp = ({
- {shortcuts.map(({ keys, action }) => (
-
-
{action}
+ {shortcuts.map(({ keys, actionKey }) => (
+
+
+ {t(`review.shortcuts.actions.${actionKey}` as const)}
+
{keys.map((key) => (
void;
pathChangeLabels?: ReviewFileTreeProps['pathChangeLabels'];
}): JSX.Element => {
+ const { t } = useAppTranslation('team');
if (node.isFile && node.data) {
const isSelected = node.data.filePath === selectedFilePath;
const isActive = node.data.filePath === activeFilePath && !isSelected;
@@ -160,7 +162,7 @@ const TreeItem = ({
- Viewed
+ {t('review.fileTree.viewed')}
)}
{node.data.isNewFile && (
- new
+ {t('review.fileTree.badges.new')}
)}
{label?.kind === 'deleted' && (
- deleted
+ {t('review.fileTree.badges.deleted')}
)}
{label && label.kind !== 'deleted' && (
@@ -208,7 +210,11 @@ const TreeItem = ({
onClick={() => onToggleFolder(node.fullPath)}
className="flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-raised hover:text-text"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
- aria-label={isOpen ? `Collapse ${node.name}` : `Expand ${node.name}`}
+ aria-label={
+ isOpen
+ ? t('review.fileTree.collapseFolder', { name: node.name })
+ : t('review.fileTree.expandFolder', { name: node.name })
+ }
>
{
+ const { t } = useAppTranslation('team');
const hunkDecisions = useStore((state) => state.hunkDecisions);
const fileDecisions = useStore((state) => state.fileDecisions);
const fileChunkCounts = useStore((state) => state.fileChunkCounts);
@@ -385,7 +392,11 @@ export const ReviewFileTree = ({
}, [activeFilePath]);
if (files.length === 0) {
- return No changed files
;
+ return (
+
+ {t('review.fileTree.empty.noChangedFiles')}
+
+ );
}
return (
@@ -396,7 +407,7 @@ export const ReviewFileTree = ({
setQuery(e.target.value)}
- placeholder="Search files…"
+ placeholder={t('review.fileTree.searchPlaceholder')}
className="h-8 w-full rounded border border-border bg-surface px-7 text-xs text-text placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-blue-500/30"
/>
@@ -412,7 +423,7 @@ export const ReviewFileTree = ({
: 'bg-surface-raised text-text-muted hover:text-text'
)}
>
- Unresolved
+ {t('review.fileTree.filters.unresolved')}
- Rejected
+ {t('review.fileTree.filters.rejected')}
- New
+ {t('review.fileTree.filters.new')}
{(filterUnresolved || filterRejected || filterNew || normalizedQuery.length > 0) && (
- Clear
+ {t('review.fileTree.filters.clear')}
)}
{filteredFiles.length === 0 ? (
-
No matching files
+
+ {t('review.fileTree.empty.noMatchingFiles')}
+
) : (
{sortTreeNodes(tree).map((node) => (
diff --git a/src/renderer/components/team/review/ReviewToolbar.tsx b/src/renderer/components/team/review/ReviewToolbar.tsx
index 6ec4f3b3..54d049f7 100644
--- a/src/renderer/components/team/review/ReviewToolbar.tsx
+++ b/src/renderer/components/team/review/ReviewToolbar.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { Check, Eye, EyeOff, GitMerge, Loader2, Pencil, Undo2, X } from 'lucide-react';
@@ -41,6 +42,7 @@ export const ReviewToolbar = ({
canUndo = false,
onUndo,
}: ReviewToolbarProps): React.ReactElement => {
+ const { t } = useAppTranslation('team');
const hasRejected = stats.rejected > 0;
const canApply = hasRejected && !applying;
const totalChanges = stats.pending + stats.accepted + stats.rejected;
@@ -53,19 +55,19 @@ export const ReviewToolbar = ({
{stats.pending > 0 && (
- {stats.pending} pending
+ {t('review.toolbar.stats.pending', { count: stats.pending })}
)}
{stats.accepted > 0 && (
- {stats.accepted} accepted
+ {t('review.toolbar.stats.accepted', { count: stats.accepted })}
)}
{stats.rejected > 0 && (
- {stats.rejected} rejected
+ {t('review.toolbar.stats.rejected', { count: stats.rejected })}
)}
@@ -74,7 +76,9 @@ export const ReviewToolbar = ({
+{changeStats.linesAdded}
-{changeStats.linesRemoved}
- across {changeStats.filesChanged} files
+
+ {t('review.toolbar.stats.acrossFiles', { count: changeStats.filesChanged })}
+
{/* Review progress */}
@@ -125,13 +129,11 @@ export const ReviewToolbar = ({
)}
>
{autoViewed ?
:
}
-
Auto
+
{t('review.toolbar.actions.auto')}
- {autoViewed
- ? 'Auto-mark files as viewed when scrolled to end (ON)'
- : 'Auto-mark files as viewed when scrolled to end (OFF)'}
+ {autoViewed ? t('review.toolbar.tooltips.autoOn') : t('review.toolbar.tooltips.autoOff')}
@@ -139,7 +141,7 @@ export const ReviewToolbar = ({
{editedCount > 0 && (
- {editedCount} edited
+ {t('review.toolbar.stats.edited', { count: editedCount })}
)}
@@ -153,10 +155,10 @@ export const ReviewToolbar = ({
className="flex items-center gap-1 rounded bg-zinc-500/15 px-2.5 py-1 text-xs text-zinc-300 transition-colors hover:bg-zinc-500/25"
>
- Undo
+ {t('review.toolbar.actions.undo')}
-
Undo last review operation (Ctrl+Z)
+
{t('review.toolbar.tooltips.undo')}
)}
@@ -170,10 +172,10 @@ export const ReviewToolbar = ({
className="flex items-center gap-1 rounded bg-green-500/15 px-2.5 py-1 text-xs text-green-400 transition-colors hover:bg-green-500/25"
>
- Accept All
+ {t('review.toolbar.actions.acceptAll')}
-
Accept all changes across all files
+
{t('review.toolbar.tooltips.acceptAll')}
@@ -190,14 +192,14 @@ export const ReviewToolbar = ({
)}
>
- Reject All
+ {t('review.toolbar.actions.rejectAll')}
{canRejectAll
- ? 'Reject all safely rejectable changes across all files'
- : 'No pending files have a safe original baseline to reject.'}
+ ? t('review.toolbar.tooltips.rejectAll')
+ : t('review.toolbar.tooltips.rejectAllDisabled')}
>
@@ -221,11 +223,13 @@ export const ReviewToolbar = ({
) : (
)}
- {applying ? 'Applying...' : 'Apply Rejections'}
+ {applying
+ ? t('review.toolbar.actions.applying')
+ : t('review.toolbar.actions.applyRejections')}
- Apply rejected hunks to disk; accepted changes are kept as-is
+ {t('review.toolbar.tooltips.applyRejections')}
)}
diff --git a/src/renderer/components/team/review/ScopeWarningBanner.tsx b/src/renderer/components/team/review/ScopeWarningBanner.tsx
index 3d850ce7..922c260b 100644
--- a/src/renderer/components/team/review/ScopeWarningBanner.tsx
+++ b/src/renderer/components/team/review/ScopeWarningBanner.tsx
@@ -1,5 +1,6 @@
import { type JSX, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { AlertTriangle, ChevronRight, Info, ShieldCheck, X } from 'lucide-react';
@@ -25,52 +26,48 @@ interface TierConfig {
badgeLabel?: string;
}
-const TIER_CONFIGS: Record
= {
- 1: {
- Icon: ShieldCheck,
- border: 'border-emerald-500/15',
- bg: 'bg-emerald-500/5',
- accentColor: 'text-emerald-400',
- title: 'Task scope determined precisely',
- detail:
- 'Both start and completion markers found in the session log. The diff includes only changes made during this specific task - other tasks that modified the same files are excluded.',
- },
- 2: {
- Icon: Info,
- border: 'border-blue-500/15',
- bg: 'bg-blue-500/5',
- accentColor: 'text-blue-400',
- title: 'End boundary estimated',
- detail:
- 'Only the start marker was found - the task has no completion marker yet. Changes shown from task start to end of session. If other tasks ran after this one in the same session, their changes may also be included.',
- },
- 3: {
- Icon: AlertTriangle,
- border: 'border-orange-500/20',
- bg: 'bg-orange-500/5',
- accentColor: 'text-orange-400',
- title: 'Start boundary estimated',
- detail:
- 'Only the completion marker was found - the start of work was not captured. If other tasks ran before this one in the same session, their changes to the same files may also be included.',
- },
- 4: {
- Icon: AlertTriangle,
- border: 'border-red-500/20',
- bg: 'bg-red-500/5',
- accentColor: 'text-red-400',
- title: 'Showing all session changes',
- detail:
- 'No task markers found in the session log. Cannot isolate this task - all file changes from the entire session are shown, including changes from other tasks. This can happen with older CLI versions or non-standard workflows.',
- },
-};
-
export const ScopeWarningBanner = ({
warnings,
confidence,
sourceKind = 'legacy',
onDismiss,
}: ScopeWarningBannerProps): JSX.Element => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
+ const tierConfigs: Record = {
+ 1: {
+ Icon: ShieldCheck,
+ border: 'border-emerald-500/15',
+ bg: 'bg-emerald-500/5',
+ accentColor: 'text-emerald-400',
+ title: t('review.scope.tiers.exact.title'),
+ detail: t('review.scope.tiers.exact.detail'),
+ },
+ 2: {
+ Icon: Info,
+ border: 'border-blue-500/15',
+ bg: 'bg-blue-500/5',
+ accentColor: 'text-blue-400',
+ title: t('review.scope.tiers.endEstimated.title'),
+ detail: t('review.scope.tiers.endEstimated.detail'),
+ },
+ 3: {
+ Icon: AlertTriangle,
+ border: 'border-orange-500/20',
+ bg: 'bg-orange-500/5',
+ accentColor: 'text-orange-400',
+ title: t('review.scope.tiers.startEstimated.title'),
+ detail: t('review.scope.tiers.startEstimated.detail'),
+ },
+ 4: {
+ Icon: AlertTriangle,
+ border: 'border-red-500/20',
+ bg: 'bg-red-500/5',
+ accentColor: 'text-red-400',
+ title: t('review.scope.tiers.allSession.title'),
+ detail: t('review.scope.tiers.allSession.detail'),
+ },
+ };
const ledgerConfig: TierConfig | null =
sourceKind === 'ledger'
? {
@@ -95,18 +92,18 @@ export const ScopeWarningBanner = ({
: 'text-orange-400',
title:
confidence.tier <= 1
- ? 'Changes captured by task ledger'
- : 'Changes captured with limited reviewability',
+ ? t('review.scope.ledger.exact.title')
+ : t('review.scope.ledger.limited.title'),
detail:
confidence.tier <= 1
- ? 'The orchestrator captured these file changes while the agent was working on this task.'
- : 'The orchestrator captured these file changes for this task, but at least one change was captured from a snapshot or metadata-only source. Review exact text diffs where available; binary or unavailable content may require manual review.',
+ ? t('review.scope.ledger.exact.detail')
+ : t('review.scope.ledger.limited.detail'),
badgeLabel:
confidence.tier <= 1
- ? 'Ledger exact'
+ ? t('review.scope.ledger.exact.badge')
: confidence.tier === 2
- ? 'Mixed reviewability'
- : 'Needs review',
+ ? t('review.scope.ledger.limited.mixedBadge')
+ : t('review.scope.ledger.limited.needsReviewBadge'),
}
: null;
const workIntervalConfig: TierConfig | null =
@@ -116,14 +113,13 @@ export const ScopeWarningBanner = ({
border: 'border-blue-500/15',
bg: 'bg-blue-500/5',
accentColor: 'text-blue-400',
- title: 'Scoped by persisted work interval',
- detail:
- 'The task start marker was not available in the session log, so the diff is scoped by the task work interval stored on the board.',
- badgeLabel: 'Interval scoped',
+ title: t('review.scope.workInterval.title'),
+ detail: t('review.scope.workInterval.detail'),
+ badgeLabel: t('review.scope.workInterval.badge'),
}
: null;
const config =
- ledgerConfig ?? workIntervalConfig ?? TIER_CONFIGS[confidence.tier] ?? TIER_CONFIGS[4];
+ ledgerConfig ?? workIntervalConfig ?? tierConfigs[confidence.tier] ?? tierConfigs[4];
const { Icon } = config;
return (
@@ -135,7 +131,7 @@ export const ScopeWarningBanner = ({
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-0.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
>
- Read more
+ {t('review.scope.readMore')}
diff --git a/src/renderer/components/team/review/ViewedProgressBar.tsx b/src/renderer/components/team/review/ViewedProgressBar.tsx
index b4dca702..e9054823 100644
--- a/src/renderer/components/team/review/ViewedProgressBar.tsx
+++ b/src/renderer/components/team/review/ViewedProgressBar.tsx
@@ -1,3 +1,5 @@
+import { useAppTranslation } from '@features/localization/renderer';
+
interface ViewedProgressBarProps {
viewed: number;
total: number;
@@ -5,6 +7,8 @@ interface ViewedProgressBarProps {
}
export const ViewedProgressBar = ({ viewed, total, progress }: ViewedProgressBarProps) => {
+ const { t } = useAppTranslation('team');
+
if (total === 0) return null;
return (
@@ -15,9 +19,7 @@ export const ViewedProgressBar = ({ viewed, total, progress }: ViewedProgressBar
style={{ width: `${progress}%` }}
/>
-
- {viewed}/{total} viewed
-
+
{t('review.progress.viewed', { viewed, total })}
);
};
diff --git a/src/renderer/components/team/schedule/CronScheduleInput.tsx b/src/renderer/components/team/schedule/CronScheduleInput.tsx
index cb9a1062..e455552d 100644
--- a/src/renderer/components/team/schedule/CronScheduleInput.tsx
+++ b/src/renderer/components/team/schedule/CronScheduleInput.tsx
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
@@ -33,11 +34,11 @@ const TIMEZONE_PRESETS = [
] as const;
const WARMUP_OPTIONS = [
- { value: 0, label: 'No warm-up' },
- { value: 5, label: '5 min' },
- { value: 10, label: '10 min' },
- { value: 15, label: '15 min' },
- { value: 30, label: '30 min' },
+ { value: 0, labelKey: 'none' },
+ { value: 5, labelKey: 'fiveMinutes' },
+ { value: 10, labelKey: 'tenMinutes' },
+ { value: 15, labelKey: 'fifteenMinutes' },
+ { value: 30, labelKey: 'thirtyMinutes' },
] as const;
// =============================================================================
@@ -45,12 +46,12 @@ const WARMUP_OPTIONS = [
// =============================================================================
const CRON_PRESETS = [
- { label: 'Every hour', cron: '0 * * * *' },
- { label: 'Every 6 hours', cron: '0 */6 * * *' },
- { label: 'Daily at 9am', cron: '0 9 * * *' },
- { label: 'Weekdays at 9am', cron: '0 9 * * 1-5' },
- { label: 'Monday at 9am', cron: '0 9 * * 1' },
- { label: 'Every 30 min', cron: '*/30 * * * *' },
+ { labelKey: 'everyHour', cron: '0 * * * *' },
+ { labelKey: 'everySixHours', cron: '0 */6 * * *' },
+ { labelKey: 'dailyAtNine', cron: '0 9 * * *' },
+ { labelKey: 'weekdaysAtNine', cron: '0 9 * * 1-5' },
+ { labelKey: 'mondayAtNine', cron: '0 9 * * 1' },
+ { labelKey: 'everyThirtyMinutes', cron: '*/30 * * * *' },
] as const;
// =============================================================================
@@ -78,10 +79,16 @@ export const CronScheduleInput = ({
warmUpMinutes,
onWarmUpMinutesChange,
}: CronScheduleInputProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
// Parse and validate cron expression
const cronInfo = useMemo(() => {
if (!cronExpression.trim()) {
- return { valid: false, description: null, nextRuns: [], error: 'Enter a cron expression' };
+ return {
+ valid: false,
+ description: null,
+ nextRuns: [],
+ error: t('schedule.cron.errors.enterExpression'),
+ };
}
try {
@@ -115,7 +122,7 @@ export const CronScheduleInput = ({
valid: false,
description: null,
nextRuns: [],
- error: err instanceof Error ? err.message : 'Invalid cron expression',
+ error: err instanceof Error ? err.message : t('schedule.cron.errors.invalidExpression'),
};
}
}, [cronExpression, timezone]);
@@ -138,7 +145,7 @@ export const CronScheduleInput = ({
- Cron expression
+ {t('schedule.cron.expression')}
@@ -159,7 +166,7 @@ export const CronScheduleInput = ({
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
onClick={() => onCronExpressionChange(preset.cron)}
>
- {preset.label}
+ {t(`schedule.cron.presets.${preset.labelKey}`)}
))}
@@ -182,7 +189,7 @@ export const CronScheduleInput = ({
style={{ color: 'var(--warning-text)' }}
>
-
High frequency schedule (less than 5 min interval)
+
{t('schedule.cron.highFrequencyWarning')}
) : null}
@@ -191,7 +198,7 @@ export const CronScheduleInput = ({
{cronInfo.valid && cronInfo.nextRuns.length > 0 ? (
- Next runs:
+ {t('schedule.cron.nextRuns')}
{cronInfo.nextRuns.map((run, i) => (
@@ -211,11 +218,11 @@ export const CronScheduleInput = ({
- Timezone
+ {t('schedule.cron.timezone')}
-
+
{TIMEZONE_PRESETS.map((tz) => (
@@ -229,7 +236,7 @@ export const CronScheduleInput = ({
{/* Warm-up time */}
-
Warm-up time
+
{t('schedule.cron.warmUpTime')}
onWarmUpMinutesChange(Number(val))}
@@ -240,13 +247,13 @@ export const CronScheduleInput = ({
{WARMUP_OPTIONS.map((opt) => (
- {opt.label}
+ {t(`schedule.cron.warmUpOptions.${opt.labelKey}`)}
))}
- Prepares selected providers before scheduled execution
+ {t('schedule.cron.warmUpDescription')}
diff --git a/src/renderer/components/team/schedule/ScheduleEmptyState.tsx b/src/renderer/components/team/schedule/ScheduleEmptyState.tsx
index b66aa67f..a4c587ee 100644
--- a/src/renderer/components/team/schedule/ScheduleEmptyState.tsx
+++ b/src/renderer/components/team/schedule/ScheduleEmptyState.tsx
@@ -1,15 +1,21 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Calendar } from 'lucide-react';
-export const ScheduleEmptyState = (): React.JSX.Element => (
-
-
-
-
No schedules yet
-
- Create a schedule to run Claude tasks automatically on a cron schedule.
-
+export const ScheduleEmptyState = (): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+ return (
+
+
+
+
+ {t('schedule.empty.title')}
+
+
+ {t('schedule.empty.description')}
+
+
-
-);
+ );
+};
diff --git a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
index e26cc6c2..4a9e9e99 100644
--- a/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
+++ b/src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
@@ -72,6 +73,7 @@ export const ScheduleRunLogDialog = ({
scheduleId,
onClose,
}: ScheduleRunLogDialogProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
// Read live run data from store — falls back to initial prop if not found
const liveRun = useStore(
useShallow((s) => {
@@ -145,7 +147,7 @@ export const ScheduleRunLogDialog = ({
- Run Log
+ {t('schedule.runLog.title')}
@@ -166,12 +168,14 @@ export const ScheduleRunLogDialog = ({
- exit {run.exitCode}
+ {t('schedule.runLog.exitCode', { code: run.exitCode })}
) : null}
{run.retryCount > 0 ? (
-
retry {run.retryCount}/2
+
+ {t('schedule.runLog.retryCount', { count: run.retryCount, max: 2 })}
+
) : null}
@@ -181,7 +185,7 @@ export const ScheduleRunLogDialog = ({
{isRunning ? (
- Task is still running...
+ {t('schedule.runLog.stillRunning')}
{run.summary ? (
{run.summary}
) : null}
@@ -192,7 +196,7 @@ export const ScheduleRunLogDialog = ({
{loading ? (
- Loading logs...
+ {t('schedule.runLog.loadingLogs')}
) : null}
@@ -216,7 +220,9 @@ export const ScheduleRunLogDialog = ({
{/* Stderr */}
{hasStderr ? (
-
Errors
+
+ {t('schedule.runLog.errors')}
+
{logs.stderr}
@@ -236,7 +242,7 @@ export const ScheduleRunLogDialog = ({
- Close
+ {t('schedule.runLog.close')}
diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx
index 2a755123..8375e9c3 100644
--- a/src/renderer/components/team/schedule/ScheduleSection.tsx
+++ b/src/renderer/components/team/schedule/ScheduleSection.tsx
@@ -1,5 +1,6 @@
import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
@@ -58,6 +59,7 @@ const ScheduleRow = ({
onResume,
onTriggerNow,
}: ScheduleRowProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState
(null);
const runs = useStore(useShallow((s) => s.scheduleRuns[schedule.id] ?? []));
@@ -97,7 +99,9 @@ const ScheduleRow = ({
{schedule.label ? {getCronDescription(schedule.cronExpression)} : null}
- Next: {formatNextRun(schedule.nextRunAt)}
+
+ {t('schedule.nextRun', { next: formatNextRun(schedule.nextRunAt) })}
+
{schedule.nextRunAt ? (
@@ -123,7 +127,7 @@ const ScheduleRow = ({
- Run now
+ {t('schedule.actions.runNow')}
@@ -139,7 +143,7 @@ const ScheduleRow = ({
onClick={() => onEdit(schedule)}
>
- Edit
+ {t('schedule.actions.edit')}
{schedule.status === 'active' ? (
onPause(schedule.id)}
>
- Pause
+ {t('schedule.actions.pause')}
) : (
onResume(schedule.id)}
>
- Resume
+ {t('schedule.actions.resume')}
)}
onDelete(schedule.id)}
>
- Delete
+ {t('schedule.actions.delete')}
@@ -178,11 +182,11 @@ const ScheduleRow = ({
{runsLoading ? (
- Loading run history...
+ {t('schedule.runHistory.loading')}
) : runs.length === 0 ? (
- No runs yet
+ {t('schedule.runHistory.empty')}
) : (
@@ -210,6 +214,7 @@ const ScheduleRow = ({
// =============================================================================
export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const { schedules, pauseSchedule, resumeSchedule, deleteSchedule, triggerNow, fetchSchedules } =
useStore(
useShallow((s) => ({
@@ -272,9 +277,7 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E
{/* Header with create button */}
- {schedules.length > 0
- ? `${schedules.length} schedule${schedules.length > 1 ? 's' : ''}`
- : ''}
+ {schedules.length > 0 ? t('schedule.count', { count: schedules.length }) : ''}
- Add Schedule
+ {t('schedule.actions.addSchedule')}
diff --git a/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx b/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx
index bc991958..a9a90310 100644
--- a/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx
+++ b/src/renderer/components/team/schedule/ScheduleStatusBadge.tsx
@@ -1,31 +1,49 @@
import React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
+
import type { ScheduleRunStatus, ScheduleStatus } from '@shared/types';
// =============================================================================
// Schedule Status Badge
// =============================================================================
-const SCHEDULE_STATUS_CONFIG: Record
= {
+const SCHEDULE_STATUS_CONFIG: Record = {
active: {
- label: 'Active',
+ labelKey: 'schedule.status.active',
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
},
- paused: { label: 'Paused', className: 'bg-amber-500/15 text-amber-400 border-amber-500/20' },
- disabled: { label: 'Disabled', className: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/20' },
+ paused: {
+ labelKey: 'schedule.status.paused',
+ className: 'bg-amber-500/15 text-amber-400 border-amber-500/20',
+ },
+ disabled: {
+ labelKey: 'schedule.status.disabled',
+ className: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/20',
+ },
};
+function getScheduleStatusLabel(
+ status: ScheduleStatus,
+ t: ReturnType['t']
+): string {
+ if (status === 'active') return t('schedule.status.active');
+ if (status === 'paused') return t('schedule.status.paused');
+ return t('schedule.status.disabled');
+}
+
interface ScheduleStatusBadgeProps {
status: ScheduleStatus;
}
export const ScheduleStatusBadge = ({ status }: ScheduleStatusBadgeProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const config = SCHEDULE_STATUS_CONFIG[status];
return (
- {config.label}
+ {getScheduleStatusLabel(status, t)}
);
};
@@ -34,22 +52,53 @@ export const ScheduleStatusBadge = ({ status }: ScheduleStatusBadgeProps): React
// Run Status Badge
// =============================================================================
-const RUN_STATUS_CONFIG: Record = {
- pending: { label: 'Pending', className: 'text-zinc-400' },
- warming_up: { label: 'Warming up', className: 'text-blue-400' },
- warm: { label: 'Warm', className: 'text-cyan-400' },
- running: { label: 'Running', className: 'text-emerald-400' },
- completed: { label: 'Completed', className: 'text-emerald-400' },
- failed: { label: 'Failed', className: 'text-red-400' },
- failed_interrupted: { label: 'Interrupted', className: 'text-amber-400' },
- cancelled: { label: 'Cancelled', className: 'text-zinc-400' },
+const RUN_STATUS_CONFIG: Record = {
+ pending: { labelKey: 'schedule.runStatus.pending', className: 'text-zinc-400' },
+ warming_up: { labelKey: 'schedule.runStatus.warmingUp', className: 'text-blue-400' },
+ warm: { labelKey: 'schedule.runStatus.warm', className: 'text-cyan-400' },
+ running: { labelKey: 'schedule.runStatus.running', className: 'text-emerald-400' },
+ completed: { labelKey: 'schedule.runStatus.completed', className: 'text-emerald-400' },
+ failed: { labelKey: 'schedule.runStatus.failed', className: 'text-red-400' },
+ failed_interrupted: { labelKey: 'schedule.runStatus.interrupted', className: 'text-amber-400' },
+ cancelled: { labelKey: 'schedule.runStatus.cancelled', className: 'text-zinc-400' },
};
+function getRunStatusLabel(
+ status: ScheduleRunStatus,
+ t: ReturnType['t']
+): string {
+ switch (status) {
+ case 'pending':
+ return t('schedule.runStatus.pending');
+ case 'warming_up':
+ return t('schedule.runStatus.warmingUp');
+ case 'warm':
+ return t('schedule.runStatus.warm');
+ case 'running':
+ return t('schedule.runStatus.running');
+ case 'completed':
+ return t('schedule.runStatus.completed');
+ case 'failed':
+ return t('schedule.runStatus.failed');
+ case 'failed_interrupted':
+ return t('schedule.runStatus.interrupted');
+ case 'cancelled':
+ return t('schedule.runStatus.cancelled');
+ default:
+ return status;
+ }
+}
+
interface RunStatusBadgeProps {
status: ScheduleRunStatus;
}
export const RunStatusBadge = ({ status }: RunStatusBadgeProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const config = RUN_STATUS_CONFIG[status];
- return {config.label} ;
+ return (
+
+ {getRunStatusLabel(status, t)}
+
+ );
};
diff --git a/src/renderer/components/team/session-injection-types.ts b/src/renderer/components/team/session-injection-types.ts
new file mode 100644
index 00000000..6b78f779
--- /dev/null
+++ b/src/renderer/components/team/session-injection-types.ts
@@ -0,0 +1 @@
+export type { ContextInjection as SessionInjection } from '@renderer/types/contextInjection';
diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
index a7b240ba..0986a54c 100644
--- a/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
+++ b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { ChevronDown, ChevronRight, Clock, FileText, Loader2 } from 'lucide-react';
@@ -67,6 +68,7 @@ export const ExactTaskLogCard = ({
detailState,
onToggle,
}: ExactTaskLogCardProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const loadStateText = describeDetailState(detailState);
return (
@@ -101,7 +103,7 @@ export const ExactTaskLogCard = ({
{formatRelativeTime(summary.timestamp)}
{anchorKindLabel(summary)}
- {!summary.canLoadDetail ? summary only : null}
+ {!summary.canLoadDetail ? {t('taskLogs.exact.summaryOnly')} : null}
@@ -111,7 +113,7 @@ export const ExactTaskLogCard = ({
{detailState?.status === 'loading' ? (
- Loading exact task logs...
+ {t('taskLogs.exact.loading')}
) : null}
{detailState?.status === 'ok' && detailState.chunks ? (
diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
index 732b2cb3..4a584c98 100644
--- a/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
+++ b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { AlertCircle, FileText, Loader2 } from 'lucide-react';
@@ -17,6 +18,7 @@ export const ExactTaskLogsSection = ({
teamName,
taskId,
}: ExactTaskLogsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [summaries, setSummaries] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -197,12 +199,12 @@ export const ExactTaskLogsSection = ({
- Exact Task Logs
+ {t('taskLogs.exact.title')}
- Loading exact task logs...
+ {t('taskLogs.exact.loading')}
);
@@ -213,7 +215,7 @@ export const ExactTaskLogsSection = ({
- Exact Task Logs
+ {t('taskLogs.exact.title')}
@@ -228,21 +230,16 @@ export const ExactTaskLogsSection = ({
- Exact Task Logs
+ {t('taskLogs.exact.title')}
-
- Exact transcript slices rendered with the same execution-log components used in Logs.
-
+
{t('taskLogs.exact.description')}
{visibleSummaries.length === 0 ? (
- No exact task logs yet
-
- Exact transcript bundles will appear here when explicit task-linked transcript metadata
- is available.
-
+ {t('taskLogs.exact.emptyTitle')}
+
{t('taskLogs.exact.emptyDescription')}
) : (
diff --git a/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
index 8249c8c5..096298b5 100644
--- a/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
+++ b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx
@@ -1,3 +1,4 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Loader2 } from 'lucide-react';
@@ -13,18 +14,19 @@ export const ExecutionSessionsSection = ({
isPreviewOnline = false,
...props
}: ExecutionSessionsSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
return (
- Execution Sessions
+ {t('taskLogs.executionSessions.title')}
{isRefreshing || isPreviewOnline ? (
{isPreviewOnline ? (
@@ -33,14 +35,14 @@ export const ExecutionSessionsSection = ({
{isRefreshing ? (
- Updating...
+ {t('taskLogs.executionSessions.updating')}
) : null}
) : null}
- Legacy session-centric transcript browsing and previews.
+ {t('taskLogs.executionSessions.description')}
diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
index e41ec239..ef4a2e65 100644
--- a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
+++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx
@@ -1,5 +1,6 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
@@ -163,11 +164,13 @@ const ActivityMetadata = ({ detail }: ActivityMetadataProps): React.JSX.Element
};
const ActivityDetailPanel = ({ detailState }: ActivityDetailPanelProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
+
if (detailState.status === 'loading') {
return (
- Loading activity details...
+ {t('taskActivity.loadingDetails')}
);
}
@@ -184,7 +187,7 @@ const ActivityDetailPanel = ({ detailState }: ActivityDetailPanelProps): React.J
if (detailState.status === 'missing') {
return (
- Detailed transcript context is no longer available for this activity.
+ {t('taskActivity.contextUnavailable')}
);
}
@@ -265,6 +268,7 @@ export const TaskActivitySection = ({
taskId,
enabled = true,
}: TaskActivitySectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [detailStates, setDetailStates] = useState
>({});
const [entries, setEntries] = useState([]);
const [expandedId, setExpandedId] = useState(null);
@@ -400,7 +404,7 @@ export const TaskActivitySection = ({
return (
- Loading task activity...
+ {t('taskActivity.loading')}
);
}
@@ -417,9 +421,7 @@ export const TaskActivitySection = ({
if (visibleEntries.length === 0) {
return (
- {hasOnlyLowSignalExecution
- ? 'No key task activity was found yet. Low-level execution details are available below in Task Log Stream.'
- : 'No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.'}
+ {hasOnlyLowSignalExecution ? t('taskActivity.lowSignalOnly') : t('taskActivity.empty')}
);
}
@@ -451,12 +453,10 @@ export const TaskActivitySection = ({
- Task Activity
+ {t('taskActivity.title')}
-
- Key explicit runtime activity linked to this task from transcript metadata.
-
+
{t('taskActivity.description')}
{content}
);
diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
index 80c4638f..c9467782 100644
--- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
+++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import {
ExecutionLogStreamView,
normalizeExecutionLogStream,
@@ -54,6 +55,7 @@ export const TaskLogStreamSection = ({
taskStatus,
liveEnabled = true,
}: TaskLogStreamSectionProps): React.JSX.Element => {
+ const { t } = useAppTranslation('team');
const [stream, setStream] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -179,7 +181,7 @@ export const TaskLogStreamSection = ({
return (
{
+ const { t } = useAppTranslation('team');
const [ownerFilter, setOwnerFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
@@ -36,7 +39,7 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
if (tasks.length === 0) {
return (
- No tasks in this team
+ {t('tasks.list.empty')}
);
}
@@ -47,10 +50,10 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
setOwnerFilter(event.target.value)}
>
- All owners
+ {t('tasks.list.filters.allOwners')}
{ownerOptions.map((owner) => (
{owner}
@@ -61,19 +64,19 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
setStatusFilter(event.target.value)}
>
- All statuses
- pending
- in_progress
- completed
- deleted
+ {t('tasks.list.filters.allStatuses')}
+ {t('tasks.status.pending')}
+ {t('tasks.status.inProgress')}
+ {t('tasks.status.completed')}
+ {t('tasks.status.deleted')}
) : null}
{ownerFilter !== 'all' || statusFilter !== 'all' ? (
- Showing {filteredTasks.length} of {tasks.length}
+ {t('tasks.list.showing', { shown: filteredTasks.length, total: tasks.length })}
) : null}
@@ -81,22 +84,22 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
- ID
+ {t('tasks.list.columns.id')}
- Subject
+ {t('tasks.list.columns.subject')}
- Owner
+ {t('tasks.list.columns.owner')}
- Status
+ {t('tasks.list.columns.status')}
- Blocked By
+ {t('tasks.list.columns.blockedBy')}
- Blocks
+ {t('tasks.list.columns.blocks')}
diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts
index aeb3839c..040b220a 100644
--- a/src/renderer/components/team/useTeamProvisioningPresentation.ts
+++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { useStore } from '@renderer/store';
import {
getCurrentProvisioningProgressForTeam,
@@ -22,6 +23,7 @@ export function useTeamProvisioningPresentation(teamName: string): {
memberDiagnostics: MemberLaunchDiagnosticsPayload[];
runInstanceKey: string | null;
} {
+ const { t } = useAppTranslation('team');
const {
progress,
cancelProvisioning,
@@ -49,8 +51,9 @@ export function useTeamProvisioningPresentation(teamName: string): {
members: teamMembers,
memberSpawnStatuses,
memberSpawnSnapshot,
+ t,
}),
- [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers]
+ [memberSpawnSnapshot, memberSpawnStatuses, progress, teamMembers, t]
);
const memberDiagnostics = useMemo(
() =>
diff --git a/src/renderer/components/terminal/TerminalModal.tsx b/src/renderer/components/terminal/TerminalModal.tsx
index 88c8dc37..b5b1de54 100644
--- a/src/renderer/components/terminal/TerminalModal.tsx
+++ b/src/renderer/components/terminal/TerminalModal.tsx
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
+import { useAppTranslation } from '@features/localization/renderer';
import { CheckCircle, Terminal, X, XCircle } from 'lucide-react';
import { EmbeddedTerminal } from './EmbeddedTerminal';
@@ -29,7 +30,7 @@ interface TerminalModalProps {
}
export function TerminalModal({
- title = 'Terminal',
+ title,
command,
args,
cwd,
@@ -37,12 +38,16 @@ export function TerminalModal({
onClose,
onExit,
autoCloseOnSuccessMs = 0,
- successMessage = 'Completed successfully',
- failureMessage = 'Process failed',
+ successMessage,
+ failureMessage,
}: TerminalModalProps): React.JSX.Element {
+ const { t } = useAppTranslation('common');
const [exited, setExited] = useState
(null);
const [countdown, setCountdown] = useState(0);
const dialogRef = useRef(null);
+ const resolvedTitle = title ?? t('terminal.title');
+ const resolvedSuccessMessage = successMessage ?? t('terminal.completedSuccessfully');
+ const resolvedFailureMessage = failureMessage ?? t('terminal.processFailed');
const handleExit = useCallback(
(exitCode: number): void => {
@@ -97,7 +102,7 @@ export function TerminalModal({
- {title}
+ {resolvedTitle}
@@ -139,9 +144,13 @@ export function TerminalModal({
- {successMessage}
+
+ {resolvedSuccessMessage}
+
{countdown > 0 && (
- Closing in {countdown}s...
+
+ {t('terminal.closingInSeconds', { count: countdown })}
+
)}
@@ -150,11 +159,13 @@ export function TerminalModal({
- {failureMessage}{' '}
- (exit code {exited})
+ {resolvedFailureMessage}{' '}
+
+ {t('terminal.exitCode', { code: exited })}
+
- Check terminal output above for details
+ {t('terminal.checkOutputForDetails')}
@@ -163,7 +174,7 @@ export function TerminalModal({
onClick={onClose}
className="shrink-0 rounded-md bg-surface-raised px-4 py-1.5 text-sm text-text transition-colors hover:bg-border-emphasis"
>
- Close
+ {t('actions.close')}
diff --git a/src/renderer/components/ui/ChipInteractionLayer.tsx b/src/renderer/components/ui/ChipInteractionLayer.tsx
index 18d24ce7..521c1365 100644
--- a/src/renderer/components/ui/ChipInteractionLayer.tsx
+++ b/src/renderer/components/ui/ChipInteractionLayer.tsx
@@ -15,6 +15,7 @@ import { syntaxHighlighting } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
+import { useAppTranslation } from '@features/localization/renderer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { chipDisplayLabel } from '@renderer/types/inlineChip';
@@ -64,6 +65,7 @@ const ChipFilePreview = ({
onOpenInEditor?: (filePath: string) => void;
onRevealFolder?: (folderPath: string) => void;
}): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const displayPath = chip.displayPath ?? chip.filePath;
const isFolder = chip.isFolder === true;
return (
@@ -82,7 +84,7 @@ const ChipFilePreview = ({
}}
>
- Reveal
+ {t('actions.reveal')}
) : !isFolder && onOpenInEditor ? (
- Open
+ {t('actions.open')}
) : null}
@@ -104,6 +106,7 @@ const ChipFilePreview = ({
};
const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const containerRef = React.useRef
(null);
const allLines = chip.codeText.split('\n');
const truncated = allLines.length > MAX_PREVIEW_LINES;
@@ -111,8 +114,8 @@ const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
const label = chipDisplayLabel(chip);
const lineRef =
chip.fromLine === chip.toLine
- ? `line ${String(chip.fromLine)}`
- : `lines ${String(chip.fromLine)}-${String(chip.toLine)}`;
+ ? t('code.line', { line: chip.fromLine })
+ : t('code.lines', { from: chip.fromLine, to: chip.toLine });
React.useEffect(() => {
const container = containerRef.current;
@@ -148,7 +151,7 @@ const ChipCodePreview = ({ chip }: { chip: InlineChip }): React.JSX.Element => {
{truncated ? (
- ({allLines.length - MAX_PREVIEW_LINES} more lines...)
+ {t('code.moreLines', { count: allLines.length - MAX_PREVIEW_LINES })}
) : null}
diff --git a/src/renderer/components/ui/ExpandableContent.tsx b/src/renderer/components/ui/ExpandableContent.tsx
index 76820818..fc621058 100644
--- a/src/renderer/components/ui/ExpandableContent.tsx
+++ b/src/renderer/components/ui/ExpandableContent.tsx
@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { ChevronDown, ChevronUp } from 'lucide-react';
const DEFAULT_COLLAPSED_HEIGHT = 200; // px
@@ -29,6 +30,7 @@ export const ExpandableContent = ({
className,
onExpand,
}: ExpandableContentProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const anchorRef = useRef(null);
const [expanded, setExpanded] = useState(false);
const [needsTruncation, setNeedsTruncation] = useState(false);
@@ -84,7 +86,7 @@ export const ExpandableContent = ({
}}
>
- Show more
+ {t('actions.showMore')}
) : null}
@@ -101,7 +103,7 @@ export const ExpandableContent = ({
}}
>
- Show less
+ {t('actions.showLess')}
) : null}
diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx
index 0f80cccf..4d7840c3 100644
--- a/src/renderer/components/ui/MemberSelect.tsx
+++ b/src/renderer/components/ui/MemberSelect.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
@@ -41,6 +42,7 @@ export const MemberSelect = ({
disabled = false,
className,
}: MemberSelectProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
const listboxId = React.useId();
@@ -102,7 +104,9 @@ export const MemberSelect = ({
{selectedMember ? (
renderMemberInline(selectedMember)
) : value === null && allowUnassigned ? (
-
Unassigned
+
+ {t('members.unassigned')}
+
) : (
{placeholder}
)}
@@ -125,7 +129,7 @@ export const MemberSelect = ({
@@ -135,7 +139,7 @@ export const MemberSelect = ({
onWheel={(e) => e.stopPropagation()}
>
- No members found.
+ {t('members.emptyMessage')}
{allowUnassigned && !search.trim() ? (
- Unassigned
+ {t('members.unassigned')}
{value === null ? (
) : null}
diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx
index e64d6341..ad01139f 100644
--- a/src/renderer/components/ui/MentionSuggestionList.tsx
+++ b/src/renderer/components/ui/MentionSuggestionList.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { getTeamColorSet, getThemedText } from '@renderer/constants/teamColors';
@@ -57,6 +58,7 @@ export const MentionSuggestionList = ({
hasFileSearch,
filesLoading,
}: MentionSuggestionListProps): React.JSX.Element => {
+ const { t } = useAppTranslation('common');
const listRef = useRef(null);
const { isLight } = useTheme();
@@ -71,10 +73,10 @@ export const MentionSuggestionList = ({
if (suggestions.length === 0) {
const emptyStateText = filesLoading
- ? 'Searching...'
+ ? t('search.searching')
: hasFileSearch
- ? 'No matching suggestions'
- : 'No matching suggestions';
+ ? t('search.noMatchingSuggestions')
+ : t('search.noMatchingSuggestions');
return (
{emptyStateText}
@@ -195,9 +197,9 @@ export const MentionSuggestionList = ({
? { color: 'rgb(245 158 11)' }
: isSkill
? { color: 'rgb(6 182 212)' }
- : colorSet
- ? { color: getThemedText(colorSet, isLight) }
- : undefined
+ : colorSet
+ ? { color: getThemedText(colorSet, isLight) }
+ : undefined
}
>
) : null}
{s.subtitle && isFileOrFolder ? (
@@ -266,7 +268,7 @@ export const MentionSuggestionList = ({
{filesLoading ? (
- Searching files...
+ {t('search.searchingFiles')}
) : null}
diff --git a/src/renderer/components/ui/dialog.tsx b/src/renderer/components/ui/dialog.tsx
index be5af7ec..94be3b6d 100644
--- a/src/renderer/components/ui/dialog.tsx
+++ b/src/renderer/components/ui/dialog.tsx
@@ -1,6 +1,7 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
+import { useAppTranslation } from '@features/localization/renderer';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@renderer/lib/utils';
import { X } from 'lucide-react';
@@ -28,31 +29,35 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef
,
React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- Close
-
-
- {children}
-
+>(({ className, children, ...props }, ref) => {
+ const { t } = useAppTranslation('common');
+
+ return (
+
+
+
+
+
+
+ {t('actions.close')}
+
+
+ {children}
+
+
-
-
-));
+
+ );
+});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
diff --git a/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx b/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
index 0b71dc1e..40a0541b 100644
--- a/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
+++ b/src/renderer/components/ui/tiptap/TiptapBubbleMenu.tsx
@@ -1,9 +1,11 @@
+import { useAppTranslation } from '@features/localization/renderer';
import { cn } from '@renderer/lib/utils';
import { useCurrentEditor, useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { Bold, Code, Italic, Strikethrough } from 'lucide-react';
export const TiptapBubbleMenu = () => {
+ const { t } = useAppTranslation('common');
const { editor } = useCurrentEditor();
const state = useEditorState({
@@ -42,7 +44,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isBold)}
onClick={() => editor.chain().focus().toggleBold().run()}
- aria-label="Bold"
+ aria-label={t('editorFormatting.bold')}
>
@@ -50,7 +52,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isItalic)}
onClick={() => editor.chain().focus().toggleItalic().run()}
- aria-label="Italic"
+ aria-label={t('editorFormatting.italic')}
>
@@ -58,7 +60,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isStrike)}
onClick={() => editor.chain().focus().toggleStrike().run()}
- aria-label="Strike"
+ aria-label={t('editorFormatting.strike')}
>
@@ -66,7 +68,7 @@ export const TiptapBubbleMenu = () => {
type="button"
className={btnClass(state.isCode)}
onClick={() => editor.chain().focus().toggleCode().run()}
- aria-label="Code"
+ aria-label={t('editorFormatting.code')}
>
diff --git a/src/renderer/hooks/useOptionalTabId.ts b/src/renderer/hooks/useOptionalTabId.ts
new file mode 100644
index 00000000..bbc7c3dc
--- /dev/null
+++ b/src/renderer/hooks/useOptionalTabId.ts
@@ -0,0 +1 @@
+export { useTabIdOptional as useOptionalTabId } from '@renderer/contexts/useTabUIContext';
diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts
index 99a14d26..0592bbad 100644
--- a/src/renderer/utils/teamProvisioningPresentation.ts
+++ b/src/renderer/utils/teamProvisioningPresentation.ts
@@ -51,9 +51,26 @@ type PendingDiagnosticBucket =
| 'noRuntime';
type PendingDiagnosticNameGroups = Record
;
+type TeamProvisioningTranslator = unknown;
const MAX_PENDING_DIAGNOSTIC_NAMES = 4;
+function translateProvisioning(
+ t: TeamProvisioningTranslator | undefined,
+ key: string,
+ fallback: string,
+ options?: Record
+): string {
+ if (!t) {
+ return fallback;
+ }
+
+ return (t as (translationKey: string, options?: Record) => string)(key, {
+ defaultValue: fallback,
+ ...options,
+ });
+}
+
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
if (!value) {
return null;
@@ -182,16 +199,28 @@ function countPermissionBlockedMembers(params: {
return count;
}
-function buildAwaitingPermissionPhrase(count: number): string {
- return count === 1
- ? '1 teammate awaiting permission approval'
- : `${count} teammates awaiting permission approval`;
+function buildAwaitingPermissionPhrase(count: number, t?: TeamProvisioningTranslator): string {
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.awaitingPermission',
+ count === 1
+ ? '1 teammate awaiting permission approval'
+ : `${count} teammates awaiting permission approval`,
+ { count }
+ );
}
-function formatMemberNameList(names: readonly string[]): string {
+function formatMemberNameList(names: readonly string[], t?: TeamProvisioningTranslator): string {
const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', ');
const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES);
- return `${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`;
+ return remainingCount > 0
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.nameListWithMore',
+ `${listedNames}, +${remainingCount} more`,
+ { names: listedNames, count: remainingCount }
+ )
+ : listedNames;
}
function getMemberNamesFromSpawnSources(params: {
@@ -317,6 +346,7 @@ function buildOpenCodeSecondaryWaitPhrase(params: {
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
+ t?: TeamProvisioningTranslator;
}): string | null {
const pendingNames = getPendingSpawnNames({
memberSpawnStatuses: params.memberSpawnStatuses,
@@ -341,25 +371,63 @@ function buildOpenCodeSecondaryWaitPhrase(params: {
memberSpawnSnapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
});
if (groups.bootstrapStalled.length === 0) {
- return `Waiting for OpenCode: ${formatMemberNameList(pendingNames)}`;
+ return translateProvisioning(
+ params.t,
+ 'provisioning.presentation.waitingForOpenCode',
+ `Waiting for OpenCode: ${formatMemberNameList(pendingNames, params.t)}`,
+ { names: formatMemberNameList(pendingNames, params.t) }
+ );
}
- const stalled = `Bootstrap stalled: ${formatMemberNameList(groups.bootstrapStalled)}`;
+ const stalled = translateProvisioning(
+ params.t,
+ 'provisioning.presentation.bootstrapStalled',
+ `Bootstrap stalled: ${formatMemberNameList(groups.bootstrapStalled, params.t)}`,
+ { names: formatMemberNameList(groups.bootstrapStalled, params.t) }
+ );
const waitingNames = pendingNames.filter((name) => !groups.bootstrapStalled.includes(name));
return waitingNames.length > 0
- ? `${stalled}; Waiting for OpenCode: ${formatMemberNameList(waitingNames)}`
+ ? translateProvisioning(
+ params.t,
+ 'provisioning.presentation.bootstrapStalledWithOpenCodeWait',
+ `${stalled}; Waiting for OpenCode: ${formatMemberNameList(waitingNames, params.t)}`,
+ { stalled, names: formatMemberNameList(waitingNames, params.t) }
+ )
: stalled;
}
-function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null {
+function formatNamedPendingDiagnostic(
+ label: string,
+ names: readonly string[],
+ t?: TeamProvisioningTranslator
+): string | null {
if (names.length === 0) {
return null;
}
- return `${label}: ${formatMemberNameList(names)}`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.namedPendingDiagnostic',
+ `${label}: ${formatMemberNameList(names, t)}`,
+ { label, names: formatMemberNameList(names, t) }
+ );
}
-function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null {
- return count && count > 0 ? `${count} ${label}` : null;
+function formatCountPendingDiagnostic(
+ count: number | undefined,
+ label: string,
+ t?: TeamProvisioningTranslator
+): string | null {
+ return count && count > 0
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.countPendingDiagnostic',
+ `${count} ${label}`,
+ {
+ count,
+ label,
+ }
+ )
+ : null;
}
function buildPendingDiagnosticPhrase({
@@ -368,12 +436,14 @@ function buildPendingDiagnosticPhrase({
memberSpawnSnapshotStatuses,
memberSpawnSnapshotUpdatedAt,
fallbackJoiningPhrase,
+ t,
}: {
summary: MemberSpawnStatusesSnapshot['summary'] | undefined;
memberSpawnStatuses: MemberSpawnStatusCollection;
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
memberSpawnSnapshotUpdatedAt?: string;
fallbackJoiningPhrase: string;
+ t?: TeamProvisioningTranslator;
}): string {
const groups = getPendingDiagnosticNameGroups({
memberSpawnStatuses,
@@ -381,12 +451,56 @@ function buildPendingDiagnosticPhrase({
memberSpawnSnapshotUpdatedAt,
});
const namedParts = [
- formatNamedPendingDiagnostic('Bootstrap stalled', groups.bootstrapStalled),
- formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
- formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
- formatNamedPendingDiagnostic('Bootstrap unconfirmed', groups.runtimeCandidate),
- formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
- formatNamedPendingDiagnostic('Waiting for runtime', groups.noRuntime),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.bootstrapStalled',
+ 'Bootstrap stalled'
+ ),
+ groups.bootstrapStalled,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(t, 'provisioning.presentation.pendingLabels.shellOnly', 'Shell-only'),
+ groups.shellOnly,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForBootstrap',
+ 'Waiting for bootstrap'
+ ),
+ groups.runtimeProcess,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.bootstrapUnconfirmed',
+ 'Bootstrap unconfirmed'
+ ),
+ groups.runtimeCandidate,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.awaitingPermission',
+ 'Awaiting permission'
+ ),
+ groups.permission,
+ t
+ ),
+ formatNamedPendingDiagnostic(
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForRuntime',
+ 'Waiting for runtime'
+ ),
+ groups.noRuntime,
+ t
+ ),
].filter(Boolean);
if (namedParts.length > 0) {
return namedParts.join(', ');
@@ -395,11 +509,51 @@ function buildPendingDiagnosticPhrase({
return fallbackJoiningPhrase;
}
const countParts = [
- formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
- formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
- formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'bootstrap unconfirmed'),
- formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
- formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'waiting for runtime'),
+ formatCountPendingDiagnostic(
+ summary.shellOnlyPendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.shellOnlyLower',
+ 'shell-only'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.runtimeProcessPendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForBootstrapLower',
+ 'waiting for bootstrap'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.runtimeCandidatePendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.bootstrapUnconfirmedLower',
+ 'bootstrap unconfirmed'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.permissionPendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.awaitingPermissionLower',
+ 'awaiting permission'
+ ),
+ t
+ ),
+ formatCountPendingDiagnostic(
+ summary.noRuntimePendingCount,
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.pendingLabels.waitingForRuntimeLower',
+ 'waiting for runtime'
+ ),
+ t
+ ),
].filter(Boolean);
return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
}
@@ -567,45 +721,79 @@ function normalizeFailureReason(reason: string): string {
}
function buildFailedSpawnPanelMessage(
- failedSpawnDetails: readonly FailedSpawnDetail[]
+ failedSpawnDetails: readonly FailedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (failedSpawnDetails.length === 0) {
return null;
}
if (failedSpawnDetails.length === 1) {
const [failed] = failedSpawnDetails;
- return `${failed.name} failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.memberFailedToStart',
+ `${failed.name} failed to start`,
+ { name: failed.name }
+ );
}
- return `${failedSpawnDetails.length} teammates failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnDetails.length} teammates failed to start`,
+ { count: failedSpawnDetails.length }
+ );
}
function buildFailedSpawnCompactDetail(
- failedSpawnDetails: readonly FailedSpawnDetail[]
+ failedSpawnDetails: readonly FailedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (failedSpawnDetails.length === 0) {
return null;
}
if (failedSpawnDetails.length === 1) {
- return `${failedSpawnDetails[0].name} failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.memberFailedToStart',
+ `${failedSpawnDetails[0].name} failed to start`,
+ { name: failedSpawnDetails[0].name }
+ );
}
- return `${failedSpawnDetails.length} teammates failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnDetails.length} teammates failed to start`,
+ { count: failedSpawnDetails.length }
+ );
}
function buildGenericFailedSpawnPanelMessage(
failedSpawnCount: number,
- expectedTeammateCount: number
+ expectedTeammateCount: number,
+ t?: TeamProvisioningTranslator
): string | null {
if (failedSpawnCount <= 0) {
return null;
}
if (failedSpawnCount === 1) {
- return '1 teammate failed to start';
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ '1 teammate failed to start',
+ { count: failedSpawnCount }
+ );
}
- return `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedRatio',
+ `${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`,
+ { count: failedSpawnCount, total: Math.max(expectedTeammateCount, failedSpawnCount) }
+ );
}
function buildSkippedSpawnPanelMessage(
- skippedSpawnDetails: readonly SkippedSpawnDetail[]
+ skippedSpawnDetails: readonly SkippedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (skippedSpawnDetails.length === 0) {
return null;
@@ -613,8 +801,18 @@ function buildSkippedSpawnPanelMessage(
if (skippedSpawnDetails.length === 1) {
const [skipped] = skippedSpawnDetails;
return skipped.reason
- ? `${skipped.name} skipped for this launch - ${normalizeFailureReason(skipped.reason)}`
- : `${skipped.name} skipped for this launch`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.memberSkippedWithReason',
+ `${skipped.name} skipped for this launch - ${normalizeFailureReason(skipped.reason)}`,
+ { name: skipped.name, reason: normalizeFailureReason(skipped.reason) }
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.memberSkipped',
+ `${skipped.name} skipped for this launch`,
+ { name: skipped.name }
+ );
}
const listedSkipped = skippedSpawnDetails
.slice(0, 3)
@@ -623,19 +821,35 @@ function buildSkippedSpawnPanelMessage(
)
.join('; ');
const remainingCount = skippedSpawnDetails.length - Math.min(skippedSpawnDetails.length, 3);
- return `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkippedList',
+ `Skipped teammates: ${listedSkipped}${remainingCount > 0 ? `; +${remainingCount} more` : ''}`,
+ { list: listedSkipped, count: remainingCount }
+ );
}
function buildSkippedSpawnCompactDetail(
- skippedSpawnDetails: readonly SkippedSpawnDetail[]
+ skippedSpawnDetails: readonly SkippedSpawnDetail[],
+ t?: TeamProvisioningTranslator
): string | null {
if (skippedSpawnDetails.length === 0) {
return null;
}
if (skippedSpawnDetails.length === 1) {
- return `${skippedSpawnDetails[0].name} skipped`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.memberSkippedCompact',
+ `${skippedSpawnDetails[0].name} skipped`,
+ { name: skippedSpawnDetails[0].name }
+ );
}
- return `${skippedSpawnDetails.length} teammates skipped`;
+ return translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkipped',
+ `${skippedSpawnDetails.length} teammates skipped`,
+ { count: skippedSpawnDetails.length }
+ );
}
export interface TeamProvisioningPresentation {
@@ -679,6 +893,7 @@ export function buildTeamProvisioningPresentation({
members,
memberSpawnStatuses,
memberSpawnSnapshot,
+ t,
}: {
progress: TeamProvisioningProgress | null | undefined;
members: readonly ProvisioningMemberLike[];
@@ -689,6 +904,7 @@ export function buildTeamProvisioningPresentation({
> & {
statuses?: MemberSpawnStatusesSnapshot['statuses'];
};
+ t?: TeamProvisioningTranslator;
}): TeamProvisioningPresentation | null {
if (!progress) {
return null;
@@ -725,19 +941,20 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
- const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails);
- const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails);
+ const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails, t);
+ const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails, t);
const genericFailedSpawnPanelMessage = buildGenericFailedSpawnPanelMessage(
failedSpawnCount,
- expectedTeammateCount
+ expectedTeammateCount,
+ t
);
const skippedSpawnDetails = getSkippedSpawnDetails({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
});
- const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails);
- const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails);
+ const skippedSpawnPanelMessage = buildSkippedSpawnPanelMessage(skippedSpawnDetails, t);
+ const skippedSpawnCompactDetail = buildSkippedSpawnCompactDetail(skippedSpawnDetails, t);
const permissionBlockedCount = countPermissionBlockedMembers({
memberSpawnStatuses,
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
@@ -796,11 +1013,19 @@ export function buildTeamProvisioningPresentation({
remainingJoinCount,
retryableOpenCodeSecondaryFailedCount,
retryableOpenCodeSecondaryFailedNames,
- panelTitle: 'Launch failed',
+ panelTitle: translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchFailed',
+ 'Launch failed'
+ ),
panelMessage: progress.error ?? failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage,
panelTone: 'error',
defaultLiveOutputOpen: true,
- compactTitle: 'Launch failed',
+ compactTitle: translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchFailed',
+ 'Launch failed'
+ ),
compactDetail: progress.message ?? null,
compactTone: 'error',
};
@@ -809,14 +1034,24 @@ export function buildTeamProvisioningPresentation({
if (isReady) {
const joiningPhrase =
remainingJoinCount === 1
- ? '1 teammate still joining'
- : `${remainingJoinCount} teammates still joining`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ '1 teammate still joining',
+ { count: remainingJoinCount }
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ `${remainingJoinCount} teammates still joining`,
+ { count: remainingJoinCount }
+ );
const pendingMembersAwaitApproval =
failedSpawnCount === 0 &&
permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount;
const pendingDetailPhrase = pendingMembersAwaitApproval
- ? buildAwaitingPermissionPhrase(permissionBlockedCount)
+ ? buildAwaitingPermissionPhrase(permissionBlockedCount, t)
: (openCodeSecondaryWaitPhrase ??
buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
@@ -824,32 +1059,73 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: joiningPhrase,
+ t,
}));
const readyCompactDetail =
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
- `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`,
+ { count: failedSpawnCount }
+ ))
: skippedSpawnCount > 0
? (skippedSpawnCompactDetail ??
- `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkipped',
+ `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`,
+ { count: skippedSpawnCount }
+ ))
: hasMembersStillJoining
? pendingDetailPhrase
: expectedTeammateCount === 0
- ? 'Lead online'
- : `All ${expectedTeammateCount} teammates joined`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.leadOnline',
+ 'Lead online'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.allTeammatesJoined',
+ `All ${expectedTeammateCount} teammates joined`,
+ { count: expectedTeammateCount }
+ );
const readyDetailMessage =
failedSpawnCount > 0
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
: skippedSpawnCount > 0
? (skippedSpawnPanelMessage ??
- `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkippedRatio',
+ `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`,
+ {
+ count: skippedSpawnCount,
+ total: Math.max(expectedTeammateCount, skippedSpawnCount),
+ }
+ ))
: expectedTeammateCount === 0
- ? 'Team provisioned - lead online'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamProvisionedLeadOnline',
+ 'Team provisioned - lead online'
+ )
: allTeammatesConfirmedAlive
- ? `Team provisioned - all ${expectedTeammateCount} teammates joined`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamProvisionedAllJoined',
+ `Team provisioned - all ${expectedTeammateCount} teammates joined`,
+ { count: expectedTeammateCount }
+ )
: hasMembersStillJoining
? pendingDetailPhrase
- : 'Team provisioned - teammates are still joining';
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamProvisionedStillJoining',
+ 'Team provisioned - teammates are still joining'
+ );
const readyDetailSeverity =
failedSpawnCount > 0 || skippedSpawnCount > 0
? 'warning'
@@ -858,16 +1134,46 @@ export function buildTeamProvisioningPresentation({
: undefined;
const readyMessage =
failedSpawnCount > 0
- ? `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.launchFinishedWithErrors',
+ `Launch finished with errors - ${failedSpawnCount}/${Math.max(expectedTeammateCount, failedSpawnCount)} teammates failed to start`,
+ { count: failedSpawnCount, total: Math.max(expectedTeammateCount, failedSpawnCount) }
+ )
: skippedSpawnCount > 0
- ? `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.launchContinuedSkipped',
+ `Launch continued - ${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped`,
+ {
+ count: skippedSpawnCount,
+ total: Math.max(expectedTeammateCount, skippedSpawnCount),
+ }
+ )
: expectedTeammateCount === 0
- ? 'Team launched - lead online'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamLaunchedLeadOnline',
+ 'Team launched - lead online'
+ )
: allTeammatesConfirmedAlive
- ? `Team launched - all ${expectedTeammateCount} teammates joined`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.ready.teamLaunchedAllJoined',
+ `Team launched - all ${expectedTeammateCount} teammates joined`,
+ { count: expectedTeammateCount }
+ )
: openCodeSecondaryWaitPhrase
- ? 'Core team ready'
- : 'Finishing launch';
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.finishingLaunch',
+ 'Finishing launch'
+ );
return {
progress,
@@ -886,7 +1192,11 @@ export function buildTeamProvisioningPresentation({
remainingJoinCount,
retryableOpenCodeSecondaryFailedCount,
retryableOpenCodeSecondaryFailedNames,
- panelTitle: 'Launch details',
+ panelTitle: translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchDetails',
+ 'Launch details'
+ ),
panelMessage:
failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining
? readyDetailMessage
@@ -902,14 +1212,34 @@ export function buildTeamProvisioningPresentation({
defaultLiveOutputOpen: false,
compactTitle:
failedSpawnCount > 0
- ? 'Launch finished with errors'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchFinishedWithErrors',
+ 'Launch finished with errors'
+ )
: skippedSpawnCount > 0
- ? 'Launch continued with skipped teammates'
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchContinuedSkipped',
+ 'Launch continued with skipped teammates'
+ )
: hasMembersStillJoining
? openCodeSecondaryWaitPhrase
- ? 'Core team ready'
- : 'Finishing launch'
- : 'Team launched',
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.finishingLaunch',
+ 'Finishing launch'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.teamLaunched',
+ 'Team launched'
+ ),
compactDetail: readyCompactDetail,
compactTone:
failedSpawnCount > 0 || skippedSpawnCount > 0
@@ -929,14 +1259,24 @@ export function buildTeamProvisioningPresentation({
if (isActive) {
const activeJoiningPhrase =
remainingJoinCount === 1
- ? '1 teammate still joining'
- : `${remainingJoinCount} teammates still joining`;
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ '1 teammate still joining',
+ { count: remainingJoinCount }
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesStillJoining',
+ `${remainingJoinCount} teammates still joining`,
+ { count: remainingJoinCount }
+ );
const activePendingDetailPhrase =
failedSpawnCount === 0 &&
hasMembersStillJoining &&
permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount
- ? buildAwaitingPermissionPhrase(permissionBlockedCount)
+ ? buildAwaitingPermissionPhrase(permissionBlockedCount, t)
: (openCodeSecondaryWaitPhrase ??
buildPendingDiagnosticPhrase({
summary: memberSpawnSnapshot?.summary,
@@ -944,6 +1284,7 @@ export function buildTeamProvisioningPresentation({
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
fallbackJoiningPhrase: activeJoiningPhrase,
+ t,
}));
return {
progress,
@@ -963,13 +1304,31 @@ export function buildTeamProvisioningPresentation({
remainingJoinCount,
retryableOpenCodeSecondaryFailedCount,
retryableOpenCodeSecondaryFailedNames,
- panelTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
+ panelTitle: openCodeSecondaryWaitPhrase
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchingTeam',
+ 'Launching team'
+ ),
panelMessage:
failedSpawnCount > 0
? (failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage ?? progress.message)
: skippedSpawnCount > 0
? (skippedSpawnPanelMessage ??
- `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkippedRatio',
+ `${skippedSpawnCount}/${Math.max(expectedTeammateCount, skippedSpawnCount)} teammates skipped for this launch`,
+ {
+ count: skippedSpawnCount,
+ total: Math.max(expectedTeammateCount, skippedSpawnCount),
+ }
+ ))
: openCodeSecondaryWaitPhrase
? openCodeSecondaryWaitPhrase
: hasMembersStillJoining &&
@@ -980,22 +1339,52 @@ export function buildTeamProvisioningPresentation({
panelMessageSeverity:
failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : progress.messageSeverity,
defaultLiveOutputOpen: false,
- compactTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team',
+ compactTitle: openCodeSecondaryWaitPhrase
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.coreTeamReady',
+ 'Core team ready'
+ )
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.panel.launchingTeam',
+ 'Launching team'
+ ),
compactDetail:
failedSpawnCount > 0
? (failedSpawnCompactDetail ??
- `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.failed.teammatesFailedToStart',
+ `${failedSpawnCount} teammate${failedSpawnCount === 1 ? '' : 's'} failed to start`,
+ { count: failedSpawnCount }
+ ))
: skippedSpawnCount > 0
? (skippedSpawnCompactDetail ??
- `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`)
+ translateProvisioning(
+ t,
+ 'provisioning.presentation.skipped.teammatesSkipped',
+ `${skippedSpawnCount} teammate${skippedSpawnCount === 1 ? '' : 's'} skipped`,
+ { count: skippedSpawnCount }
+ ))
: openCodeSecondaryWaitPhrase
? openCodeSecondaryWaitPhrase
: hasMembersStillJoining && failedSpawnCount === 0 && permissionBlockedCount > 0
? permissionBlockedCount === remainingJoinCount
- ? buildAwaitingPermissionPhrase(permissionBlockedCount)
- : `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
+ ? buildAwaitingPermissionPhrase(permissionBlockedCount, t)
+ : translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesConfirmedRatio',
+ `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`,
+ { count: heartbeatConfirmedCount, total: expectedTeammateCount }
+ )
: expectedTeammateCount > 0 && progressStepIndex >= 2
- ? `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`
+ ? translateProvisioning(
+ t,
+ 'provisioning.presentation.joining.teammatesConfirmedRatio',
+ `${heartbeatConfirmedCount}/${expectedTeammateCount} teammates confirmed`,
+ { count: heartbeatConfirmedCount, total: expectedTeammateCount }
+ )
: progress.message,
compactTone: failedSpawnCount > 0 || skippedSpawnCount > 0 ? 'warning' : 'default',
};
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index c9715fb2..1cfde9e7 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -342,6 +342,8 @@ export interface AppConfig {
claudeRootPath: string | null;
/** Agent communication language ('system' = use OS locale) */
agentLanguage: string;
+ /** Application interface locale preference ('system' = use OS locale) */
+ appLocale: string;
/** Whether to auto-expand AI response groups when opening a transcript or receiving new messages */
autoExpandAIGroups: boolean;
/** Whether to use the native OS title bar instead of the custom one (Linux/Windows) */
diff --git a/test/features/localization/core/catalogPolicy.test.ts b/test/features/localization/core/catalogPolicy.test.ts
new file mode 100644
index 00000000..e850af0d
--- /dev/null
+++ b/test/features/localization/core/catalogPolicy.test.ts
@@ -0,0 +1,51 @@
+import {
+ extractInterpolationVariables,
+ validateCatalogCompleteness,
+} from '@features/localization/core/domain/catalogPolicy';
+import { describe, expect, it } from 'vitest';
+
+describe('catalogPolicy', () => {
+ it('accepts matching catalog shape', () => {
+ const issues = validateCatalogCompleteness(
+ {
+ en: { common: { greeting: 'Hello {{name}}' } },
+ pseudo: { common: { greeting: 'Hi {{name}}' } },
+ },
+ 'en'
+ );
+
+ expect(issues).toEqual([]);
+ });
+
+ it('reports missing and extra keys', () => {
+ const issues = validateCatalogCompleteness(
+ {
+ en: { common: { actions: { save: 'Save', cancel: 'Cancel' } } },
+ pseudo: { common: { actions: { save: 'Save', close: 'Close' } } },
+ },
+ 'en'
+ );
+
+ expect(issues.map((issue) => issue.type)).toEqual(['missing-key', 'extra-key']);
+ });
+
+ it('reports interpolation mismatches', () => {
+ const issues = validateCatalogCompleteness(
+ {
+ en: { common: { greeting: 'Hello {{name}}' } },
+ pseudo: { common: { greeting: 'Hello {{user}}' } },
+ },
+ 'en'
+ );
+
+ expect(issues).toHaveLength(1);
+ expect(issues[0].type).toBe('interpolation-mismatch');
+ });
+
+ it('extracts sorted interpolation variables', () => {
+ expect(extractInterpolationVariables('{{count}} items for {{name}}')).toEqual([
+ 'count',
+ 'name',
+ ]);
+ });
+});
diff --git a/test/features/localization/core/localePolicy.test.ts b/test/features/localization/core/localePolicy.test.ts
new file mode 100644
index 00000000..66a77076
--- /dev/null
+++ b/test/features/localization/core/localePolicy.test.ts
@@ -0,0 +1,30 @@
+import {
+ extractPrimaryLocaleSubtag,
+ normalizeAppLocalePreference,
+ resolveAppLocale,
+} from '@features/localization/core/domain/localePolicy';
+import { describe, expect, it } from 'vitest';
+
+describe('localePolicy', () => {
+ it('normalizes unsupported preferences to system', () => {
+ expect(normalizeAppLocalePreference('uk')).toBe('system');
+ expect(normalizeAppLocalePreference(null)).toBe('system');
+ expect(normalizeAppLocalePreference('en')).toBe('en');
+ expect(normalizeAppLocalePreference('ru')).toBe('ru');
+ });
+
+ it('extracts the primary locale subtag', () => {
+ expect(extractPrimaryLocaleSubtag('en-US')).toBe('en');
+ expect(extractPrimaryLocaleSubtag('EN_us')).toBe('en');
+ expect(extractPrimaryLocaleSubtag('')).toBeNull();
+ });
+
+ it('resolves system locale to supported primary locale', () => {
+ expect(resolveAppLocale({ preference: 'system', systemLocale: 'en-US' })).toBe('en');
+ expect(resolveAppLocale({ preference: 'system', systemLocale: 'ru-RU' })).toBe('ru');
+ });
+
+ it('falls back when the system locale is not supported yet', () => {
+ expect(resolveAppLocale({ preference: 'system', systemLocale: 'uk-UA' })).toBe('en');
+ });
+});
diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts
index 45d9a7cd..d9fa8d8a 100644
--- a/test/main/ipc/configValidation.test.ts
+++ b/test/main/ipc/configValidation.test.ts
@@ -42,6 +42,22 @@ describe('configValidation', () => {
}
});
+ it('accepts supported general.appLocale updates', () => {
+ const result = validateConfigUpdatePayload('general', { appLocale: 'ru' });
+ expect(result.valid).toBe(true);
+ if (result.valid) {
+ expect(result.data).toEqual({ appLocale: 'ru' });
+ }
+ });
+
+ it('rejects unsupported general.appLocale updates', () => {
+ const result = validateConfigUpdatePayload('general', { appLocale: 'uk' });
+ expect(result.valid).toBe(false);
+ if (!result.valid) {
+ expect(result.error).toContain('supported app locale');
+ }
+ });
+
it('accepts absolute general.claudeRootPath updates', () => {
const result = validateConfigUpdatePayload('general', {
claudeRootPath: '/Users/test/.claude',
diff --git a/test/renderer/store/extensionsSlice.test.ts b/test/renderer/store/extensionsSlice.test.ts
index 83f36347..ce787ee6 100644
--- a/test/renderer/store/extensionsSlice.test.ts
+++ b/test/renderer/store/extensionsSlice.test.ts
@@ -55,15 +55,15 @@ vi.mock('../../../src/renderer/api', () => ({
}));
import { api } from '../../../src/renderer/api';
-import type { AppConfig, CliInstallationStatus } from '../../../src/shared/types';
import {
getMcpDiagnosticKey,
- getMcpProjectStateKey,
getMcpOperationKey,
+ getMcpProjectStateKey,
getPluginOperationKey,
} from '../../../src/shared/utils/extensionNormalizers';
import { createDefaultCliExtensionCapabilities } from '../../../src/shared/utils/providerExtensionCapabilities';
+import type { AppConfig, CliInstallationStatus } from '../../../src/shared/types';
import type {
EnrichedPlugin,
McpCatalogItem,
@@ -239,6 +239,7 @@ function makeAppConfig(multimodelEnabled: boolean): AppConfig {
multimodelEnabled,
claudeRootPath: null,
agentLanguage: 'system',
+ appLocale: 'system',
autoExpandAIGroups: true,
useNativeTitleBar: false,
telemetryEnabled: false,
From 3a7f9ea10bde9d45a060a3ac9bf117e7a39dec01 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:40:31 +0300
Subject: [PATCH 05/16] feat: add team log member filtering
---
.../components/layout/TeamTabSectionNav.tsx | 2 +-
.../components/team/ClaudeLogsDialog.tsx | 71 --
.../team/ClaudeLogsFilterPopover.tsx | 20 +-
.../components/team/ClaudeLogsPanel.tsx | 7 +-
.../components/team/ClaudeLogsSection.tsx | 306 ++++-
.../components/team/claudeLogsFilterState.ts | 12 +
.../team/members/MemberDetailDialog.tsx | 34 +-
.../MemberLogStreamWithLegacyFallback.tsx | 52 +
.../team/sidebar/teamSidebarUiState.ts | 3 +-
.../components/team/teamLogSources.ts | 74 ++
.../team/useClaudeLogsController.ts | 48 +-
src/renderer/components/ui/MemberSelect.tsx | 43 +-
.../layout/TeamTabSectionNav.test.tsx | 46 +
.../components/team/ClaudeLogsPanel.test.ts | 44 +
.../components/team/ClaudeLogsSection.test.ts | 1036 +++++++++++++++++
.../team/members/MemberDetailDialog.test.ts | 2 +-
.../components/team/teamLogSources.test.ts | 81 ++
.../team/useClaudeLogsController.test.tsx | 276 +++++
.../components/ui/MemberSelect.test.tsx | 174 +++
19 files changed, 2185 insertions(+), 146 deletions(-)
delete mode 100644 src/renderer/components/team/ClaudeLogsDialog.tsx
create mode 100644 src/renderer/components/team/claudeLogsFilterState.ts
create mode 100644 src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx
create mode 100644 src/renderer/components/team/teamLogSources.ts
create mode 100644 test/renderer/components/layout/TeamTabSectionNav.test.tsx
create mode 100644 test/renderer/components/team/ClaudeLogsSection.test.ts
create mode 100644 test/renderer/components/team/teamLogSources.test.ts
create mode 100644 test/renderer/components/team/useClaudeLogsController.test.tsx
create mode 100644 test/renderer/components/ui/MemberSelect.test.tsx
diff --git a/src/renderer/components/layout/TeamTabSectionNav.tsx b/src/renderer/components/layout/TeamTabSectionNav.tsx
index 4cd48f64..8ae5e1ec 100644
--- a/src/renderer/components/layout/TeamTabSectionNav.tsx
+++ b/src/renderer/components/layout/TeamTabSectionNav.tsx
@@ -15,7 +15,7 @@ const SECTIONS: readonly { id: string; label: string; icon: LucideIcon }[] = [
{ id: 'team', label: 'Team', icon: Users },
{ id: 'sessions', label: 'Sessions', icon: History },
{ id: 'kanban', label: 'Kanban', icon: Columns3 },
- { id: 'claude-logs', label: 'Claude Logs', icon: Terminal },
+ { id: 'claude-logs', label: 'Logs', icon: Terminal },
{ id: 'messages', label: 'Messages', icon: MessageSquare },
];
diff --git a/src/renderer/components/team/ClaudeLogsDialog.tsx b/src/renderer/components/team/ClaudeLogsDialog.tsx
deleted file mode 100644
index d93f671a..00000000
--- a/src/renderer/components/team/ClaudeLogsDialog.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * ClaudeLogsDialog
- *
- * Fullscreen-style dialog for viewing Claude logs in a large viewport.
- * Uses the same ClaudeLogsPanel as the compact sidebar but with more space.
- * Only one CliLogsRichView is mounted at a time — when this dialog is open,
- * the compact panel hides its log viewer.
- */
-
-import React from 'react';
-
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
-import { Terminal } from 'lucide-react';
-
-import { ClaudeLogsPanel } from './ClaudeLogsPanel';
-
-import type { ClaudeLogsController } from './useClaudeLogsController';
-
-// =============================================================================
-// Props
-// =============================================================================
-
-interface ClaudeLogsDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- ctrl: ClaudeLogsController;
-}
-
-// =============================================================================
-// Component
-// =============================================================================
-
-export const ClaudeLogsDialog = ({
- open,
- onOpenChange,
- ctrl,
-}: ClaudeLogsDialogProps): React.JSX.Element => {
- return (
-
-
-
-
-
-
-
- Claude logs
- {ctrl.badge != null && (
-
- ({ctrl.badge})
-
- )}
- {ctrl.online && (
-
-
-
-
- )}
-
-
-
-
-
-
-
-
- );
-};
diff --git a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
index aeeb70e6..80fd6f13 100644
--- a/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
+++ b/src/renderer/components/team/ClaudeLogsFilterPopover.tsx
@@ -6,18 +6,12 @@ import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Filter } from 'lucide-react';
-export type ClaudeLogStream = 'stdout' | 'stderr';
-export type ClaudeLogKind = 'output' | 'thinking' | 'tool';
-
-export interface ClaudeLogsFilterState {
- streams: Set;
- kinds: Set;
-}
-
-export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
- streams: new Set(['stdout', 'stderr']),
- kinds: new Set(['output', 'thinking', 'tool']),
-};
+import {
+ type ClaudeLogKind,
+ type ClaudeLogsFilterState,
+ type ClaudeLogStream,
+ DEFAULT_CLAUDE_LOGS_FILTER,
+} from './claudeLogsFilterState';
function setEquals(a: Set, b: Set): boolean {
if (a.size !== b.size) return false;
@@ -108,7 +102,7 @@ export const ClaudeLogsFilterPopover = ({
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
- aria-label="Filter Claude logs"
+ aria-label="Filter logs"
>
{activeCount > 0 && (
diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx
index 05b5784c..9fef9377 100644
--- a/src/renderer/components/team/ClaudeLogsPanel.tsx
+++ b/src/renderer/components/team/ClaudeLogsPanel.tsx
@@ -29,6 +29,7 @@ interface ClaudeLogsPanelProps {
/** Extra className for the panel wrapper. */
className?: string;
compactMetaInTooltip?: boolean;
+ toolbarControlsStart?: React.ReactNode;
}
// =============================================================================
@@ -41,6 +42,7 @@ export const ClaudeLogsPanel = ({
viewerMaxHeight,
className,
compactMetaInTooltip = false,
+ toolbarControlsStart,
}: ClaudeLogsPanelProps): React.JSX.Element => {
const {
data,
@@ -86,7 +88,10 @@ export const ClaudeLogsPanel = ({
'Team is not running.'
)}
-
+
+ {toolbarControlsStart ? (
+
{toolbarControlsStart}
+ ) : null}
{data.total > 0 ? (
<>
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx
index 818e68fb..cfadb871 100644
--- a/src/renderer/components/team/ClaudeLogsSection.tsx
+++ b/src/renderer/components/team/ClaudeLogsSection.tsx
@@ -1,16 +1,33 @@
-import { memo, useMemo, useState } from 'react';
+import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@renderer/components/ui/dialog';
+import { MemberSelect } from '@renderer/components/ui/MemberSelect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
-import { Brain, Expand, MessageSquare, Wrench } from 'lucide-react';
+import { useStore } from '@renderer/store';
+import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
+import { isLeadMember } from '@shared/utils/leadDetection';
+import { Brain, Expand, MessageSquare, Terminal, Wrench } from 'lucide-react';
-import { ClaudeLogsDialog } from './ClaudeLogsDialog';
+import { MemberLogStreamWithLegacyFallback } from './members/MemberLogStreamWithLegacyFallback';
import { ClaudeLogsPanel } from './ClaudeLogsPanel';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
+import {
+ buildSelectableLogMembers,
+ formatMemberLogSourceDescription,
+ formatMemberLogSourceLabel,
+ getMemberNameFromLogSourceKey,
+ LEAD_LOG_SOURCE_KEY,
+ memberLogSourceKey,
+ normalizeMemberLogSourceName,
+ resolveLeadLogMember,
+} from './teamLogSources';
import { useClaudeLogsController } from './useClaudeLogsController';
+import type { TeamLogSourceKey } from './teamLogSources';
import type { LastLogPreview } from './useClaudeLogsController';
+import type { ResolvedTeamMember } from '@shared/types';
// =============================================================================
// Constants
@@ -78,6 +95,177 @@ const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.E
);
};
+const TeamLogsSourceSelector = ({
+ leadMember,
+ members,
+ selectedKey,
+ onChange,
+ className,
+}: {
+ leadMember: ResolvedTeamMember;
+ members: readonly ResolvedTeamMember[];
+ selectedKey: TeamLogSourceKey;
+ onChange: (key: TeamLogSourceKey) => void;
+ className?: string;
+}): React.JSX.Element | null => {
+ const sourceMembers = useMemo(() => [leadMember, ...members], [leadMember, members]);
+ const selectedMemberName =
+ selectedKey === LEAD_LOG_SOURCE_KEY
+ ? leadMember.name
+ : getMemberNameFromLogSourceKey(selectedKey);
+
+ if (sourceMembers.length <= 1) return null;
+
+ return (
+
+ {
+ const selectedMember = sourceMembers.find((member) => member.name === memberName);
+ if (!selectedMember || isLeadMember(selectedMember)) {
+ onChange(LEAD_LOG_SOURCE_KEY);
+ return;
+ }
+ onChange(memberLogSourceKey(selectedMember.name));
+ }}
+ placeholder="Select log source..."
+ searchPlaceholder="Search log sources..."
+ emptyMessage="No log sources found."
+ ariaLabel="Log source"
+ getMemberLabel={(member) =>
+ isLeadMember(member) ? 'Lead' : formatMemberLogSourceLabel(member)
+ }
+ getMemberDescription={formatMemberLogSourceDescription}
+ />
+
+ );
+};
+
+const MemberSourcePill = ({ member }: { member: ResolvedTeamMember }): React.JSX.Element => (
+
+ {formatMemberLogSourceLabel(member)}
+
+);
+
+const MemberLogsSourcePanel = ({
+ teamName,
+ member,
+ enabled,
+ maxHeight,
+}: {
+ teamName: string;
+ member: ResolvedTeamMember;
+ enabled: boolean;
+ maxHeight?: number;
+}): React.JSX.Element => {
+ const content = (
+
+ );
+
+ if (maxHeight === undefined) {
+ return content;
+ }
+
+ return (
+
+ {content}
+
+ );
+};
+
+const TeamLogsDialog = ({
+ open,
+ onOpenChange,
+ teamName,
+ leadMember,
+ members,
+ selectedKey,
+ onSourceChange,
+ showingLeadLogs,
+ ctrl,
+ selectedMember,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ teamName: string;
+ leadMember: ResolvedTeamMember;
+ members: readonly ResolvedTeamMember[];
+ selectedKey: TeamLogSourceKey;
+ onSourceChange: (key: TeamLogSourceKey) => void;
+ showingLeadLogs: boolean;
+ ctrl: ReturnType
;
+ selectedMember: ResolvedTeamMember | null;
+}): React.JSX.Element => {
+ const sourceSelector =
+ members.length > 0 ? (
+
+ ) : null;
+
+ return (
+
+
+
+
+
+
+
+ Logs
+ {showingLeadLogs && ctrl.badge != null ? (
+
+ ({ctrl.badge})
+
+ ) : null}
+ {showingLeadLogs && ctrl.online ? (
+
+
+
+
+ ) : null}
+
+
+
+
+ {showingLeadLogs ? (
+
+ ) : selectedMember ? (
+ <>
+ {sourceSelector ? (
+
{sourceSelector}
+ ) : null}
+
+ >
+ ) : (
+
+ Select a log source.
+
+ )}
+
+
+
+ );
+};
+
// =============================================================================
// Main component
// =============================================================================
@@ -88,22 +276,79 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
sidebarViewerMaxHeight,
onOpenChange,
}: ClaudeLogsSectionProps): React.JSX.Element {
- const ctrl = useClaudeLogsController(teamName);
+ const teamMembers = useStore((state) => selectResolvedMembersForTeamName(state, teamName));
+ const [selectedSourceState, setSelectedSourceState] = useState<{
+ teamName: string;
+ sourceKey: TeamLogSourceKey;
+ }>(() => ({ teamName, sourceKey: LEAD_LOG_SOURCE_KEY }));
+ const selectedSourceKey =
+ selectedSourceState.teamName === teamName ? selectedSourceState.sourceKey : LEAD_LOG_SOURCE_KEY;
+ const setSelectedSourceKey = useCallback(
+ (sourceKey: TeamLogSourceKey) => {
+ setSelectedSourceState({ teamName, sourceKey });
+ },
+ [teamName]
+ );
+ const leadMember = useMemo(() => resolveLeadLogMember(teamMembers), [teamMembers]);
+ const selectableMembers = useMemo(() => buildSelectableLogMembers(teamMembers), [teamMembers]);
+ const selectedMemberName = getMemberNameFromLogSourceKey(selectedSourceKey);
+ const selectedMemberSourceName = selectedMemberName
+ ? normalizeMemberLogSourceName(selectedMemberName)
+ : null;
+ const selectedMember = useMemo(
+ () =>
+ selectedMemberSourceName
+ ? (selectableMembers.find(
+ (member) => normalizeMemberLogSourceName(member.name) === selectedMemberSourceName
+ ) ?? null)
+ : null,
+ [selectableMembers, selectedMemberSourceName]
+ );
+ const effectiveSelectedSourceKey =
+ selectedSourceKey === LEAD_LOG_SOURCE_KEY
+ ? LEAD_LOG_SOURCE_KEY
+ : selectedMember
+ ? memberLogSourceKey(selectedMember.name)
+ : LEAD_LOG_SOURCE_KEY;
+ const showingLeadLogs = effectiveSelectedSourceKey === LEAD_LOG_SOURCE_KEY;
+ const ctrl = useClaudeLogsController(teamName, { enabled: showingLeadLogs });
const [dialogOpen, setDialogOpen] = useState(false);
const isSidebar = position === 'sidebar';
- const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
+ const showHeaderSkeleton =
+ showingLeadLogs && ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
+
+ useEffect(() => {
+ if (selectedSourceState.teamName !== teamName) {
+ setSelectedSourceState({ teamName, sourceKey: LEAD_LOG_SOURCE_KEY });
+ }
+ }, [selectedSourceState.teamName, teamName]);
+
+ useEffect(() => {
+ if (selectedSourceKey === LEAD_LOG_SOURCE_KEY) return;
+ if (selectedMember) {
+ const canonicalSourceKey = memberLogSourceKey(selectedMember.name);
+ if (selectedSourceKey !== canonicalSourceKey) {
+ setSelectedSourceKey(canonicalSourceKey);
+ }
+ return;
+ }
+ setSelectedSourceKey(LEAD_LOG_SOURCE_KEY);
+ }, [selectedMember, selectedSourceKey, setSelectedSourceKey]);
const sectionHeaderExtra = useMemo(
() => (
- {ctrl.online ? (
+ {showingLeadLogs && ctrl.online ? (
) : null}
- {ctrl.lastLogPreview ? : null}
+ {showingLeadLogs && ctrl.lastLogPreview ? (
+
+ ) : null}
+ {!showingLeadLogs && selectedMember ? : null}
{showHeaderSkeleton ? (
@@ -114,9 +359,18 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
) : null}
),
- [ctrl.online, ctrl.lastLogPreview, isSidebar, showHeaderSkeleton]
+ [
+ ctrl.online,
+ ctrl.lastLogPreview,
+ isSidebar,
+ selectedMember,
+ showingLeadLogs,
+ showHeaderSkeleton,
+ ]
);
+ const canOpenFullscreen = showingLeadLogs ? ctrl.data.total > 0 : selectedMember !== null;
+
const afterBadge = showHeaderSkeleton ? (
<>
@@ -124,7 +378,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
>
- ) : ctrl.data.total > 0 ? (
+ ) : canOpenFullscreen ? (
{/* When dialog is open, hide the compact log viewer to avoid two competing scroll containers */}
+
{dialogOpen ? (
Viewing in fullscreen mode
- ) : (
+ ) : showingLeadLogs ? (
+ ) : selectedMember ? (
+
+ ) : (
+
+ Select a log source.
+
)}
-
+
>
);
});
diff --git a/src/renderer/components/team/claudeLogsFilterState.ts b/src/renderer/components/team/claudeLogsFilterState.ts
new file mode 100644
index 00000000..8af480b8
--- /dev/null
+++ b/src/renderer/components/team/claudeLogsFilterState.ts
@@ -0,0 +1,12 @@
+export type ClaudeLogStream = 'stdout' | 'stderr';
+export type ClaudeLogKind = 'output' | 'thinking' | 'tool';
+
+export interface ClaudeLogsFilterState {
+ streams: Set;
+ kinds: Set;
+}
+
+export const DEFAULT_CLAUDE_LOGS_FILTER: ClaudeLogsFilterState = {
+ streams: new Set(['stdout', 'stderr']),
+ kinds: new Set(['output', 'thinking', 'tool']),
+};
diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx
index 531e6656..41d5280b 100644
--- a/src/renderer/components/team/members/MemberDetailDialog.tsx
+++ b/src/renderer/components/team/members/MemberDetailDialog.tsx
@@ -1,9 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
-import {
- isMemberLogStreamUiEnabled,
- MemberLogStreamSection,
-} from '@features/member-log-stream/renderer';
// import { MemberWorkSyncStatusPanel } from '@features/member-work-sync/renderer';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
@@ -43,7 +39,7 @@ import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats';
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
-import { MemberLogsTab } from './MemberLogsTab';
+import { MemberLogStreamWithLegacyFallback } from './MemberLogStreamWithLegacyFallback';
import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab';
@@ -197,7 +193,6 @@ export const MemberDetailDialog = ({
const [activeTab, setActiveTab] = useState(initialTab);
const [restarting, setRestarting] = useState(false);
const [restartError, setRestartError] = useState(null);
- const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
const runtimeSummary = useMemo(
() =>
@@ -269,7 +264,6 @@ export const MemberDetailDialog = ({
setActiveTab(initialTab);
setRestartError(null);
setRestarting(false);
- setShowLegacyLogsFallback(false);
}, [initialTab, member, open]);
const {
@@ -279,7 +273,6 @@ export const MemberDetailDialog = ({
} = useMemberStats(teamName, member?.name ?? null);
const totalTokens = memberStats ? memberStats.inputTokens + memberStats.outputTokens : null;
- const memberLogStreamEnabled = isMemberLogStreamUiEnabled();
if (!member) return null;
@@ -395,26 +388,11 @@ export const MemberDetailDialog = ({
/>
- {memberLogStreamEnabled ? (
-
-
- {showLegacyLogsFallback ? (
-
-
- Legacy Logs Fallback
-
-
-
- ) : null}
-
- ) : (
-
- )}
+
diff --git a/src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx b/src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx
new file mode 100644
index 00000000..6462fb95
--- /dev/null
+++ b/src/renderer/components/team/members/MemberLogStreamWithLegacyFallback.tsx
@@ -0,0 +1,52 @@
+import { useEffect, useState } from 'react';
+
+import {
+ isMemberLogStreamUiEnabled,
+ MemberLogStreamSection,
+} from '@features/member-log-stream/renderer';
+
+import { MemberLogsTab } from './MemberLogsTab';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+interface MemberLogStreamWithLegacyFallbackProps {
+ teamName: string;
+ member: ResolvedTeamMember;
+ enabled?: boolean;
+}
+
+export const MemberLogStreamWithLegacyFallback = ({
+ teamName,
+ member,
+ enabled = true,
+}: Readonly): React.JSX.Element => {
+ const streamUiEnabled = isMemberLogStreamUiEnabled();
+ const [showLegacyLogsFallback, setShowLegacyLogsFallback] = useState(false);
+
+ useEffect(() => {
+ setShowLegacyLogsFallback(false);
+ }, [member.name, streamUiEnabled, teamName]);
+
+ if (!streamUiEnabled) {
+ return ;
+ }
+
+ return (
+
+
+ {showLegacyLogsFallback ? (
+
+
+ Legacy Logs Fallback
+
+
+
+ ) : null}
+
+ );
+};
diff --git a/src/renderer/components/team/sidebar/teamSidebarUiState.ts b/src/renderer/components/team/sidebar/teamSidebarUiState.ts
index 91b6107a..af6f34a0 100644
--- a/src/renderer/components/team/sidebar/teamSidebarUiState.ts
+++ b/src/renderer/components/team/sidebar/teamSidebarUiState.ts
@@ -1,6 +1,5 @@
-import { DEFAULT_CLAUDE_LOGS_FILTER } from '../ClaudeLogsFilterPopover';
+import { type ClaudeLogsFilterState, DEFAULT_CLAUDE_LOGS_FILTER } from '../claudeLogsFilterState';
-import type { ClaudeLogsFilterState } from '../ClaudeLogsFilterPopover';
import type { ClaudeLogsViewerState } from '../CliLogsRichView';
import type { MessagesFilterState } from '../messages/MessagesFilterPopover';
diff --git a/src/renderer/components/team/teamLogSources.ts b/src/renderer/components/team/teamLogSources.ts
new file mode 100644
index 00000000..b5d04efc
--- /dev/null
+++ b/src/renderer/components/team/teamLogSources.ts
@@ -0,0 +1,74 @@
+import { formatAgentRole } from '@renderer/utils/formatAgentRole';
+import { isLeadMember } from '@shared/utils/leadDetection';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+export const LEAD_LOG_SOURCE_KEY = 'lead';
+
+const FALLBACK_LEAD_LOG_MEMBER: ResolvedTeamMember = {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+};
+
+export type TeamLogSourceKey = typeof LEAD_LOG_SOURCE_KEY | `member:${string}`;
+
+export function memberLogSourceKey(memberName: string): TeamLogSourceKey {
+ return `member:${memberName}`;
+}
+
+export function getMemberNameFromLogSourceKey(sourceKey: TeamLogSourceKey): string | null {
+ if (sourceKey === LEAD_LOG_SOURCE_KEY) return null;
+ return sourceKey.slice('member:'.length);
+}
+
+export function formatMemberLogSourceLabel(member: ResolvedTeamMember): string {
+ return member.removedAt ? `${member.name} (removed)` : member.name;
+}
+
+export function formatMemberLogSourceDescription(member: ResolvedTeamMember): string | null {
+ if (isLeadMember(member)) return 'Team Lead';
+ if (member.removedAt) return 'Removed';
+ return formatAgentRole(member.role) ?? formatAgentRole(member.agentType) ?? null;
+}
+
+export function normalizeMemberLogSourceName(memberName: string): string {
+ return memberName.trim().toLowerCase();
+}
+
+export function buildSelectableLogMembers(
+ members: readonly ResolvedTeamMember[]
+): ResolvedTeamMember[] {
+ const sourceByName = new Map<
+ string,
+ {
+ member: ResolvedTeamMember;
+ index: number;
+ }
+ >();
+
+ members.forEach((member, index) => {
+ const sourceName = normalizeMemberLogSourceName(member.name);
+ if (!sourceName || sourceName === 'user' || isLeadMember(member)) return;
+
+ const existing = sourceByName.get(sourceName);
+ if (!existing || (existing.member.removedAt && !member.removedAt)) {
+ sourceByName.set(sourceName, { member, index: existing?.index ?? index });
+ }
+ });
+
+ return [...sourceByName.values()]
+ .sort((left, right) => left.index - right.index)
+ .map((entry) => entry.member);
+}
+
+export function resolveLeadLogMember(members: readonly ResolvedTeamMember[]): ResolvedTeamMember {
+ const leadMembers = members.filter((member) => isLeadMember(member));
+ return (
+ leadMembers.find((member) => !member.removedAt) ?? leadMembers[0] ?? FALLBACK_LEAD_LOG_MEMBER
+ );
+}
diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts
index 1f75aa5e..6237c700 100644
--- a/src/renderer/components/team/useClaudeLogsController.ts
+++ b/src/renderer/components/team/useClaudeLogsController.ts
@@ -17,9 +17,8 @@ import {
getTeamClaudeLogsSidebarUiState,
setTeamClaudeLogsSidebarUiState,
} from './sidebar/teamSidebarUiState';
-import { DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
+import { type ClaudeLogsFilterState, DEFAULT_CLAUDE_LOGS_FILTER } from './claudeLogsFilterState';
-import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover';
import type { ClaudeLogsViewerState } from './CliLogsRichView';
import type { TeamClaudeLogsResponse } from '@shared/types';
@@ -367,7 +366,11 @@ function filterStreamJsonText(
// Hook
// =============================================================================
-export function useClaudeLogsController(teamName: string): ClaudeLogsController {
+export function useClaudeLogsController(
+ teamName: string,
+ options: { enabled?: boolean } = {}
+): ClaudeLogsController {
+ const enabled = options.enabled ?? true;
const isAlive = useStore((s) =>
s.selectedTeamName === teamName ? (s.selectedTeamData?.isAlive ?? false) : false
);
@@ -407,6 +410,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
const logContainerRef = useRef(null);
const committedRef = useRef({ lines: [], total: 0, hasMore: false });
const pendingCountRef = useRef(0);
+ const pendingPollingFetchRef = useRef<(() => void) | null>(null);
// ── Reset on team change ──────────────────────────────────────────────
useEffect(() => {
@@ -447,6 +451,13 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
useEffect(() => {
let cancelled = false;
+ if (!enabled) {
+ setLoading(false);
+ return () => {
+ cancelled = true;
+ };
+ }
+
const computeNewCount = (
committed: TeamClaudeLogsResponse,
latest: TeamClaudeLogsResponse
@@ -460,8 +471,17 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
return Math.max(0, diff);
};
- const fetchLogs = async (): Promise => {
- if (inFlightRef.current) return;
+ const fetchLogs = async (options: { queueIfBusy?: boolean } = {}): Promise => {
+ if (inFlightRef.current) {
+ if (options.queueIfBusy) {
+ pendingPollingFetchRef.current = () => {
+ if (!cancelled) {
+ void fetchLogs();
+ }
+ };
+ }
+ return;
+ }
inFlightRef.current = true;
try {
setLoading(true);
@@ -483,20 +503,27 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
setError(e instanceof Error ? e.message : String(e));
} finally {
inFlightRef.current = false;
- if (!cancelled) setLoading(false);
+ const pendingFetch = pendingPollingFetchRef.current;
+ pendingPollingFetchRef.current = null;
+ if (pendingFetch) {
+ pendingFetch();
+ } else if (!cancelled) {
+ setLoading(false);
+ }
}
};
- void fetchLogs();
+ void fetchLogs({ queueIfBusy: true });
const id = window.setInterval(() => void fetchLogs(), POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
- }, [teamName, loadedCount]);
+ }, [enabled, teamName, loadedCount]);
// ── Load older logs ───────────────────────────────────────────────────
const loadOlderLogs = useCallback(async (): Promise => {
+ if (!enabled) return;
if (loadingMoreRef.current || inFlightRef.current) return;
const current = committedRef.current;
@@ -526,7 +553,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
loadingMoreRef.current = false;
setLoadingMore(false);
}
- }, [teamName]);
+ }, [enabled, teamName]);
// ── Auto-load when content fits in container ──────────────────────────
const isNearBottom = useCallback(
@@ -550,6 +577,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
// ── Apply pending logs ────────────────────────────────────────────────
const applyPending = useCallback(async (): Promise => {
+ if (!enabled) return;
if (applyingPendingRef.current) return;
applyingPendingRef.current = true;
@@ -575,7 +603,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
} finally {
applyingPendingRef.current = false;
}
- }, [loadedCount, pending, teamName]);
+ }, [enabled, loadedCount, pending, teamName]);
// ── Computed values ───────────────────────────────────────────────────
const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]);
diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx
index 0f80cccf..71d4555e 100644
--- a/src/renderer/components/ui/MemberSelect.tsx
+++ b/src/renderer/components/ui/MemberSelect.tsx
@@ -27,6 +27,11 @@ interface MemberSelectProps {
size?: 'sm' | 'md';
disabled?: boolean;
className?: string;
+ searchPlaceholder?: string;
+ emptyMessage?: string;
+ getMemberLabel?: (member: ResolvedTeamMember) => string;
+ getMemberDescription?: (member: ResolvedTeamMember) => string | null | undefined;
+ ariaLabel?: string;
}
const UNASSIGNED_VALUE = '__unassigned__';
@@ -40,6 +45,11 @@ export const MemberSelect = ({
size = 'sm',
disabled = false,
className,
+ searchPlaceholder = 'Search members...',
+ emptyMessage = 'No members found.',
+ getMemberLabel,
+ getMemberDescription,
+ ariaLabel,
}: MemberSelectProps): React.JSX.Element => {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
@@ -57,13 +67,26 @@ export const MemberSelect = ({
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
+ const resolveMemberLabel = React.useCallback(
+ (member: ResolvedTeamMember): string =>
+ getMemberLabel?.(member) ?? (member.name === 'team-lead' ? 'lead' : member.name),
+ [getMemberLabel]
+ );
+ const resolveMemberDescription = React.useCallback(
+ (member: ResolvedTeamMember): string | null | undefined =>
+ getMemberDescription?.(member) ??
+ formatAgentRole(member.role) ??
+ formatAgentRole(member.agentType),
+ [getMemberDescription]
+ );
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
const resolvedColor = colorMap.get(member.name);
const colors = getTeamColorSet(resolvedColor ?? '');
+ const label = resolveMemberLabel(member);
return (
-
+
- {member.name === 'team-lead' ? 'lead' : member.name}
+ {label}
);
@@ -92,6 +115,7 @@ export const MemberSelect = ({
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
+ aria-label={ariaLabel}
disabled={disabled}
className={cn(
`flex ${triggerHeight} w-full items-center justify-between rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1 text-xs shadow-sm transition-colors placeholder:text-[var(--color-text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] disabled:cursor-not-allowed disabled:opacity-50`,
@@ -125,7 +149,7 @@ export const MemberSelect = ({
@@ -135,7 +159,7 @@ export const MemberSelect = ({
onWheel={(e) => e.stopPropagation()}
>
- No members found.
+ {emptyMessage}
{allowUnassigned && !search.trim() ? (
{
if (!search.trim()) return true;
const q = search.toLowerCase();
+ const label = resolveMemberLabel(m);
+ const description = resolveMemberDescription(m);
return (
m.name.toLowerCase().includes(q) ||
+ label.toLowerCase().includes(q) ||
+ (description?.toLowerCase().includes(q) ?? false) ||
(m.role?.toLowerCase().includes(q) ?? false) ||
(m.agentType?.toLowerCase().includes(q) ?? false)
);
@@ -167,7 +195,8 @@ export const MemberSelect = ({
const isSelected = m.name === value;
const resolvedColor = colorMap.get(m.name);
const colors = getTeamColorSet(resolvedColor ?? '');
- const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
+ const label = resolveMemberLabel(m);
+ const role = resolveMemberDescription(m);
return (
- {m.name === 'team-lead' ? 'lead' : m.name}
+ {label}
{role ? (
diff --git a/test/renderer/components/layout/TeamTabSectionNav.test.tsx b/test/renderer/components/layout/TeamTabSectionNav.test.tsx
new file mode 100644
index 00000000..b9a92779
--- /dev/null
+++ b/test/renderer/components/layout/TeamTabSectionNav.test.tsx
@@ -0,0 +1,46 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { TeamTabSectionNav } from '@renderer/components/layout/TeamTabSectionNav';
+import { useStore } from '@renderer/store';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+describe('TeamTabSectionNav', () => {
+ let host: HTMLDivElement;
+ let root: ReturnType;
+
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ useStore.setState({ messagesPanelMode: 'inline' } as never);
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ root = createRoot(host);
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it('labels the logs section as Logs in the section jump menu', async () => {
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+
+ const trigger = host.querySelector('button[title="Jump to section"]') as HTMLButtonElement;
+ await act(async () => {
+ trigger.click();
+ await Promise.resolve();
+ });
+
+ const menu = document.body.querySelector('[role="menu"]') as HTMLElement | null;
+ expect(menu?.textContent).toContain('Logs');
+ expect(menu?.textContent).not.toContain('Claude Logs');
+ });
+});
diff --git a/test/renderer/components/team/ClaudeLogsPanel.test.ts b/test/renderer/components/team/ClaudeLogsPanel.test.ts
index 7e32a9fb..5def40e0 100644
--- a/test/renderer/components/team/ClaudeLogsPanel.test.ts
+++ b/test/renderer/components/team/ClaudeLogsPanel.test.ts
@@ -1,5 +1,6 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
+
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
@@ -106,6 +107,49 @@ describe('ClaudeLogsPanel', () => {
});
});
+ it('renders leading toolbar controls before the search field', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const ctrl = createController({
+ isAlive: true,
+ data: {
+ lines: ['lead output'],
+ total: 1,
+ hasMore: false,
+ },
+ filteredText: 'lead output',
+ });
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsPanel, {
+ ctrl,
+ toolbarControlsStart: React.createElement(
+ 'div',
+ { 'data-testid': 'toolbar-source' },
+ 'Lead'
+ ),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const source = host.querySelector('[data-testid="toolbar-source"]');
+ const search = host.querySelector('input[placeholder="Search logs..."]');
+ expect(source).not.toBeNull();
+ expect(search).not.toBeNull();
+ expect(source?.compareDocumentPosition(search as Node)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING
+ );
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
it('shows the offline empty state only when no logs exist', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
diff --git a/test/renderer/components/team/ClaudeLogsSection.test.ts b/test/renderer/components/team/ClaudeLogsSection.test.ts
new file mode 100644
index 00000000..4d708500
--- /dev/null
+++ b/test/renderer/components/team/ClaudeLogsSection.test.ts
@@ -0,0 +1,1036 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
+import type { ResolvedTeamMember } from '@shared/types';
+
+const sectionState = vi.hoisted(() => ({
+ members: [] as ResolvedTeamMember[],
+ controllerCalls: [] as { teamName: string; enabled: boolean | undefined }[],
+ memberLogStreamCalls: [] as {
+ teamName: string;
+ memberName: string;
+ enabled: boolean | undefined;
+ }[],
+ memberLogStreamUiEnabled: true,
+}));
+
+function createController(): ClaudeLogsController {
+ return {
+ data: {
+ lines: ['{"type":"assistant","content":[{"type":"text","text":"lead output"}]}'],
+ total: 1,
+ hasMore: false,
+ },
+ loading: false,
+ loadingMore: false,
+ error: null,
+ pendingNewCount: 0,
+ isAlive: true,
+ filteredText: '{"type":"assistant","content":[{"type":"text","text":"lead output"}]}',
+ online: true,
+ badge: '1 raw',
+ showMoreVisible: false,
+ lastLogPreview: { type: 'output', label: 'Output', summary: 'lead output' },
+ searchQuery: '',
+ setSearchQuery: vi.fn(),
+ filter: { streams: new Set(), kinds: new Set() } as ClaudeLogsController['filter'],
+ setFilter: vi.fn(),
+ filterOpen: false,
+ setFilterOpen: vi.fn(),
+ viewerState: {} as ClaudeLogsController['viewerState'],
+ onViewerStateChange: vi.fn(),
+ applyPending: vi.fn(() => Promise.resolve()),
+ loadOlderLogs: vi.fn(() => Promise.resolve()),
+ containerRefCallback: vi.fn(),
+ handleScroll: vi.fn(),
+ };
+}
+
+vi.mock('@renderer/store', () => ({
+ useStore: (selector: (state: unknown) => unknown) => selector({}),
+}));
+
+vi.mock('@renderer/store/slices/teamSlice', () => ({
+ selectResolvedMembersForTeamName: () => sectionState.members,
+}));
+
+vi.mock('@renderer/components/team/useClaudeLogsController', () => ({
+ useClaudeLogsController: (teamName: string, options?: { enabled?: boolean }) => {
+ sectionState.controllerCalls.push({ teamName, enabled: options?.enabled });
+ return createController();
+ },
+}));
+
+vi.mock('@renderer/components/team/ClaudeLogsPanel', () => ({
+ ClaudeLogsPanel: ({ toolbarControlsStart }: { toolbarControlsStart?: React.ReactNode }) =>
+ React.createElement(
+ 'div',
+ { 'data-testid': 'lead-logs-panel' },
+ toolbarControlsStart,
+ 'lead-panel'
+ ),
+}));
+
+vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({
+ CollapsibleTeamSection: ({
+ children,
+ afterBadge,
+ badge,
+ headerExtra,
+ }: {
+ children: React.ReactNode;
+ afterBadge?: React.ReactNode;
+ badge?: string;
+ headerExtra?: React.ReactNode;
+ }) =>
+ React.createElement(
+ 'section',
+ null,
+ React.createElement('div', { 'data-testid': 'logs-header' }, badge, afterBadge, headerExtra),
+ children
+ ),
+}));
+
+vi.mock('@renderer/components/ui/MemberSelect', () => ({
+ MemberSelect: ({
+ members,
+ value,
+ onChange,
+ getMemberLabel,
+ searchPlaceholder,
+ emptyMessage,
+ ariaLabel,
+ }: {
+ members: ResolvedTeamMember[];
+ value: string | null;
+ onChange: (value: string | null) => void;
+ getMemberLabel?: (member: ResolvedTeamMember) => string;
+ searchPlaceholder?: string;
+ emptyMessage?: string;
+ ariaLabel?: string;
+ }) =>
+ React.createElement(
+ 'div',
+ {
+ 'data-testid': 'member-select',
+ 'data-search-placeholder': searchPlaceholder,
+ 'data-empty-message': emptyMessage,
+ },
+ React.createElement(
+ 'select',
+ {
+ 'aria-label': 'Log source',
+ 'data-trigger-aria-label': ariaLabel,
+ value: value ?? '',
+ onChange: (event: React.ChangeEvent) =>
+ onChange(event.currentTarget.value || null),
+ },
+ members.map((member) =>
+ React.createElement(
+ 'option',
+ { key: member.name, value: member.name },
+ getMemberLabel?.(member) ?? member.name
+ )
+ )
+ )
+ ),
+}));
+
+vi.mock('@renderer/components/team/members/MemberLogsTab', () => ({
+ MemberLogsTab: ({ memberName }: { memberName: string }) =>
+ React.createElement('div', { 'data-testid': 'legacy-member-logs' }, memberName),
+}));
+
+vi.mock('@renderer/components/ui/dialog', () => ({
+ Dialog: ({
+ children,
+ open,
+ }: {
+ children: React.ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }) => (open ? React.createElement('div', { 'data-testid': 'logs-dialog' }, children) : null),
+ DialogContent: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+ DialogHeader: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+ DialogTitle: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('h2', null, children),
+}));
+
+vi.mock('@features/member-log-stream/renderer', () => ({
+ isMemberLogStreamUiEnabled: () => sectionState.memberLogStreamUiEnabled,
+ MemberLogStreamSection: ({
+ teamName,
+ member,
+ enabled,
+ onInitialLoadErrorChange,
+ }: {
+ teamName: string;
+ member: ResolvedTeamMember;
+ enabled?: boolean;
+ onInitialLoadErrorChange?: (hasError: boolean) => void;
+ }) => {
+ sectionState.memberLogStreamCalls.push({ teamName, memberName: member.name, enabled });
+ return React.createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'member-log-stream',
+ 'data-removed': member.removedAt ? 'true' : 'false',
+ onClick: () => onInitialLoadErrorChange?.(true),
+ },
+ member.name
+ );
+ },
+}));
+
+vi.mock('@renderer/components/ui/button', () => ({
+ Button: ({
+ children,
+ onClick,
+ 'aria-label': ariaLabel,
+ }: {
+ children: React.ReactNode;
+ onClick?: (event: React.MouseEvent) => void;
+ 'aria-label'?: string;
+ }) =>
+ React.createElement('button', { type: 'button', onClick, 'aria-label': ariaLabel }, children),
+}));
+
+vi.mock('@renderer/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+ TooltipContent: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+}));
+
+import { ClaudeLogsSection } from '@renderer/components/team/ClaudeLogsSection';
+
+describe('ClaudeLogsSection source filtering', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Builder',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+ sectionState.controllerCalls = [];
+ sectionState.memberLogStreamCalls = [];
+ sectionState.memberLogStreamUiEnabled = true;
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('shows lead logs by default and exposes teammate sources', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsSection, {
+ teamName: 'demo-team',
+ sidebarViewerMaxHeight: 240,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ expect(select).not.toBeNull();
+ expect(host.textContent).not.toContain('Logs for');
+ expect(Array.from(select.options).map((option) => option.textContent)).toEqual([
+ 'Lead',
+ 'Builder',
+ ]);
+ expect(select.value).toBe('team-lead');
+ const memberSelect = host.querySelector('[data-testid="member-select"]');
+ expect(memberSelect).not.toBeNull();
+ expect(memberSelect?.getAttribute('data-search-placeholder')).toBe('Search log sources...');
+ expect(select.getAttribute('data-trigger-aria-label')).toBe('Log source');
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
+ expect(sectionState.memberLogStreamCalls).toEqual([]);
+ expect(sectionState.controllerCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ enabled: true,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('keeps the lead-only team UI simple without an unnecessary source selector', async () => {
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('select[aria-label="Log source"]')).toBeNull();
+ expect(host.textContent).not.toContain('Logs for');
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
+ expect(sectionState.controllerCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ enabled: true,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('reuses the member log stream section when a teammate is selected', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsSection, {
+ teamName: 'demo-team',
+ sidebarViewerMaxHeight: 240,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull();
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
+ expect(sectionState.memberLogStreamCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ memberName: 'Builder',
+ enabled: true,
+ });
+ expect(sectionState.controllerCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ enabled: false,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('switches back to lead logs from a selected teammate in the compact section', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
+
+ await act(async () => {
+ select.value = 'team-lead';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
+ expect(host.querySelector('[data-testid="member-log-stream"]')).toBeNull();
+ expect(sectionState.controllerCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ enabled: true,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('switches directly between multiple teammate log sources', async () => {
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Builder',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Reviewer',
+ role: 'reviewer',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ expect(Array.from(select.options).map((option) => option.textContent)).toEqual([
+ 'Lead',
+ 'Builder',
+ 'Reviewer',
+ ]);
+
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
+
+ await act(async () => {
+ select.value = 'Reviewer';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Reviewer');
+ expect(sectionState.memberLogStreamCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ memberName: 'Reviewer',
+ enabled: true,
+ });
+ expect(sectionState.controllerCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ enabled: false,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('shows the same legacy fallback as the member popup after a stream error', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const streamButton = host.querySelector('[data-testid="member-log-stream"]') as HTMLButtonElement;
+ await act(async () => {
+ streamButton.click();
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('Builder');
+ expect(host.textContent).toContain('Legacy Logs Fallback');
+ expect(host.querySelector('[data-testid="legacy-member-logs"]')?.textContent).toBe('Builder');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('keeps removed teammates available for historical logs and labels them', async () => {
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Builder',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ removedAt: 1715000000000,
+ },
+ ];
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ expect(Array.from(select.options).map((option) => option.textContent)).toEqual([
+ 'Lead',
+ 'Builder (removed)',
+ ]);
+
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
+ expect(host.textContent).toContain('Builder (removed)');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('deduplicates active and removed teammate sources by name and prefers the active member', async () => {
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Builder',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ removedAt: 1715000000000,
+ },
+ {
+ name: 'Builder',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ expect(Array.from(select.options).map((option) => option.textContent)).toEqual([
+ 'Lead',
+ 'Builder',
+ ]);
+
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const stream = host.querySelector('[data-testid="member-log-stream"]') as HTMLElement;
+ expect(stream.textContent).toBe('Builder');
+ expect(stream.getAttribute('data-removed')).toBe('false');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('keeps the selected source stable when a teammate name only changes casing', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ let select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'builder',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsSection, {
+ teamName: 'demo-team',
+ sidebarViewerMaxHeight: 240,
+ })
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ expect(select.value).toBe('builder');
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('builder');
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('resets to lead logs when the team changes even if teammate names overlap', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'team-a' }));
+ await Promise.resolve();
+ });
+
+ let select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
+
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Builder',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'team-b' }));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ expect(select.value).toBe('team-lead');
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
+ expect(sectionState.memberLogStreamCalls).not.toContainEqual({
+ teamName: 'team-b',
+ memberName: 'Builder',
+ enabled: true,
+ });
+ expect(sectionState.controllerCalls.at(-1)).toEqual({
+ teamName: 'team-b',
+ enabled: true,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('falls back to legacy member logs when the stream UI gate is disabled', async () => {
+ sectionState.memberLogStreamUiEnabled = false;
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsSection, {
+ teamName: 'demo-team',
+ sidebarViewerMaxHeight: 240,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="member-log-stream"]')).toBeNull();
+ expect(host.querySelector('[data-testid="legacy-member-logs"]')?.textContent).toBe('Builder');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('opens selected teammate logs in fullscreen without switching back to lead', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const fullscreenButton = host.querySelector(
+ 'button[aria-label="Open fullscreen logs"]'
+ ) as HTMLButtonElement;
+ await act(async () => {
+ fullscreenButton.click();
+ await Promise.resolve();
+ });
+
+ const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement;
+ expect(dialog.textContent).toContain('Logs');
+ expect(dialog.textContent).not.toContain('Logs for');
+ expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe(
+ 'Builder'
+ );
+ expect(dialog.textContent).toContain('Builder');
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('can switch log sources from the fullscreen dialog', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const fullscreenButton = host.querySelector(
+ 'button[aria-label="Open fullscreen logs"]'
+ ) as HTMLButtonElement;
+ await act(async () => {
+ fullscreenButton.click();
+ await Promise.resolve();
+ });
+
+ const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement;
+ expect(dialog.textContent).toContain('Logs');
+ expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe(
+ 'team-lead'
+ );
+ expect(
+ dialog.querySelector('[data-testid="lead-logs-panel"] [data-testid="member-select"]')
+ ).not.toBeNull();
+
+ const dialogSelect = dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ dialogSelect.value = 'Builder';
+ dialogSelect.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="member-log-stream"]')?.textContent).toBe('Builder');
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).toBeNull();
+ expect(sectionState.controllerCalls.at(-1)).toEqual({
+ teamName: 'demo-team',
+ enabled: false,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('switches back to lead logs from teammate logs in the fullscreen dialog', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const fullscreenButton = host.querySelector(
+ 'button[aria-label="Open fullscreen logs"]'
+ ) as HTMLButtonElement;
+ await act(async () => {
+ fullscreenButton.click();
+ await Promise.resolve();
+ });
+
+ const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement;
+ const dialogSelect = dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ expect(dialogSelect.value).toBe('Builder');
+
+ await act(async () => {
+ dialogSelect.value = 'team-lead';
+ dialogSelect.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(dialog.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
+ expect(dialog.querySelector('[data-testid="member-log-stream"]')).toBeNull();
+ expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe(
+ 'team-lead'
+ );
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('returns to lead logs when the selected teammate disappears from the roster', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Reviewer',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsSection, {
+ teamName: 'demo-team',
+ sidebarViewerMaxHeight: 240,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ expect(host.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
+ expect((host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe(
+ 'team-lead'
+ );
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('keeps fullscreen open and falls back to lead logs when the selected teammate disappears', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ClaudeLogsSection, { teamName: 'demo-team' }));
+ await Promise.resolve();
+ });
+
+ const select = host.querySelector('select[aria-label="Log source"]') as HTMLSelectElement;
+ await act(async () => {
+ select.value = 'Builder';
+ select.dispatchEvent(new Event('change', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const fullscreenButton = host.querySelector(
+ 'button[aria-label="Open fullscreen logs"]'
+ ) as HTMLButtonElement;
+ await act(async () => {
+ fullscreenButton.click();
+ await Promise.resolve();
+ });
+
+ sectionState.members = [
+ {
+ name: 'team-lead',
+ agentType: 'team-lead',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ {
+ name: 'Reviewer',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ },
+ ];
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsSection, {
+ teamName: 'demo-team',
+ sidebarViewerMaxHeight: 240,
+ })
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const dialog = host.querySelector('[data-testid="logs-dialog"]') as HTMLElement;
+ expect(dialog).not.toBeNull();
+ expect((dialog.querySelector('select[aria-label="Log source"]') as HTMLSelectElement).value).toBe(
+ 'team-lead'
+ );
+ expect(dialog.querySelector('[data-testid="lead-logs-panel"]')).not.toBeNull();
+ expect(dialog.querySelector('[data-testid="member-log-stream"]')).toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+});
diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts
index 2b8dc1ca..2cbeddaa 100644
--- a/test/renderer/components/team/members/MemberDetailDialog.test.ts
+++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts
@@ -1,8 +1,8 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '@renderer/store';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
diff --git a/test/renderer/components/team/teamLogSources.test.ts b/test/renderer/components/team/teamLogSources.test.ts
new file mode 100644
index 00000000..eb57f4ae
--- /dev/null
+++ b/test/renderer/components/team/teamLogSources.test.ts
@@ -0,0 +1,81 @@
+import {
+ buildSelectableLogMembers,
+ formatMemberLogSourceDescription,
+ formatMemberLogSourceLabel,
+ getMemberNameFromLogSourceKey,
+ memberLogSourceKey,
+ resolveLeadLogMember,
+} from '@renderer/components/team/teamLogSources';
+import { describe, expect, it } from 'vitest';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+function member(
+ name: string,
+ overrides: Partial = {}
+): ResolvedTeamMember {
+ return {
+ name,
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ ...overrides,
+ };
+}
+
+describe('team log source helpers', () => {
+ it('builds teammate sources without lead, user, blank names, or duplicate removed entries', () => {
+ const sources = buildSelectableLogMembers([
+ member('team-lead', { agentType: 'team-lead' }),
+ member('user'),
+ member(' '),
+ member('Builder', { removedAt: 1715000000000 }),
+ member('Reviewer'),
+ member('builder'),
+ ]);
+
+ expect(sources.map((source) => source.name)).toEqual(['builder', 'Reviewer']);
+ expect(sources[0]?.removedAt).toBeUndefined();
+ });
+
+ it('keeps first active duplicate source and preserves original ordering slot', () => {
+ const sources = buildSelectableLogMembers([
+ member('Zed'),
+ member('alpha', { removedAt: 1715000000000 }),
+ member('Beta'),
+ member('ALPHA'),
+ member('alpha-late'),
+ ]);
+
+ expect(sources.map((source) => source.name)).toEqual(['Zed', 'ALPHA', 'Beta', 'alpha-late']);
+ });
+
+ it('resolves active lead before removed lead and falls back safely when roster has no lead', () => {
+ expect(
+ resolveLeadLogMember([
+ member('team-lead', { agentType: 'team-lead', removedAt: 1715000000000 }),
+ member('captain', { agentType: 'orchestrator' }),
+ ]).name
+ ).toBe('captain');
+
+ const fallback = resolveLeadLogMember([member('Builder')]);
+ expect(fallback.name).toBe('team-lead');
+ expect(fallback.agentType).toBe('team-lead');
+ });
+
+ it('formats source labels, descriptions, and stable member source keys', () => {
+ const removed = member('Builder', { removedAt: 1715000000000 });
+ const developer = member('Reviewer', { role: 'reviewer' });
+ const lead = member('lead-alias', { agentType: 'lead' });
+
+ expect(formatMemberLogSourceLabel(removed)).toBe('Builder (removed)');
+ expect(formatMemberLogSourceDescription(removed)).toBe('Removed');
+ expect(formatMemberLogSourceDescription(developer)).toBe('Reviewer');
+ expect(formatMemberLogSourceDescription(lead)).toBe('Team Lead');
+ expect(getMemberNameFromLogSourceKey(memberLogSourceKey('name:with:colon'))).toBe(
+ 'name:with:colon'
+ );
+ });
+});
diff --git a/test/renderer/components/team/useClaudeLogsController.test.tsx b/test/renderer/components/team/useClaudeLogsController.test.tsx
new file mode 100644
index 00000000..e0c34a99
--- /dev/null
+++ b/test/renderer/components/team/useClaudeLogsController.test.tsx
@@ -0,0 +1,276 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ClaudeLogsFilterState } from '@renderer/components/team/claudeLogsFilterState';
+import type { ClaudeLogsViewerState } from '@renderer/components/team/CliLogsRichView';
+import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
+import type { TeamClaudeLogsResponse } from '@shared/types';
+
+const controllerState = vi.hoisted(() => ({
+ getClaudeLogs: vi.fn<() => Promise>(),
+ setSidebarState: vi.fn(),
+}));
+
+function createLogsResponse(text = 'lead'): TeamClaudeLogsResponse {
+ return {
+ lines: [`{"type":"assistant","content":[{"type":"text","text":"${text}"}]}`],
+ total: 1,
+ hasMore: false,
+ };
+}
+
+function createDeferred(): {
+ promise: Promise;
+ resolve: (value: T) => void;
+ reject: (error: unknown) => void;
+} {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve;
+ reject = promiseReject;
+ });
+ return { promise, resolve, reject };
+}
+
+vi.mock('@renderer/api', () => ({
+ api: {
+ teams: {
+ getClaudeLogs: controllerState.getClaudeLogs,
+ },
+ },
+}));
+
+vi.mock('@renderer/store', () => ({
+ useStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ selectedTeamName: 'demo-team',
+ selectedTeamData: { isAlive: true },
+ }),
+}));
+
+vi.mock('@renderer/components/team/sidebar/teamSidebarUiState', () => ({
+ getTeamClaudeLogsSidebarUiState: () => ({
+ searchQuery: '',
+ filter: {
+ streams: new Set(['stdout', 'stderr']),
+ kinds: new Set(['output', 'thinking', 'tool']),
+ } satisfies ClaudeLogsFilterState,
+ filterOpen: false,
+ viewerState: {} as ClaudeLogsViewerState,
+ }),
+ setTeamClaudeLogsSidebarUiState: controllerState.setSidebarState,
+}));
+
+import { useClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
+
+function ControllerHarness({ enabled }: Readonly<{ enabled: boolean }>): React.JSX.Element {
+ useClaudeLogsController('demo-team', { enabled });
+ return React.createElement('div');
+}
+
+function ControllerCaptureHarness({
+ enabled,
+ onController,
+}: Readonly<{
+ enabled: boolean;
+ onController: (controller: ClaudeLogsController) => void;
+}>): React.JSX.Element {
+ const controller = useClaudeLogsController('demo-team', { enabled });
+ onController(controller);
+ return React.createElement('div');
+}
+
+describe('useClaudeLogsController enabled option', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ controllerState.getClaudeLogs.mockResolvedValue(createLogsResponse());
+ controllerState.setSidebarState.mockClear();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('does not fetch lead logs while disabled and loads them when re-enabled', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: false }));
+ await Promise.resolve();
+ });
+
+ expect(controllerState.getClaudeLogs).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: true }));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledWith('demo-team', {
+ offset: 0,
+ limit: 100,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('queues a fresh lead fetch when re-enabled before the previous request settles', async () => {
+ const firstRequest = createDeferred();
+ controllerState.getClaudeLogs
+ .mockReturnValueOnce(firstRequest.promise)
+ .mockResolvedValue(createLogsResponse('fresh lead'));
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: true }));
+ await Promise.resolve();
+ });
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: false }));
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: true }));
+ await Promise.resolve();
+ });
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ firstRequest.resolve(createLogsResponse('stale lead'));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(2);
+ expect(controllerState.getClaudeLogs).toHaveBeenLastCalledWith('demo-team', {
+ offset: 0,
+ limit: 100,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('does not run a queued lead fetch after being disabled again', async () => {
+ const firstRequest = createDeferred();
+ controllerState.getClaudeLogs
+ .mockReturnValueOnce(firstRequest.promise)
+ .mockResolvedValue(createLogsResponse('unexpected lead'));
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: true }));
+ await Promise.resolve();
+ });
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: false }));
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: true }));
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: false }));
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ firstRequest.resolve(createLogsResponse('stale lead'));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('does not load more or apply pending lead logs while disabled', async () => {
+ let latestController: ClaudeLogsController | null = null;
+ const getLatestController = (): ClaudeLogsController => {
+ if (!latestController) {
+ throw new Error('Controller was not captured');
+ }
+ return latestController;
+ };
+ controllerState.getClaudeLogs.mockResolvedValue({
+ ...createLogsResponse('lead with more'),
+ hasMore: true,
+ total: 150,
+ });
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ControllerCaptureHarness, {
+ enabled: true,
+ onController: (controller) => {
+ latestController = controller;
+ },
+ })
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+ expect(getLatestController().data.hasMore).toBe(true);
+
+ await act(async () => {
+ root.render(
+ React.createElement(ControllerCaptureHarness, {
+ enabled: false,
+ onController: (controller) => {
+ latestController = controller;
+ },
+ })
+ );
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ const disabledController = getLatestController();
+ await disabledController.loadOlderLogs();
+ await disabledController.applyPending();
+ await Promise.resolve();
+ });
+
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+});
diff --git a/test/renderer/components/ui/MemberSelect.test.tsx b/test/renderer/components/ui/MemberSelect.test.tsx
new file mode 100644
index 00000000..50bb4b63
--- /dev/null
+++ b/test/renderer/components/ui/MemberSelect.test.tsx
@@ -0,0 +1,174 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { MemberSelect } from '@renderer/components/ui/MemberSelect';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+vi.mock('@renderer/hooks/useTheme', () => ({
+ useTheme: () => ({
+ theme: 'dark',
+ resolvedTheme: 'dark',
+ isDark: true,
+ isLight: false,
+ }),
+}));
+
+function member(
+ name: string,
+ overrides: Partial = {}
+): ResolvedTeamMember {
+ return {
+ name,
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ ...overrides,
+ };
+}
+
+async function flush(): Promise {
+ await Promise.resolve();
+ await Promise.resolve();
+}
+
+describe('MemberSelect', () => {
+ let host: HTMLDivElement;
+ let root: ReturnType;
+ let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView;
+
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+ HTMLElement.prototype.scrollIntoView = vi.fn();
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ root = createRoot(host);
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ root.unmount();
+ await flush();
+ });
+ document.body.innerHTML = '';
+ HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it('preserves Create Task defaults for unassigned and lead display', async () => {
+ const onChange = vi.fn();
+
+ await act(async () => {
+ root.render(
+
+ );
+ await flush();
+ });
+
+ const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement;
+ expect(trigger.textContent).toContain('Unassigned');
+ expect(trigger.getAttribute('aria-label')).toBeNull();
+
+ await act(async () => {
+ trigger.click();
+ await flush();
+ });
+
+ const list = document.body.querySelector('[cmdk-list]') as HTMLElement | null;
+ expect(list?.textContent).toContain('Unassigned');
+ expect(list?.textContent).toContain('lead');
+ expect(list?.textContent).toContain('alice');
+ expect(document.body.querySelector('input')?.getAttribute('placeholder')).toBe(
+ 'Search members...'
+ );
+ });
+
+ it('supports custom log-source labels, descriptions, search text, and selection', async () => {
+ const onChange = vi.fn();
+
+ await act(async () => {
+ root.render(
+
+ candidate.name === 'team-lead'
+ ? 'Lead'
+ : candidate.removedAt
+ ? `${candidate.name} (removed)`
+ : candidate.name
+ }
+ getMemberDescription={(candidate) =>
+ candidate.name === 'team-lead'
+ ? 'Team Lead'
+ : candidate.removedAt
+ ? 'Removed'
+ : 'Reviewer'
+ }
+ />
+ );
+ await flush();
+ });
+
+ const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement;
+ expect(trigger.textContent).toContain('Lead');
+ expect(trigger.getAttribute('aria-label')).toBe('Log source');
+
+ await act(async () => {
+ trigger.click();
+ await flush();
+ });
+
+ const input = document.body.querySelector('input') as HTMLInputElement;
+ const list = document.body.querySelector('[cmdk-list]') as HTMLElement | null;
+ expect(input.getAttribute('placeholder')).toBe('Search log sources...');
+ expect(list?.textContent).toContain('Lead');
+ expect(list?.textContent).toContain('Team Lead');
+ expect(list?.textContent).toContain('Builder (removed)');
+ expect(list?.textContent).toContain('Removed');
+
+ await act(async () => {
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
+ valueSetter?.call(input, 'removed');
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ await flush();
+ });
+
+ expect(list?.textContent).toContain('Builder (removed)');
+ expect(list?.textContent).not.toContain('Reviewer');
+ expect(list?.textContent).not.toContain('Team Lead');
+
+ const builderItem = Array.from(list?.querySelectorAll('[cmdk-item]') ?? []).find((item) =>
+ item.textContent?.includes('Builder (removed)')
+ ) as HTMLElement | undefined;
+ expect(builderItem).toBeDefined();
+
+ await act(async () => {
+ builderItem?.click();
+ await flush();
+ });
+
+ expect(onChange).toHaveBeenCalledWith('Builder');
+ });
+});
From d0b0a18e3b2fe7d105d679053e71ead1d54d96e7 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:57:04 +0300
Subject: [PATCH 06/16] chore(ci): tighten dependency update gates
- Disable routine Dependabot PR creation while keeping grouped security update handling for npm and GitHub Actions.
- Add dependency-review workflow for dependency manifest and lockfile pull requests.
- Checked current upstream action majors before committing: actions/checkout v6 and dependency-review-action v5.
---
.github/dependabot.yml | 19 ++++++++---------
.github/workflows/dependency-review.yml | 28 +++++++++++++++++++++++++
2 files changed, 37 insertions(+), 10 deletions(-)
create mode 100644 .github/workflows/dependency-review.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 9b040905..21712b46 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -9,20 +9,16 @@ updates:
day: monday
time: "09:00"
timezone: Etc/UTC
- cooldown:
- default-days: 3
- open-pull-requests-limit: 5
+ open-pull-requests-limit: 0
commit-message:
prefix: chore
prefix-development: chore
include: scope
groups:
- npm-minor-and-patch:
+ npm-security:
+ applies-to: security-updates
patterns:
- "*"
- update-types:
- - minor
- - patch
- package-ecosystem: github-actions
directory: /
@@ -31,9 +27,12 @@ updates:
day: tuesday
time: "09:00"
timezone: Etc/UTC
- cooldown:
- default-days: 3
- open-pull-requests-limit: 3
+ open-pull-requests-limit: 0
commit-message:
prefix: chore
include: scope
+ groups:
+ github-actions-security:
+ applies-to: security-updates
+ patterns:
+ - "*"
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
new file mode 100644
index 00000000..9cbf4b8f
--- /dev/null
+++ b/.github/workflows/dependency-review.yml
@@ -0,0 +1,28 @@
+name: Dependency Review
+
+on:
+ pull_request:
+ paths:
+ - "**/package.json"
+ - "**/package-lock.json"
+ - "**/pnpm-lock.yaml"
+ - "pnpm-workspace.yaml"
+
+permissions:
+ contents: read
+
+jobs:
+ dependency-review:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Dependency Review
+ uses: actions/dependency-review-action@v5
+ with:
+ fail-on-severity: high
+ fail-on-scopes: runtime, development, unknown
+ license-check: false
+ show-patched-versions: true
From 9d34a534a2a84e9c5badad254606b3f83407e8a3 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:57:36 +0300
Subject: [PATCH 07/16] docs(release): require release notes closeout
- Add a required release closeout gate before a release is considered finished.
- Require user-facing notes, current Downloads links and gh release verification.
---
docs/RELEASE.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index acef573b..21f47455 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -131,6 +131,18 @@ EOF
)"
```
+### 4. Required release closeout gate
+
+Do not publish or call a release finished until this is true:
+
+- The GitHub release body is not just auto-generated `Full Changelog`.
+- The release body starts with short user-facing notes: what changed, why users care, and the most important fixes.
+- The `Downloads` table from the template is present and every link points to the current `v` assets.
+- The asset names in the notes match the assets uploaded by `release.yml`.
+- `gh release view v --json body,assets,isDraft,isPrerelease` confirms the release is public, has notes, and has the expected installer assets.
+
+If a draft was published before notes were written, immediately edit the public release body with `gh release edit`; do not leave a release with only generated notes.
+
## Release Notes Template
```markdown
From 7aa87f2278eafa183fc7483f42f10a0570f3a45c Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:57:50 +0300
Subject: [PATCH 08/16] fix(team): heal stale confirmed bootstrap diagnostics
- Carry bootstrap run ids from bootstrap-state into member evidence and compare them with current run identity.
- Allow small confirmation clock skew for delayed Anthropic app acceptance without accepting stale rapid relaunch evidence.
- Clean confirmed bootstrap members that only have stale persisted runtime pid diagnostics.
- Cover process-table unavailable, post-stop stale pid and mixed launch reconcile cases.
---
.../services/team/TeamBootstrapStateReader.ts | 12 +-
.../services/team/TeamProvisioningService.ts | 280 +++++++--
...ovisioningOpenCodeRuntimeEvidencePolicy.ts | 33 +-
src/shared/types/team.ts | 2 +-
...oningOpenCodeRuntimeEvidencePolicy.test.ts | 79 +++
.../team/TeamProvisioningService.test.ts | 584 +++++++++++++++++-
.../utils/memberLaunchDiagnostics.test.ts | 32 +
7 files changed, 977 insertions(+), 45 deletions(-)
diff --git a/src/main/services/team/TeamBootstrapStateReader.ts b/src/main/services/team/TeamBootstrapStateReader.ts
index 66144d3d..6226a206 100644
--- a/src/main/services/team/TeamBootstrapStateReader.ts
+++ b/src/main/services/team/TeamBootstrapStateReader.ts
@@ -332,7 +332,8 @@ function toIso(value: unknown, fallback: string): string {
function normalizeBootstrapMemberState(
memberName: string,
raw: RawBootstrapMemberState,
- updatedAt: string
+ updatedAt: string,
+ runtimeRunId?: string
): PersistedTeamLaunchMemberState {
const status = typeof raw.status === 'string' ? raw.status : 'pending';
const hardFailure = status === 'failed';
@@ -363,6 +364,7 @@ function normalizeBootstrapMemberState(
typeof raw.failureReason === 'string' && raw.failureReason.trim().length > 0
? raw.failureReason.trim()
: undefined,
+ ...(runtimeRunId ? { runtimeRunId } : {}),
firstSpawnAcceptedAt: agentToolAccepted ? toIso(raw.lastAttemptAt, updatedAt) : undefined,
lastHeartbeatAt: bootstrapConfirmed ? toIso(raw.lastObservedAt, updatedAt) : undefined,
lastRuntimeAliveAt: runtimeAlive ? toIso(raw.lastObservedAt, updatedAt) : undefined,
@@ -622,6 +624,7 @@ export async function readBootstrapLaunchSnapshot(
}
try {
const updatedAt = toIso(raw.updatedAt, new Date().toISOString());
+ const runtimeRunId = typeof raw.runId === 'string' ? raw.runId.trim() : '';
const rawMembers = Array.isArray(raw.members) ? raw.members : [];
const members: Record = {};
const expectedMembers: string[] = [];
@@ -632,7 +635,12 @@ export async function readBootstrapLaunchSnapshot(
const memberName = typeof rawMember.name === 'string' ? rawMember.name.trim() : '';
if (!memberName || memberName === 'team-lead' || memberName === 'user') continue;
expectedMembers.push(memberName);
- members[memberName] = normalizeBootstrapMemberState(memberName, rawMember, updatedAt);
+ members[memberName] = normalizeBootstrapMemberState(
+ memberName,
+ rawMember,
+ updatedAt,
+ runtimeRunId || undefined
+ );
}
const terminal =
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 511754d1..0a60789d 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -939,6 +939,17 @@ function buildRuntimeDiagnosticForSpawn(
: 'process table unavailable';
}
+function isConfirmedBootstrapStaleRuntimeDiagnostic(reason?: string): boolean {
+ const text = reason?.trim();
+ return text === 'persisted runtime pid is not alive';
+}
+
+function shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(reason?: string): boolean {
+ return (
+ isAutoClearableLaunchFailureReason(reason) || isConfirmedBootstrapStaleRuntimeDiagnostic(reason)
+ );
+}
+
function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined {
const refs = normalizeRuntimeStringArray(value);
return refs.length > 0
@@ -9676,9 +9687,8 @@ export class TeamProvisioningService {
}
private getRunLeadName(run: ProvisioningRun): string {
- return (
- run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'
- );
+ const members = Array.isArray(run.request?.members) ? run.request.members : [];
+ return members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
}
private rememberRecentCrossTeamLeadDeliveryMessageIds(
@@ -23396,34 +23406,62 @@ export class TeamProvisioningService {
(current.launchState === 'runtime_pending_bootstrap' ||
current.launchState === 'failed_to_start') &&
isProcessBootstrapTransportDiagnostic(current.runtimeDiagnostic);
- const runtimeDiagnostic = shouldPreserveProcessBootstrapTransportDiagnostic
- ? current.runtimeDiagnostic
- : buildRuntimeDiagnosticForSpawn(metadata);
- const metadataLivenessKind =
- current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive'
- ? metadata.livenessKind === 'runtime_process' ||
- metadata.livenessKind === 'confirmed_bootstrap'
- ? metadata.livenessKind
- : current.livenessKind
- : metadata.livenessKind;
+ const hasStrongEvidence = isStrongRuntimeEvidence(metadata);
+ const hasConfirmedBootstrap =
+ current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive';
+ const shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap =
+ hasConfirmedBootstrap && !hasStrongEvidence;
+ let runtimeDiagnostic: string | undefined;
+ let runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity | undefined;
+ if (shouldPreserveProcessBootstrapTransportDiagnostic) {
+ runtimeDiagnostic = current.runtimeDiagnostic;
+ runtimeDiagnosticSeverity = current.runtimeDiagnosticSeverity;
+ } else if (shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap) {
+ if (
+ current.runtimeDiagnostic &&
+ !shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(current.runtimeDiagnostic)
+ ) {
+ runtimeDiagnostic = current.runtimeDiagnostic;
+ runtimeDiagnosticSeverity = current.runtimeDiagnosticSeverity;
+ } else {
+ const metadataRuntimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
+ if (
+ metadataRuntimeDiagnostic &&
+ !shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(metadataRuntimeDiagnostic)
+ ) {
+ runtimeDiagnostic = metadataRuntimeDiagnostic;
+ runtimeDiagnosticSeverity = metadata.runtimeDiagnosticSeverity;
+ }
+ }
+ } else {
+ runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
+ runtimeDiagnosticSeverity = metadata.runtimeDiagnosticSeverity;
+ }
+ const metadataLivenessKind = hasConfirmedBootstrap
+ ? metadata.livenessKind === 'runtime_process' ||
+ metadata.livenessKind === 'confirmed_bootstrap'
+ ? metadata.livenessKind
+ : current.livenessKind === 'stale_metadata' || current.livenessKind === 'registered_only'
+ ? 'confirmed_bootstrap'
+ : (current.livenessKind ?? 'confirmed_bootstrap')
+ : metadata.livenessKind;
const nextEntry: MemberSpawnStatusEntry = {
...current,
...(metadata.model ? { runtimeModel: metadata.model } : {}),
...(metadataLivenessKind ? { livenessKind: metadataLivenessKind } : {}),
- ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
+ ...(runtimeDiagnostic || shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap
+ ? { runtimeDiagnostic }
+ : {}),
...(shouldPreserveProcessBootstrapTransportDiagnostic
- ? { runtimeDiagnosticSeverity: current.runtimeDiagnosticSeverity }
- : metadata.runtimeDiagnosticSeverity
- ? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
+ ? { runtimeDiagnosticSeverity }
+ : runtimeDiagnosticSeverity || shouldSuppressWeakRuntimeMetadataForConfirmedBootstrap
+ ? { runtimeDiagnosticSeverity }
: {}),
livenessLastCheckedAt: nowIso(),
};
const failureReason = current.hardFailureReason ?? current.error;
- const hasStrongEvidence = isStrongRuntimeEvidence(metadata);
const hasWeakEvidence =
- metadata.livenessKind != null &&
- !isStrongRuntimeEvidence(metadata) &&
- current.bootstrapConfirmed !== true;
+ metadata.livenessKind != null && !hasStrongEvidence && current.bootstrapConfirmed !== true;
if (
hasStrongEvidence &&
!openCodeSecondaryBootstrapPending &&
@@ -25827,7 +25865,13 @@ export class TeamProvisioningService {
}
const current =
run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
- if (!isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')) {
+ if (
+ !isBootstrapMemberEvidenceCurrentForMember(
+ { ...current, runtimeRunId: run.runId },
+ bootstrapMember,
+ 'confirmation'
+ )
+ ) {
continue;
}
if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) {
@@ -25920,7 +25964,13 @@ export class TeamProvisioningService {
if (!current || bootstrapMember?.bootstrapConfirmed !== true) {
continue;
}
- if (!isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')) {
+ if (
+ !isBootstrapMemberEvidenceCurrentForMember(
+ { ...current, runtimeRunId: run.runId },
+ bootstrapMember,
+ 'confirmation'
+ )
+ ) {
continue;
}
if (
@@ -28278,6 +28328,105 @@ export class TeamProvisioningService {
return false;
}
+ private needsConfirmedBootstrapDiagnosticReconcile(
+ snapshot: PersistedTeamLaunchSnapshot | null
+ ): boolean {
+ if (!snapshot) {
+ return false;
+ }
+ for (const member of Object.values(snapshot.members)) {
+ if (
+ member?.bootstrapConfirmed !== true ||
+ member.hardFailure === true ||
+ isPersistedOpenCodeSecondaryLaneMember(member)
+ ) {
+ continue;
+ }
+ if (
+ member.livenessKind === 'stale_metadata' ||
+ member.livenessKind === 'registered_only' ||
+ member.pidSource === 'persisted_metadata' ||
+ shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(member.runtimeDiagnostic)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private cleanConfirmedBootstrapRuntimeDiagnostics(
+ snapshot: PersistedTeamLaunchSnapshot | null
+ ): PersistedTeamLaunchSnapshot | null {
+ if (!snapshot) {
+ return null;
+ }
+
+ let changed = false;
+ const updatedAt = nowIso();
+ const members: Record = { ...snapshot.members };
+ for (const memberName of this.getPersistedLaunchMemberNames(snapshot)) {
+ const current = members[memberName];
+ if (
+ !current ||
+ current.bootstrapConfirmed !== true ||
+ current.hardFailure === true ||
+ isPersistedOpenCodeSecondaryLaneMember(current)
+ ) {
+ continue;
+ }
+
+ const hasConfirmedBootstrapStaleRuntimeState =
+ current.livenessKind === 'stale_metadata' ||
+ current.livenessKind === 'registered_only' ||
+ current.pidSource === 'persisted_metadata' ||
+ shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(current.runtimeDiagnostic) ||
+ current.bootstrapStalled === true;
+ if (!hasConfirmedBootstrapStaleRuntimeState) {
+ continue;
+ }
+
+ const next: PersistedTeamLaunchMemberState = {
+ ...current,
+ livenessKind:
+ current.livenessKind === 'stale_metadata' ||
+ current.livenessKind === 'registered_only' ||
+ current.livenessKind == null
+ ? 'confirmed_bootstrap'
+ : current.livenessKind,
+ pidSource:
+ current.pidSource === 'persisted_metadata' || current.pidSource == null
+ ? 'runtime_bootstrap'
+ : current.pidSource,
+ bootstrapStalled: undefined,
+ diagnostics: undefined,
+ lastEvaluatedAt: updatedAt,
+ };
+ if (shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(next.runtimeDiagnostic)) {
+ next.runtimeDiagnostic = undefined;
+ next.runtimeDiagnosticSeverity = undefined;
+ } else if (!next.runtimeDiagnostic) {
+ next.runtimeDiagnosticSeverity = undefined;
+ }
+ next.launchState = deriveMemberLaunchState(next);
+ members[memberName] = next;
+ changed = true;
+ }
+
+ if (!changed) {
+ return snapshot;
+ }
+
+ return createPersistedLaunchSnapshot({
+ teamName: snapshot.teamName,
+ expectedMembers: snapshot.expectedMembers,
+ bootstrapExpectedMembers: snapshot.bootstrapExpectedMembers,
+ leadSessionId: snapshot.leadSessionId,
+ launchPhase: snapshot.launchPhase,
+ members,
+ updatedAt,
+ });
+ }
+
private async reconcilePersistedLaunchState(teamName: string): Promise<{
snapshot: ReturnType | null;
statuses: Record;
@@ -28315,11 +28464,15 @@ export class TeamProvisioningService {
const promotedRecoveredMixedSnapshot = promoteOpenCodePersistedFailureReasonsFromDiagnostics(
stableRecoveredMixedSnapshotWithCommittedEvidence
);
+ const cleanedRecoveredMixedSnapshot = this.cleanConfirmedBootstrapRuntimeDiagnostics(
+ promotedRecoveredMixedSnapshot
+ );
const stableRecoveredMixedSnapshot =
- promotedRecoveredMixedSnapshot &&
- promotedRecoveredMixedSnapshot !== stableRecoveredMixedSnapshotWithCommittedEvidence
- ? await this.writeLaunchStateSnapshot(teamName, promotedRecoveredMixedSnapshot)
- : promotedRecoveredMixedSnapshot;
+ cleanedRecoveredMixedSnapshot &&
+ (promotedRecoveredMixedSnapshot !== stableRecoveredMixedSnapshotWithCommittedEvidence ||
+ cleanedRecoveredMixedSnapshot !== promotedRecoveredMixedSnapshot)
+ ? await this.writeLaunchStateSnapshot(teamName, cleanedRecoveredMixedSnapshot)
+ : cleanedRecoveredMixedSnapshot;
const filteredBootstrapSnapshot = bootstrapSnapshot
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
: null;
@@ -28331,6 +28484,7 @@ export class TeamProvisioningService {
stableRecoveredMixedSnapshot,
overlaidBootstrapSnapshot
) &&
+ !this.needsConfirmedBootstrapDiagnosticReconcile(stableRecoveredMixedSnapshot) &&
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(stableRecoveredMixedSnapshot))
) {
return {
@@ -28361,15 +28515,18 @@ export class TeamProvisioningService {
);
const shouldPersistFailureReasonPromotion =
promotedPersisted !== filteredPersistedWithBootstrapStall;
+ const cleanedPersisted = this.cleanConfirmedBootstrapRuntimeDiagnostics(promotedPersisted);
+ const shouldPersistConfirmedBootstrapDiagnosticCleanup = cleanedPersisted !== promotedPersisted;
const shouldPersistBootstrapStallOverlay =
filteredPersistedWithBootstrapStall !== filteredPersisted;
const persistedWithCommittedEvidence =
- promotedPersisted &&
+ cleanedPersisted &&
(shouldPersistCommittedEvidenceOverlay ||
shouldPersistFailureReasonPromotion ||
+ shouldPersistConfirmedBootstrapDiagnosticCleanup ||
shouldPersistBootstrapStallOverlay)
- ? await this.writeLaunchStateSnapshot(teamName, promotedPersisted)
- : promotedPersisted;
+ ? await this.writeLaunchStateSnapshot(teamName, cleanedPersisted)
+ : cleanedPersisted;
const preferredSnapshot = choosePreferredLaunchSnapshot(
overlaidBootstrapSnapshot,
persistedWithCommittedEvidence
@@ -28413,6 +28570,7 @@ export class TeamProvisioningService {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
let configMembers = new Set();
+ let configBootstrapRunIds = new Map();
let leadName = 'team-lead';
try {
const raw = await tryReadRegularFileUtf8(configPath, {
@@ -28421,14 +28579,26 @@ export class TeamProvisioningService {
});
if (raw) {
const config = JSON.parse(raw) as {
- members?: { name?: string; agentType?: string }[];
+ members?: { name?: string; agentType?: string; bootstrapRunId?: string }[];
};
- leadName = config.members?.find((member) => isLeadMember(member))?.name?.trim() || leadName;
+ const configuredMembers = config.members ?? [];
+ leadName =
+ configuredMembers.find((member) => isLeadMember(member))?.name?.trim() || leadName;
configMembers = new Set(
- (config.members ?? [])
+ configuredMembers
.map((member) => (typeof member?.name === 'string' ? member.name.trim() : ''))
.filter((name) => name.length > 0 && !isLeadMember({ name }))
);
+ configBootstrapRunIds = new Map(
+ configuredMembers.flatMap((member) => {
+ const name = typeof member?.name === 'string' ? member.name.trim() : '';
+ const runId =
+ typeof member?.bootstrapRunId === 'string' ? member.bootstrapRunId.trim() : '';
+ return name.length > 0 && runId.length > 0 && !isLeadMember({ name })
+ ? [[name, runId] as const]
+ : [];
+ })
+ );
}
} catch {
// best-effort
@@ -28449,6 +28619,7 @@ export class TeamProvisioningService {
persistedWithCommittedEvidence,
overlaidBootstrapSnapshot
) &&
+ !this.needsConfirmedBootstrapDiagnosticReconcile(persistedWithCommittedEvidence) &&
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(persistedWithCommittedEvidence))
) {
return {
@@ -28473,10 +28644,23 @@ export class TeamProvisioningService {
lastEvaluatedAt: now,
};
const isOpenCodeSecondaryLaneMember = isPersistedOpenCodeSecondaryLaneMember(current);
+ const matchedConfigNames = [...configMembers].filter((name) =>
+ matchesObservedMemberNameForExpected(name, expected)
+ );
+ const configBootstrapRunId = matchedConfigNames
+ .map((name) => configBootstrapRunIds.get(name))
+ .find((runId): runId is string => typeof runId === 'string' && runId.length > 0);
+ const currentBootstrapEvidenceBoundary = configBootstrapRunId
+ ? { ...current, runtimeRunId: configBootstrapRunId }
+ : current;
if (
bootstrapMember?.agentToolAccepted &&
!current.agentToolAccepted &&
- isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'acceptance')
+ isBootstrapMemberEvidenceCurrentForMember(
+ currentBootstrapEvidenceBoundary,
+ bootstrapMember,
+ 'acceptance'
+ )
) {
current.agentToolAccepted = true;
current.firstSpawnAcceptedAt =
@@ -28486,14 +28670,15 @@ export class TeamProvisioningService {
bootstrapMember?.bootstrapConfirmed &&
!current.bootstrapConfirmed &&
!isOpenCodeSecondaryLaneMember &&
- isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')
+ isBootstrapMemberEvidenceCurrentForMember(
+ currentBootstrapEvidenceBoundary,
+ bootstrapMember,
+ 'confirmation'
+ )
) {
current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
}
- const matchedConfigNames = [...configMembers].filter((name) =>
- matchesObservedMemberNameForExpected(name, expected)
- );
const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) =>
matchesObservedMemberNameForExpected(name, expected)
);
@@ -28619,6 +28804,25 @@ export class TeamProvisioningService {
finalTimeoutReached: graceExpired,
});
}
+ if (current.bootstrapConfirmed && !current.hardFailure && !isOpenCodeSecondaryLaneMember) {
+ current.livenessKind =
+ current.livenessKind === 'stale_metadata' ||
+ current.livenessKind === 'registered_only' ||
+ current.livenessKind == null
+ ? 'confirmed_bootstrap'
+ : current.livenessKind;
+ current.pidSource =
+ current.pidSource === 'persisted_metadata' || current.pidSource == null
+ ? 'runtime_bootstrap'
+ : current.pidSource;
+ if (shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(current.runtimeDiagnostic)) {
+ current.runtimeDiagnostic = undefined;
+ current.runtimeDiagnosticSeverity = undefined;
+ } else if (!current.runtimeDiagnostic) {
+ current.runtimeDiagnosticSeverity = undefined;
+ }
+ current.bootstrapStalled = undefined;
+ }
if (
isOpenCodeSecondaryLaneMember &&
shouldMarkPersistedOpenCodeBootstrapStalled(current, Date.now())
diff --git a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
index 13e9d308..6e972fd7 100644
--- a/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
+++ b/src/main/services/team/provisioning/TeamProvisioningOpenCodeRuntimeEvidencePolicy.ts
@@ -23,6 +23,7 @@ export const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC =
export const OPENCODE_APP_MANAGED_BOOTSTRAP_PENDING_DIAGNOSTIC =
'OpenCode app-managed bootstrap evidence is pending after materialized session.';
+const BOOTSTRAP_EVIDENCE_BOUNDARY_SKEW_MS = 10_000;
const OPENCODE_MEMBER_SESSION_RECORDED_AT_PATTERN =
/\bmember_session_recorded\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.+-]+Z?)\b/i;
@@ -584,13 +585,29 @@ export function isRecoverableOpenCodeRuntimeEvidence(
}
export function isBootstrapMemberEvidenceCurrentForMember(
- current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string },
+ current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string; runtimeRunId?: string },
bootstrapMember: Pick<
PersistedTeamLaunchMemberState,
- 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'lastRuntimeAliveAt' | 'lastEvaluatedAt'
+ | 'firstSpawnAcceptedAt'
+ | 'lastHeartbeatAt'
+ | 'lastRuntimeAliveAt'
+ | 'lastEvaluatedAt'
+ | 'runtimeRunId'
>,
evidenceKind: 'acceptance' | 'confirmation'
): boolean {
+ const currentRuntimeRunId =
+ typeof current.runtimeRunId === 'string' ? current.runtimeRunId.trim() : '';
+ const bootstrapRuntimeRunId =
+ typeof bootstrapMember.runtimeRunId === 'string' ? bootstrapMember.runtimeRunId.trim() : '';
+ if (
+ currentRuntimeRunId.length > 0 &&
+ bootstrapRuntimeRunId.length > 0 &&
+ currentRuntimeRunId !== bootstrapRuntimeRunId
+ ) {
+ return false;
+ }
+
const bootstrapFirstSpawnAcceptedMs = Date.parse(bootstrapMember.firstSpawnAcceptedAt ?? '');
const bootstrapLastEvaluatedMs = Date.parse(bootstrapMember.lastEvaluatedAt ?? '');
const hasDurableBootstrapSpawnAcceptedAt =
@@ -615,5 +632,15 @@ export function isBootstrapMemberEvidenceCurrentForMember(
Number.isFinite(firstSpawnAcceptedMs) &&
(!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs);
const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
- return !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs;
+ const hasCompatibleRuntimeRunIdForSkew =
+ currentRuntimeRunId.length === 0 ||
+ (bootstrapRuntimeRunId.length > 0 && currentRuntimeRunId === bootstrapRuntimeRunId);
+ const withinBootstrapConfirmationClockSkew =
+ evidenceKind === 'confirmation' &&
+ Number.isFinite(boundaryMs) &&
+ boundaryMs - evidenceMs <= BOOTSTRAP_EVIDENCE_BOUNDARY_SKEW_MS &&
+ hasCompatibleRuntimeRunIdForSkew;
+ return (
+ !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs || withinBootstrapConfirmationClockSkew
+ );
}
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index 8a96ae5d..cff49c08 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -1123,7 +1123,7 @@ export interface PersistedTeamLaunchMemberState {
hardFailureReason?: string;
pendingPermissionRequestIds?: string[];
runtimePid?: number;
- /** OpenCode runtime run id that produced the current runtimeSessionId/liveness evidence. */
+ /** Runtime/bootstrap run id that produced current liveness or bootstrap evidence. */
runtimeRunId?: string;
runtimeSessionId?: string;
bootstrapEvidenceSource?: OpenCodeBootstrapEvidenceSource;
diff --git a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
index 90f37cc1..5708ed81 100644
--- a/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
+++ b/test/main/services/team/TeamProvisioningOpenCodeRuntimeEvidencePolicy.test.ts
@@ -316,6 +316,85 @@ describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => {
expect(hasRecoverableOpenCodeBootstrapDiagnostic([])).toBe(false);
});
+ it('accepts bootstrap evidence that slightly predates delayed spawn acceptance', () => {
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z',
+ lastEvaluatedAt: '2026-01-01T00:01:00.000Z',
+ runtimeRunId: 'run-new',
+ },
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:33.000Z',
+ lastHeartbeatAt: '2026-01-01T00:00:42.500Z',
+ lastEvaluatedAt: '2026-01-01T00:00:42.500Z',
+ },
+ 'confirmation'
+ )
+ ).toBe(false);
+
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z',
+ lastEvaluatedAt: '2026-01-01T00:01:00.000Z',
+ runtimeRunId: 'run-new',
+ },
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:33.000Z',
+ lastHeartbeatAt: '2026-01-01T00:00:42.500Z',
+ lastEvaluatedAt: '2026-01-01T00:00:42.500Z',
+ runtimeRunId: 'run-old',
+ },
+ 'confirmation'
+ )
+ ).toBe(false);
+
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z',
+ lastEvaluatedAt: '2026-01-01T00:01:00.000Z',
+ },
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:33.000Z',
+ lastHeartbeatAt: '2026-01-01T00:00:42.500Z',
+ lastEvaluatedAt: '2026-01-01T00:00:42.500Z',
+ },
+ 'confirmation'
+ )
+ ).toBe(true);
+
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z',
+ lastEvaluatedAt: '2026-01-01T00:01:00.000Z',
+ },
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:20.000Z',
+ lastHeartbeatAt: '2026-01-01T00:00:20.000Z',
+ lastEvaluatedAt: '2026-01-01T00:00:20.000Z',
+ },
+ 'confirmation'
+ )
+ ).toBe(false);
+
+ expect(
+ isBootstrapMemberEvidenceCurrentForMember(
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:45.000Z',
+ lastEvaluatedAt: '2026-01-01T00:01:00.000Z',
+ },
+ {
+ firstSpawnAcceptedAt: '2026-01-01T00:00:42.500Z',
+ lastEvaluatedAt: '2026-01-01T00:00:42.500Z',
+ },
+ 'acceptance'
+ )
+ ).toBe(false);
+ });
+
it('classifies recoverable persisted OpenCode runtime candidates', () => {
expect(
isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ runtimeSessionId: 'rt-1' }))
diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts
index 9318c8c3..23cc5a35 100644
--- a/test/main/services/team/TeamProvisioningService.test.ts
+++ b/test/main/services/team/TeamProvisioningService.test.ts
@@ -371,13 +371,15 @@ function writeBootstrapState(
lastObservedAt?: number;
failureReason?: string;
}[],
- updatedAt = new Date().toISOString()
+ updatedAt = new Date().toISOString(),
+ options?: { runId?: string }
): void {
fs.writeFileSync(
getTeamBootstrapStatePath(teamName),
`${JSON.stringify(
{
version: 1,
+ ...(options?.runId ? { runId: options.runId } : {}),
teamName,
updatedAt,
phase: 'completed',
@@ -390,6 +392,17 @@ function writeBootstrapState(
);
}
+function writeMemberBootstrapRunId(teamName: string, memberName: string, runId: string): void {
+ const configPath = path.join(tempTeamsBase, teamName, 'config.json');
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
+ members?: Array>;
+ };
+ config.members = (config.members ?? []).map((member) =>
+ member.name === memberName ? { ...member, bootstrapRunId: runId } : member
+ );
+ fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
+}
+
function writeAliveProcessRegistry(teamName: string): void {
const teamDir = path.join(tempTeamsBase, teamName);
fs.mkdirSync(teamDir, { recursive: true });
@@ -20028,10 +20041,293 @@ describe('TeamProvisioningService', () => {
status: 'online',
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
+ runtimeAlive: false,
+ livenessKind: 'confirmed_bootstrap',
hardFailure: false,
error: undefined,
});
expect(result.statuses.jack?.hardFailureReason).toBeUndefined();
+ expect(result.statuses.jack?.runtimeDiagnostic).toBeUndefined();
+ expect(result.statuses.jack?.runtimeDiagnosticSeverity).toBeUndefined();
+ });
+
+ it('heals process-table unavailable failure when Anthropic bootstrap confirmation slightly predates delayed app acceptance', async () => {
+ allowConsoleLogs();
+ const teamName = 'zz-unit-process-table-unavailable-bootstrap-skew-heals';
+ const leadSessionId = 'lead-session';
+ const projectPath = '/Users/test/proj';
+ const bootstrapAttemptAt = '2026-05-24T09:25:33.388Z';
+ const bootstrapConfirmedAt = '2026-05-24T09:25:42.494Z';
+ const appAcceptedAt = '2026-05-24T09:25:45.178Z';
+ const cleanupAt = '2026-05-24T09:31:05.525Z';
+ const runtimePid = 97_255;
+ const bootstrapRunId = 'run-process-table-unavailable-skew';
+ const reason = 'runtime pid could not be verified because process table is unavailable';
+
+ writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']);
+ writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId);
+ writeLaunchState(
+ teamName,
+ leadSessionId,
+ {
+ tom: {
+ providerId: 'anthropic',
+ model: 'haiku',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'failed_to_start',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ runtimePid,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: reason,
+ livenessKind: 'registered_only',
+ runtimeDiagnostic: reason,
+ runtimeDiagnosticSeverity: 'warning',
+ firstSpawnAcceptedAt: appAcceptedAt,
+ runtimeLastSeenAt: cleanupAt,
+ lastEvaluatedAt: cleanupAt,
+ },
+ },
+ { launchPhase: 'finished', updatedAt: cleanupAt }
+ );
+ writeBootstrapState(
+ teamName,
+ [
+ {
+ name: 'tom',
+ status: 'bootstrap_confirmed',
+ lastAttemptAt: Date.parse(bootstrapAttemptAt),
+ lastObservedAt: Date.parse(bootstrapConfirmedAt),
+ },
+ ],
+ cleanupAt,
+ { runId: bootstrapRunId }
+ );
+
+ const svc = new TeamProvisioningService();
+ privateHarness(svc).getLiveTeamAgentRuntimeMetadata = vi.fn(
+ async () =>
+ new Map([
+ [
+ 'tom',
+ {
+ alive: false,
+ backendType: 'process',
+ providerId: 'anthropic',
+ livenessKind: 'registered_only',
+ pidSource: 'persisted_metadata',
+ runtimeDiagnostic: reason,
+ runtimeDiagnosticSeverity: 'warning',
+ metricsPid: runtimePid,
+ model: 'haiku',
+ },
+ ],
+ ])
+ );
+
+ const result = await svc.getMemberSpawnStatuses(teamName);
+
+ expect(result.teamLaunchState).toBe('clean_success');
+ expect(result.statuses.tom).toMatchObject({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ runtimeAlive: false,
+ livenessKind: 'confirmed_bootstrap',
+ hardFailure: false,
+ error: undefined,
+ });
+ expect(result.statuses.tom?.hardFailureReason).toBeUndefined();
+ expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined();
+ expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined();
+ });
+
+ it('does not heal rapid relaunch failures from previous bootstrap-state run id', async () => {
+ allowConsoleLogs();
+ const teamName = 'zz-unit-process-table-unavailable-stale-rapid-run-ignored';
+ const leadSessionId = 'lead-session';
+ const projectPath = '/Users/test/proj';
+ const bootstrapAttemptAt = '2026-05-24T09:25:33.388Z';
+ const bootstrapConfirmedAt = '2026-05-24T09:25:42.494Z';
+ const appAcceptedAt = '2026-05-24T09:25:45.178Z';
+ const cleanupAt = '2026-05-24T09:31:05.525Z';
+ const runtimePid = 97_255;
+ const currentRunId = 'run-new-process-table-unavailable';
+ const staleRunId = 'run-old-process-table-unavailable';
+ const reason = 'runtime pid could not be verified because process table is unavailable';
+
+ writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']);
+ writeMemberBootstrapRunId(teamName, 'tom', currentRunId);
+ writeLaunchState(
+ teamName,
+ leadSessionId,
+ {
+ tom: {
+ providerId: 'anthropic',
+ model: 'haiku',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'failed_to_start',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ runtimePid,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: reason,
+ livenessKind: 'registered_only',
+ runtimeDiagnostic: reason,
+ runtimeDiagnosticSeverity: 'warning',
+ firstSpawnAcceptedAt: appAcceptedAt,
+ runtimeLastSeenAt: cleanupAt,
+ lastEvaluatedAt: cleanupAt,
+ },
+ },
+ { launchPhase: 'finished', updatedAt: cleanupAt }
+ );
+ writeBootstrapState(
+ teamName,
+ [
+ {
+ name: 'tom',
+ status: 'bootstrap_confirmed',
+ lastAttemptAt: Date.parse(bootstrapAttemptAt),
+ lastObservedAt: Date.parse(bootstrapConfirmedAt),
+ },
+ ],
+ cleanupAt,
+ { runId: staleRunId }
+ );
+
+ const svc = new TeamProvisioningService();
+ privateHarness(svc).getLiveTeamAgentRuntimeMetadata = vi.fn(
+ async () =>
+ new Map([
+ [
+ 'tom',
+ {
+ alive: false,
+ backendType: 'process',
+ providerId: 'anthropic',
+ livenessKind: 'registered_only',
+ pidSource: 'persisted_metadata',
+ runtimeDiagnostic: reason,
+ runtimeDiagnosticSeverity: 'warning',
+ metricsPid: runtimePid,
+ model: 'haiku',
+ },
+ ],
+ ])
+ );
+
+ const result = await svc.getMemberSpawnStatuses(teamName);
+
+ expect(result.teamLaunchState).toBe('partial_failure');
+ expect(result.statuses.tom).toMatchObject({
+ status: 'error',
+ launchState: 'failed_to_start',
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ });
+ });
+
+ it('heals post-stop stale pid diagnostics when bootstrap-state already confirmed the Anthropic member', async () => {
+ allowConsoleLogs();
+ const teamName = 'zz-unit-post-stop-stale-pid-bootstrap-skew-heals';
+ const leadSessionId = 'lead-session';
+ const projectPath = '/Users/test/proj';
+ const bootstrapAttemptAt = '2026-05-24T09:25:33.388Z';
+ const bootstrapConfirmedAt = '2026-05-24T09:25:42.904Z';
+ const appAcceptedAt = '2026-05-24T09:25:45.178Z';
+ const originalFailureAt = '2026-05-24T09:31:05.525Z';
+ const postStopRefreshAt = '2026-05-24T11:36:56.881Z';
+ const runtimePid = 97_255;
+ const bootstrapRunId = 'run-post-stop-stale-pid-bootstrap-skew';
+ const originalReason = 'runtime pid could not be verified because process table is unavailable';
+ const postStopDiagnostic = 'persisted runtime pid is not alive';
+
+ writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']);
+ writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId);
+ writeLaunchState(
+ teamName,
+ leadSessionId,
+ {
+ tom: {
+ providerId: 'anthropic',
+ model: 'haiku',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'failed_to_start',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ runtimePid,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: originalReason,
+ livenessKind: 'stale_metadata',
+ runtimeDiagnostic: postStopDiagnostic,
+ runtimeDiagnosticSeverity: 'warning',
+ firstSpawnAcceptedAt: appAcceptedAt,
+ runtimeLastSeenAt: originalFailureAt,
+ lastEvaluatedAt: originalFailureAt,
+ },
+ },
+ { launchPhase: 'finished', updatedAt: postStopRefreshAt }
+ );
+ writeBootstrapState(
+ teamName,
+ [
+ {
+ name: 'tom',
+ status: 'bootstrap_confirmed',
+ lastAttemptAt: Date.parse(bootstrapAttemptAt),
+ lastObservedAt: Date.parse(bootstrapConfirmedAt),
+ },
+ ],
+ '2026-05-24T09:26:08.090Z',
+ { runId: bootstrapRunId }
+ );
+
+ const svc = new TeamProvisioningService();
+ privateHarness(svc).getLiveTeamAgentRuntimeMetadata = vi.fn(
+ async () =>
+ new Map([
+ [
+ 'tom',
+ {
+ alive: false,
+ backendType: 'process',
+ providerId: 'anthropic',
+ livenessKind: 'stale_metadata',
+ pidSource: 'persisted_metadata',
+ runtimeDiagnostic: postStopDiagnostic,
+ runtimeDiagnosticSeverity: 'warning',
+ metricsPid: runtimePid,
+ model: 'haiku',
+ },
+ ],
+ ])
+ );
+
+ const result = await svc.getMemberSpawnStatuses(teamName);
+
+ expect(result.teamLaunchState).toBe('clean_success');
+ expect(result.statuses.tom).toMatchObject({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ runtimeAlive: false,
+ livenessKind: 'confirmed_bootstrap',
+ hardFailure: false,
+ error: undefined,
+ });
+ expect(result.statuses.tom?.hardFailureReason).toBeUndefined();
+ expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined();
+ expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined();
});
it('does not heal cleanup-finalized launch failures from stale bootstrap-state confirmation', async () => {
@@ -25884,6 +26180,292 @@ describe('TeamProvisioningService', () => {
});
});
+ it('reconciles mixed launch when Anthropic primary bootstrap confirmation slightly predates delayed app acceptance', async () => {
+ const teamName = 'mixed-anthropic-primary-bootstrap-skew-heals';
+ const reason = 'runtime pid could not be verified because process table is unavailable';
+ const postStopDiagnostic = 'persisted runtime pid is not alive';
+ const bootstrapRunId = 'run-mixed-anthropic-primary-bootstrap-skew';
+ writeTeamMeta(teamName, {
+ providerId: 'codex',
+ providerBackendId: 'codex-native',
+ model: 'gpt-5.5',
+ });
+ writeMembersMeta(teamName, [
+ { name: 'alice', providerId: 'codex', model: 'gpt-5.5' },
+ { name: 'tom', providerId: 'anthropic', model: 'haiku' },
+ { name: 'bob', providerId: 'opencode', model: 'opencode/deepseek-v4-flash-free' },
+ { name: 'jack', providerId: 'opencode', model: 'opencode/big-pickle' },
+ ]);
+ writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['alice', 'tom']);
+ writeMemberBootstrapRunId(teamName, 'alice', bootstrapRunId);
+ writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId);
+ writeBootstrapState(
+ teamName,
+ [
+ {
+ name: 'alice',
+ status: 'bootstrap_confirmed',
+ lastAttemptAt: Date.parse('2026-05-24T09:25:28.034Z'),
+ lastObservedAt: Date.parse('2026-05-24T09:26:07.735Z'),
+ },
+ {
+ name: 'tom',
+ status: 'bootstrap_confirmed',
+ lastAttemptAt: Date.parse('2026-05-24T09:25:33.388Z'),
+ lastObservedAt: Date.parse('2026-05-24T09:25:42.494Z'),
+ },
+ ],
+ '2026-05-24T09:26:08.090Z',
+ { runId: bootstrapRunId }
+ );
+ fs.writeFileSync(
+ getTeamLaunchStatePath(teamName),
+ `${JSON.stringify(
+ createPersistedLaunchSnapshot({
+ teamName,
+ leadSessionId: 'lead-session',
+ launchPhase: 'finished',
+ expectedMembers: ['alice', 'tom', 'bob', 'jack'],
+ bootstrapExpectedMembers: ['alice', 'tom'],
+ members: {
+ alice: {
+ name: 'alice',
+ providerId: 'codex',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ firstSpawnAcceptedAt: '2026-05-24T09:25:45.176Z',
+ lastHeartbeatAt: '2026-05-24T09:26:07.735Z',
+ lastEvaluatedAt: '2026-05-24T09:26:09.249Z',
+ },
+ tom: {
+ name: 'tom',
+ providerId: 'anthropic',
+ model: 'haiku',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'failed_to_start',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ runtimePid: 97_255,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason: reason,
+ livenessKind: 'stale_metadata',
+ runtimeDiagnostic: postStopDiagnostic,
+ runtimeDiagnosticSeverity: 'warning',
+ firstSpawnAcceptedAt: '2026-05-24T09:25:45.178Z',
+ runtimeLastSeenAt: '2026-05-24T09:31:05.525Z',
+ lastEvaluatedAt: '2026-05-24T09:31:05.525Z',
+ },
+ bob: {
+ name: 'bob',
+ providerId: 'opencode',
+ model: 'opencode/deepseek-v4-flash-free',
+ laneId: 'secondary:opencode:bob',
+ laneKind: 'secondary',
+ laneOwnerProviderId: 'opencode',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ runtimePid: 2_756,
+ runtimeSessionId: 'ses_bob',
+ livenessKind: 'confirmed_bootstrap',
+ lastHeartbeatAt: '2026-05-24T09:31:39.741Z',
+ lastEvaluatedAt: '2026-05-24T09:31:39.741Z',
+ },
+ jack: {
+ name: 'jack',
+ providerId: 'opencode',
+ model: 'opencode/big-pickle',
+ laneId: 'secondary:opencode:jack',
+ laneKind: 'secondary',
+ laneOwnerProviderId: 'opencode',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ runtimePid: 2_756,
+ runtimeSessionId: 'ses_jack',
+ livenessKind: 'confirmed_bootstrap',
+ lastHeartbeatAt: '2026-05-24T09:31:39.741Z',
+ lastEvaluatedAt: '2026-05-24T09:31:39.741Z',
+ },
+ },
+ updatedAt: '2026-05-24T11:36:56.881Z',
+ }),
+ null,
+ 2
+ )}\n`,
+ 'utf8'
+ );
+
+ const svc = new TeamProvisioningService();
+ const result = await svc.getMemberSpawnStatuses(teamName);
+
+ expect(result.teamLaunchState).toBe('clean_success');
+ expect(result.statuses.tom).toMatchObject({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ runtimeAlive: false,
+ livenessKind: 'confirmed_bootstrap',
+ hardFailure: false,
+ error: undefined,
+ });
+ expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined();
+ expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined();
+ expect(result.statuses.bob).toMatchObject({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ });
+ expect(result.statuses.jack).toMatchObject({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ });
+ });
+
+ it('cleans stale confirmed primary diagnostics from an already successful mixed launch', async () => {
+ const teamName = 'mixed-confirmed-primary-stale-diagnostic-cleans';
+ writeTeamMeta(teamName, {
+ providerId: 'codex',
+ providerBackendId: 'codex-native',
+ model: 'gpt-5.5',
+ });
+ writeMembersMeta(teamName, [
+ { name: 'alice', providerId: 'codex', model: 'gpt-5.5' },
+ { name: 'tom', providerId: 'anthropic', model: 'haiku' },
+ { name: 'bob', providerId: 'opencode', model: 'opencode/deepseek-v4-flash-free' },
+ { name: 'jack', providerId: 'opencode', model: 'opencode/big-pickle' },
+ ]);
+ writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['alice', 'tom']);
+ fs.writeFileSync(
+ getTeamLaunchStatePath(teamName),
+ `${JSON.stringify(
+ createPersistedLaunchSnapshot({
+ teamName,
+ leadSessionId: 'lead-session',
+ launchPhase: 'finished',
+ expectedMembers: ['alice', 'tom', 'bob', 'jack'],
+ members: {
+ alice: {
+ name: 'alice',
+ providerId: 'codex',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ lastEvaluatedAt: '2026-05-24T12:04:48.900Z',
+ },
+ tom: {
+ name: 'tom',
+ providerId: 'anthropic',
+ model: 'haiku',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ runtimePid: 97_255,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ livenessKind: 'stale_metadata',
+ pidSource: 'persisted_metadata',
+ runtimeDiagnostic: 'persisted runtime pid is not alive',
+ runtimeDiagnosticSeverity: 'warning',
+ firstSpawnAcceptedAt: '2026-05-24T09:25:45.178Z',
+ lastHeartbeatAt: '2026-05-24T09:25:42.904Z',
+ runtimeLastSeenAt: '2026-05-24T09:31:05.525Z',
+ lastEvaluatedAt: '2026-05-24T12:04:48.900Z',
+ },
+ bob: {
+ name: 'bob',
+ providerId: 'opencode',
+ model: 'opencode/deepseek-v4-flash-free',
+ laneId: 'secondary:opencode:bob',
+ laneKind: 'secondary',
+ laneOwnerProviderId: 'opencode',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ runtimePid: 2_756,
+ runtimeSessionId: 'ses_bob',
+ livenessKind: 'confirmed_bootstrap',
+ lastHeartbeatAt: '2026-05-24T09:31:39.741Z',
+ lastEvaluatedAt: '2026-05-24T09:31:39.741Z',
+ },
+ jack: {
+ name: 'jack',
+ providerId: 'opencode',
+ model: 'opencode/big-pickle',
+ laneId: 'secondary:opencode:jack',
+ laneKind: 'secondary',
+ laneOwnerProviderId: 'opencode',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: true,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ runtimePid: 2_756,
+ runtimeSessionId: 'ses_jack',
+ livenessKind: 'confirmed_bootstrap',
+ lastHeartbeatAt: '2026-05-24T09:31:39.741Z',
+ lastEvaluatedAt: '2026-05-24T09:31:39.741Z',
+ },
+ },
+ updatedAt: '2026-05-24T12:04:48.900Z',
+ }),
+ null,
+ 2
+ )}\n`,
+ 'utf8'
+ );
+
+ const svc = new TeamProvisioningService();
+ const result = await svc.getMemberSpawnStatuses(teamName);
+
+ expect(result.teamLaunchState).toBe('clean_success');
+ expect(result.statuses.tom).toMatchObject({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ runtimeAlive: false,
+ livenessKind: 'confirmed_bootstrap',
+ hardFailure: false,
+ error: undefined,
+ });
+ expect(result.statuses.tom?.runtimeDiagnostic).toBeUndefined();
+ expect(result.statuses.tom?.runtimeDiagnosticSeverity).toBeUndefined();
+ const persisted = JSON.parse(
+ await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
+ );
+ expect(persisted.members.tom).toMatchObject({
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ livenessKind: 'confirmed_bootstrap',
+ });
+ expect(persisted.members.tom.runtimeDiagnostic).toBeUndefined();
+ expect(persisted.members.tom.runtimeDiagnosticSeverity).toBeUndefined();
+ });
+
it('does not collapse persisted mixed secondary failures when primary bootstrap snapshot is clean and richer', async () => {
const teamName = 'mixed-clean-bootstrap-does-not-collapse-secondary-failure';
writeMembersMeta(teamName, [
diff --git a/test/renderer/utils/memberLaunchDiagnostics.test.ts b/test/renderer/utils/memberLaunchDiagnostics.test.ts
index 3875a14a..80b8ec8e 100644
--- a/test/renderer/utils/memberLaunchDiagnostics.test.ts
+++ b/test/renderer/utils/memberLaunchDiagnostics.test.ts
@@ -91,6 +91,38 @@ describe('member launch diagnostics', () => {
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"');
});
+ it('does not surface post-stop stale runtime warnings as confirmed member card errors', () => {
+ const payload = buildMemberLaunchDiagnosticsPayload({
+ teamName: 'forge-labs-11',
+ runId: 'e90c7699-54d7-449e-8a4a-6a3276396926',
+ memberName: 'tom',
+ spawnEntry: {
+ status: 'online',
+ launchState: 'confirmed_alive',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ livenessKind: 'confirmed_bootstrap',
+ updatedAt: '2026-05-24T12:04:48.900Z',
+ },
+ runtimeEntry: {
+ memberName: 'tom',
+ alive: false,
+ restartable: true,
+ livenessKind: 'stale_metadata',
+ runtimeDiagnostic: 'persisted runtime pid is not alive',
+ runtimeDiagnosticSeverity: 'warning',
+ updatedAt: '2026-05-24T12:04:48.900Z',
+ },
+ });
+
+ expect(payload.memberCardError).toBeUndefined();
+ expect(hasMemberLaunchDiagnosticsError(payload)).toBe(false);
+ expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBeUndefined();
+ expect(payload.runtimeDiagnostic).toBe('persisted runtime pid is not alive');
+ });
+
it('includes runtime advisory evidence in copy diagnostics', () => {
const payload = buildMemberLaunchDiagnosticsPayload({
memberName: 'alice',
From 57931c0abd747c5b8886909f5edffb58d2af813d Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:58:22 +0300
Subject: [PATCH 09/16] fix(renderer): defer model validation while providers
load
- Treat checking, deferred and loading provider model catalog states as pending instead of unavailable.
- Show selected provider activity inside create and launch dialogs while keeping ready providers visible during checks.
- Remove the global provider status header so provider activity is scoped to launch flows.
---
.../common/GlobalProviderStatusHeader.tsx | 85 ---------
.../common/ProviderActivityStatusStrip.tsx | 50 +++--
.../components/layout/TabbedLayout.tsx | 2 -
.../team/dialogs/CreateTeamDialog.tsx | 75 +++++++-
.../team/dialogs/LaunchTeamDialog.tsx | 74 +++++++-
...teamModelAvailability.codexCatalog.test.ts | 27 +++
src/renderer/utils/teamModelAvailability.ts | 35 +++-
.../common/GlobalProviderStatusHeader.test.ts | 177 ------------------
8 files changed, 228 insertions(+), 297 deletions(-)
delete mode 100644 src/renderer/components/common/GlobalProviderStatusHeader.tsx
delete mode 100644 test/renderer/components/common/GlobalProviderStatusHeader.test.ts
diff --git a/src/renderer/components/common/GlobalProviderStatusHeader.tsx b/src/renderer/components/common/GlobalProviderStatusHeader.tsx
deleted file mode 100644
index 8daa8480..00000000
--- a/src/renderer/components/common/GlobalProviderStatusHeader.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useMemo } from 'react';
-
-import {
- CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
- CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
- mergeCodexCliStatusWithSnapshot,
- useCodexAccountSnapshot,
-} from '@features/codex-account/renderer';
-import { isElectronMode } from '@renderer/api';
-import { useStore } from '@renderer/store';
-import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
-import { useShallow } from 'zustand/react/shallow';
-
-import { ProviderActivityStatusStrip } from './ProviderActivityStatusStrip';
-
-export const GlobalProviderStatusHeader = (): React.JSX.Element | null => {
- const isElectron = useMemo(() => isElectronMode(), []);
- const {
- cliStatus,
- cliStatusLoading,
- cliProviderStatusLoading,
- multimodelEnabled,
- isDashboardFocused,
- } = useStore(
- useShallow((state) => {
- const focusedPane = state.paneLayout.panes.find(
- (pane) => pane.id === state.paneLayout.focusedPaneId
- );
- const activeTab = focusedPane?.tabs.find((tab) => tab.id === focusedPane.activeTabId) ?? null;
-
- return {
- cliStatus: state.cliStatus,
- cliStatusLoading: state.cliStatusLoading,
- cliProviderStatusLoading: state.cliProviderStatusLoading,
- multimodelEnabled: state.appConfig?.general?.multimodelEnabled ?? true,
- isDashboardFocused:
- !focusedPane || focusedPane.tabs.length === 0 || activeTab?.type === 'dashboard',
- };
- })
- );
-
- const loadingCliStatus = useMemo(
- () =>
- !cliStatus && cliStatusLoading && multimodelEnabled
- ? createLoadingMultimodelCliStatus()
- : cliStatus,
- [cliStatus, cliStatusLoading, multimodelEnabled]
- );
-
- const codexAccount = useCodexAccountSnapshot({
- enabled:
- isElectron &&
- multimodelEnabled &&
- loadingCliStatus?.flavor === 'agent_teams_orchestrator' &&
- Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')),
- includeRateLimits: false,
- initialRefreshDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MIN_DELAY_MS,
- initialRefreshMaxDelayMs: CODEX_ACCOUNT_STARTUP_IDLE_MAX_DELAY_MS,
- });
-
- const effectiveCliStatus = useMemo(
- () => mergeCodexCliStatusWithSnapshot(loadingCliStatus, codexAccount.snapshot),
- [codexAccount.snapshot, loadingCliStatus]
- );
- const codexSnapshotPending =
- codexAccount.loading &&
- Boolean(loadingCliStatus?.providers.some((provider) => provider.providerId === 'codex')) &&
- !codexAccount.snapshot;
-
- if (isDashboardFocused) {
- return null;
- }
-
- return (
-
- );
-};
diff --git a/src/renderer/components/common/ProviderActivityStatusStrip.tsx b/src/renderer/components/common/ProviderActivityStatusStrip.tsx
index 0d6965b3..cee21915 100644
--- a/src/renderer/components/common/ProviderActivityStatusStrip.tsx
+++ b/src/renderer/components/common/ProviderActivityStatusStrip.tsx
@@ -4,7 +4,7 @@ import { isElectronMode } from '@renderer/api';
import { formatProviderStatusText } from '@renderer/components/runtime/providerConnectionUi';
import { createLoadingMultimodelCliStatus } from '@renderer/store/slices/cliInstallerSlice';
import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
-import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
+import { isTeamProviderModelVerificationPending } from '@renderer/utils/teamModelAvailability';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { ProviderBrandLogo } from './ProviderBrandLogo';
@@ -27,17 +27,13 @@ interface ProviderActivityStatusStripProps {
readonly providerIds?: readonly CliProviderId[];
readonly className?: string;
readonly label?: string | null;
+ readonly layout?: 'inline' | 'stacked';
+ readonly showReadyProviders?: boolean;
+ readonly readyStatusText?: string;
}
function isProviderCardLoading(provider: CliProviderStatus, providerLoading: boolean): boolean {
- return (
- providerLoading ||
- (!provider.authenticated &&
- (provider.statusMessage === 'Checking...' ||
- provider.statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE) &&
- provider.models.length === 0 &&
- provider.backend == null)
- );
+ return providerLoading || isTeamProviderModelVerificationPending(provider.providerId, provider);
}
function shouldMaskCodexNegativeBootstrapState(
@@ -97,6 +93,7 @@ function useProviderActivityDisplay({
multimodelEnabled,
codexSnapshotPending = false,
providerIds,
+ showReadyProviders,
}: Pick<
ProviderActivityStatusStripProps,
| 'cliStatus'
@@ -106,6 +103,7 @@ function useProviderActivityDisplay({
| 'multimodelEnabled'
| 'codexSnapshotPending'
| 'providerIds'
+ | 'showReadyProviders'
>): {
displayProviderIds: CliProviderId[];
providerStateMap: Map;
@@ -201,6 +199,10 @@ function useProviderActivityDisplay({
}, [errorProviderIds, loadingProviderIds, visibleProviderIds]);
const displayProviderIds = useMemo(() => {
+ if (showReadyProviders) {
+ return visibleProviderIds;
+ }
+
if (loadingProviderIds.length > 0) {
const activeCycleIds = (
cycleProviderIds.length > 0 ? cycleProviderIds : loadingProviderIds
@@ -213,7 +215,14 @@ function useProviderActivityDisplay({
}
return [];
- }, [cycleProviderIds, errorProviderIds, loadingProviderIds, providerStateMap]);
+ }, [
+ cycleProviderIds,
+ errorProviderIds,
+ loadingProviderIds,
+ providerStateMap,
+ showReadyProviders,
+ visibleProviderIds,
+ ]);
return {
displayProviderIds,
@@ -237,6 +246,9 @@ export const ProviderActivityStatusStrip = ({
providerIds,
className = '',
label = 'Provider Activity',
+ layout = 'inline',
+ showReadyProviders = false,
+ readyStatusText = 'Checked',
}: ProviderActivityStatusStripProps): React.JSX.Element | null => {
const { displayProviderIds, providerStateMap, shouldRender } = useProviderActivityDisplay({
cliStatus,
@@ -246,14 +258,24 @@ export const ProviderActivityStatusStrip = ({
multimodelEnabled,
codexSnapshotPending,
providerIds,
+ showReadyProviders,
});
if (!shouldRender) {
return null;
}
+ const rootClassName =
+ layout === 'stacked'
+ ? `flex min-w-0 flex-col items-start gap-1.5 ${className}`.trim()
+ : `flex min-w-0 flex-wrap items-center gap-2 ${className}`.trim();
+ const itemsClassName =
+ layout === 'stacked'
+ ? 'flex min-w-0 w-full flex-wrap items-center gap-1.5'
+ : 'flex min-w-0 flex-1 flex-wrap items-center gap-2';
+
return (
-
+
{label ? (
) : null}
-
+
{displayProviderIds.map((providerId) => {
const providerState = providerStateMap.get(providerId);
if (!providerState) {
@@ -280,13 +302,13 @@ export const ProviderActivityStatusStrip = ({
? 'Checking...'
: tone === 'error'
? formatProviderStatusText(providerState.provider)
- : 'Checked';
+ : readyStatusText;
return (
{
>
-
{/* Command Palette (Cmd+K) */}
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
index 05dcfe38..d57f838a 100644
--- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx
@@ -77,6 +77,7 @@ import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus
import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions';
import {
getTeamModelSelectionError,
+ isTeamProviderRuntimeStatusLoading,
normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
@@ -699,6 +700,29 @@ export const CreateTeamDialog = ({
),
[effectiveCliStatus?.providers]
);
+ const runtimeProviderLoadingById = useMemo(
+ () =>
+ new Map(
+ selectedMemberProviders.map(
+ (providerId) =>
+ [
+ providerId,
+ isTeamProviderRuntimeStatusLoading(
+ providerId,
+ runtimeProviderStatusById.get(providerId),
+ cliProviderStatusLoading[providerId] === true ||
+ (providerId === 'codex' && codexSnapshotPending)
+ ),
+ ] as const
+ )
+ ),
+ [
+ cliProviderStatusLoading,
+ codexSnapshotPending,
+ runtimeProviderStatusById,
+ selectedMemberProviders,
+ ]
+ );
const selectedProviderBackendId = useMemo(
() =>
resolveUiOwnedProviderBackendId(
@@ -1033,9 +1057,15 @@ export const CreateTeamDialog = ({
}
}
+ const loadingProviderIds = selectedMemberProviders.filter((providerId) =>
+ runtimeProviderLoadingById.get(providerId)
+ );
+ const readyProviderIds = selectedMemberProviders.filter(
+ (providerId) => !runtimeProviderLoadingById.get(providerId)
+ );
const providerPlans = buildProviderPreparePlans({
cwd: effectiveCwd,
- providerIds: selectedMemberProviders,
+ providerIds: readyProviderIds,
selectedModelChecksByProvider,
backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current,
limitContext: effectiveAnthropicRuntimeLimitContext,
@@ -1048,7 +1078,7 @@ export const CreateTeamDialog = ({
return lastSignature !== plan.requestSignature && pendingSignature !== plan.requestSignature;
});
const loadingMessage = getProvisioningProviderProgressMessage(
- changedPlans.map((plan) => plan.providerId),
+ [...loadingProviderIds, ...changedPlans.map((plan) => plan.providerId)],
selectedMemberProviders.length
);
const getSelectedWarnings = (): string[] =>
@@ -1089,6 +1119,20 @@ export const CreateTeamDialog = ({
};
let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders);
+ for (const providerId of loadingProviderIds) {
+ lastPrepareProviderSignatureByIdRef.current.delete(providerId);
+ pendingPrepareProviderSignatureByIdRef.current.delete(providerId);
+ prepareProviderRequestSeqByIdRef.current.delete(providerId);
+ prepareWarningsByProviderIdRef.current.delete(providerId);
+ checks = updateProviderCheck(checks, providerId, {
+ status: 'checking',
+ backendSummary: runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null,
+ details: [
+ `${getProviderLabel(providerId)} provider status is still loading. Model checks will start automatically.`,
+ ],
+ supportDiagnostics: undefined,
+ });
+ }
for (const plan of changedPlans) {
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
@@ -1229,6 +1273,7 @@ export const CreateTeamDialog = ({
effectiveAnthropicRuntimeLimitContext,
prepareProviderInvalidationEpochById,
runtimeProviderStatusById,
+ runtimeProviderLoadingById,
selectedModel,
selectedModelChecksByProvider,
selectedModelChecksByProviderSignature,
@@ -1724,13 +1769,15 @@ export const CreateTeamDialog = ({
}
}
- const leadError = getTeamModelSelectionError(
- selectedProviderId,
- selectedModel,
- runtimeProviderStatusById.get(selectedProviderId)
- );
- if (leadError) {
- return leadError;
+ if (!runtimeProviderLoadingById.get(selectedProviderId)) {
+ const leadError = getTeamModelSelectionError(
+ selectedProviderId,
+ selectedModel,
+ runtimeProviderStatusById.get(selectedProviderId)
+ );
+ if (leadError) {
+ return leadError;
+ }
}
for (const member of effectiveMemberDrafts) {
@@ -1739,6 +1786,9 @@ export const CreateTeamDialog = ({
}
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
+ if (runtimeProviderLoadingById.get(providerId)) {
+ continue;
+ }
const memberError = getTeamModelSelectionError(
providerId,
member.model,
@@ -1756,6 +1806,7 @@ export const CreateTeamDialog = ({
}, [
effectiveMemberDrafts,
runtimeProviderStatusById,
+ runtimeProviderLoadingById,
selectedModel,
selectedProviderId,
soloTeam,
@@ -2488,6 +2539,12 @@ export const CreateTeamDialog = ({
codexSnapshotPending={codexSnapshotPending}
providerIds={selectedMemberProviders}
className="mb-2"
+ label="Selected providers"
+ layout="stacked"
+ showReadyProviders={
+ effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading'
+ }
+ readyStatusText="Ready"
/>
) : null}
{canCreate &&
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
index 7d4fff47..a25dfb7c 100644
--- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
+++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
@@ -68,6 +68,7 @@ import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus
import { getAvailableTeamEffortValue } from '@renderer/utils/teamEffortOptions';
import {
getTeamModelSelectionError,
+ isTeamProviderRuntimeStatusLoading,
normalizeExplicitTeamModelForUi,
} from '@renderer/utils/teamModelAvailability';
import { getTeamProviderLabel as getCatalogTeamProviderLabel } from '@renderer/utils/teamModelCatalog';
@@ -609,6 +610,29 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
),
[effectiveCliStatus?.providers]
);
+ const runtimeProviderLoadingById = useMemo(
+ () =>
+ new Map(
+ selectedMemberProviders.map(
+ (providerId) =>
+ [
+ providerId,
+ isTeamProviderRuntimeStatusLoading(
+ providerId,
+ runtimeProviderStatusById.get(providerId),
+ cliProviderStatusLoading[providerId] === true ||
+ (providerId === 'codex' && codexSnapshotPending)
+ ),
+ ] as const
+ )
+ ),
+ [
+ cliProviderStatusLoading,
+ codexSnapshotPending,
+ runtimeProviderStatusById,
+ selectedMemberProviders,
+ ]
+ );
useEffect(() => {
if (!open) {
@@ -1620,9 +1644,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}
}
+ const loadingProviderIds = selectedMemberProviders.filter((providerId) =>
+ runtimeProviderLoadingById.get(providerId)
+ );
+ const readyProviderIds = selectedMemberProviders.filter(
+ (providerId) => !runtimeProviderLoadingById.get(providerId)
+ );
const providerPlans = buildProviderPreparePlans({
cwd: effectiveCwd,
- providerIds: selectedMemberProviders,
+ providerIds: readyProviderIds,
selectedModelChecksByProvider,
backendSummaryByProvider: runtimeBackendSummaryByProviderRef.current,
limitContext: effectiveAnthropicRuntimeLimitContext,
@@ -1634,7 +1664,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
lastPrepareProviderSignatureByIdRef.current.get(plan.providerId) !== plan.requestSignature
);
const loadingMessage = getProvisioningProviderProgressMessage(
- changedPlans.map((plan) => plan.providerId),
+ [...loadingProviderIds, ...changedPlans.map((plan) => plan.providerId)],
selectedMemberProviders.length
);
const getSelectedWarnings = (): string[] =>
@@ -1675,6 +1705,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
};
let checks = alignProvisioningChecks(prepareChecksRef.current, selectedMemberProviders);
+ for (const providerId of loadingProviderIds) {
+ lastPrepareProviderSignatureByIdRef.current.delete(providerId);
+ prepareProviderRequestSeqByIdRef.current.delete(providerId);
+ prepareWarningsByProviderIdRef.current.delete(providerId);
+ checks = updateProviderCheck(checks, providerId, {
+ status: 'checking',
+ backendSummary: runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null,
+ details: [
+ `${getProviderLabel(providerId)} provider status is still loading. Model checks will start automatically.`,
+ ],
+ supportDiagnostics: undefined,
+ });
+ }
for (const plan of changedPlans) {
checks = updateProviderCheck(checks, plan.providerId, {
status: plan.selectedModelIds.length > 0 ? plan.cachedSnapshot.status : 'checking',
@@ -1789,6 +1832,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
effectiveCwd,
effectiveAnthropicRuntimeLimitContext,
prepareProviderInvalidationEpochById,
+ runtimeProviderLoadingById,
runtimeProviderStatusById,
selectedMemberProviders,
selectedModelChecksByProvider,
@@ -2056,13 +2100,15 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}
}
- const leadError = getTeamModelSelectionError(
- selectedProviderId,
- selectedModel,
- runtimeProviderStatusById.get(selectedProviderId)
- );
- if (leadError) {
- return leadError;
+ if (!runtimeProviderLoadingById.get(selectedProviderId)) {
+ const leadError = getTeamModelSelectionError(
+ selectedProviderId,
+ selectedModel,
+ runtimeProviderStatusById.get(selectedProviderId)
+ );
+ if (leadError) {
+ return leadError;
+ }
}
if (!isLaunchMode) {
@@ -2075,6 +2121,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}
const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
+ if (runtimeProviderLoadingById.get(providerId)) {
+ continue;
+ }
const memberError = getTeamModelSelectionError(
providerId,
member.model,
@@ -2092,6 +2141,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}, [
effectiveMemberDrafts,
isLaunchMode,
+ runtimeProviderLoadingById,
runtimeProviderStatusById,
selectedModel,
selectedProviderId,
@@ -3054,6 +3104,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
multimodelEnabled={multimodelEnabled}
codexSnapshotPending={codexSnapshotPending}
providerIds={selectedMemberProviders}
+ label="Selected providers"
+ layout="stacked"
+ showReadyProviders={
+ effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading'
+ }
+ readyStatusText="Ready"
className="mb-2"
/>
{effectivePrepare.state === 'idle' || effectivePrepare.state === 'loading' ? (
diff --git a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts
index 2d4073f1..a46a06b5 100644
--- a/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts
+++ b/src/renderer/utils/__tests__/teamModelAvailability.codexCatalog.test.ts
@@ -5,6 +5,7 @@ import {
getAvailableTeamProviderModels,
getTeamModelSelectionError,
isTeamModelAvailableForUi,
+ isTeamProviderModelVerificationPending,
normalizeTeamModelForUi,
} from '../teamModelAvailability';
@@ -204,6 +205,32 @@ describe('team model availability Codex catalog integration', () => {
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
});
+ it('does not reject a selected Codex model while provider status is still loading', () => {
+ const providerStatus = {
+ ...createCodexProviderStatus([
+ {
+ id: 'gpt-5.1-codex',
+ launchModel: 'gpt-5.1-codex',
+ displayName: 'GPT-5.1 Codex',
+ hidden: false,
+ supportedReasoningEfforts: ['low', 'medium', 'high'],
+ defaultReasoningEffort: 'medium',
+ inputModalities: ['text', 'image'],
+ supportsPersonality: false,
+ isDefault: true,
+ upgrade: false,
+ source: 'static-fallback',
+ },
+ ]),
+ verificationState: 'unknown' as const,
+ statusMessage: 'Checking...',
+ };
+
+ expect(isTeamProviderModelVerificationPending('codex', providerStatus)).toBe(true);
+ expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
+ expect(normalizeTeamModelForUi('codex', 'gpt-5.5', providerStatus)).toBe('gpt-5.5');
+ });
+
it('orders GPT-5.5 first after the virtual default option', () => {
const providerStatus = createCodexProviderStatus([
{
diff --git a/src/renderer/utils/teamModelAvailability.ts b/src/renderer/utils/teamModelAvailability.ts
index 68d5af04..9b11f080 100644
--- a/src/renderer/utils/teamModelAvailability.ts
+++ b/src/renderer/utils/teamModelAvailability.ts
@@ -1,3 +1,5 @@
+import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller';
+
import {
getProviderScopedTeamModelLabel,
getRuntimeAwareProviderScopedTeamModelLabel,
@@ -43,6 +45,7 @@ export type TeamModelRuntimeProviderStatus = Pick<
| 'providerId'
| 'models'
| 'modelCatalog'
+ | 'modelCatalogRefreshState'
| 'modelAvailability'
| 'modelVerificationState'
| 'runtimeCapabilities'
@@ -163,6 +166,21 @@ export function isTeamProviderModelVerificationPending(
return true;
}
+ const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? '';
+ const statusMessagePending =
+ statusMessage === 'checking...' ||
+ statusMessage === CLI_PROVIDER_STATUS_DEFERRED_MESSAGE.toLowerCase();
+ if (providerStatus.verificationState !== 'error' && statusMessagePending) {
+ return true;
+ }
+
+ if (
+ providerStatus.verificationState !== 'error' &&
+ providerStatus.modelCatalogRefreshState === 'loading'
+ ) {
+ return true;
+ }
+
const hasRuntimeModelTruth =
providerStatus.models.length > 0 ||
(providerStatus.modelCatalog?.models.length ?? 0) > 0 ||
@@ -193,10 +211,25 @@ export function isTeamProviderModelVerificationPending(
return false;
}
- const statusMessage = providerStatus.statusMessage?.trim().toLowerCase() ?? '';
return statusMessage.length === 0 || statusMessage === 'checking...';
}
+export function isTeamProviderRuntimeStatusLoading(
+ providerId: SupportedProviderId | undefined,
+ providerStatus?: TeamModelRuntimeProviderStatus | null,
+ providerLoading = false
+): boolean {
+ if (!providerId) {
+ return false;
+ }
+
+ if (providerLoading) {
+ return true;
+ }
+
+ return isTeamProviderModelVerificationPending(providerId, providerStatus);
+}
+
function getFallbackTeamProviderModels(providerId: SupportedProviderId): string[] {
return getVisibleTeamProviderModels(
providerId,
diff --git a/test/renderer/components/common/GlobalProviderStatusHeader.test.ts b/test/renderer/components/common/GlobalProviderStatusHeader.test.ts
deleted file mode 100644
index c28c352a..00000000
--- a/test/renderer/components/common/GlobalProviderStatusHeader.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import React, { act } from 'react';
-import { createRoot } from 'react-dom/client';
-
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-
-interface StoreState {
- cliStatus: Record
| null;
- cliStatusLoading: boolean;
- cliProviderStatusLoading: Record;
- appConfig: {
- general: {
- multimodelEnabled: boolean;
- };
- };
- paneLayout: {
- focusedPaneId: string;
- panes: Array<{
- id: string;
- activeTabId: string | null;
- tabs: Array<{
- id: string;
- type: string;
- }>;
- }>;
- };
-}
-
-const storeState = {} as StoreState;
-const codexAccountHookState = {
- snapshot: null,
- loading: false,
- error: null,
- refresh: vi.fn(() => Promise.resolve(undefined)),
- startChatgptLogin: vi.fn(() => Promise.resolve(true)),
- cancelChatgptLogin: vi.fn(() => Promise.resolve(true)),
- logout: vi.fn(() => Promise.resolve(true)),
-};
-
-vi.mock('@renderer/api', () => ({
- isElectronMode: () => true,
-}));
-
-vi.mock('@renderer/components/common/ProviderBrandLogo', () => ({
- ProviderBrandLogo: ({ providerId }: { providerId: string }) =>
- React.createElement('span', { 'data-testid': `provider-logo-${providerId}` }, providerId),
-}));
-
-vi.mock('@features/codex-account/renderer', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- useCodexAccountSnapshot: () => codexAccountHookState,
- };
-});
-
-vi.mock('@renderer/store', () => ({
- useStore: (selector: (state: StoreState) => unknown) => selector(storeState),
-}));
-
-import { GlobalProviderStatusHeader } from '@renderer/components/common/GlobalProviderStatusHeader';
-
-function createProvider(overrides: Record): Record {
- return {
- providerId: 'anthropic',
- displayName: 'Anthropic',
- supported: true,
- authenticated: false,
- authMethod: null,
- verificationState: 'unknown',
- statusMessage: 'Checking...',
- detailMessage: null,
- models: [],
- modelVerificationState: 'idle',
- modelAvailability: [],
- canLoginFromUi: true,
- capabilities: {
- teamLaunch: true,
- oneShot: true,
- extensions: {},
- },
- backend: null,
- availableBackends: [],
- connection: null,
- ...overrides,
- };
-}
-
-function createMultimodelStatus(providers: Record[]): Record {
- return {
- flavor: 'agent_teams_orchestrator',
- displayName: 'Multimodel runtime',
- supportsSelfUpdate: false,
- showVersionDetails: false,
- showBinaryPath: false,
- installed: true,
- installedVersion: '0.0.3',
- binaryPath: '/tmp/claude-multimodel',
- latestVersion: null,
- updateAvailable: false,
- authLoggedIn: providers.some((provider) => provider.authenticated === true),
- authStatusChecking: false,
- authMethod: null,
- providers,
- };
-}
-
-function setFocusedTab(type: string): void {
- storeState.paneLayout = {
- focusedPaneId: 'pane-1',
- panes: [
- {
- id: 'pane-1',
- activeTabId: type === 'empty' ? null : 'tab-1',
- tabs: type === 'empty' ? [] : [{ id: 'tab-1', type }],
- },
- ],
- };
-}
-
-describe('GlobalProviderStatusHeader', () => {
- beforeEach(() => {
- vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
- storeState.cliStatus = createMultimodelStatus([createProvider({})]);
- storeState.cliStatusLoading = false;
- storeState.cliProviderStatusLoading = { anthropic: true };
- storeState.appConfig = {
- general: {
- multimodelEnabled: true,
- },
- };
- setFocusedTab('team');
- });
-
- afterEach(() => {
- document.body.innerHTML = '';
- vi.unstubAllGlobals();
- });
-
- it('shows provider activity on non-dashboard screens', async () => {
- const host = document.createElement('div');
- document.body.appendChild(host);
- const root = createRoot(host);
-
- await act(async () => {
- root.render(React.createElement(GlobalProviderStatusHeader));
- await Promise.resolve();
- });
-
- expect(host.textContent).toContain('Provider Activity');
- expect(host.textContent).toContain('Anthropic');
- expect(host.textContent).toContain('Checking...');
-
- await act(async () => {
- root.unmount();
- await Promise.resolve();
- });
- });
-
- it('hides on dashboard screens', async () => {
- setFocusedTab('dashboard');
- const host = document.createElement('div');
- document.body.appendChild(host);
- const root = createRoot(host);
-
- await act(async () => {
- root.render(React.createElement(GlobalProviderStatusHeader));
- await Promise.resolve();
- });
-
- expect(host.textContent).toBe('');
-
- await act(async () => {
- root.unmount();
- await Promise.resolve();
- });
- });
-});
From 7e8f4b377dd6aaa542ead41daa45e9314206a6ca Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 15:58:38 +0300
Subject: [PATCH 10/16] feat(logs): add compact lead log source selector
- Add avatar trigger mode to MemberSelect for dense toolbar surfaces.
- Render the lead log source selector beside compact sidebar log search and filters.
- Cover toolbar accessory rendering, avatar trigger behavior and lead alias detection.
---
.../components/team/ClaudeLogsPanel.tsx | 17 ++++-
.../components/team/ClaudeLogsSection.tsx | 21 ++++++
.../components/team/claudeLogsSourceMember.ts | 11 +++
src/renderer/components/ui/MemberSelect.tsx | 73 +++++++++++++++----
.../components/team/ClaudeLogsPanel.test.ts | 52 +++++++++++++
.../components/team/ClaudeLogsSection.test.ts | 29 ++++++++
.../components/ui/MemberSelect.test.tsx | 64 ++++++++++++++++
7 files changed, 252 insertions(+), 15 deletions(-)
create mode 100644 src/renderer/components/team/claudeLogsSourceMember.ts
create mode 100644 test/renderer/components/team/ClaudeLogsSection.test.ts
create mode 100644 test/renderer/components/ui/MemberSelect.test.tsx
diff --git a/src/renderer/components/team/ClaudeLogsPanel.tsx b/src/renderer/components/team/ClaudeLogsPanel.tsx
index 05b5784c..4325808f 100644
--- a/src/renderer/components/team/ClaudeLogsPanel.tsx
+++ b/src/renderer/components/team/ClaudeLogsPanel.tsx
@@ -29,6 +29,7 @@ interface ClaudeLogsPanelProps {
/** Extra className for the panel wrapper. */
className?: string;
compactMetaInTooltip?: boolean;
+ toolbarAccessory?: React.ReactNode;
}
// =============================================================================
@@ -41,6 +42,7 @@ export const ClaudeLogsPanel = ({
viewerMaxHeight,
className,
compactMetaInTooltip = false,
+ toolbarAccessory,
}: ClaudeLogsPanelProps): React.JSX.Element => {
const {
data,
@@ -86,10 +88,20 @@ export const ClaudeLogsPanel = ({
'Team is not running.'
)}
-
+
{data.total > 0 ? (
<>
-
+
)}
+ {toolbarAccessory}
selectResolvedMembersForTeamName(s, teamName));
const [dialogOpen, setDialogOpen] = useState(false);
const isSidebar = position === 'sidebar';
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
+ const leadLogMember = useMemo(
+ () => resolvedMembers.find((member) => !member.removedAt && isLeadLogSourceMember(member)),
+ [resolvedMembers]
+ );
+ const sidebarLogSourceSelect =
+ isSidebar && leadLogMember ? (
+ undefined}
+ size="sm"
+ triggerVariant="avatar"
+ popoverAlign="end"
+ />
+ ) : null;
const sectionHeaderExtra = useMemo(
() => (
@@ -173,6 +193,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
viewerClassName={cn('max-h-[213px]', isSidebar && 'cli-logs-sidebar')}
viewerMaxHeight={isSidebar ? sidebarViewerMaxHeight : undefined}
compactMetaInTooltip={isSidebar}
+ toolbarAccessory={sidebarLogSourceSelect}
/>
)}
diff --git a/src/renderer/components/team/claudeLogsSourceMember.ts b/src/renderer/components/team/claudeLogsSourceMember.ts
new file mode 100644
index 00000000..2cf41b7d
--- /dev/null
+++ b/src/renderer/components/team/claudeLogsSourceMember.ts
@@ -0,0 +1,11 @@
+import { isLeadMember } from '@shared/utils/leadDetection';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+export function isLeadLogSourceMember(member: ResolvedTeamMember): boolean {
+ if (isLeadMember(member)) return true;
+ const normalizedName = member.name.trim().toLowerCase();
+ if (normalizedName === 'lead') return true;
+ const normalizedRole = member.role?.trim().toLowerCase();
+ return normalizedRole === 'lead' || normalizedRole === 'team lead';
+}
diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx
index 0f80cccf..ec79b656 100644
--- a/src/renderer/components/ui/MemberSelect.tsx
+++ b/src/renderer/components/ui/MemberSelect.tsx
@@ -8,9 +8,10 @@ import {
agentAvatarUrl,
buildMemberAvatarMap,
buildMemberColorMap,
+ displayMemberName,
} from '@renderer/utils/memberHelpers';
import { Command as CommandPrimitive } from 'cmdk';
-import { Check, ChevronsUpDown } from 'lucide-react';
+import { Check, ChevronsUpDown, UserRound } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
@@ -25,6 +26,9 @@ interface MemberSelectProps {
allowUnassigned?: boolean;
/** Size variant */
size?: 'sm' | 'md';
+ /** Full select by default. Avatar mode is for dense toolbars/sidebar surfaces. */
+ triggerVariant?: 'default' | 'avatar';
+ popoverAlign?: 'start' | 'center' | 'end';
disabled?: boolean;
className?: string;
}
@@ -38,6 +42,8 @@ export const MemberSelect = ({
placeholder = 'Select member...',
allowUnassigned = false,
size = 'sm',
+ triggerVariant = 'default',
+ popoverAlign,
disabled = false,
className,
}: MemberSelectProps): React.JSX.Element => {
@@ -57,6 +63,28 @@ export const MemberSelect = ({
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
const textSize = size === 'md' ? 'text-xs' : 'text-[10px]';
const triggerHeight = size === 'md' ? 'h-9' : 'h-8';
+ const isAvatarTrigger = triggerVariant === 'avatar';
+ const effectivePopoverAlign = popoverAlign ?? (isAvatarTrigger ? 'end' : 'start');
+ const avatarTriggerSize = size === 'md' ? 'size-9' : 'size-8';
+ const selectedLabel =
+ selectedMember != null
+ ? displayMemberName(selectedMember.name)
+ : value
+ ? displayMemberName(value)
+ : allowUnassigned
+ ? 'Unassigned'
+ : placeholder;
+
+ const renderAvatarByName = (name: string): React.ReactNode => (
+
+ );
+ const renderMemberAvatar = (member: ResolvedTeamMember): React.ReactNode =>
+ renderAvatarByName(member.name);
// eslint-disable-next-line sonarjs/function-return-type -- option renderer returns mixed node structure
const renderMemberInline = (member: ResolvedTeamMember): React.ReactNode => {
@@ -90,29 +118,48 @@ export const MemberSelect = ({
-
- {selectedMember ? (
- renderMemberInline(selectedMember)
- ) : value === null && allowUnassigned ? (
- Unassigned
+ {isAvatarTrigger ? (
+ selectedMember ? (
+ renderMemberAvatar(selectedMember)
+ ) : value ? (
+ renderAvatarByName(value)
) : (
- {placeholder}
- )}
-
-
+
+ )
+ ) : (
+ <>
+
+ {selectedMember ? (
+ renderMemberInline(selectedMember)
+ ) : value === null && allowUnassigned ? (
+ Unassigned
+ ) : (
+ {placeholder}
+ )}
+
+
+ >
+ )}
{
await Promise.resolve();
});
});
+
+ it('renders toolbar accessory beside log search and filters', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const ctrl = createController({
+ isAlive: true,
+ data: {
+ lines: ['[stdout] ready'],
+ total: 1,
+ hasMore: false,
+ },
+ filteredText: '[stdout]\nready',
+ });
+
+ await act(async () => {
+ root.render(
+ React.createElement(ClaudeLogsPanel, {
+ ctrl,
+ compactMetaInTooltip: true,
+ toolbarAccessory: React.createElement(
+ 'button',
+ { type: 'button', 'data-testid': 'log-member-selector' },
+ 'Lead'
+ ),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const search = host.querySelector('input[placeholder="Search logs..."]');
+ const accessory = host.querySelector('[data-testid="log-member-selector"]');
+ const filter = host.querySelector('[data-testid="logs-filter"]');
+
+ expect(search).not.toBeNull();
+ expect(accessory).not.toBeNull();
+ expect(filter).not.toBeNull();
+ expect(search?.parentElement?.className).toContain('flex-1');
+ expect(search?.compareDocumentPosition(accessory as Node) ?? 0).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING
+ );
+ expect(accessory?.compareDocumentPosition(filter as Node) ?? 0).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING
+ );
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
});
diff --git a/test/renderer/components/team/ClaudeLogsSection.test.ts b/test/renderer/components/team/ClaudeLogsSection.test.ts
new file mode 100644
index 00000000..e4683640
--- /dev/null
+++ b/test/renderer/components/team/ClaudeLogsSection.test.ts
@@ -0,0 +1,29 @@
+import { isLeadLogSourceMember } from '@renderer/components/team/claudeLogsSourceMember';
+import { describe, expect, it } from 'vitest';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+function member(overrides: Partial): ResolvedTeamMember {
+ return {
+ name: 'alice',
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ ...overrides,
+ };
+}
+
+describe('isLeadLogSourceMember', () => {
+ it('accepts canonical and cached lead aliases for compact log source UI', () => {
+ expect(isLeadLogSourceMember(member({ name: 'team-lead' }))).toBe(true);
+ expect(isLeadLogSourceMember(member({ name: 'Lead' }))).toBe(true);
+ expect(isLeadLogSourceMember(member({ name: 'current', role: 'Team Lead' }))).toBe(true);
+ });
+
+ it('does not treat arbitrary leadership-like roles as the lead log source', () => {
+ expect(isLeadLogSourceMember(member({ name: 'alice', role: 'Tech Lead' }))).toBe(false);
+ expect(isLeadLogSourceMember(member({ name: 'lead-reviewer' }))).toBe(false);
+ });
+});
diff --git a/test/renderer/components/ui/MemberSelect.test.tsx b/test/renderer/components/ui/MemberSelect.test.tsx
new file mode 100644
index 00000000..b9083e87
--- /dev/null
+++ b/test/renderer/components/ui/MemberSelect.test.tsx
@@ -0,0 +1,64 @@
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { MemberSelect } from '@renderer/components/ui/MemberSelect';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+function member(name: string): ResolvedTeamMember {
+ return {
+ name,
+ status: 'active',
+ currentTaskId: null,
+ taskCount: 0,
+ lastActiveAt: null,
+ messageCount: 0,
+ };
+}
+
+describe('MemberSelect', () => {
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.unstubAllGlobals();
+ });
+
+ it('uses an avatar trigger for dense surfaces while keeping the full member list popover', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onChange = vi.fn();
+
+ await act(async () => {
+ root.render(
+
+ );
+ await Promise.resolve();
+ });
+
+ const trigger = host.querySelector('button[role="combobox"]') as HTMLButtonElement | null;
+ expect(trigger).not.toBeNull();
+ expect(trigger?.getAttribute('aria-label')).toBe('Select member: Lead');
+ expect(trigger?.getAttribute('title')).toBe('Lead');
+ expect(host.textContent).not.toContain('Lead');
+
+ await act(async () => {
+ trigger?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(document.body.textContent).toContain('Lead');
+ expect(document.body.textContent).toContain('Alice');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+});
From 5abd096c61567c937783a69df347654de6db08fc Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 16:05:57 +0300
Subject: [PATCH 11/16] fix: address log filter review feedback
---
.../team/useClaudeLogsController.ts | 2 +-
src/renderer/components/ui/MemberSelect.tsx | 4 +-
.../team/useClaudeLogsController.test.tsx | 41 +++++++++++++++++++
.../components/ui/MemberSelect.test.tsx | 24 +++++------
4 files changed, 54 insertions(+), 17 deletions(-)
diff --git a/src/renderer/components/team/useClaudeLogsController.ts b/src/renderer/components/team/useClaudeLogsController.ts
index 6237c700..25b2bc87 100644
--- a/src/renderer/components/team/useClaudeLogsController.ts
+++ b/src/renderer/components/team/useClaudeLogsController.ts
@@ -514,7 +514,7 @@ export function useClaudeLogsController(
};
void fetchLogs({ queueIfBusy: true });
- const id = window.setInterval(() => void fetchLogs(), POLL_MS);
+ const id = window.setInterval(() => void fetchLogs({ queueIfBusy: true }), POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx
index 1ece6e05..7c1d23a7 100644
--- a/src/renderer/components/ui/MemberSelect.tsx
+++ b/src/renderer/components/ui/MemberSelect.tsx
@@ -46,8 +46,8 @@ export const MemberSelect = ({
size = 'sm',
disabled = false,
className,
- searchPlaceholder = 'Search members...',
- emptyMessage = 'No members found.',
+ searchPlaceholder,
+ emptyMessage,
getMemberLabel,
getMemberDescription,
ariaLabel,
diff --git a/test/renderer/components/team/useClaudeLogsController.test.tsx b/test/renderer/components/team/useClaudeLogsController.test.tsx
index e0c34a99..e70c8877 100644
--- a/test/renderer/components/team/useClaudeLogsController.test.tsx
+++ b/test/renderer/components/team/useClaudeLogsController.test.tsx
@@ -92,6 +92,7 @@ describe('useClaudeLogsController enabled option', () => {
afterEach(() => {
document.body.innerHTML = '';
+ vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllGlobals();
});
@@ -170,6 +171,46 @@ describe('useClaudeLogsController enabled option', () => {
});
});
+ it('queues interval-driven polls when the current request is still in flight', async () => {
+ vi.useFakeTimers();
+ const firstRequest = createDeferred();
+ controllerState.getClaudeLogs
+ .mockReturnValueOnce(firstRequest.promise)
+ .mockResolvedValue(createLogsResponse('interval fresh lead'));
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(React.createElement(ControllerHarness, { enabled: true }));
+ await Promise.resolve();
+ });
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(2000);
+ await Promise.resolve();
+ });
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ firstRequest.resolve(createLogsResponse('stale lead'));
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(controllerState.getClaudeLogs).toHaveBeenCalledTimes(2);
+ expect(controllerState.getClaudeLogs).toHaveBeenLastCalledWith('demo-team', {
+ offset: 0,
+ limit: 100,
+ });
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
it('does not run a queued lead fetch after being disabled again', async () => {
const firstRequest = createDeferred();
controllerState.getClaudeLogs
diff --git a/test/renderer/components/ui/MemberSelect.test.tsx b/test/renderer/components/ui/MemberSelect.test.tsx
index 50bb4b63..248e2e1b 100644
--- a/test/renderer/components/ui/MemberSelect.test.tsx
+++ b/test/renderer/components/ui/MemberSelect.test.tsx
@@ -112,20 +112,16 @@ describe('MemberSelect', () => {
searchPlaceholder="Search log sources..."
emptyMessage="No log sources found."
ariaLabel="Log source"
- getMemberLabel={(candidate) =>
- candidate.name === 'team-lead'
- ? 'Lead'
- : candidate.removedAt
- ? `${candidate.name} (removed)`
- : candidate.name
- }
- getMemberDescription={(candidate) =>
- candidate.name === 'team-lead'
- ? 'Team Lead'
- : candidate.removedAt
- ? 'Removed'
- : 'Reviewer'
- }
+ getMemberLabel={(candidate) => {
+ if (candidate.name === 'team-lead') return 'Lead';
+ if (candidate.removedAt) return `${candidate.name} (removed)`;
+ return candidate.name;
+ }}
+ getMemberDescription={(candidate) => {
+ if (candidate.name === 'team-lead') return 'Team Lead';
+ if (candidate.removedAt) return 'Removed';
+ return 'Reviewer';
+ }}
/>
);
await flush();
From 6ebe375cf9147daf1971bf67404d6c8ff5afd9a6 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 16:43:16 +0300
Subject: [PATCH 12/16] fix: restore runtime status ci expectations
---
.../renderer/locales/en/team.json | 4 ++
.../renderer/locales/ru/team.json | 4 ++
.../localization/renderer/resources.d.ts | 4 ++
.../runtime/providerConnectionUi.ts | 43 ++++++++++++++++---
.../ProvisioningProviderStatusList.tsx | 12 +-----
5 files changed, 50 insertions(+), 17 deletions(-)
diff --git a/src/features/localization/renderer/locales/en/team.json b/src/features/localization/renderer/locales/en/team.json
index cc284f8d..bb5fd673 100644
--- a/src/features/localization/renderer/locales/en/team.json
+++ b/src/features/localization/renderer/locales/en/team.json
@@ -2062,6 +2062,10 @@
},
"joining": {
"teammatesStillJoining": "{{count}} teammates still joining",
+ "teammatesStillJoining_one": "{{count}} teammate still joining",
+ "teammatesStillJoining_few": "{{count}} teammates still joining",
+ "teammatesStillJoining_many": "{{count}} teammates still joining",
+ "teammatesStillJoining_other": "{{count}} teammates still joining",
"teammatesConfirmedRatio": "{{count}}/{{total}} teammates confirmed"
},
"ready": {
diff --git a/src/features/localization/renderer/locales/ru/team.json b/src/features/localization/renderer/locales/ru/team.json
index 159379e7..4eb9fd8e 100644
--- a/src/features/localization/renderer/locales/ru/team.json
+++ b/src/features/localization/renderer/locales/ru/team.json
@@ -2062,6 +2062,10 @@
},
"joining": {
"teammatesStillJoining": "{{count}} участник(ов) ещё подключается",
+ "teammatesStillJoining_one": "{{count}} участник ещё подключается",
+ "teammatesStillJoining_few": "{{count}} участника ещё подключаются",
+ "teammatesStillJoining_many": "{{count}} участников ещё подключается",
+ "teammatesStillJoining_other": "{{count}} участника(ов) ещё подключается",
"teammatesConfirmedRatio": "{{count}}/{{total}} участников подтверждено"
},
"ready": {
diff --git a/src/features/localization/renderer/resources.d.ts b/src/features/localization/renderer/resources.d.ts
index 96aec47d..884e9862 100644
--- a/src/features/localization/renderer/resources.d.ts
+++ b/src/features/localization/renderer/resources.d.ts
@@ -4503,6 +4503,10 @@ export default interface Resources {
joining: {
teammatesConfirmedRatio: '{{count}}/{{total}} teammates confirmed';
teammatesStillJoining: '{{count}} teammates still joining';
+ teammatesStillJoining_few: '{{count}} teammates still joining';
+ teammatesStillJoining_many: '{{count}} teammates still joining';
+ teammatesStillJoining_one: '{{count}} teammate still joining';
+ teammatesStillJoining_other: '{{count}} teammates still joining';
};
nameListWithMore: '{{names}}, +{{count}} more';
namedPendingDiagnostic: '{{label}}: {{names}}';
diff --git a/src/renderer/components/runtime/providerConnectionUi.ts b/src/renderer/components/runtime/providerConnectionUi.ts
index d1b7c22d..e5fbb883 100644
--- a/src/renderer/components/runtime/providerConnectionUi.ts
+++ b/src/renderer/components/runtime/providerConnectionUi.ts
@@ -2,7 +2,32 @@ import { CLI_PROVIDER_STATUS_DEFERRED_MESSAGE } from '@shared/types/cliInstaller
import type { CliProviderAuthMode, CliProviderStatus } from '@shared/types';
-type ProviderConnectionTranslator = unknown;
+type ProviderConnectionTranslator = object;
+
+function interpolateProviderConnectionFallback(
+ value: string,
+ options?: Record
+): string {
+ if (!options) {
+ return value;
+ }
+
+ return value.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match: string, optionKey: string) => {
+ const optionValue = options[optionKey];
+ if (optionValue === undefined || optionValue === null) {
+ return match;
+ }
+ if (
+ typeof optionValue === 'string' ||
+ typeof optionValue === 'number' ||
+ typeof optionValue === 'boolean' ||
+ typeof optionValue === 'bigint'
+ ) {
+ return String(optionValue);
+ }
+ return match;
+ });
+}
function translateProviderConnection(
t: ProviderConnectionTranslator | undefined,
@@ -10,14 +35,20 @@ function translateProviderConnection(
fallback: string,
options?: Record
): string {
+ const interpolatedFallback = interpolateProviderConnectionFallback(fallback, options);
if (!t) {
- return fallback;
+ return interpolatedFallback;
}
- return (t as (translationKey: string, options?: Record) => string)(key, {
- defaultValue: fallback,
- ...options,
- });
+ const translated = (t as (translationKey: string, options?: Record) => string)(
+ key,
+ {
+ ...options,
+ defaultValue: fallback,
+ }
+ );
+
+ return interpolateProviderConnectionFallback(translated, options);
}
const CODEX_NATIVE_LABEL = 'Codex native';
diff --git a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
index fe6957e7..5163256f 100644
--- a/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
+++ b/src/renderer/components/team/dialogs/ProvisioningProviderStatusList.tsx
@@ -895,16 +895,6 @@ function getProvisioningProviderSettingsActionLabel(
: null;
}
-function getDisplayDetailText(
- detail: string,
- status: ProvisioningProviderCheckStatus,
- providerId: TeamProviderId,
- t: TeamTranslator
-): string {
- const summary = summarizeDetail(detail, status, providerId);
- return summary ? localizeProvisioningDetailSummary(summary, t) : detail;
-}
-
function getSupportDiagnosticsPayload(check: ProvisioningProviderCheck): string | null {
if (check.providerId !== 'opencode') {
return null;
@@ -990,7 +980,7 @@ export const ProvisioningProviderStatusList = ({
check.providerId
)}`}
>
- {getDisplayDetailText(detail, check.status, check.providerId, t)}
+ {detail}
))}
From 4a4c67fcb9f2223a65d8eb1823dd5032965e11bc Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 17:02:36 +0300
Subject: [PATCH 13/16] fix(team): reconcile bootstrap runtime snapshots
---
.../services/team/TeamProvisioningService.ts | 61 +++++++++---
.../team/TeamProvisioningService.test.ts | 94 ++++++++++++++++++-
2 files changed, 142 insertions(+), 13 deletions(-)
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 0a60789d..76dc33f9 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -11219,7 +11219,6 @@ export class TeamProvisioningService {
updatedAt: input.observedAt,
});
await this.writeLaunchStateSnapshot(input.teamName, snapshot);
- this.invalidateRuntimeSnapshotCaches(input.teamName);
if (shouldEmitMemberSpawnChange) {
this.teamChangeEmitter?.({
type: 'member-spawn',
@@ -13161,8 +13160,8 @@ export class TeamProvisioningService {
await this.launchStateStore.read(teamName)
);
- const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
const spawnStatusSnapshot = await this.getMemberSpawnStatuses(teamName).catch(() => null);
+ const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
const activeRuntimeRunId =
run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || '';
const spawnStatusRunId = spawnStatusSnapshot?.runId?.trim() ?? '';
@@ -13476,6 +13475,9 @@ export class TeamProvisioningService {
launchMember?.launchState === 'confirmed_alive' ||
spawnStatusMember?.bootstrapConfirmed === true ||
spawnStatusMember?.launchState === 'confirmed_alive';
+ const spawnStatusConfirmsBootstrap =
+ spawnStatusMember?.bootstrapConfirmed === true ||
+ spawnStatusMember?.launchState === 'confirmed_alive';
const hasOpenCodeRuntimeHandle =
isOpenCodeMember &&
(typeof liveRuntimeMember?.pid === 'number' ||
@@ -13489,22 +13491,54 @@ export class TeamProvisioningService {
spawnStatusMember?.hardFailure !== true &&
spawnStatusMember?.launchState !== 'failed_to_start' &&
spawnStatusMember?.launchState !== 'runtime_pending_permission';
- const effectiveAlive = liveRuntimeMember?.alive === true || confirmedOpenCodeRuntimeAlive;
+ const confirmedSpawnRuntimeFallback =
+ !isOpenCodeMember &&
+ spawnStatusConfirmsBootstrap &&
+ spawnStatusMember?.hardFailure !== true &&
+ spawnStatusMember?.launchState !== 'failed_to_start' &&
+ !isStrongRuntimeEvidence(liveRuntimeMember);
+ const confirmedSpawnRuntimeDiagnostic =
+ spawnStatusMember?.runtimeDiagnostic ?? liveRuntimeMember?.runtimeDiagnostic;
+ const shouldKeepConfirmedSpawnRuntimeDiagnostic =
+ !!confirmedSpawnRuntimeDiagnostic &&
+ !shouldClearRuntimeDiagnosticAfterBootstrapConfirmation(confirmedSpawnRuntimeDiagnostic);
+ const effectiveAlive =
+ liveRuntimeMember?.alive === true ||
+ confirmedOpenCodeRuntimeAlive ||
+ confirmedSpawnRuntimeFallback;
const effectiveLivenessKind =
confirmedOpenCodeRuntimeAlive &&
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
? 'confirmed_bootstrap'
- : liveRuntimeMember?.livenessKind;
+ : confirmedSpawnRuntimeFallback
+ ? 'confirmed_bootstrap'
+ : liveRuntimeMember?.livenessKind;
+ const effectivePidSource =
+ confirmedSpawnRuntimeFallback &&
+ (liveRuntimeMember?.pidSource === 'persisted_metadata' ||
+ liveRuntimeMember?.pidSource == null)
+ ? 'runtime_bootstrap'
+ : liveRuntimeMember?.pidSource;
const effectiveRuntimeDiagnostic =
confirmedOpenCodeRuntimeAlive &&
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
? 'OpenCode bootstrap confirmed; runtime host/session evidence present.'
- : liveRuntimeMember?.runtimeDiagnostic;
+ : confirmedSpawnRuntimeFallback
+ ? shouldKeepConfirmedSpawnRuntimeDiagnostic
+ ? confirmedSpawnRuntimeDiagnostic
+ : 'bootstrap confirmed'
+ : liveRuntimeMember?.runtimeDiagnostic;
const effectiveRuntimeDiagnosticSeverity =
confirmedOpenCodeRuntimeAlive &&
liveRuntimeMember?.livenessKind === 'runtime_process_candidate'
? 'info'
- : liveRuntimeMember?.runtimeDiagnosticSeverity;
+ : confirmedSpawnRuntimeFallback
+ ? shouldKeepConfirmedSpawnRuntimeDiagnostic
+ ? (spawnStatusMember?.runtimeDiagnosticSeverity ??
+ liveRuntimeMember?.runtimeDiagnosticSeverity ??
+ 'info')
+ : 'info'
+ : liveRuntimeMember?.runtimeDiagnosticSeverity;
if (
rssPid &&
!usageStatsByPid.has(rssPid) &&
@@ -13584,7 +13618,7 @@ export class TeamProvisioningService {
...(usageStats?.runtimeLoadTruncated ? { runtimeLoadTruncated: true } : {}),
...(resourceHistory && resourceHistory.length > 0 ? { resourceHistory } : {}),
...(effectiveLivenessKind ? { livenessKind: effectiveLivenessKind } : {}),
- ...(liveRuntimeMember?.pidSource ? { pidSource: liveRuntimeMember.pidSource } : {}),
+ ...(effectivePidSource ? { pidSource: effectivePidSource } : {}),
...(liveRuntimeMember?.processCommand
? { processCommand: liveRuntimeMember.processCommand }
: {}),
@@ -15486,7 +15520,6 @@ export class TeamProvisioningService {
updatedAt,
});
await this.writeLaunchStateSnapshot(teamName, nextSnapshot);
- this.invalidateRuntimeSnapshotCaches(teamName);
}
private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun {
@@ -25166,6 +25199,7 @@ export class TeamProvisioningService {
await this.launchStateStore.clear(teamName);
this.launchStateWrittenRunIdByTeam.delete(teamName);
await clearBootstrapState(teamName);
+ this.invalidateRuntimeSnapshotCaches(teamName);
}
private async applyOpenCodeSecondaryEvidenceOverlay(params: {
@@ -25429,9 +25463,13 @@ export class TeamProvisioningService {
teamName: string,
snapshot: PersistedTeamLaunchSnapshot
): Promise {
- const result = await this.enqueueLaunchStateStoreOperation(teamName, () =>
- this.writeLaunchStateSnapshotNow(teamName, snapshot)
- );
+ const result = await this.enqueueLaunchStateStoreOperation(teamName, async () => {
+ const writeResult = await this.writeLaunchStateSnapshotNow(teamName, snapshot);
+ if (writeResult.wrote) {
+ this.invalidateRuntimeSnapshotCaches(teamName);
+ }
+ return writeResult;
+ });
return result.snapshot;
}
@@ -26851,7 +26889,6 @@ export class TeamProvisioningService {
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
await this.clearPersistedLaunchStateNow(run.teamName, { expectedRunId: run.runId });
- this.invalidateRuntimeSnapshotCaches(run.teamName);
return null;
}
diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts
index 23cc5a35..91dfd4dd 100644
--- a/test/main/services/team/TeamProvisioningService.test.ts
+++ b/test/main/services/team/TeamProvisioningService.test.ts
@@ -2993,6 +2993,22 @@ describe('TeamProvisioningService', () => {
).toBe(false);
});
+ it('invalidates runtime cache when launch-state is cleared', async () => {
+ const svc = new TeamProvisioningService();
+ const teamName = 'launch-state-clear-invalidates-runtime-cache';
+ (svc as any).launchStateStore = {
+ read: vi.fn(async () => null),
+ write: vi.fn(async () => {}),
+ clear: vi.fn(async () => {}),
+ };
+ const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches');
+
+ await (svc as any).clearPersistedLaunchState(teamName);
+
+ expect((svc as any).launchStateStore.clear).toHaveBeenCalledWith(teamName);
+ expect(invalidateRuntime).toHaveBeenCalledTimes(1);
+ });
+
it('does not rewrite launch-state or invalidate runtime cache for a recent semantic no-op', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z'));
@@ -4779,6 +4795,82 @@ describe('TeamProvisioningService', () => {
});
});
+ it('reconciles persisted launch state before building runtime snapshot metadata', async () => {
+ const teamName = 'zz-runtime-snapshot-reconciles-before-live-metadata';
+ const leadSessionId = 'lead-session';
+ const projectPath = '/Users/test/proj';
+ const bootstrapAttemptAt = '2026-05-24T09:25:33.388Z';
+ const bootstrapConfirmedAt = '2026-05-24T09:25:42.904Z';
+ const appAcceptedAt = '2026-05-24T09:25:45.178Z';
+ const staleRefreshAt = '2026-05-24T11:36:58.278Z';
+ const runtimePid = 97_255;
+ const bootstrapRunId = 'run-runtime-snapshot-reconcile-first';
+ const staleDiagnostic = 'persisted runtime pid is not alive';
+
+ writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']);
+ writeMemberBootstrapRunId(teamName, 'tom', bootstrapRunId);
+ writeLaunchState(
+ teamName,
+ leadSessionId,
+ {
+ tom: {
+ providerId: 'anthropic',
+ model: 'haiku',
+ laneId: 'primary',
+ laneKind: 'primary',
+ laneOwnerProviderId: 'codex',
+ launchState: 'failed_to_start',
+ agentToolAccepted: true,
+ runtimeAlive: false,
+ runtimePid,
+ bootstrapConfirmed: false,
+ hardFailure: true,
+ hardFailureReason:
+ 'runtime pid could not be verified because process table is unavailable',
+ livenessKind: 'stale_metadata',
+ runtimeDiagnostic: staleDiagnostic,
+ runtimeDiagnosticSeverity: 'warning',
+ firstSpawnAcceptedAt: appAcceptedAt,
+ runtimeLastSeenAt: staleRefreshAt,
+ lastEvaluatedAt: staleRefreshAt,
+ },
+ },
+ { launchPhase: 'finished', updatedAt: staleRefreshAt }
+ );
+ writeBootstrapState(
+ teamName,
+ [
+ {
+ name: 'tom',
+ status: 'bootstrap_confirmed',
+ lastAttemptAt: Date.parse(bootstrapAttemptAt),
+ lastObservedAt: Date.parse(bootstrapConfirmedAt),
+ },
+ ],
+ '2026-05-24T09:26:08.090Z',
+ { runId: bootstrapRunId }
+ );
+
+ const svc = new TeamProvisioningService();
+
+ const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
+ const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath(teamName), 'utf8'));
+
+ expect(snapshot.members.tom).toMatchObject({
+ alive: true,
+ livenessKind: 'confirmed_bootstrap',
+ runtimeDiagnostic: 'bootstrap confirmed',
+ runtimeDiagnosticSeverity: 'info',
+ });
+ expect(snapshot.members.tom?.runtimeDiagnostic).not.toBe(staleDiagnostic);
+ expect(persisted.members.tom).toMatchObject({
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ hardFailure: false,
+ });
+ expect(persisted.members.tom?.runtimeDiagnostic).not.toBe(staleDiagnostic);
+ });
+
it('does not treat a reused OpenCode runtime pid as live', async () => {
const teamName = 'pure-opencode-reused-pid-team';
const projectPath = '/Users/test/project';
@@ -4797,7 +4889,7 @@ describe('TeamProvisioningService', () => {
runtimeSessionId: 'session-alice',
},
});
- vi.mocked(listRuntimeProcessTableForCurrentPlatform).mockResolvedValueOnce([
+ vi.mocked(listRuntimeProcessTableForCurrentPlatform).mockResolvedValue([
{ pid: 333, ppid: 1, command: 'node unrelated-worker.js' },
]);
vi.mocked(pidusage).mockResolvedValueOnce({
From a324f6cc6620797689fe6794f62a1938f7e6b13c Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 17:03:01 +0300
Subject: [PATCH 14/16] fix(team): show stopped runtime from spawn status
---
.../components/team/teamRuntimeDisplayRows.ts | 72 ++++++++++++++-
.../team/teamRuntimeDisplayRows.test.ts | 92 ++++++++++++++++++-
2 files changed, 158 insertions(+), 6 deletions(-)
diff --git a/src/renderer/components/team/teamRuntimeDisplayRows.ts b/src/renderer/components/team/teamRuntimeDisplayRows.ts
index 430c2d9a..0737cbf0 100644
--- a/src/renderer/components/team/teamRuntimeDisplayRows.ts
+++ b/src/renderer/components/team/teamRuntimeDisplayRows.ts
@@ -42,6 +42,12 @@ interface SpawnDegradation {
diagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity;
}
+interface SpawnStoppedEvidence {
+ reason: string;
+ diagnostic?: string;
+ diagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
+}
+
const ACTIVE_SPAWN_STATUSES = new Set(['waiting', 'spawning']);
export function buildTeamRuntimeDisplayRows({
@@ -134,12 +140,16 @@ function buildRuntimeBackedDisplayRow(
): TeamRuntimeDisplayRow {
const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error';
const spawnDegradation = getSpawnDegradation(spawn);
- const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
+ const spawnStoppedEvidence = spawnDegradation ? null : getSpawnStoppedEvidence(runtime, spawn);
+ const state = spawnStoppedEvidence
+ ? 'stopped'
+ : getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
const degradedReason = spawnDegradation
? withLiveProcessContext(spawnDegradation.reason, runtime)
: undefined;
const stateReason =
degradedReason ??
+ spawnStoppedEvidence?.reason ??
runtime.runtimeDiagnostic ??
(runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive');
@@ -157,8 +167,13 @@ function buildRuntimeBackedDisplayRow(
diagnostic:
spawnDegradation && degradedReason
? withLiveProcessContext(spawnDegradation.diagnostic ?? degradedReason, runtime)
- : runtime.runtimeDiagnostic,
- diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity,
+ : spawnStoppedEvidence
+ ? spawnStoppedEvidence.diagnostic
+ : runtime.runtimeDiagnostic,
+ diagnosticSeverity:
+ spawnDegradation?.diagnosticSeverity ??
+ spawnStoppedEvidence?.diagnosticSeverity ??
+ runtime.runtimeDiagnosticSeverity,
pidLabel: formatRuntimePidLabel(runtime),
actionsAllowed: false,
};
@@ -207,6 +222,24 @@ function getSpawnDegradation(spawn?: MemberSpawnStatusEntry): SpawnDegradation |
return null;
}
+function getSpawnStoppedEvidence(
+ runtime: TeamAgentRuntimeEntry,
+ spawn?: MemberSpawnStatusEntry
+): SpawnStoppedEvidence | null {
+ if (!spawn || spawn.runtimeAlive !== false || runtime.livenessKind !== 'confirmed_bootstrap') {
+ return null;
+ }
+ if (spawn.status !== 'online' && spawn.launchState !== 'confirmed_alive') {
+ return null;
+ }
+ const reason = spawn.runtimeDiagnostic ?? 'Spawn status reports runtime is not alive';
+ return {
+ reason,
+ diagnostic: spawn.runtimeDiagnostic ?? reason,
+ diagnosticSeverity: spawn.runtimeDiagnosticSeverity ?? 'warning',
+ };
+}
+
function getRuntimeBackedState(
runtime: TeamAgentRuntimeEntry,
hasErrorDiagnostic: boolean,
@@ -220,7 +253,11 @@ function getRuntimeBackedState(
}
function withLiveProcessContext(reason: string, runtime: TeamAgentRuntimeEntry): string {
- if (runtime.alive !== true || /process is still alive/i.test(reason)) {
+ if (
+ runtime.alive !== true ||
+ runtime.livenessKind === 'confirmed_bootstrap' ||
+ /process is still alive/i.test(reason)
+ ) {
return reason;
}
return `${reason}. Process is still alive.`;
@@ -245,6 +282,21 @@ function buildSpawnBackedDisplayRow(
};
}
+ const spawnStoppedEvidence = getSpawnOnlyStoppedEvidence(spawn);
+ if (spawnStoppedEvidence) {
+ return {
+ memberName,
+ state: 'stopped',
+ stateReason: spawnStoppedEvidence.reason,
+ source: 'spawn-status',
+ updatedAt: spawn.livenessLastCheckedAt ?? spawn.updatedAt,
+ runtimeModel: spawn.runtimeModel,
+ diagnostic: spawnStoppedEvidence.diagnostic,
+ diagnosticSeverity: spawnStoppedEvidence.diagnosticSeverity,
+ actionsAllowed: false,
+ };
+ }
+
if (
(spawn.status === 'online' && hasConfirmedSpawnLiveness(spawn)) ||
isConfirmedSpawnLaunch(spawn)
@@ -306,6 +358,18 @@ function buildSpawnBackedDisplayRow(
};
}
+function getSpawnOnlyStoppedEvidence(spawn: MemberSpawnStatusEntry): SpawnStoppedEvidence | null {
+ if (spawn.runtimeAlive !== false) return null;
+ if (spawn.status !== 'online' && spawn.launchState !== 'confirmed_alive') return null;
+
+ const reason = spawn.runtimeDiagnostic ?? 'Spawn status reports runtime is not alive';
+ return {
+ reason,
+ diagnostic: spawn.runtimeDiagnostic ?? reason,
+ diagnosticSeverity: spawn.runtimeDiagnosticSeverity ?? 'warning',
+ };
+}
+
function hasConfirmedSpawnLiveness(spawn: MemberSpawnStatusEntry): boolean {
return (
spawn.runtimeAlive === true ||
diff --git a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts
index 3298f44e..8ac8c479 100644
--- a/test/renderer/components/team/teamRuntimeDisplayRows.test.ts
+++ b/test/renderer/components/team/teamRuntimeDisplayRows.test.ts
@@ -1,6 +1,5 @@
-import { describe, expect, it } from 'vitest';
-
import { buildTeamRuntimeDisplayRows } from '@renderer/components/team/teamRuntimeDisplayRows';
+import { describe, expect, it } from 'vitest';
import type {
MemberSpawnStatusEntry,
@@ -92,6 +91,95 @@ describe('buildTeamRuntimeDisplayRows', () => {
});
});
+ it('does not show bootstrap-only runtime evidence as running when spawn says runtime is stopped', () => {
+ const rows = buildTeamRuntimeDisplayRows({
+ members: [{ name: 'alice' }],
+ runtimeSnapshot: createRuntimeSnapshot({
+ alice: createRuntimeEntry({
+ alive: true,
+ livenessKind: 'confirmed_bootstrap',
+ runtimeDiagnostic: 'bootstrap confirmed',
+ runtimeDiagnosticSeverity: 'info',
+ }),
+ }),
+ spawnStatuses: {
+ alice: createSpawnStatus({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ runtimeAlive: false,
+ runtimeDiagnostic: 'persisted runtime pid is not alive',
+ runtimeDiagnosticSeverity: 'warning',
+ }),
+ },
+ });
+
+ expect(rows[0]).toMatchObject({
+ memberName: 'alice',
+ state: 'stopped',
+ source: 'mixed',
+ stateReason: 'persisted runtime pid is not alive',
+ diagnosticSeverity: 'warning',
+ actionsAllowed: false,
+ });
+ });
+
+ it('keeps spawn degradation stronger than stopped evidence for mixed rows', () => {
+ const rows = buildTeamRuntimeDisplayRows({
+ members: [{ name: 'alice' }],
+ runtimeSnapshot: createRuntimeSnapshot({
+ alice: createRuntimeEntry({
+ alive: true,
+ livenessKind: 'confirmed_bootstrap',
+ runtimeDiagnostic: 'bootstrap confirmed',
+ }),
+ }),
+ spawnStatuses: {
+ alice: createSpawnStatus({
+ status: 'online',
+ launchState: 'runtime_pending_permission',
+ bootstrapConfirmed: true,
+ runtimeAlive: false,
+ pendingPermissionRequestIds: ['perm-1'],
+ runtimeDiagnostic: 'Runtime is waiting for permission approval',
+ runtimeDiagnosticSeverity: 'warning',
+ }),
+ },
+ });
+
+ expect(rows[0]).toMatchObject({
+ memberName: 'alice',
+ state: 'degraded',
+ source: 'mixed',
+ stateReason: 'Runtime is waiting for permission approval',
+ diagnosticSeverity: 'warning',
+ actionsAllowed: false,
+ });
+ });
+
+ it('does not show spawn-only confirmed bootstrap as running when spawn says runtime is stopped', () => {
+ const rows = buildTeamRuntimeDisplayRows({
+ members: [{ name: 'alice' }],
+ spawnStatuses: {
+ alice: createSpawnStatus({
+ status: 'online',
+ launchState: 'confirmed_alive',
+ bootstrapConfirmed: true,
+ runtimeAlive: false,
+ }),
+ },
+ });
+
+ expect(rows[0]).toMatchObject({
+ memberName: 'alice',
+ state: 'stopped',
+ source: 'spawn-status',
+ stateReason: 'Spawn status reports runtime is not alive',
+ diagnosticSeverity: 'warning',
+ actionsAllowed: false,
+ });
+ });
+
it('treats confirmed spawn bootstrap as running even if stale status is still waiting', () => {
const rows = buildTeamRuntimeDisplayRows({
members: [{ name: 'alice' }],
From 44a8f04db97ad6c618153ac45e7ab0a9228c6f47 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 20:42:54 +0300
Subject: [PATCH 15/16] docs(release): document public notes standard
---
docs/RELEASE.md | 21 ++++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
index 21f47455..dd87624a 100644
--- a/docs/RELEASE.md
+++ b/docs/RELEASE.md
@@ -1,5 +1,9 @@
# Release Guide
+## Published: v2.1.2 (2026-05-23)
+
+Performance and reliability release: faster startup, deferred provider/runtime hydration, resilient file watching under watcher limits, safer context switching, better team launch diagnostics, and packaged app entry/runtime fixes. GitHub release: [v2.1.2](https://github.com/777genius/agent-teams-ai/releases/tag/v2.1.2).
+
## Published: v1.2.0 (2026-03-31)
Agent Graph, per-team tool approval, interactive AskUserQuestion, task comment notifications, cross-team ghost nodes. Major graph improvements: force-directed visualization with kanban task layout, fullscreen/tab mode, animated particles, member hexagons with avatars, popover actions. Permission system overhaul with proper Write/Edit/NotebookEdit seeding and MCP tool catalog integration. Full list: [CHANGELOG.md](./CHANGELOG.md).
@@ -131,6 +135,17 @@ EOF
)"
```
+Public release notes must follow this standard every time:
+
+- Start with a short user-facing summary. Explain what changed and why users should care.
+- Do not add a duplicate `## Agent Teams v` heading inside the release body; the GitHub release title already shows the version.
+- Use the sections `What's New`, `Improvements`, and `Bug Fixes`; omit a section only if it would be empty.
+- Keep internal-only CI, lint, dependency, and refactor work out of public notes unless it directly explains a user-visible fix.
+- Put `Downloads` as the final section, after all text notes.
+- Use badge/button links in `Downloads`, not bare asset links.
+- Verify actual asset names with `gh release view v --repo 777genius/agent-teams-ai --json assets` before writing links.
+- Prefer versioned installer links for release-specific notes: `Agent.Teams.AI--arm64.dmg`, `Agent.Teams.AI--x64.dmg`, `Agent.Teams.AI.Setup..exe`, `Agent.Teams.AI-.AppImage`, `agent-teams-ai__amd64.deb`, `agent-teams-ai-.x86_64.rpm`, and `agent-teams-ai-.pacman`.
+
### 4. Required release closeout gate
Do not publish or call a release finished until this is true:
@@ -146,8 +161,6 @@ If a draft was published before notes were written, immediately edit the public
## Release Notes Template
```markdown
-## Agent Teams v
-
<1-2 sentence summary of the release>
### What's New
@@ -181,7 +194,9 @@ If a draft was published before notes were written, immediately edit the public
- May trigger SmartScreen — click "More info" → "Run anyway"
+ May trigger SmartScreen - click "More info" then "Run anyway"
+
+ Windows required: launch Agent Teams AI as Administrator, especially when using OpenCode runtimes.
From 776957b6076f250d371277aabbee7a1f1a097d50 Mon Sep 17 00:00:00 2001
From: 777genius
Date: Sun, 24 May 2026 20:43:11 +0300
Subject: [PATCH 16/16] fix(team): keep runtime adapter lead activity idle
---
.../services/team/TeamProvisioningService.ts | 13 +++-
.../team/TeamProvisioningService.test.ts | 59 +++++++++++++++++++
2 files changed, 71 insertions(+), 1 deletion(-)
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 76dc33f9..e3838b26 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -12168,7 +12168,18 @@ export class TeamProvisioningService {
const runId = this.getTrackedRunId(teamName);
if (!runId) return { state: 'offline', runId: null };
const run = this.runs.get(runId);
- if (!run || run.processKilled || run.cancelRequested) return { state: 'offline', runId: null };
+ if (!run) {
+ const runtimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
+ const runtimeProgress = this.runtimeAdapterProgressByRunId.get(runId);
+ if (
+ runtimeAdapterRun?.runId === runId &&
+ !['cancelled', 'disconnected', 'failed'].includes(runtimeProgress?.state ?? '')
+ ) {
+ return { state: 'idle', runId };
+ }
+ return { state: 'offline', runId: null };
+ }
+ if (run.processKilled || run.cancelRequested) return { state: 'offline', runId: null };
// Read-repair active lead task intervals for runs that were already active
// before interval tracking was introduced or before the renderer polled state.
this.syncLeadTaskActivityForState(run, run.leadActivityState, run.leadActivityState);
diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts
index 91dfd4dd..17180495 100644
--- a/test/main/services/team/TeamProvisioningService.test.ts
+++ b/test/main/services/team/TeamProvisioningService.test.ts
@@ -679,6 +679,8 @@ interface LeadActivityTestRun {
interface LeadActivityServiceInternals {
runs: Map;
aliveRunByTeam: Map;
+ runtimeAdapterProgressByRunId: Map;
+ runtimeAdapterRunByTeam: Map;
setLeadActivity(run: LeadActivityTestRun, state: LeadActivityTestState): void;
}
@@ -2511,6 +2513,63 @@ describe('TeamProvisioningService', () => {
});
describe('lead activity task intervals', () => {
+ it('reports runtime adapter teams as idle instead of offline when no legacy run exists', () => {
+ const svc = new TeamProvisioningService();
+ const internals = svc as unknown as LeadActivityServiceInternals;
+ const teamName = 'opencode-runtime-adapter-lead-activity-team';
+ const runId = 'opencode-runtime-adapter-run';
+
+ internals.aliveRunByTeam.set(teamName, runId);
+ internals.runtimeAdapterRunByTeam.set(teamName, {
+ runId,
+ providerId: 'opencode',
+ cwd: '/tmp/opencode-runtime-adapter-lead-activity-team',
+ members: {},
+ });
+ internals.runtimeAdapterProgressByRunId.set(runId, {
+ runId,
+ teamName,
+ state: 'ready',
+ message: 'OpenCode team launch is waiting for runtime evidence or permissions',
+ startedAt: '2026-05-02T10:00:00.000Z',
+ updatedAt: '2026-05-02T10:00:05.000Z',
+ });
+
+ expect(svc.isTeamAlive(teamName)).toBe(true);
+ expect(svc.getLeadActivityState(teamName)).toEqual({
+ state: 'idle',
+ runId,
+ });
+ });
+
+ it('keeps terminal runtime adapter progress offline without a legacy run', () => {
+ const svc = new TeamProvisioningService();
+ const internals = svc as unknown as LeadActivityServiceInternals;
+ const teamName = 'opencode-runtime-adapter-terminal-lead-activity-team';
+ const runId = 'opencode-runtime-adapter-terminal-run';
+
+ internals.aliveRunByTeam.set(teamName, runId);
+ internals.runtimeAdapterRunByTeam.set(teamName, {
+ runId,
+ providerId: 'opencode',
+ cwd: '/tmp/opencode-runtime-adapter-terminal-lead-activity-team',
+ members: {},
+ });
+ internals.runtimeAdapterProgressByRunId.set(runId, {
+ runId,
+ teamName,
+ state: 'failed',
+ message: 'OpenCode team launch failed readiness gate',
+ startedAt: '2026-05-02T10:00:00.000Z',
+ updatedAt: '2026-05-02T10:00:05.000Z',
+ });
+
+ expect(svc.getLeadActivityState(teamName)).toEqual({
+ state: 'offline',
+ runId: null,
+ });
+ });
+
it('read-repairs active lead task intervals once when lead activity is polled', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-02T10:00:00.000Z'));