From 9fe9f81046f9582d1de2037edd03153bd952f11f Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 27 Apr 2026 13:46:11 +0300 Subject: [PATCH] feat(team): add worktree readiness checks --- src/main/ipc/teams.ts | 99 ++++++- .../services/team/ChangeExtractorService.ts | 34 ++- .../services/team/TeamProvisioningService.ts | 21 +- .../services/team/TeamWorktreeGitService.ts | 158 ++++++++++++ .../OpenCodePromptDeliveryWatchdog.ts | 3 +- src/preload/constants/ipcChannels.ts | 9 + src/preload/index.ts | 19 ++ src/renderer/api/httpClient.ts | 17 ++ .../chat/viewers/MarkdownViewer.tsx | 8 +- .../team/dialogs/CreateTeamDialog.tsx | 55 +++- .../team/dialogs/LaunchTeamDialog.tsx | 29 ++- .../dialogs/WorktreeGitReadinessBanner.tsx | 243 ++++++++++++++++++ .../team/members/MemberDraftRow.tsx | 15 +- .../team/members/MembersEditorSection.tsx | 15 +- .../team/members/TeamRosterEditorSection.tsx | 3 + src/shared/types/api.ts | 4 + src/shared/types/team.ts | 18 ++ test/main/ipc/teams.test.ts | 40 +++ .../team/ChangeExtractorService.test.ts | 122 ++++++--- .../OpenCodePromptDeliveryWatchdog.test.ts | 43 ++++ .../team/TeamProvisioningService.test.ts | 149 +++++++++++ .../team/TeamWorktreeGitService.test.ts | 70 +++++ .../chat/viewers/MarkdownViewer.test.tsx | 91 +++++++ .../team/dialogs/LaunchTeamDialog.test.ts | 19 ++ .../WorktreeGitReadinessBanner.test.ts | 66 +++++ 25 files changed, 1258 insertions(+), 92 deletions(-) create mode 100644 src/main/services/team/TeamWorktreeGitService.ts create mode 100644 src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx create mode 100644 test/main/services/team/OpenCodePromptDeliveryWatchdog.test.ts create mode 100644 test/main/services/team/TeamWorktreeGitService.test.ts create mode 100644 test/renderer/components/chat/viewers/MarkdownViewer.test.tsx create mode 100644 test/renderer/components/team/dialogs/WorktreeGitReadinessBanner.test.ts diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a796c4f9..02176f9a 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -11,6 +11,7 @@ import { TEAM_ALIVE_LIST, TEAM_CANCEL_PROVISIONING, TEAM_CREATE, + TEAM_CREATE_INITIAL_GIT_COMMIT, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, TEAM_DELETE_DRAFT, @@ -37,6 +38,8 @@ import { TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_LOG_STREAM_SUMMARY, + TEAM_GET_WORKTREE_GIT_STATUS, + TEAM_INITIALIZE_GIT_REPOSITORY, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -130,6 +133,7 @@ import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; +import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService'; import { validateFromField, @@ -205,6 +209,7 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TeamWorktreeGitStatus, TeamViewSnapshot, ToolApprovalFileContent, ToolApprovalSettings, @@ -514,6 +519,7 @@ let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); const teamMetaStore = new TeamMetaStore(); +const worktreeGitService = new TeamWorktreeGitService(); const ALLOWED_ATTACHMENT_TYPES = new Set([ 'image/png', @@ -525,6 +531,16 @@ const ALLOWED_ATTACHMENT_TYPES = new Set([ ]); const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file +function isValidStoredAttachmentMimeType(value: unknown): value is string { + if (typeof value !== 'string') return false; + const v = value.trim(); + if (!v) return false; + if (v.length > 200) return false; + if (v.includes('\0') || /[\r\n]/.test(v)) return false; + const slash = v.indexOf('/'); + return slash > 0 && slash < v.length - 1; +} + /** * Prevents GC from collecting Notification objects in the deprecated showTeamNativeNotification. * @see https://blog.bloomca.me/2025/02/22/electron-mac-notifications.html @@ -574,6 +590,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_SET_TOOL_ACTIVITY_TRACKING, handleSetToolActivityTracking); ipcMain.handle(TEAM_GET_CLAUDE_LOGS, handleGetClaudeLogs); ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning); + ipcMain.handle(TEAM_GET_WORKTREE_GIT_STATUS, handleGetWorktreeGitStatus); + ipcMain.handle(TEAM_INITIALIZE_GIT_REPOSITORY, handleInitializeGitRepository); + ipcMain.handle(TEAM_CREATE_INITIAL_GIT_COMMIT, handleCreateInitialGitCommit); ipcMain.handle(TEAM_CREATE, handleCreateTeam); ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam); ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus); @@ -652,6 +671,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_SET_TOOL_ACTIVITY_TRACKING); ipcMain.removeHandler(TEAM_GET_CLAUDE_LOGS); ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING); + ipcMain.removeHandler(TEAM_GET_WORKTREE_GIT_STATUS); + ipcMain.removeHandler(TEAM_INITIALIZE_GIT_REPOSITORY); + ipcMain.removeHandler(TEAM_CREATE_INITIAL_GIT_COMMIT); ipcMain.removeHandler(TEAM_CREATE); ipcMain.removeHandler(TEAM_LAUNCH); ipcMain.removeHandler(TEAM_PROVISIONING_STATUS); @@ -820,6 +842,54 @@ async function handleGetProjectBranch( } } +function validateProjectPathInput( + projectPath: unknown +): { valid: true; value: string } | { valid: false; error: string } { + if (typeof projectPath !== 'string' || projectPath.trim().length === 0) { + return { valid: false, error: 'projectPath must be a non-empty string' }; + } + return { valid: true, value: path.normalize(projectPath.trim()) }; +} + +async function handleGetWorktreeGitStatus( + _event: IpcMainInvokeEvent, + projectPath: unknown +): Promise> { + const validated = validateProjectPathInput(projectPath); + if (!validated.valid) { + return { success: false, error: validated.error }; + } + return wrapTeamHandler('getWorktreeGitStatus', () => + worktreeGitService.getStatus(validated.value) + ); +} + +async function handleInitializeGitRepository( + _event: IpcMainInvokeEvent, + projectPath: unknown +): Promise> { + const validated = validateProjectPathInput(projectPath); + if (!validated.valid) { + return { success: false, error: validated.error }; + } + return wrapTeamHandler('initializeGitRepository', () => + worktreeGitService.initializeRepository(validated.value) + ); +} + +async function handleCreateInitialGitCommit( + _event: IpcMainInvokeEvent, + projectPath: unknown +): Promise> { + const validated = validateProjectPathInput(projectPath); + if (!validated.valid) { + return { success: false, error: validated.error }; + } + return wrapTeamHandler('createInitialGitCommit', () => + worktreeGitService.createInitialCommit(validated.value) + ); +} + async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { setCurrentMainOp('team:list'); const startedAt = Date.now(); @@ -4267,10 +4337,9 @@ async function handleAddTaskComment( if ( typeof a.id !== 'string' || typeof a.filename !== 'string' || - typeof a.mimeType !== 'string' || + !isValidStoredAttachmentMimeType(a.mimeType) || typeof a.base64Data !== 'string' || - a.base64Data.length === 0 || - !ALLOWED_ATTACHMENT_TYPES.has(a.mimeType) + a.base64Data.length === 0 ) { throw new Error('Invalid attachment data'); } @@ -4283,7 +4352,7 @@ async function handleAddTaskComment( vTask.value!, safeId, a.filename, - a.mimeType, + a.mimeType.trim(), a.base64Data ); savedAttachments.push(meta); @@ -4386,11 +4455,8 @@ async function handleSaveTaskAttachment( if (typeof filename !== 'string' || filename.trim().length === 0) { return { success: false, error: 'filename must be a non-empty string' }; } - if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { - return { - success: false, - error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`, - }; + if (!isValidStoredAttachmentMimeType(mimeType)) { + return { success: false, error: 'Invalid mimeType' }; } if (typeof base64Data !== 'string' || base64Data.length === 0) { return { success: false, error: 'base64Data must be a non-empty string' }; @@ -4407,7 +4473,7 @@ async function handleSaveTaskAttachment( vTask.value!, safeAttId, filename, - mimeType, + mimeType.trim(), base64Data ); // Write metadata into the task JSON @@ -4430,7 +4496,7 @@ async function handleGetTaskAttachment( if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { return { success: false, error: 'attachmentId must be a non-empty string' }; } - if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { + if (!isValidStoredAttachmentMimeType(mimeType)) { return { success: false, error: 'Invalid mimeType' }; } const safeAttId = attachmentId.trim(); @@ -4439,7 +4505,7 @@ async function handleGetTaskAttachment( } return wrapTeamHandler('getTaskAttachment', () => - taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType) + taskAttachmentStore.getAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType.trim()) ); } @@ -4457,7 +4523,7 @@ async function handleDeleteTaskAttachment( if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { return { success: false, error: 'attachmentId must be a non-empty string' }; } - if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { + if (!isValidStoredAttachmentMimeType(mimeType)) { return { success: false, error: 'Invalid mimeType' }; } const safeAttId = attachmentId.trim(); @@ -4466,7 +4532,12 @@ async function handleDeleteTaskAttachment( } return wrapTeamHandler('deleteTaskAttachment', async () => { - await taskAttachmentStore.deleteAttachment(vTeam.value!, vTask.value!, safeAttId, mimeType); + await taskAttachmentStore.deleteAttachment( + vTeam.value!, + vTask.value!, + safeAttId, + mimeType.trim() + ); // Remove metadata from task JSON await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); }); diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 3d64e58d..6e7a7d86 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -209,9 +209,7 @@ export class ChangeExtractorService { return ledgerResult; } - if (!includeDetails) { - this.enqueueOpenCodeLedgerBackfill(resolvedInput); - } else if (await this.tryBackfillOpenCodeLedger(resolvedInput)) { + if (await this.tryBackfillOpenCodeLedger(resolvedInput)) { const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput); if (backfilledLedgerResult) { await this.recordTaskChangePresence( @@ -435,6 +433,28 @@ export class ChangeExtractorService { } this.openCodeBackfillCache.delete(cacheKey); + if (deliveryContextRecords.length === 0) { + this.openCodeBackfillCache.set(cacheKey, { + backfilledAt: 0, + expiresAt: Date.now() + this.openCodeBackfillCacheTtl, + }); + void appendOpenCodeTaskChangeDiag({ + event: 'backfill_skipped', + reason: 'no-delivery-context', + teamName: input.teamName, + taskId: input.taskId, + displayId: input.taskMeta?.displayId ?? null, + memberName: input.effectiveOptions.owner ?? null, + projectDir, + workspaceRoot, + sourceGeneration, + deliveryRecordCount: 0, + deliveryContextFingerprint, + attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, + }).catch(() => undefined); + return false; + } + const existing = this.openCodeBackfillInFlight.get(cacheKey); if (existing) { return existing; @@ -454,14 +474,6 @@ export class ChangeExtractorService { return promise; } - private enqueueOpenCodeLedgerBackfill(input: ResolvedTaskChangeComputeInput): void { - void this.tryBackfillOpenCodeLedger(input).catch((error) => { - logger.debug( - `Background OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` - ); - }); - } - private async runOpenCodeBackfill( input: ResolvedTaskChangeComputeInput, projectDir: string, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a9da6df6..53088641 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1504,6 +1504,20 @@ function nowIso(): string { return new Date().toISOString(); } +export function isOpenCodePromptDeliveryRetryAttemptDue(input: { + attemptDue: boolean; + ledgerRecord: Pick; +}): boolean { + if (!input.attemptDue) { + return false; + } + return ( + input.ledgerRecord.status === 'retry_scheduled' || + input.ledgerRecord.status === 'failed_retryable' || + isOpenCodePromptDeliveryRetryableResponseState(input.ledgerRecord.responseState) + ); +} + function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry { const updatedAt = nowIso(); return { @@ -5573,9 +5587,10 @@ export class TeamProvisioningService { }; } - const retryDueBeforeObserve = - attemptDue && - (ledgerRecord.status === 'retry_scheduled' || ledgerRecord.status === 'failed_retryable'); + const retryDueBeforeObserve = isOpenCodePromptDeliveryRetryAttemptDue({ + attemptDue, + ledgerRecord, + }); if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) { const observed = await adapter.observeMessageDelivery({ ...(runtimeRunId ? { runId: runtimeRunId } : {}), diff --git a/src/main/services/team/TeamWorktreeGitService.ts b/src/main/services/team/TeamWorktreeGitService.ts new file mode 100644 index 00000000..f03e2c85 --- /dev/null +++ b/src/main/services/team/TeamWorktreeGitService.ts @@ -0,0 +1,158 @@ +import { execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { TeamWorktreeGitStatus } from '@shared/types'; + +const GIT_TIMEOUT_MS = 20_000; + +function execGit(args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'git', + args, + { cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, + (error, stdout, stderr) => { + if (error) { + const message = String(stderr || error.message || 'git command failed').trim(); + reject(new Error(message)); + return; + } + resolve(String(stdout).trim()); + } + ); + }); +} + +function normalizeGitError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isGitUnavailable(error: unknown): boolean { + const message = normalizeGitError(error).toLowerCase(); + return message.includes('enoent') || message.includes('git: command not found'); +} + +async function assertUsableDirectory(projectPath: string): Promise { + const trimmed = projectPath.trim(); + if (!trimmed || !path.isAbsolute(trimmed)) { + throw new Error('Project path must be an absolute directory path.'); + } + const stat = await fs.promises.stat(trimmed).catch(() => null); + if (!stat?.isDirectory()) { + throw new Error(`Project path is not a directory: ${trimmed}`); + } + return await fs.promises.realpath(trimmed).catch(() => trimmed); +} + +function blockedStatus( + projectPath: string, + reason: NonNullable, + message: string, + overrides: Partial = {} +): TeamWorktreeGitStatus { + return { + projectPath, + isGitRepo: false, + hasHead: false, + canUseWorktrees: false, + reason, + message, + ...overrides, + }; +} + +export class TeamWorktreeGitService { + async getStatus(projectPath: string): Promise { + let cwd: string; + try { + cwd = await assertUsableDirectory(projectPath); + } catch (error) { + return blockedStatus(projectPath.trim(), 'invalid_project_path', normalizeGitError(error)); + } + + let rootPath: string; + try { + const rootRaw = await execGit(['rev-parse', '--show-toplevel'], cwd); + rootPath = await fs.promises.realpath(rootRaw).catch(() => rootRaw); + } catch (error) { + if (isGitUnavailable(error)) { + return blockedStatus(cwd, 'git_unavailable', 'Git is not available on this machine.'); + } + return blockedStatus( + cwd, + 'not_git_repo', + 'Worktree isolation requires a Git repository. This project is not a Git repo yet.' + ); + } + + const hasHead = await execGit(['rev-parse', '--verify', 'HEAD'], cwd) + .then(() => true) + .catch(() => false); + const branch = await execGit(['branch', '--show-current'], cwd) + .then((value) => value || undefined) + .catch(() => undefined); + + if (!hasHead) { + return blockedStatus( + cwd, + 'missing_head', + 'Create an initial commit before using worktrees. We will not commit files automatically.', + { + isGitRepo: true, + rootPath, + branch, + } + ); + } + + return { + projectPath: cwd, + isGitRepo: true, + hasHead: true, + canUseWorktrees: true, + rootPath, + branch, + }; + } + + async initializeRepository(projectPath: string): Promise { + const current = await this.getStatus(projectPath); + if (current.isGitRepo) { + return current; + } + if (current.reason !== 'not_git_repo') { + return current; + } + + const cwd = await assertUsableDirectory(projectPath); + await execGit(['init'], cwd); + return this.getStatus(cwd); + } + + async createInitialCommit(projectPath: string): Promise { + const current = await this.getStatus(projectPath); + if (!current.isGitRepo || !current.rootPath) { + return current; + } + if (current.hasHead) { + return current; + } + + await execGit(['add', '-A'], current.rootPath); + await execGit( + [ + '-c', + 'user.name=Agent Teams', + '-c', + 'user.email=agent-teams@local', + 'commit', + '--allow-empty', + '-m', + 'chore: initial commit', + ], + current.rootPath + ); + return this.getStatus(current.rootPath); + } +} diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts index 78394fd2..0c6e3c12 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts @@ -91,7 +91,8 @@ export function isOpenCodePromptDeliveryRetryableResponseState( state === 'empty_assistant_turn' || state === 'tool_error' || state === 'reconcile_failed' || - state === 'not_observed' + state === 'not_observed' || + state === 'session_stale' ); } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index b12a70e1..43fadd19 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -255,6 +255,15 @@ export const TEAM_LAUNCH = 'team:launch'; /** Warm up provisioning runtime before create */ export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning'; +/** Inspect whether a project can support git worktree isolation */ +export const TEAM_GET_WORKTREE_GIT_STATUS = 'team:getWorktreeGitStatus'; + +/** Initialize a git repository for worktree isolation */ +export const TEAM_INITIALIZE_GIT_REPOSITORY = 'team:initializeGitRepository'; + +/** Create the first commit required by git worktrees */ +export const TEAM_CREATE_INITIAL_GIT_COMMIT = 'team:createInitialGitCommit'; + /** Get provisioning status by runId */ export const TEAM_PROVISIONING_STATUS = 'team:provisioningStatus'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 21ad0c80..50d07719 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -118,6 +118,7 @@ import { TEAM_CANCEL_PROVISIONING, TEAM_CHANGE, TEAM_CREATE, + TEAM_CREATE_INITIAL_GIT_COMMIT, TEAM_CREATE_CONFIG, TEAM_CREATE_TASK, TEAM_DELETE_DRAFT, @@ -144,6 +145,8 @@ import { TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_TASK_LOG_STREAM, TEAM_GET_TASK_LOG_STREAM_SUMMARY, + TEAM_GET_WORKTREE_GIT_STATUS, + TEAM_INITIALIZE_GIT_REPOSITORY, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, @@ -315,6 +318,7 @@ import type { TeamTaskStatus, TeamUpdateConfigRequest, TeamViewSnapshot, + TeamWorktreeGitStatus, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -892,6 +896,21 @@ const electronAPI: ElectronAPI = { modelVerificationMode ); }, + getWorktreeGitStatus: async (projectPath: string) => { + return invokeIpcWithResult(TEAM_GET_WORKTREE_GIT_STATUS, projectPath); + }, + initializeGitRepository: async (projectPath: string) => { + return invokeIpcWithResult( + TEAM_INITIALIZE_GIT_REPOSITORY, + projectPath + ); + }, + createInitialGitCommit: async (projectPath: string) => { + return invokeIpcWithResult( + TEAM_CREATE_INITIAL_GIT_COMMIT, + projectPath + ); + }, createTeam: async (request: TeamCreateRequest) => { return invokeIpcWithResult(TEAM_CREATE, request); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 9d1b9e7d..d231be2e 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -67,6 +67,7 @@ import type { TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, + TeamWorktreeGitStatus, TeamsAPI, TeamSummary, TeamTask, @@ -755,6 +756,22 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, + getWorktreeGitStatus: async (projectPath: string): Promise => { + return { + projectPath, + isGitRepo: false, + hasHead: false, + canUseWorktrees: false, + reason: 'git_error', + message: 'Worktree Git setup is not available in browser mode', + }; + }, + initializeGitRepository: async (_projectPath: string): Promise => { + throw new Error('Git repository initialization is not available in browser mode'); + }, + createInitialGitCommit: async (_projectPath: string): Promise => { + throw new Error('Initial Git commit is not available in browser mode'); + }, createTeam: async (_request: TeamCreateRequest): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 1e671096..a6ec0fe2 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -324,6 +324,10 @@ function hastToText(node: HastNode): string { return ''; } +function extractLanguageFromClassName(className?: string): string { + return /(?:^|\s)language-([^\s]+)/.exec(className ?? '')?.[1] ?? ''; +} + // ============================================================================= // Component factories // ============================================================================= @@ -583,8 +587,8 @@ function createViewerMarkdownComponents( const isBlock = (hasLanguage ?? false) || isMultiLine; if (isBlock) { - const lang = codeClassName?.replace('language-', '') ?? ''; - const raw = typeof children === 'string' ? children : ''; + const lang = extractLanguageFromClassName(codeClassName); + const raw = typeof children === 'string' ? children : extractTextFromReactNode(children); const text = raw.replace(/\n$/, ''); const lines = text.split('\n'); return ( diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 96596b19..c45884df 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -131,6 +131,12 @@ import { import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibilityNotice'; import { computeEffectiveTeamModel } from './TeamModelSelector'; import { getNextSuggestedTeamName } from './teamNameSets'; +import { + getWorktreeGitBlockingMessage, + getWorktreeGitControlDisabledReason, + useWorktreeGitReadiness, + WorktreeGitReadinessBanner, +} from './WorktreeGitReadinessBanner'; import type { MemberDraft } from '@renderer/components/team/members/MembersEditorSection'; @@ -549,6 +555,20 @@ export const CreateTeamDialog = ({ () => (syncModelsWithLead ? members.map(clearMemberModelOverrides) : members), [members, syncModelsWithLead] ); + const hasSelectedWorktreeIsolation = + !soloTeam && + effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree'); + const worktreeGitReadiness = useWorktreeGitReadiness( + effectiveCwd || null, + open && canCreate && !soloTeam + ); + const worktreeIsolationDisabledReason = + !soloTeam && canCreate ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) : null; + const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage( + worktreeGitReadiness, + hasSelectedWorktreeIsolation + ); + const worktreeGitBlocksSubmission = Boolean(worktreeGitBlockingMessage); const tmuxRuntime = useTmuxRuntimeReadiness(open && canCreate); const selectedMemberProviders = useMemo(() => { @@ -1425,7 +1445,8 @@ export const CreateTeamDialog = ({ isNameProvisioning || !requestValidation.valid || !!modelValidationError || - teammateRuntimeCompatibility.blocksSubmission; + teammateRuntimeCompatibility.blocksSubmission || + worktreeGitBlocksSubmission; const internalArgs = useMemo(() => { const args: string[] = []; @@ -1569,6 +1590,10 @@ export const CreateTeamDialog = ({ setLocalError(teammateRuntimeCompatibility.message); return; } + if (worktreeGitBlockingMessage) { + setLocalError(worktreeGitBlockingMessage); + return; + } setFieldErrors({}); setLocalError(null); setIsSubmitting(true); @@ -1781,6 +1806,7 @@ export const CreateTeamDialog = ({ onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange} showWorktreeIsolationControls={!soloTeam} teammateWorktreeDefault={teammateWorktreeDefault} + worktreeIsolationDisabledReason={worktreeIsolationDisabledReason} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} disableGeminiOption={isGeminiUiFrozen()} leadModelIssueText={leadModelIssueText} @@ -1802,17 +1828,22 @@ export const CreateTeamDialog = ({ } headerBottom={ - soloTeam ? ( -
- -

- Only the team lead (main process) will be started — no teammates will be - spawned. Works like a regular Claude session 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. -

-
- ) : null +
+ {soloTeam ? ( +
+ +

+ Only the team lead (main process) will be started — no teammates will + be spawned. Works like a regular Claude session 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. +

+
+ ) : null} + {!soloTeam && canCreate ? ( + + ) : null} +
} /> diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 4dcf1792..aec4b90b 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -140,6 +140,12 @@ import { OPENCODE_TEAM_LEAD_DISABLED_REASON, TeamModelSelector, } from './TeamModelSelector'; +import { + getWorktreeGitBlockingMessage, + getWorktreeGitControlDisabledReason, + useWorktreeGitReadiness, + WorktreeGitReadinessBanner, +} from './WorktreeGitReadinessBanner'; import type { ActiveTeamRef } from './CreateTeamDialog'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; @@ -1282,6 +1288,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen ? '' : selectedProjectPath.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectCwd : customCwd.trim(); + const hasSelectedWorktreeIsolation = + isLaunchMode && + effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree'); + const worktreeGitReadiness = useWorktreeGitReadiness(effectiveCwd || null, open && isLaunchMode); + const worktreeIsolationDisabledReason = isLaunchMode + ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) + : null; + const worktreeGitBlockingMessage = getWorktreeGitBlockingMessage( + worktreeGitReadiness, + hasSelectedWorktreeIsolation + ); const prepareRuntimeStatusSignature = useMemo( () => buildProviderPrepareRuntimeStatusSignature( @@ -1726,13 +1743,21 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const validationErrors = useMemo(() => { const errors: string[] = []; if (!effectiveCwd) errors.push('Working directory is required'); + if (worktreeGitBlockingMessage) errors.push(worktreeGitBlockingMessage); if (isSchedule) { if (!effectiveTeamName) errors.push('Team is required'); if (!promptDraft.value.trim()) errors.push('Prompt is required'); if (!cronExpression.trim()) errors.push('Cron expression is required'); } return errors; - }, [effectiveCwd, isSchedule, effectiveTeamName, promptDraft.value, cronExpression]); + }, [ + effectiveCwd, + worktreeGitBlockingMessage, + isSchedule, + effectiveTeamName, + promptDraft.value, + cronExpression, + ]); const modelValidationError = useMemo(() => { const leadError = getTeamModelSelectionError( selectedProviderId, @@ -2422,6 +2447,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen onSyncModelsWithTeammatesChange={setSyncModelsWithLead} showWorktreeIsolationControls teammateWorktreeDefault={teammateWorktreeDefault} + worktreeIsolationDisabledReason={worktreeIsolationDisabledReason} onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} leadWarningText={leadRuntimeWarningText} memberWarningById={combinedMemberRuntimeWarningById} @@ -2429,6 +2455,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen memberModelIssueById={memberModelIssueById} softDeleteMembers disableGeminiOption={isGeminiUiFrozen()} + headerBottom={} />
diff --git a/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx new file mode 100644 index 00000000..b9dc9559 --- /dev/null +++ b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Button } from '@renderer/components/ui/button'; +import { AlertTriangle, CheckCircle2, GitBranch, Loader2 } from 'lucide-react'; + +import type { TeamWorktreeGitStatus } from '@shared/types'; + +interface WorktreeGitReadinessState { + status: TeamWorktreeGitStatus | null; + loading: boolean; + actionLoading: 'init' | 'commit' | null; + error: string | null; + refresh: () => Promise; + initializeRepository: () => Promise; + createInitialCommit: () => Promise; +} + +export function useWorktreeGitReadiness( + projectPath: string | null, + enabled: boolean +): WorktreeGitReadinessState { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [actionLoading, setActionLoading] = useState<'init' | 'commit' | null>(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (!enabled || !projectPath?.trim()) { + setStatus(null); + setError(null); + setLoading(false); + return; + } + setLoading(true); + setError(null); + try { + setStatus(await api.teams.getWorktreeGitStatus(projectPath)); + } catch (err) { + setStatus(null); + setError(err instanceof Error ? err.message : 'Failed to inspect Git repository'); + } finally { + setLoading(false); + } + }, [enabled, projectPath]); + + useEffect(() => { + let cancelled = false; + if (!enabled || !projectPath?.trim()) { + setStatus(null); + setError(null); + setLoading(false); + return; + } + setLoading(true); + setError(null); + void api.teams + .getWorktreeGitStatus(projectPath) + .then((nextStatus) => { + if (!cancelled) setStatus(nextStatus); + }) + .catch((err) => { + if (!cancelled) { + setStatus(null); + setError(err instanceof Error ? err.message : 'Failed to inspect Git repository'); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [enabled, projectPath]); + + const initializeRepository = useCallback(async () => { + if (!projectPath?.trim()) return; + setActionLoading('init'); + setError(null); + try { + setStatus(await api.teams.initializeGitRepository(projectPath)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to initialize Git repository'); + } finally { + setActionLoading(null); + } + }, [projectPath]); + + const createInitialCommit = useCallback(async () => { + if (!projectPath?.trim()) return; + setActionLoading('commit'); + setError(null); + try { + setStatus(await api.teams.createInitialGitCommit(projectPath)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create initial Git commit'); + } finally { + setActionLoading(null); + } + }, [projectPath]); + + return useMemo( + () => ({ + status, + loading, + actionLoading, + error, + refresh, + initializeRepository, + createInitialCommit, + }), + [actionLoading, createInitialCommit, error, initializeRepository, loading, refresh, status] + ); +} + +export function getWorktreeGitBlockingMessage( + state: Pick, + hasSelectedWorktreeIsolation: boolean +): string | null { + if (!hasSelectedWorktreeIsolation) { + return null; + } + if (state.loading) { + return 'Checking Git repository status before enabling worktree isolation.'; + } + if (state.error) { + return state.error; + } + if (!state.status) { + return 'Worktree isolation requires a Git repository with an initial commit.'; + } + return state.status.canUseWorktrees ? null : (state.status.message ?? null); +} + +export function getWorktreeGitControlDisabledReason( + state: Pick +): string | null { + if (state.loading) { + return 'Checking Git repository status...'; + } + if (state.error) { + return state.error; + } + if (!state.status) { + return null; + } + return state.status.canUseWorktrees ? null : (state.status.message ?? null); +} + +export function WorktreeGitReadinessBanner({ + state, + showReady = false, +}: { + state: WorktreeGitReadinessState; + showReady?: boolean; +}): React.JSX.Element | null { + const { status, loading, actionLoading, error, initializeRepository, createInitialCommit } = + state; + + if (loading) { + return ( +
+ +

Checking Git repository status for teammate worktrees...

+
+ ); + } + + if (error) { + return ( +
+ +

{error}

+
+ ); + } + + if (!status) { + return null; + } + + if (status.canUseWorktrees) { + if (!showReady) return null; + return ( +
+ +

+ Git worktrees are ready + {status.branch ? ` on branch ${status.branch}` : ''}. +

+
+ ); + } + + return ( +
+
+ +
+

Worktree isolation needs Git setup

+

+ {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. +

+ ) : null} +
+
+
+ {status.reason === 'not_git_repo' ? ( + + ) : null} + {status.reason === 'missing_head' ? ( + + ) : null} +
+
+ ); +} diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index b6e7b401..e6c9fd3f 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -76,6 +76,7 @@ interface MemberDraftRowProps { disableGeminiOption?: boolean; modelIssueText?: string | null; showWorktreeIsolationControls?: boolean; + worktreeIsolationDisabledReason?: string | null; onWorktreeIsolationChange?: (id: string, enabled: boolean) => void; lockedModelAction?: { label: string; @@ -124,6 +125,7 @@ export const MemberDraftRow = ({ disableGeminiOption = false, modelIssueText, showWorktreeIsolationControls = false, + worktreeIsolationDisabledReason, onWorktreeIsolationChange, lockedModelAction, }: MemberDraftRowProps): React.JSX.Element => { @@ -218,6 +220,8 @@ export const MemberDraftRow = ({ : lockProviderModel ? (lockedModelAction?.description ?? modelLockReason) : undefined; + const worktreeIsolationDisabled = + isRemoved || Boolean(worktreeIsolationDisabledReason && member.isolation !== 'worktree'); const hasModelIssue = Boolean(modelIssueText); const runtimeSummary = formatTeamModelSummary( effectiveProviderId, @@ -349,13 +353,13 @@ export const MemberDraftRow = ({
onWorktreeIsolationChange?.(member.id, checked === true) } @@ -364,7 +368,7 @@ export const MemberDraftRow = ({ htmlFor={`member-${member.id}-worktree-isolation`} className={cn( 'flex cursor-pointer items-center gap-1.5 text-xs font-normal', - isRemoved && 'cursor-not-allowed' + worktreeIsolationDisabled && 'cursor-not-allowed' )} > @@ -373,8 +377,9 @@ export const MemberDraftRow = ({
- Run this teammate in a separate git worktree. Apply/reject changes targets that - worktree, not the lead workspace. + {worktreeIsolationDisabledReason && member.isolation !== 'worktree' + ? worktreeIsolationDisabledReason + : 'Run this teammate in a separate git worktree. Apply/reject changes targets that worktree, not the lead workspace.'} ) : null} diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 70e698d9..6624d788 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -117,6 +117,7 @@ export interface MembersEditorSectionProps { addMemberLockReason?: string; showWorktreeIsolationControls?: boolean; teammateWorktreeDefault?: boolean; + worktreeIsolationDisabledReason?: string | null; onTeammateWorktreeDefaultChange?: (enabled: boolean) => void; } @@ -154,6 +155,7 @@ export const MembersEditorSection = ({ addMemberLockReason, showWorktreeIsolationControls = false, teammateWorktreeDefault = false, + worktreeIsolationDisabledReason, onTeammateWorktreeDefaultChange, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); @@ -247,6 +249,9 @@ export const MembersEditorSection = ({ }; const updateMemberIsolation = (memberId: string, enabled: boolean): void => { + if (enabled && worktreeIsolationDisabledReason) { + return; + } onChange( members.map((c) => c.id === memberId ? { ...c, isolation: enabled ? 'worktree' : undefined } : c @@ -255,6 +260,9 @@ export const MembersEditorSection = ({ }; const updateTeammateWorktreeDefault = (enabled: boolean): void => { + if (enabled && worktreeIsolationDisabledReason) { + return; + } onTeammateWorktreeDefaultChange?.(enabled); onChange( members.map((member) => @@ -348,10 +356,14 @@ export const MembersEditorSection = ({ {!hideContent && ( <> {showWorktreeIsolationControls ? ( -
+
updateTeammateWorktreeDefault(checked === true)} />