From b192ed4baecab97270f7f057c6420049cba55675 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 5 May 2026 10:35:33 +0300 Subject: [PATCH] feat(team): improve composer persistence flow --- src/main/ipc/teams.ts | 42 +- .../services/team/TeamProvisioningService.ts | 107 ++++- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 9 + src/renderer/api/httpClient.ts | 7 + .../components/team/activity/ActivityItem.tsx | 66 +-- .../team/dialogs/EditTeamDialog.tsx | 128 +++++- .../team/dialogs/editTeamRuntimeChanges.ts | 35 +- .../team/members/MemberDetailDialog.tsx | 76 ++-- .../team/messages/ActionModeSelector.tsx | 4 + .../MessageComposer.pendingSend.test.tsx | 370 ++++++++++++++++ .../team/messages/MessageComposer.tsx | 156 +++++-- .../team/messages/MessagesPanel.tsx | 25 ++ .../hooks/useComposerDraft.lifecycle.test.tsx | 412 ++++++++++++++++++ src/renderer/hooks/useComposerDraft.ts | 185 +++++++- src/renderer/services/composerDraftStorage.ts | 1 + src/renderer/store/slices/teamSlice.ts | 26 ++ .../utils/bootstrapPromptSanitizer.ts | 13 +- src/shared/types/api.ts | 5 + src/shared/types/team.ts | 4 + test/main/ipc/teams.test.ts | 1 + .../team/dialogs/EditTeamDialog.test.ts | 287 +++++++++++- .../dialogs/editTeamRuntimeChanges.test.ts | 52 +++ .../team/messages/MessagesPanel.test.ts | 77 ++++ test/renderer/store/teamSlice.test.ts | 55 +++ 25 files changed, 2009 insertions(+), 137 deletions(-) create mode 100644 src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx create mode 100644 src/renderer/hooks/useComposerDraft.lifecycle.test.tsx diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ff45aecf..31831d5b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -29,6 +29,7 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, + TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ACTIVITY, @@ -189,6 +190,7 @@ import type { MemberLogSummary, MemberSpawnStatusesSnapshot, MessagesPage, + OpenCodeRuntimeDeliveryStatus, RetryFailedOpenCodeSecondaryLanesResult, SendMessageRequest, SendMessageResult, @@ -686,6 +688,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus); ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning); ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage); + ipcMain.handle(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, handleGetOpenCodeRuntimeDeliveryStatus); ipcMain.handle(TEAM_GET_MESSAGES_PAGE, handleGetMessagesPage); ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta); ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); @@ -771,6 +774,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_PROVISIONING_STATUS); ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING); ipcMain.removeHandler(TEAM_SEND_MESSAGE); + ipcMain.removeHandler(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS); ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE); ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META); ipcMain.removeHandler(TEAM_CREATE_TASK); @@ -3034,6 +3038,30 @@ async function handleSendMessage( }); } +async function handleGetOpenCodeRuntimeDeliveryStatus( + _event: IpcMainInvokeEvent, + teamName: unknown, + messageId: unknown +): Promise> { + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' }; + } + if (typeof messageId !== 'string' || messageId.trim().length === 0) { + return { success: false, error: 'messageId must be a non-empty string' }; + } + const safeMessageId = messageId.trim(); + if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) { + return { success: false, error: 'Invalid messageId' }; + } + return wrapTeamHandler('getOpenCodeRuntimeDeliveryStatus', async () => + getTeamProvisioningService().getOpenCodeRuntimeDeliveryStatus( + validatedTeamName.value!, + safeMessageId + ) + ); +} + async function handleCreateTask( _event: IpcMainInvokeEvent, teamName: unknown, @@ -3899,9 +3927,16 @@ async function handleRestartMember( if (!validatedMemberName.valid) { return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' }; } - return wrapTeamHandler('restartMember', async () => - getTeamProvisioningService().restartMember(validatedTeamName.value!, validatedMemberName.value!) - ); + return wrapTeamHandler('restartMember', async () => { + try { + await getTeamProvisioningService().restartMember( + validatedTeamName.value!, + validatedMemberName.value! + ); + } finally { + getTeamDataService().invalidateMessageFeed(validatedTeamName.value!); + } + }); } async function handleRetryFailedOpenCodeSecondaryLanes( @@ -4297,6 +4332,7 @@ async function handleReplaceMembers( : []; await teamDataService.replaceMembers(tn, { members }); + teamDataService.invalidateMessageFeed(tn); if (!isTeamAlive) { return; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 285da83e..b848d0fe 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -365,6 +365,7 @@ import type { MemberSpawnStatus, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + OpenCodeRuntimeDeliveryStatus, PersistedTeamLaunchMemberState, PersistedTeamLaunchPhase, PersistedTeamLaunchSnapshot, @@ -3577,7 +3578,8 @@ function buildMemberSpawnPrompt( member: TeamCreateRequest['members'][number], displayName: string, teamName: string, - leadName: string + leadName: string, + options?: { restart?: boolean } ): string { const role = member.role?.trim() || 'team member'; const providerLine = @@ -3591,10 +3593,13 @@ function buildMemberSpawnPrompt( const workflowBlock = member.workflow?.trim() ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` : ''; + const restartContext = options?.restart + ? '\n\nThe team has already been reconnected and you are being re-attached as a persistent teammate.\nThis is a teammate restart. Repeat bootstrap exactly once, then wait for normal work instructions.' + : ''; const actionModeProtocol = protocols.buildActionModeProtocolText( protocols.MEMBER_DELEGATE_DESCRIPTION ); - return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock} + return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${providerLine}${modelLine}${effortLine}${workflowBlock}${restartContext} ${getAgentLanguageInstruction()} Your FIRST action: call MCP tool member_briefing with: @@ -10642,6 +10647,59 @@ export class TeamProvisioningService { }); } + async getOpenCodeRuntimeDeliveryStatus( + teamName: string, + messageId: string + ): Promise { + const normalizedMessageId = messageId.trim(); + if (!normalizedMessageId) { + return null; + } + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => null + ); + const laneIds = [ + ...new Set( + Object.values(laneIndex?.lanes ?? {}) + .map((entry) => entry.laneId.trim()) + .filter(Boolean) + ), + ]; + for (const laneId of laneIds) { + const records = await this.createOpenCodePromptDeliveryLedger(teamName, laneId) + .list() + .catch(() => []); + const record = records.find((candidate) => candidate.inboxMessageId === normalizedMessageId); + if (record) { + return this.toOpenCodeRuntimeDeliveryStatus(record); + } + } + return null; + } + + private toOpenCodeRuntimeDeliveryStatus( + record: OpenCodePromptDeliveryLedgerRecord + ): OpenCodeRuntimeDeliveryStatus { + const failed = record.status === 'failed_terminal'; + const responded = + record.status === 'responded' && + Boolean(record.inboxReadCommittedAt || record.visibleReplyMessageId); + return { + messageId: record.inboxMessageId, + providerId: 'opencode', + attempted: true, + delivered: !failed, + responsePending: !failed && !responded, + responseState: record.responseState, + ledgerStatus: record.status, + visibleReplyMessageId: record.visibleReplyMessageId ?? undefined, + visibleReplyCorrelation: record.visibleReplyCorrelation ?? undefined, + acceptanceUnknown: record.acceptanceUnknown, + reason: record.lastReason ?? undefined, + diagnostics: record.diagnostics, + }; + } + private createOpenCodeRuntimeDeliveryPorts(): RuntimeDeliveryDestinationPort[] { const userMessagesPort: RuntimeDeliveryDestinationPort = { kind: 'user_sent_messages', @@ -12088,6 +12146,37 @@ export class TeamProvisioningService { }); } + private persistOpenCodeMemberRestartSystemMessage(input: { + teamName: string; + leadName: string; + leadSessionId: string | null; + displayName: string; + member: TeamCreateRequest['members'][number]; + reason: 'manual_restart' | 'member_updated'; + }): void { + const timestamp = nowIso(); + const prompt = buildMemberSpawnPrompt( + input.member, + input.displayName, + input.teamName, + input.leadName, + { restart: true } + ); + const reasonSummary = + input.reason === 'member_updated' ? 'after member settings update' : 'by user request'; + this.persistSentMessage(input.teamName, { + from: input.leadName, + to: input.member.name, + text: prompt, + timestamp, + read: true, + source: 'system_notification', + leadSessionId: input.leadSessionId ?? undefined, + messageId: `member-restart:${input.teamName}:${input.member.name}:${randomUUID()}`, + summary: `Restarting ${input.member.name} ${reasonSummary}`, + }); + } + private async launchDirectTmuxMemberRestart(input: { run: ProvisioningRun; teamName: string; @@ -12165,7 +12254,8 @@ export class TeamProvisioningService { }, input.displayName, input.teamName, - input.leadName + input.leadName, + { restart: true } ); const bootstrapExpectedAfter = nowIso(); const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({ @@ -13167,6 +13257,17 @@ export class TeamProvisioningService { this.clearMemberSpawnToolTracking(run, memberName); run.pendingMemberRestarts.delete(memberName); + if (options?.reason === 'manual_restart' || options?.reason === 'member_updated') { + this.persistOpenCodeMemberRestartSystemMessage({ + teamName, + leadName: this.getRunLeadName(run), + leadSessionId: run.detectedSessionId?.trim() || config.leadSessionId?.trim() || run.runId, + displayName: config.description?.trim() || config.name, + member: this.buildConfiguredProvisioningMember(configuredMember), + reason: options.reason, + }); + } + await this.launchSingleMixedSecondaryLane(run, laneState); } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 9264004d..c32faa37 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -234,6 +234,9 @@ export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder'; /** Send inbox message to team member */ export const TEAM_SEND_MESSAGE = 'team:sendMessage'; +/** Read latest OpenCode runtime delivery status for a sent inbox message */ +export const TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS = 'team:getOpenCodeRuntimeDeliveryStatus'; + /** Paginated messages for timeline/messages panel */ export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0f3b5abb..162a9dd4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -136,6 +136,7 @@ import { TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_GET_MESSAGES_PAGE, + TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ACTIVITY, @@ -281,6 +282,7 @@ import type { MemberSpawnStatusesSnapshot, MessagesPage, NotificationTrigger, + OpenCodeRuntimeDeliveryStatus, ProjectBranchChangeEvent, RejectResult, ReplaceMembersRequest, @@ -934,6 +936,13 @@ const electronAPI: ElectronAPI = { sendMessage: async (teamName: string, request: SendMessageRequest) => { return invokeIpcWithResult(TEAM_SEND_MESSAGE, teamName, request); }, + getOpenCodeRuntimeDeliveryStatus: async (teamName: string, messageId: string) => { + return invokeIpcWithResult( + TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS, + teamName, + messageId + ); + }, getMessagesPage: async ( teamName: string, options?: { cursor?: string | null; limit?: number } diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 49b67a91..6e5ee57a 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -35,6 +35,7 @@ import type { KanbanColumnId, NotificationsAPI, NotificationTrigger, + OpenCodeRuntimeDeliveryStatus, PaginatedSessionsResult, Project, RepositoryGroup, @@ -794,6 +795,12 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { throw new Error('Team messaging is not available in browser mode'); }, + getOpenCodeRuntimeDeliveryStatus: async ( + _teamName: string, + _messageId: string + ): Promise => { + throw new Error('OpenCode runtime delivery status is not available in browser mode'); + }, getMessagesPage: async () => { return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' }; }, diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 4cde29bc..366e5521 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -381,6 +381,7 @@ const PassiveIdlePeerSummaryRow = ({ const BootstrapSystemRow = ({ teamName, + eventKind, senderName, recipientName, runtime, @@ -390,6 +391,7 @@ const BootstrapSystemRow = ({ onMemberNameClick, }: { teamName: string; + eventKind: 'start' | 'restart'; senderName: string; recipientName: string; runtime?: string; @@ -397,34 +399,41 @@ const BootstrapSystemRow = ({ recipientColor?: string; timestamp: string; onMemberNameClick?: (memberName: string) => void; -}): React.JSX.Element => ( -
- - start - - - - - - {runtime || 'Starting teammate'} - - - {timestamp} - -
-); +}): React.JSX.Element => { + const isRestart = eventKind === 'restart'; + return ( +
+ + {isRestart ? 'restart' : 'start'} + + + + + + {runtime || (isRestart ? 'Restarting teammate' : 'Starting teammate')} + + + {timestamp} + +
+ ); +}; const BootstrapAcknowledgementRow = ({ teamName, @@ -921,6 +930,7 @@ export const ActivityItem = memo( return ( [member.name.trim().toLowerCase(), member] as const)), [builtMembers] ); + const currentMemberRosterSnapshot = useMemo( + () => buildEditTeamMemberRosterSnapshot(currentMembers), + [currentMembers] + ); + const nextMemberRosterSnapshot = useMemo( + () => buildEditTeamMemberRosterSnapshot(builtMembers), + [builtMembers] + ); + const hasMemberRosterChanges = currentMemberRosterSnapshot !== nextMemberRosterSnapshot; + const currentMembersByName = useMemo( + () => + new Map(currentMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)), + [currentMembers] + ); + const isLiveMixedOpenCodeSideLaneTeam = useMemo( + () => + isTeamAlive && + leadMember?.providerId !== 'opencode' && + currentMembers.some((member) => !member.removedAt && member.providerId === 'opencode'), + [currentMembers, isTeamAlive, leadMember?.providerId] + ); const effectiveMembersToRestart = useMemo(() => { const retryMembers = Object.entries(membersPendingRestartRetry).flatMap( ([normalizedName, expectedRuntimeContractKey]) => { @@ -270,10 +292,35 @@ export const EditTeamDialog = ({ new Set( [...membersToRestart, ...retryMembers] .map((memberName) => memberName.trim()) + .filter((memberName) => { + const nextMember = builtMembersByName.get(memberName.toLowerCase()); + return nextMember?.providerId !== 'opencode'; + }) .filter(Boolean) ) ); }, [builtMembersByName, membersPendingRestartRetry, membersToRestart]); + const openCodeMembersHandledByLiveRoster = useMemo(() => { + if (!isTeamAlive) { + return []; + } + return Array.from( + new Set( + membersToRestart + .map((memberName) => memberName.trim()) + .filter((memberName) => { + const nextMember = builtMembersByName.get(memberName.toLowerCase()); + return nextMember?.providerId === 'opencode'; + }) + .filter(Boolean) + ) + ); + }, [builtMembersByName, isTeamAlive, membersToRestart]); + const liveRuntimeRefreshMemberNames = useMemo( + () => + Array.from(new Set([...effectiveMembersToRestart, ...openCodeMembersHandledByLiveRoster])), + [effectiveMembersToRestart, openCodeMembersHandledByLiveRoster] + ); const liveIdentityChanges = useMemo( () => isTeamAlive @@ -289,6 +336,34 @@ export const EditTeamDialog = ({ () => (isTeamAlive ? liveIdentityChanges.removed : []), [isTeamAlive, liveIdentityChanges.removed] ); + const unsupportedLiveMixedPrimaryRuntimeChangeNames = useMemo(() => { + if (!isLiveMixedOpenCodeSideLaneTeam) { + return []; + } + return membersToRestart.filter((memberName) => { + const nextMember = builtMembersByName.get(memberName.trim().toLowerCase()); + return nextMember?.providerId !== 'opencode'; + }); + }, [builtMembersByName, isLiveMixedOpenCodeSideLaneTeam, membersToRestart]); + const unsupportedLiveMixedPrimaryRemovalNames = useMemo(() => { + if (!isLiveMixedOpenCodeSideLaneTeam) { + return []; + } + return liveRemovedExistingMembers.filter((memberName) => { + const currentMember = currentMembersByName.get(memberName.trim().toLowerCase()); + return currentMember?.providerId !== 'opencode'; + }); + }, [currentMembersByName, isLiveMixedOpenCodeSideLaneTeam, liveRemovedExistingMembers]); + const unsupportedLiveMixedPrimaryMutationNames = useMemo( + () => + Array.from( + new Set([ + ...unsupportedLiveMixedPrimaryRuntimeChangeNames, + ...unsupportedLiveMixedPrimaryRemovalNames, + ]) + ), + [unsupportedLiveMixedPrimaryRemovalNames, unsupportedLiveMixedPrimaryRuntimeChangeNames] + ); const hasNewLiveTeammates = useMemo( () => isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()), @@ -296,7 +371,7 @@ export const EditTeamDialog = ({ ); const memberWarningById = useMemo(() => { const restartNames = new Set( - effectiveMembersToRestart.map((memberName) => memberName.trim().toLowerCase()) + liveRuntimeRefreshMemberNames.map((memberName) => memberName.trim().toLowerCase()) ); if (restartNames.size === 0) { return undefined; @@ -309,7 +384,7 @@ export const EditTeamDialog = ({ : null, ]) ); - }, [effectiveMembersToRestart, members]); + }, [liveRuntimeRefreshMemberNames, members]); const handleSave = (): void => { if (!name.trim()) { @@ -359,12 +434,19 @@ export const EditTeamDialog = ({ ); 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(', ')}` + ); + return; + } setSaving(true); setError(null); setSaveOutcomeError(null); void (async () => { let configSaved = false; let membersSaved = false; + let refreshAfterSaveAttempted = false; let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers; try { await api.teams.updateConfig(teamName, { @@ -373,14 +455,17 @@ export const EditTeamDialog = ({ color, }); configSaved = true; - for (const removedMemberName of liveRemovedExistingMembers) { - await api.teams.removeMember(teamName, removedMemberName); - committedMembersForSnapshot = applyRemovedMembersToSnapshot(committedMembersForSnapshot, [ - removedMemberName, - ]); + if (hasMemberRosterChanges) { + for (const removedMemberName of liveRemovedExistingMembers) { + await api.teams.removeMember(teamName, removedMemberName); + committedMembersForSnapshot = applyRemovedMembersToSnapshot( + committedMembersForSnapshot, + [removedMemberName] + ); + } + await api.teams.replaceMembers(teamName, { members: builtMembers }); + membersSaved = true; } - await api.teams.replaceMembers(teamName, { members: builtMembers }); - membersSaved = true; pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({ name: name.trim(), description: description.trim(), @@ -409,6 +494,7 @@ export const EditTeamDialog = ({ } } + refreshAfterSaveAttempted = true; await Promise.resolve(onSaved()); if (restartFailures.length === 0) { setMembersPendingRestartRetry({}); @@ -445,6 +531,12 @@ export const EditTeamDialog = ({ color: color.trim(), members: committedMembersForSnapshot, }); + if (refreshAfterSaveAttempted) { + setSaveOutcomeError( + `Team settings were saved, but failed to refresh the latest view: ${message}` + ); + return; + } let refreshErrorDetail: string | null = null; try { await Promise.resolve(onSaved()); @@ -601,12 +693,19 @@ export const EditTeamDialog = ({ changes or stop the team first.

) : null} - {isTeamAlive && effectiveMembersToRestart.length > 0 ? ( + {unsupportedLiveMixedPrimaryMutationNames.length > 0 ? ( +

+ Live edits/removals for primary-owned teammates in mixed OpenCode teams require + stopping and relaunching the team:{' '} + {unsupportedLiveMixedPrimaryMutationNames.join(', ')}. +

+ ) : null} + {isTeamAlive && liveRuntimeRefreshMemberNames.length > 0 ? (

- Saving will restart{' '} - {effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to + Saving will restart or relaunch{' '} + {liveRuntimeRefreshMemberNames.length === 1 ? 'this teammate' : 'these teammates'} to apply role, workflow, worktree isolation, provider, model, or effort changes:{' '} - {effectiveMembersToRestart.join(', ')}. + {liveRuntimeRefreshMemberNames.join(', ')}.

) : null}
@@ -662,7 +761,8 @@ export const EditTeamDialog = ({ isTeamProvisioning || !name.trim() || hasDuplicateMembers || - Boolean(invalidMemberNamesError) + Boolean(invalidMemberNamesError) || + unsupportedLiveMixedPrimaryMutationNames.length > 0 } > {saving && } diff --git a/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts b/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts index c9e2921f..eb300d43 100644 --- a/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts +++ b/src/renderer/components/team/dialogs/editTeamRuntimeChanges.ts @@ -1,10 +1,13 @@ import { isTeamEffortLevel } from '@shared/utils/effortLevels'; +import { migrateProviderBackendId } from '@shared/utils/providerBackend'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; import type { EffortLevel, ResolvedTeamMember, + TeamFastMode, + TeamProviderBackendId, TeamProviderId, TeamProvisioningMemberInput, } from '@shared/types'; @@ -127,18 +130,22 @@ function normalizeEditableMemberSnapshot(member: { role?: string; workflow?: string; providerId?: string; + providerBackendId?: string; model?: string; effort?: string; isolation?: string; + fastMode?: string; removedAt?: number | string | null; }): { name: string; role?: string; workflow?: string; providerId?: TeamProviderId; + providerBackendId?: TeamProviderBackendId; model?: string; effort?: EffortLevel; isolation?: 'worktree'; + fastMode?: TeamFastMode; } | null { if (member.removedAt) { return null; @@ -147,24 +154,42 @@ function normalizeEditableMemberSnapshot(member: { if (!name || name.toLowerCase() === 'team-lead') { return null; } + const runtime = normalizeRestartSensitiveMemberContract(member); return { name, role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, - ...normalizeRestartSensitiveMemberContract(member), + providerBackendId: migrateProviderBackendId(runtime.providerId, member.providerBackendId), + fastMode: + member.fastMode === 'inherit' || member.fastMode === 'on' || member.fastMode === 'off' + ? member.fastMode + : undefined, + ...runtime, }; } +export function buildEditTeamMemberRosterSnapshot(members: readonly ResolvedTeamMember[]): string; +export function buildEditTeamMemberRosterSnapshot( + members: readonly TeamProvisioningMemberInput[] +): string; +export function buildEditTeamMemberRosterSnapshot( + members: readonly (ResolvedTeamMember | TeamProvisioningMemberInput)[] +): string { + const normalizedMembers = members + .map(normalizeEditableMemberSnapshot) + .filter((member): member is NonNullable => member !== null) + .sort((a, b) => a.name.localeCompare(b.name)); + + return JSON.stringify(normalizedMembers); +} + export function buildEditTeamSourceSnapshot(params: { name: string; description: string; color: string; members: readonly ResolvedTeamMember[]; }): string { - const members = params.members - .map(normalizeEditableMemberSnapshot) - .filter((member): member is NonNullable => member !== null) - .sort((a, b) => a.name.localeCompare(b.name)); + const members = JSON.parse(buildEditTeamMemberRosterSnapshot(params.members)) as unknown; return JSON.stringify({ name: params.name.trim(), diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index b9606264..92458c37 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -215,8 +215,20 @@ export const MemberDetailDialog = ({ const effectiveLaunchInfoMessage = openCodeBootstrapStalled ? OPENCODE_BOOTSTRAP_STALLED_MESSAGE : undefined; - const restartButtonLabel = - openCodeNoRuntimeEvidence || openCodeRelaunchActionable ? 'Relaunch OpenCode' : 'Restart'; + const isOpenCodeMember = member?.providerId === 'opencode'; + const restartButtonLabel = isOpenCodeMember ? 'Relaunch OpenCode' : 'Restart'; + const hasLiveRestartContext = isTeamAlive === true || isTeamProvisioning === true; + const canControlledOpenCodeRelaunch = + member == null + ? false + : isOpenCodeMember && !member.removedAt && !isLeadMember(member) && hasLiveRestartContext; + const canRestartFromDialog = + member == null + ? false + : Boolean(onRestartMember) && + !isLeadMember(member) && + hasLiveRestartContext && + (runtimeEntry?.restartable !== false || canControlledOpenCodeRelaunch); useEffect(() => { if (!open || !member) { @@ -380,37 +392,35 @@ export const MemberDetailDialog = ({ ) : ( <> - {onRestartMember && - !isLeadMember(member) && - (isTeamAlive || isTeamProvisioning) && - runtimeEntry?.restartable !== false && ( - - )} + {canRestartFromDialog && ( + + )}