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 && (
- {
- setRestartError(null);
- setRestarting(true);
- try {
- await onRestartMember(member.name);
- } catch (error) {
- setRestartError(
- error instanceof Error ? error.message : 'Failed to restart member'
- );
- } finally {
- setRestarting(false);
- }
- }}
- >
- {restarting ? (
-
- ) : (
-
- )}
- {restartButtonLabel}
-
- )}
+ {canRestartFromDialog && (
+ {
+ setRestartError(null);
+ setRestarting(true);
+ try {
+ if (!onRestartMember) return;
+ await onRestartMember(member.name);
+ } catch (error) {
+ setRestartError(
+ error instanceof Error ? error.message : 'Failed to restart member'
+ );
+ } finally {
+ setRestarting(false);
+ }
+ }}
+ >
+ {restarting ? (
+
+ ) : (
+
+ )}
+ {restartButtonLabel}
+
+ )}
Send Message
diff --git a/src/renderer/components/team/messages/ActionModeSelector.tsx b/src/renderer/components/team/messages/ActionModeSelector.tsx
index ef64e86d..d5184e12 100644
--- a/src/renderer/components/team/messages/ActionModeSelector.tsx
+++ b/src/renderer/components/team/messages/ActionModeSelector.tsx
@@ -14,6 +14,7 @@ interface ActionModeSelectorProps {
value: ActionMode;
onChange: (mode: ActionMode) => void;
showDelegate: boolean;
+ disabled?: boolean;
}
const MODE_CONFIG: {
@@ -50,6 +51,7 @@ export const ActionModeSelector = ({
value,
onChange,
showDelegate,
+ disabled = false,
}: ActionModeSelectorProps): React.JSX.Element => {
const modes = showDelegate ? MODE_CONFIG : MODE_CONFIG.filter((m) => m.mode !== 'delegate');
@@ -76,10 +78,12 @@ export const ActionModeSelector = ({
'px-2 py-0.5 text-[10px] font-medium transition-colors',
isFirst && 'rounded-l-full',
isLast && 'rounded-r-full',
+ disabled && 'cursor-not-allowed opacity-50',
isActive
? cfg.activeClass
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
+ disabled={disabled}
onClick={() => onChange(cfg.mode)}
>
{cfg.label}
diff --git a/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx
new file mode 100644
index 00000000..8e4cbb48
--- /dev/null
+++ b/src/renderer/components/team/messages/MessageComposer.pendingSend.test.tsx
@@ -0,0 +1,370 @@
+/* eslint-disable @typescript-eslint/naming-convention -- vi.mock component exports must stay PascalCase. */
+import React, { act } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ResolvedTeamMember } from '@shared/types';
+
+const draftHarness = vi.hoisted(() => {
+ const initialState = {
+ text: 'hello teammate',
+ chips: [] as unknown[],
+ attachments: [] as unknown[],
+ actionMode: 'do',
+ isSaved: true,
+ isLoaded: true,
+ };
+ const state = { ...initialState };
+ const methods = {
+ addChip: vi.fn(),
+ addFiles: vi.fn().mockResolvedValue(undefined),
+ clearAttachmentError: vi.fn(),
+ clearAttachments: vi.fn(),
+ clearDraft: vi.fn(() => {
+ state.text = '';
+ state.chips = [];
+ state.attachments = [];
+ }),
+ finalizePendingSendClear: vi.fn(),
+ handleDrop: vi.fn(),
+ handlePaste: vi.fn(),
+ hideDraftForPendingSend: vi.fn((_content: { text: string }) => {
+ state.text = '';
+ state.chips = [];
+ state.attachments = [];
+ }),
+ removeAttachment: vi.fn(),
+ removeChip: vi.fn(),
+ restoreDraft: vi.fn((content: { text: string }) => {
+ state.text = content.text;
+ }),
+ setActionMode: vi.fn((mode: string) => {
+ state.actionMode = mode;
+ }),
+ setText: vi.fn((text: string) => {
+ state.text = text;
+ }),
+ };
+
+ return {
+ methods,
+ reset: () => {
+ Object.assign(state, initialState);
+ for (const method of Object.values(methods)) {
+ method.mockClear();
+ }
+ },
+ state,
+ };
+});
+
+vi.mock('@renderer/api', () => ({
+ api: {
+ teams: {
+ aliveList: vi.fn(() => new Promise(() => undefined)),
+ },
+ },
+}));
+
+vi.mock('@renderer/components/team/attachments/AttachmentPreviewList', () => ({
+ AttachmentPreviewList: () => null,
+}));
+
+vi.mock('@renderer/components/team/attachments/DropZoneOverlay', () => ({
+ DropZoneOverlay: () => null,
+}));
+
+vi.mock('@renderer/components/team/MemberBadge', () => ({
+ MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
+}));
+
+vi.mock('@renderer/components/team/messages/ActionModeSelector', () => ({
+ ActionModeSelector: ({ disabled }: { disabled?: boolean }) =>
+ React.createElement('button', { disabled, type: 'button' }, 'Do'),
+}));
+
+vi.mock('@renderer/components/team/messages/OpenCodeDeliveryWarning', () => ({
+ OpenCodeDeliveryWarning: () => null,
+}));
+
+vi.mock('@renderer/components/ui/MentionableTextarea', () => {
+ const MockMentionableTextarea = React.forwardRef<
+ HTMLTextAreaElement,
+ {
+ value: string;
+ disabled?: boolean;
+ cornerAction?: React.ReactNode;
+ cornerActionLeft?: React.ReactNode;
+ footerRight?: React.ReactNode;
+ }
+ >(({ value, disabled, cornerAction, cornerActionLeft, footerRight }, ref) =>
+ React.createElement(
+ 'div',
+ null,
+ React.createElement('textarea', {
+ 'aria-label': 'Message',
+ disabled,
+ readOnly: true,
+ ref,
+ value,
+ }),
+ React.createElement('div', null, cornerActionLeft),
+ React.createElement('div', null, cornerAction),
+ React.createElement('div', null, footerRight)
+ )
+ );
+ MockMentionableTextarea.displayName = 'MockMentionableTextarea';
+ return { MentionableTextarea: MockMentionableTextarea };
+});
+
+vi.mock('@renderer/components/ui/popover', () => ({
+ Popover: ({ children }: { children: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+ PopoverContent: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+ PopoverTrigger: ({ children }: { children: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+}));
+
+vi.mock('@renderer/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+ TooltipContent: ({ children }: { children: React.ReactNode }) =>
+ React.createElement('div', null, children),
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
+ React.createElement(React.Fragment, null, children),
+}));
+
+/* eslint-enable @typescript-eslint/naming-convention -- End PascalCase vi.mock component exports. */
+
+vi.mock('@renderer/hooks/useComposerDraft', () => ({
+ useComposerDraft: () => ({
+ text: draftHarness.state.text,
+ setText: draftHarness.methods.setText,
+ chips: draftHarness.state.chips,
+ addChip: draftHarness.methods.addChip,
+ removeChip: draftHarness.methods.removeChip,
+ attachments: draftHarness.state.attachments,
+ attachmentError: null,
+ canAddMore: true,
+ addFiles: draftHarness.methods.addFiles,
+ removeAttachment: draftHarness.methods.removeAttachment,
+ clearAttachments: draftHarness.methods.clearAttachments,
+ clearAttachmentError: draftHarness.methods.clearAttachmentError,
+ handlePaste: draftHarness.methods.handlePaste,
+ handleDrop: draftHarness.methods.handleDrop,
+ actionMode: draftHarness.state.actionMode,
+ setActionMode: draftHarness.methods.setActionMode,
+ isSaved: draftHarness.state.isSaved,
+ isLoaded: draftHarness.state.isLoaded,
+ clearDraft: draftHarness.methods.clearDraft,
+ hideDraftForPendingSend: draftHarness.methods.hideDraftForPendingSend,
+ finalizePendingSendClear: draftHarness.methods.finalizePendingSendClear,
+ restoreDraft: draftHarness.methods.restoreDraft,
+ }),
+}));
+
+vi.mock('@renderer/hooks/useTaskSuggestions', () => ({
+ useTaskSuggestions: () => ({ suggestions: [] }),
+}));
+
+vi.mock('@renderer/hooks/useTeamSuggestions', () => ({
+ useTeamSuggestions: () => ({ suggestions: [] }),
+}));
+
+vi.mock('@renderer/store', () => ({
+ useStore: (selector: (state: Record) => unknown) =>
+ selector({
+ crossTeamTargets: [],
+ fetchCrossTeamTargets: vi.fn(),
+ fetchSkillsCatalog: vi.fn(),
+ selectedTeamData: null,
+ selectedTeamName: null,
+ skillsProjectCatalogByProjectPath: {},
+ skillsUserCatalog: [],
+ }),
+}));
+
+vi.mock('@renderer/store/slices/teamSlice', () => ({
+ isTeamProvisioningActive: () => false,
+}));
+
+import { MessageComposer } from './MessageComposer';
+
+const members: ResolvedTeamMember[] = [
+ {
+ agentType: 'developer',
+ currentTaskId: null,
+ lastActiveAt: null,
+ messageCount: 0,
+ name: 'alice',
+ role: 'Developer',
+ status: 'idle',
+ taskCount: 0,
+ },
+];
+
+function renderComposer(overrides: Partial> = {}): {
+ host: HTMLDivElement;
+ render: (next?: Partial>) => void;
+ root: ReturnType;
+ onSend: ReturnType;
+} {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ const onSend = vi.fn();
+ const baseProps: React.ComponentProps = {
+ teamName: 'team-alpha',
+ members,
+ isTeamAlive: true,
+ sending: false,
+ sendError: null,
+ sendWarning: null,
+ sendDebugDetails: null,
+ lastResult: null,
+ onSend,
+ };
+
+ const render = (next: Partial> = {}): void => {
+ act(() => {
+ root.render(React.createElement(MessageComposer, { ...baseProps, ...overrides, ...next }));
+ });
+ };
+ render();
+
+ return { host, render, root, onSend };
+}
+
+function getSendButton(host: HTMLElement): HTMLButtonElement {
+ const button = Array.from(host.querySelectorAll('button')).find(
+ (candidate) => candidate.textContent?.trim() === 'Send'
+ );
+ if (!(button instanceof HTMLButtonElement)) {
+ throw new Error('Send button not found');
+ }
+ return button;
+}
+
+describe('MessageComposer pending send lifecycle', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ draftHarness.reset();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.clearAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('hides the submitted draft when sending starts and finalizes it on success', () => {
+ const { host, onSend, render, root } = renderComposer();
+
+ act(() => {
+ getSendButton(host).click();
+ });
+ expect(onSend).toHaveBeenCalledWith(
+ 'alice',
+ 'hello teammate',
+ 'hello teammate',
+ undefined,
+ 'do',
+ []
+ );
+ expect(draftHarness.methods.hideDraftForPendingSend).not.toHaveBeenCalled();
+
+ render({ sending: true });
+
+ expect(draftHarness.methods.hideDraftForPendingSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: 'hello teammate',
+ actionMode: 'do',
+ pendingSendId: expect.any(String) as string,
+ })
+ );
+ expect(draftHarness.state.text).toBe('');
+
+ render({ sending: false });
+
+ expect(draftHarness.methods.finalizePendingSendClear).toHaveBeenCalledWith(
+ undefined,
+ expect.objectContaining({
+ text: 'hello teammate',
+ actionMode: 'do',
+ pendingSendId: expect.any(String) as string,
+ })
+ );
+ expect(draftHarness.methods.restoreDraft).not.toHaveBeenCalled();
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('restores the submitted draft when sending fails after the optimistic hide', () => {
+ const { host, render, root } = renderComposer();
+
+ act(() => {
+ getSendButton(host).click();
+ });
+ render({ sending: true });
+ render({ sending: false, sendError: 'runtime failed' });
+
+ expect(draftHarness.methods.restoreDraft).toHaveBeenCalledWith(
+ expect.objectContaining({ text: 'hello teammate' })
+ );
+ expect(draftHarness.state.text).toBe('hello teammate');
+ expect(draftHarness.methods.finalizePendingSendClear).not.toHaveBeenCalled();
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('does not consume a new pending send before a sending transition is observed', () => {
+ const { host, render, root } = renderComposer({
+ lastResult: { deliveredToInbox: true, messageId: 'previous-message' },
+ });
+
+ act(() => {
+ getSendButton(host).click();
+ });
+ render({
+ lastResult: { deliveredToInbox: true, messageId: 'previous-message' },
+ sending: false,
+ });
+
+ expect(draftHarness.methods.hideDraftForPendingSend).not.toHaveBeenCalled();
+ expect(draftHarness.methods.finalizePendingSendClear).not.toHaveBeenCalled();
+ expect(draftHarness.methods.clearDraft).not.toHaveBeenCalled();
+ expect(draftHarness.state.text).toBe('hello teammate');
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('clears the draft when a fast send completes before a sending render is observed', () => {
+ const previousResult = { deliveredToInbox: true, messageId: 'previous-message' };
+ const { host, render, root } = renderComposer({ lastResult: previousResult });
+
+ act(() => {
+ getSendButton(host).click();
+ });
+ render({
+ lastResult: { deliveredToInbox: true, messageId: 'new-message' },
+ sending: false,
+ });
+
+ expect(draftHarness.methods.clearDraft).toHaveBeenCalledOnce();
+ expect(draftHarness.methods.hideDraftForPendingSend).not.toHaveBeenCalled();
+ expect(draftHarness.methods.finalizePendingSendClear).not.toHaveBeenCalled();
+ expect(draftHarness.state.text).toBe('');
+
+ act(() => {
+ root.unmount();
+ });
+ });
+});
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx
index de98c4cf..a66b3e56 100644
--- a/src/renderer/components/team/messages/MessageComposer.tsx
+++ b/src/renderer/components/team/messages/MessageComposer.tsx
@@ -37,6 +37,7 @@ import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'l
import { useShallow } from 'zustand/react/shallow';
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
+import type { ComposerDraftContent } from '@renderer/hooks/useComposerDraft';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
import type {
@@ -75,6 +76,24 @@ interface MessageComposerProps {
) => void;
}
+interface PendingSendState {
+ teamName: string;
+ snapshot: ComposerDraftContent;
+ previousDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null | undefined;
+ previousLastResult: SendMessageResult | null | undefined;
+ observedSending: boolean;
+ optimisticallyCleared: boolean;
+}
+
+let pendingSendIdCounter = 0;
+
+function createPendingSendId(): string {
+ const randomId = globalThis.crypto?.randomUUID?.();
+ if (randomId) return randomId;
+ pendingSendIdCounter += 1;
+ return `${Date.now()}-${pendingSendIdCounter}`;
+}
+
export const MessageComposer = ({
teamName,
members,
@@ -310,14 +329,18 @@ export const MessageComposer = ({
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
// );
const supportsAttachments = isLeadRecipient && !isCrossTeam && !!isTeamAlive;
- const canAttach = supportsAttachments && draft.canAddMore;
+ const canAttach = supportsAttachments && draft.canAddMore && !sending;
const attachmentRestrictionReason = !supportsAttachments
? isCrossTeam
? 'File attachments are not supported for cross-team messages'
: !isLeadRecipient
? 'Files can only be sent to the team lead'
: 'Team must be online to attach files'
- : undefined;
+ : sending
+ ? 'Wait for current message to finish sending before adding files'
+ : !draft.canAddMore
+ ? 'Maximum attachments reached'
+ : undefined;
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
const slashCommandRestrictionReason = standaloneSlashCommand
? draft.attachments.length > 0
@@ -340,19 +363,32 @@ export const MessageComposer = ({
!slashCommandRestrictionReason &&
(!isCrossTeam || onCrossTeamSend !== undefined);
- // Track whether we initiated a send — clear draft only on confirmed success
- const pendingSendRef = useRef(false);
+ const pendingSendRef = useRef(null);
const handleCycleActionMode = useCallback(() => {
+ if (sending) return;
const modes: ActionMode[] = canDelegate ? ['do', 'ask', 'delegate'] : ['do', 'ask'];
const idx = modes.indexOf(actionMode);
setActionMode(modes[(idx + 1) % modes.length]);
- }, [actionMode, canDelegate, setActionMode]);
+ }, [actionMode, canDelegate, sending, setActionMode]);
const handleSend = useCallback(() => {
if (!canSend) return;
dismissMentionsRef.current?.();
- pendingSendRef.current = true;
+ pendingSendRef.current = {
+ teamName,
+ snapshot: {
+ text: draft.text,
+ chips: draft.chips,
+ attachments: draft.attachments,
+ actionMode,
+ pendingSendId: createPendingSendId(),
+ },
+ previousDebugDetails: sendDebugDetails,
+ previousLastResult: lastResult,
+ observedSending: false,
+ optimisticallyCleared: false,
+ };
const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions);
const serialized = serializeChipsWithText(trimmed, draft.chips);
if (isCrossTeam && selectedTeam && onCrossTeamSend) {
@@ -377,35 +413,67 @@ export const MessageComposer = ({
onCrossTeamSend,
isCrossTeam,
selectedTeam,
+ sendDebugDetails,
draft.attachments,
draft.chips,
draft.text,
+ lastResult,
taskSuggestions,
+ teamName,
]);
- // Clear draft only after send completes successfully (sending: true -> false, no error).
- // Layout effect prevents a visible paint where the optimistic message is already in the list
- // but the submitted text is still shown in the composer.
+ // Clear once the send starts, not after the IPC finishes. For OpenCode teammates the message
+ // can already be visible from inbox refresh while runtime delivery diagnostics are still pending.
useLayoutEffect(() => {
- if (!sending && pendingSendRef.current) {
- pendingSendRef.current = false;
- if (!sendError && sendDebugDetails?.delivered !== false) {
- draft.clearDraft();
- }
- }
- }, [sending, sendError, sendDebugDetails, draft]);
+ const pending = pendingSendRef.current;
+ if (!pending) return;
+ const isPendingCurrentTeam = pending.teamName === teamName;
- const { addFiles: draftAddFiles } = draft;
- const handleFileInputChange = useCallback(
- (e: React.ChangeEvent) => {
- const input = e.target;
- if (input.files?.length) {
- void draftAddFiles(input.files);
+ if (sending) {
+ pending.observedSending = true;
+ if (isPendingCurrentTeam && !pending.optimisticallyCleared) {
+ pending.optimisticallyCleared = true;
+ draft.hideDraftForPendingSend(pending.snapshot);
}
- input.value = '';
- },
- [draftAddFiles]
- );
+ return;
+ }
+
+ const hasNewResult =
+ lastResult?.messageId != null &&
+ lastResult.messageId !== pending.previousLastResult?.messageId;
+ const hasNewDebugDetails =
+ sendDebugDetails?.messageId != null &&
+ sendDebugDetails.messageId !== pending.previousDebugDetails?.messageId;
+ const hasCompletionSignal =
+ pending.observedSending || sendError !== null || hasNewResult || hasNewDebugDetails;
+ if (!hasCompletionSignal) return;
+
+ pendingSendRef.current = null;
+ const failed = sendError !== null || sendDebugDetails?.delivered === false;
+ if (failed) {
+ if (!isPendingCurrentTeam) return;
+ const currentDraftIsEmpty =
+ draft.text.length === 0 && draft.chips.length === 0 && draft.attachments.length === 0;
+ if (pending.optimisticallyCleared && currentDraftIsEmpty) {
+ draft.restoreDraft(pending.snapshot);
+ } else if (!currentDraftIsEmpty) {
+ draft.finalizePendingSendClear(undefined, pending.snapshot);
+ }
+ return;
+ }
+
+ if (!isPendingCurrentTeam) {
+ draft.finalizePendingSendClear(pending.teamName, pending.snapshot);
+ return;
+ }
+
+ if (!pending.optimisticallyCleared) {
+ draft.clearDraft();
+ return;
+ }
+
+ draft.finalizePendingSendClear(undefined, pending.snapshot);
+ }, [teamName, sending, sendError, sendDebugDetails, lastResult, draft]);
const showFileRestrictionError = useCallback(() => {
setFileRestrictionError(
@@ -417,6 +485,23 @@ export const MessageComposer = ({
}, 4000);
}, [attachmentRestrictionReason]);
+ const { addFiles: draftAddFiles } = draft;
+ const handleFileInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ const input = e.target;
+ if (input.files?.length) {
+ if (!canAttach) {
+ showFileRestrictionError();
+ input.value = '';
+ return;
+ }
+ void draftAddFiles(input.files);
+ }
+ input.value = '';
+ },
+ [canAttach, draftAddFiles, showFileRestrictionError]
+ );
+
// Cleanup restriction error timer on unmount
useEffect(() => {
const ref = fileRestrictionTimerRef;
@@ -448,7 +533,7 @@ export const MessageComposer = ({
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
- if (!supportsAttachments) {
+ if (!canAttach) {
const files = e.dataTransfer?.files;
if (files?.length) {
showFileRestrictionError();
@@ -457,13 +542,13 @@ export const MessageComposer = ({
}
draftHandleDrop(e);
},
- [supportsAttachments, draftHandleDrop, showFileRestrictionError]
+ [canAttach, draftHandleDrop, showFileRestrictionError]
);
const { handlePaste: draftHandlePaste } = draft;
const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => {
- if (!supportsAttachments) {
+ if (!canAttach) {
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
if (hasFiles) {
e.preventDefault();
@@ -473,7 +558,7 @@ export const MessageComposer = ({
}
draftHandlePaste(e);
},
- [supportsAttachments, draftHandlePaste, showFileRestrictionError]
+ [canAttach, draftHandlePaste, showFileRestrictionError]
);
const remaining = MAX_TEXT_LENGTH - trimmed.length;
@@ -546,9 +631,11 @@ export const MessageComposer = ({
{!isTeamAlive
? 'Team must be online to attach files'
- : !draft.canAddMore
- ? 'Maximum attachments reached'
- : 'Attach files (paste or drag & drop)'}
+ : sending
+ ? 'Wait for current message to finish sending'
+ : !draft.canAddMore
+ ? 'Maximum attachments reached'
+ : 'Attach files (paste or drag & drop)'}
>
@@ -850,7 +937,7 @@ export const MessageComposer = ({
}
cornerAction={
diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx
index 1784de68..145dd9b0 100644
--- a/src/renderer/components/team/messages/MessagesPanel.tsx
+++ b/src/renderer/components/team/messages/MessagesPanel.tsx
@@ -70,6 +70,7 @@ const BOTTOM_SHEET_HEADER_HEIGHT = 40;
const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
const BOTTOM_SHEET_FULL_SNAP_INDEX = 4;
+const OPENCODE_RUNTIME_DELIVERY_STATUS_REFRESH_DELAYS_MS = [15_000, 45_000, 90_000] as const;
interface MessagesPanelProps {
teamName: string;
@@ -246,6 +247,7 @@ export const MessagesPanel = memo(function MessagesPanel({
sendMessageDebugDetails,
lastSendMessageResult,
clearSendMessageRuntimeDiagnostics,
+ refreshSendMessageRuntimeDeliveryStatus,
teams,
openTeamTab,
messages,
@@ -262,6 +264,7 @@ export const MessagesPanel = memo(function MessagesPanel({
sendMessageDebugDetails: s.sendMessageDebugDetails,
lastSendMessageResult: s.lastSendMessageResult,
clearSendMessageRuntimeDiagnostics: s.clearSendMessageRuntimeDiagnostics,
+ refreshSendMessageRuntimeDeliveryStatus: s.refreshSendMessageRuntimeDeliveryStatus,
teams: s.teams,
openTeamTab: s.openTeamTab,
messages: selectTeamMessages(s, teamName),
@@ -577,6 +580,28 @@ export const MessagesPanel = memo(function MessagesPanel({
sendMessageRuntimeReplyVisible,
]);
+ useEffect(() => {
+ const debugDetails = sendMessageDebugDetails;
+ const messageId = debugDetails?.messageId;
+ if (!messageId || sendMessageRuntimeReplyVisible || debugDetails?.responsePending !== true) {
+ return;
+ }
+ const timers = OPENCODE_RUNTIME_DELIVERY_STATUS_REFRESH_DELAYS_MS.map((delayMs) =>
+ window.setTimeout(() => {
+ void refreshSendMessageRuntimeDeliveryStatus(teamName, messageId);
+ }, delayMs)
+ );
+ return () => {
+ timers.forEach((timer) => window.clearTimeout(timer));
+ };
+ }, [
+ refreshSendMessageRuntimeDeliveryStatus,
+ sendMessageDebugDetails?.messageId,
+ sendMessageDebugDetails?.responsePending,
+ sendMessageRuntimeReplyVisible,
+ teamName,
+ ]);
+
const handleSend = useCallback(
(
member: string,
diff --git a/src/renderer/hooks/useComposerDraft.lifecycle.test.tsx b/src/renderer/hooks/useComposerDraft.lifecycle.test.tsx
new file mode 100644
index 00000000..b2f40352
--- /dev/null
+++ b/src/renderer/hooks/useComposerDraft.lifecycle.test.tsx
@@ -0,0 +1,412 @@
+import React, { act, useEffect } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const {
+ deleteSnapshotMock,
+ emptySnapshotMock,
+ loadSnapshotMock,
+ migrateLegacyMock,
+ saveSnapshotMock,
+} = vi.hoisted(() => ({
+ deleteSnapshotMock: vi.fn(),
+ emptySnapshotMock: vi.fn((teamName: string) => ({
+ version: 1,
+ teamName,
+ text: '',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ updatedAt: Date.now(),
+ })),
+ loadSnapshotMock: vi.fn(),
+ migrateLegacyMock: vi.fn(),
+ saveSnapshotMock: vi.fn(),
+}));
+
+vi.mock('@renderer/services/composerDraftStorage', () => ({
+ composerDraftStorage: {
+ deleteSnapshot: deleteSnapshotMock,
+ emptySnapshot: emptySnapshotMock,
+ loadSnapshot: loadSnapshotMock,
+ migrateLegacy: migrateLegacyMock,
+ saveSnapshot: saveSnapshotMock,
+ },
+}));
+
+import { useComposerDraft } from './useComposerDraft';
+
+const HookProbe = ({
+ onLoaded,
+}: {
+ onLoaded: (draft: ReturnType
) => void;
+}): React.JSX.Element | null => {
+ const draft = useComposerDraft('team-alpha');
+
+ useEffect(() => {
+ if (draft.isLoaded) {
+ onLoaded(draft);
+ }
+ }, [draft, onLoaded]);
+
+ return null;
+};
+
+async function renderLoadedHook(): Promise<{
+ getDraft: () => ReturnType;
+ root: ReturnType;
+}> {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+ let latestDraft: ReturnType | null = null;
+
+ await act(async () => {
+ root.render(
+ React.createElement(HookProbe, {
+ onLoaded: (draft) => {
+ latestDraft = draft;
+ },
+ })
+ );
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ if (!latestDraft) {
+ throw new Error('useComposerDraft did not load');
+ }
+
+ return {
+ getDraft: () => {
+ if (!latestDraft) throw new Error('useComposerDraft did not load');
+ return latestDraft;
+ },
+ root,
+ };
+}
+
+describe('useComposerDraft pending send lifecycle', () => {
+ beforeEach(() => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ deleteSnapshotMock.mockReset();
+ emptySnapshotMock.mockClear();
+ loadSnapshotMock.mockReset();
+ migrateLegacyMock.mockReset();
+ saveSnapshotMock.mockReset();
+ loadSnapshotMock.mockResolvedValue(null);
+ migrateLegacyMock.mockResolvedValue(null);
+ saveSnapshotMock.mockResolvedValue(undefined);
+ deleteSnapshotMock.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ it('hides submitted content immediately and can restore it on failed delivery', async () => {
+ const { getDraft, root } = await renderLoadedHook();
+
+ act(() => {
+ getDraft().setText('hello teammate');
+ });
+ expect(getDraft().text).toBe('hello teammate');
+
+ act(() => {
+ getDraft().hideDraftForPendingSend({
+ text: 'hello teammate',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ });
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(getDraft().text).toBe('');
+ expect(saveSnapshotMock).toHaveBeenCalledWith(
+ 'team-alpha',
+ expect.objectContaining({ text: 'hello teammate' })
+ );
+ expect(deleteSnapshotMock).not.toHaveBeenCalled();
+
+ act(() => {
+ getDraft().restoreDraft({
+ text: 'hello teammate',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ });
+ });
+
+ expect(getDraft().text).toBe('hello teammate');
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('waits for pending snapshot persistence before deleting on successful delivery', async () => {
+ let resolveSave!: () => void;
+ saveSnapshotMock.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ })
+ );
+ const { getDraft, root } = await renderLoadedHook();
+
+ act(() => {
+ getDraft().hideDraftForPendingSend({
+ text: 'fast success',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ });
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ act(() => {
+ getDraft().finalizePendingSendClear();
+ });
+
+ expect(deleteSnapshotMock).not.toHaveBeenCalled();
+
+ resolveSave();
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(deleteSnapshotMock).toHaveBeenCalledWith('team-alpha');
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('persists a new draft after the submitted draft is hidden and then completes', async () => {
+ let resolveFirstSave!: () => void;
+ saveSnapshotMock
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirstSave = resolve;
+ })
+ )
+ .mockResolvedValue(undefined);
+ const { getDraft, root } = await renderLoadedHook();
+
+ act(() => {
+ getDraft().hideDraftForPendingSend({
+ text: 'submitted text',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ });
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ act(() => {
+ getDraft().setText('next draft');
+ });
+ act(() => {
+ getDraft().finalizePendingSendClear();
+ });
+
+ expect(saveSnapshotMock).toHaveBeenCalledTimes(1);
+
+ resolveFirstSave();
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveSnapshotMock).toHaveBeenCalledTimes(2);
+ expect(saveSnapshotMock).toHaveBeenNthCalledWith(
+ 2,
+ 'team-alpha',
+ expect.objectContaining({ text: 'next draft' })
+ );
+ expect(deleteSnapshotMock).not.toHaveBeenCalled();
+ expect(getDraft().text).toBe('next draft');
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('deletes a completed snapshot for another team without interrupting the current draft', async () => {
+ vi.useFakeTimers();
+ const { getDraft, root } = await renderLoadedHook();
+ loadSnapshotMock.mockResolvedValueOnce({
+ version: 1,
+ teamName: 'team-beta',
+ text: 'submitted elsewhere',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ pendingSendId: 'pending-beta',
+ updatedAt: Date.now(),
+ });
+
+ act(() => {
+ getDraft().setText('current draft');
+ });
+ act(() => {
+ getDraft().finalizePendingSendClear('team-beta', {
+ text: 'submitted elsewhere',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ pendingSendId: 'pending-beta',
+ });
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(deleteSnapshotMock).toHaveBeenCalledWith('team-beta');
+ expect(getDraft().text).toBe('current draft');
+
+ await act(async () => {
+ vi.advanceTimersByTime(400);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveSnapshotMock).toHaveBeenCalledWith(
+ 'team-alpha',
+ expect.objectContaining({ text: 'current draft' })
+ );
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('does not delete another team draft when storage no longer matches the submitted snapshot', async () => {
+ const { getDraft, root } = await renderLoadedHook();
+ loadSnapshotMock.mockResolvedValueOnce({
+ version: 1,
+ teamName: 'team-beta',
+ text: 'newer draft',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ updatedAt: Date.now(),
+ });
+
+ act(() => {
+ getDraft().finalizePendingSendClear('team-beta', {
+ text: 'submitted elsewhere',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ });
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(deleteSnapshotMock).not.toHaveBeenCalled();
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('does not delete an identical newer draft when its pending send marker differs', async () => {
+ const { getDraft, root } = await renderLoadedHook();
+ loadSnapshotMock.mockResolvedValueOnce({
+ version: 1,
+ teamName: 'team-beta',
+ text: 'same text',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ pendingSendId: 'newer-send',
+ updatedAt: Date.now(),
+ });
+
+ act(() => {
+ getDraft().finalizePendingSendClear('team-beta', {
+ text: 'same text',
+ chips: [],
+ attachments: [],
+ actionMode: 'do',
+ pendingSendId: 'older-send',
+ });
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(deleteSnapshotMock).not.toHaveBeenCalled();
+
+ act(() => {
+ root.unmount();
+ });
+ });
+
+ it('does not mark a changed draft saved when an older debounced save resolves late', async () => {
+ vi.useFakeTimers();
+ let resolveFirstSave!: () => void;
+ saveSnapshotMock
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveFirstSave = resolve;
+ })
+ )
+ .mockResolvedValue(undefined);
+ const { getDraft, root } = await renderLoadedHook();
+
+ act(() => {
+ getDraft().setText('first draft');
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(400);
+ await Promise.resolve();
+ });
+ expect(saveSnapshotMock).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ getDraft().setText('second draft');
+ });
+ expect(getDraft().isSaved).toBe(false);
+
+ resolveFirstSave();
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(getDraft().isSaved).toBe(false);
+
+ await act(async () => {
+ vi.advanceTimersByTime(400);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(saveSnapshotMock).toHaveBeenCalledTimes(2);
+ expect(saveSnapshotMock).toHaveBeenNthCalledWith(
+ 2,
+ 'team-alpha',
+ expect.objectContaining({ text: 'second draft' })
+ );
+ expect(getDraft().isSaved).toBe(true);
+
+ act(() => {
+ root.unmount();
+ });
+ });
+});
diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts
index 9b2eff53..dc6a76ae 100644
--- a/src/renderer/hooks/useComposerDraft.ts
+++ b/src/renderer/hooks/useComposerDraft.ts
@@ -63,6 +63,20 @@ export interface UseComposerDraftResult {
// Clear all
clearDraft: () => void;
+ hideDraftForPendingSend: (content: ComposerDraftContent) => void;
+ finalizePendingSendClear: (
+ teamNameOverride?: string,
+ submittedContent?: ComposerDraftContent
+ ) => void;
+ restoreDraft: (content: ComposerDraftContent) => void;
+}
+
+export interface ComposerDraftContent {
+ text: string;
+ chips: InlineChip[];
+ attachments: AttachmentPayload[];
+ actionMode?: AgentActionMode;
+ pendingSendId?: string;
}
// ---------------------------------------------------------------------------
@@ -71,6 +85,26 @@ export interface UseComposerDraftResult {
const DEBOUNCE_MS = 400;
+function draftPayloadEquals(left: unknown, right: unknown): boolean {
+ return JSON.stringify(left) === JSON.stringify(right);
+}
+
+function snapshotMatchesContent(
+ snapshot: ComposerDraftSnapshot | null,
+ content: ComposerDraftContent
+): boolean {
+ if (snapshot == null) return false;
+ if (content.pendingSendId != null) {
+ return snapshot.pendingSendId === content.pendingSendId;
+ }
+ if (snapshot.text !== content.text) return false;
+ if (content.actionMode != null && snapshot.actionMode !== content.actionMode) return false;
+ return (
+ draftPayloadEquals(snapshot.chips, content.chips) &&
+ draftPayloadEquals(snapshot.attachments, content.attachments)
+ );
+}
+
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
@@ -98,6 +132,8 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
// Debounce timer
const timerRef = useRef | null>(null);
const pendingRef = useRef<{ teamName: string; snapshot: ComposerDraftSnapshot } | null>(null);
+ const persistQueueRef = useRef>(Promise.resolve());
+ const persistenceVersionRef = useRef(0);
// Keep teamNameRef in sync
useEffect(() => {
@@ -127,6 +163,12 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
};
}, []);
+ const enqueuePersist = useCallback((operation: () => Promise): Promise => {
+ const queued = persistQueueRef.current.catch(() => undefined).then(operation);
+ persistQueueRef.current = queued.catch(() => undefined);
+ return queued;
+ }, []);
+
const flushPending = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
@@ -140,16 +182,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
pending.snapshot.chips.length === 0 &&
pending.snapshot.attachments.length === 0;
if (isEmpty) {
- void composerDraftStorage.deleteSnapshot(pending.teamName);
+ void enqueuePersist(() => composerDraftStorage.deleteSnapshot(pending.teamName));
} else {
- void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot);
+ void enqueuePersist(() =>
+ composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot)
+ );
}
}
- }, []);
+ }, [enqueuePersist]);
const scheduleSave = useCallback(() => {
const snapshot = buildSnapshot();
pendingRef.current = { teamName: teamNameRef.current, snapshot };
+ const persistenceVersion = ++persistenceVersionRef.current;
if (timerRef.current != null) {
clearTimeout(timerRef.current);
@@ -165,16 +210,18 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
pending.snapshot.text.length === 0 &&
pending.snapshot.chips.length === 0 &&
pending.snapshot.attachments.length === 0;
- if (isEmpty) {
- void composerDraftStorage.deleteSnapshot(pending.teamName);
- if (mountedRef.current) setIsSaved(true);
- } else {
- void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot).then(() => {
- if (mountedRef.current) setIsSaved(true);
- });
- }
+ const persist = enqueuePersist(() =>
+ isEmpty
+ ? composerDraftStorage.deleteSnapshot(pending.teamName)
+ : composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot)
+ );
+ void persist.then(() => {
+ if (mountedRef.current && persistenceVersionRef.current === persistenceVersion) {
+ setIsSaved(true);
+ }
+ });
}, DEBOUNCE_MS);
- }, [buildSnapshot]);
+ }, [buildSnapshot, enqueuePersist]);
// ---------------------------------------------------------------------------
// Apply snapshot to state
@@ -458,6 +505,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
// Clear all
// ---------------------------------------------------------------------------
+ const toSnapshot = useCallback((content: ComposerDraftContent): ComposerDraftSnapshot => {
+ return {
+ version: 1,
+ teamName: teamNameRef.current,
+ text: content.text,
+ chips: content.chips,
+ attachments: content.attachments,
+ actionMode: content.actionMode ?? actionModeRef.current,
+ ...(content.pendingSendId ? { pendingSendId: content.pendingSendId } : {}),
+ updatedAt: Date.now(),
+ };
+ }, []);
+
const clearDraft = useCallback(() => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
@@ -476,8 +536,102 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
setAttachmentError(null);
setIsSaved(false);
- void composerDraftStorage.deleteSnapshot(teamNameRef.current);
- }, []);
+ ++persistenceVersionRef.current;
+ const teamNameForDelete = teamNameRef.current;
+ void enqueuePersist(() => composerDraftStorage.deleteSnapshot(teamNameForDelete));
+ }, [enqueuePersist]);
+
+ const hideDraftForPendingSend = useCallback(
+ (content: ComposerDraftContent) => {
+ if (timerRef.current != null) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ pendingRef.current = null;
+
+ ++persistenceVersionRef.current;
+ const teamNameForSave = teamNameRef.current;
+ const snapshot = toSnapshot(content);
+ void enqueuePersist(() => composerDraftStorage.saveSnapshot(teamNameForSave, snapshot));
+
+ textRef.current = '';
+ chipsRef.current = [];
+ attachmentsRef.current = [];
+
+ setTextState('');
+ setChipsState([]);
+ setAttachmentsState([]);
+ setAttachmentError(null);
+ setIsSaved(false);
+ },
+ [enqueuePersist, toSnapshot]
+ );
+
+ const deleteSubmittedSnapshotIfCurrent = useCallback(
+ (teamNameForDelete: string, submittedContent?: ComposerDraftContent) =>
+ enqueuePersist(async () => {
+ if (submittedContent != null) {
+ const currentSnapshot = await composerDraftStorage.loadSnapshot(teamNameForDelete);
+ if (!snapshotMatchesContent(currentSnapshot, submittedContent)) {
+ return;
+ }
+ }
+ await composerDraftStorage.deleteSnapshot(teamNameForDelete);
+ }),
+ [enqueuePersist]
+ );
+
+ const finalizePendingSendClear = useCallback(
+ (teamNameOverride?: string, submittedContent?: ComposerDraftContent) => {
+ const currentTeamName = teamNameRef.current;
+ const teamNameForPersist = teamNameOverride ?? currentTeamName;
+ const isCurrentTeam = teamNameForPersist === currentTeamName;
+
+ if (!isCurrentTeam) {
+ void deleteSubmittedSnapshotIfCurrent(teamNameForPersist, submittedContent);
+ return;
+ }
+
+ if (timerRef.current != null) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ pendingRef.current = null;
+ const persistenceVersion = ++persistenceVersionRef.current;
+
+ const isEmpty =
+ textRef.current.length === 0 &&
+ chipsRef.current.length === 0 &&
+ attachmentsRef.current.length === 0;
+ if (isEmpty) {
+ void deleteSubmittedSnapshotIfCurrent(teamNameForPersist, submittedContent);
+ setIsSaved(false);
+ return;
+ }
+
+ const snapshot = buildSnapshot();
+ void enqueuePersist(() =>
+ composerDraftStorage.saveSnapshot(teamNameForPersist, snapshot)
+ ).then(() => {
+ if (mountedRef.current && persistenceVersionRef.current === persistenceVersion) {
+ setIsSaved(true);
+ }
+ });
+ },
+ [buildSnapshot, deleteSubmittedSnapshotIfCurrent, enqueuePersist]
+ );
+
+ const restoreDraft = useCallback(
+ (content: ComposerDraftContent) => {
+ userTouchedRef.current = true;
+ const snapshot = toSnapshot(content);
+ applySnapshot(snapshot);
+ setAttachmentError(null);
+ setIsSaved(false);
+ scheduleSave();
+ },
+ [applySnapshot, scheduleSave, toSnapshot]
+ );
return {
text,
@@ -499,5 +653,8 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
isSaved,
isLoaded,
clearDraft,
+ hideDraftForPendingSend,
+ finalizePendingSendClear,
+ restoreDraft,
};
}
diff --git a/src/renderer/services/composerDraftStorage.ts b/src/renderer/services/composerDraftStorage.ts
index b8ec54d7..43151171 100644
--- a/src/renderer/services/composerDraftStorage.ts
+++ b/src/renderer/services/composerDraftStorage.ts
@@ -25,6 +25,7 @@ export interface ComposerDraftSnapshot {
chips: InlineChip[];
attachments: AttachmentPayload[];
actionMode?: AgentActionMode;
+ pendingSendId?: string;
updatedAt: number;
}
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 1c0effeb..7e8ddc6a 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -2367,6 +2367,7 @@ export interface TeamSlice {
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
lastSendMessageResult: SendMessageResult | null;
clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => void;
+ refreshSendMessageRuntimeDeliveryStatus: (teamName: string, messageId: string) => Promise;
reviewActionError: string | null;
provisioningRuns: Record;
/** Synthetic TeamSummary snapshots for teams currently being provisioned (before config.json exists). */
@@ -4502,6 +4503,30 @@ export const createTeamSlice: StateCreator = (set,
});
},
+ refreshSendMessageRuntimeDeliveryStatus: async (teamName: string, messageId: string) => {
+ const normalizedMessageId = messageId.trim();
+ if (!normalizedMessageId) return;
+ if (get().sendMessageDebugDetails?.messageId !== normalizedMessageId) return;
+ const status = await unwrapIpc('team:getOpenCodeRuntimeDeliveryStatus', () =>
+ api.teams.getOpenCodeRuntimeDeliveryStatus(teamName, normalizedMessageId)
+ );
+ if (!status) return;
+ const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
+ deliveredToInbox: true,
+ messageId: normalizedMessageId,
+ runtimeDelivery: status,
+ });
+ set((state) => {
+ if (state.sendMessageDebugDetails?.messageId !== normalizedMessageId) {
+ return {};
+ }
+ return {
+ sendMessageWarning: diagnostics.warning,
+ sendMessageDebugDetails: diagnostics.debugDetails,
+ };
+ });
+ },
+
fetchCrossTeamTargets: async () => {
set({ crossTeamTargetsLoading: true });
try {
@@ -4677,6 +4702,7 @@ export const createTeamSlice: StateCreator = (set,
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
} finally {
await Promise.allSettled([
+ get().refreshTeamMessagesHead(teamName),
get().fetchMemberSpawnStatuses(teamName),
get().fetchTeamAgentRuntime(teamName),
]);
diff --git a/src/renderer/utils/bootstrapPromptSanitizer.ts b/src/renderer/utils/bootstrapPromptSanitizer.ts
index 4d58919f..c777c010 100644
--- a/src/renderer/utils/bootstrapPromptSanitizer.ts
+++ b/src/renderer/utils/bootstrapPromptSanitizer.ts
@@ -110,6 +110,7 @@ function buildRuntimeSummary(
}
export interface BootstrapPromptDisplay {
+ eventKind: 'start' | 'restart';
teammateName?: string;
teamName?: string;
runtime?: string;
@@ -144,9 +145,16 @@ export function getBootstrapPromptDisplay(
const model = matchOverrideField(text, 'Model override');
const effort = matchOverrideField(text, 'Effort override');
const runtime = buildRuntimeSummary(providerId, model, effort);
+ const eventKind = text.includes(
+ 'The team has already been reconnected and you are being re-attached as a persistent teammate.'
+ )
+ ? 'restart'
+ : 'start';
const displayName = teammateName ? displayMemberName(teammateName) : 'teammate';
- const summary = `Starting ${displayName}`;
- const bodyLines = [`Lead is starting \`${displayName}\` as a teammate.`];
+ const summary = `${eventKind === 'restart' ? 'Restarting' : 'Starting'} ${displayName}`;
+ const bodyLines = [
+ `Lead is ${eventKind === 'restart' ? 'restarting' : 'starting'} \`${displayName}\` as a teammate.`,
+ ];
if (runtime) {
bodyLines.push(`Runtime: ${runtime}`);
@@ -157,6 +165,7 @@ export function getBootstrapPromptDisplay(
bodyLines.push('Startup instructions are hidden in the UI.');
return {
+ eventKind,
teammateName,
teamName,
runtime,
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index 42a19156..5e41c625 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -58,6 +58,7 @@ import type {
MemberLogSummary,
MemberSpawnStatusesSnapshot,
MessagesPage,
+ OpenCodeRuntimeDeliveryStatus,
ProjectBranchChangeEvent,
ReplaceMembersRequest,
RetryFailedOpenCodeSecondaryLanesResult,
@@ -468,6 +469,10 @@ export interface TeamsAPI {
getProvisioningStatus: (runId: string) => Promise;
cancelProvisioning: (runId: string) => Promise;
sendMessage: (teamName: string, request: SendMessageRequest) => Promise;
+ getOpenCodeRuntimeDeliveryStatus: (
+ teamName: string,
+ messageId: string
+ ) => Promise;
getMessagesPage: (
teamName: string,
options?: { cursor?: string | null; limit?: number }
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index a9224eae..7507ca1a 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -736,6 +736,10 @@ export interface SendMessageResult {
};
}
+export type OpenCodeRuntimeDeliveryStatus = NonNullable & {
+ messageId: string;
+};
+
export interface AddTaskCommentRequest {
text: string;
attachments?: CommentAttachmentPayload[];
diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts
index d8a159b7..b3e60e9c 100644
--- a/test/main/ipc/teams.test.ts
+++ b/test/main/ipc/teams.test.ts
@@ -250,6 +250,7 @@ describe('ipc teams handlers', () => {
addTaskRelationship: vi.fn(async () => undefined),
removeTaskRelationship: vi.fn(async () => undefined),
replaceMembers: vi.fn(async () => undefined),
+ invalidateMessageFeed: vi.fn(() => undefined),
createTeamConfig: vi.fn(async () => undefined),
getSavedRequest: vi.fn(async (): Promise => null),
};
diff --git a/test/renderer/components/team/dialogs/EditTeamDialog.test.ts b/test/renderer/components/team/dialogs/EditTeamDialog.test.ts
index 45a77c3b..568d0c3e 100644
--- a/test/renderer/components/team/dialogs/EditTeamDialog.test.ts
+++ b/test/renderer/components/team/dialogs/EditTeamDialog.test.ts
@@ -477,6 +477,140 @@ describe('EditTeamDialog', () => {
});
});
+ it('saves config-only edits without touching the roster', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
+ vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined);
+ const onSaved = vi.fn();
+ const onClose = vi.fn();
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(EditTeamDialog, {
+ open: true,
+ teamName: 'live-team',
+ currentName: 'Current Team',
+ currentDescription: 'desc',
+ currentColor: 'blue',
+ currentMembers: [
+ { name: 'bob', role: 'Developer', providerId: 'codex', model: 'gpt-5.2' },
+ { name: 'alice', role: 'Reviewer', providerId: 'opencode', model: 'openrouter/a' },
+ ] as any,
+ leadMember: { name: 'team-lead', role: 'Team Lead', providerId: 'codex' } as any,
+ isTeamAlive: true,
+ projectPath: '/tmp/project',
+ onClose,
+ onChangeLeadRuntime: vi.fn(),
+ onSaved,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const nameInput = host.querySelector('#edit-team-name') as HTMLInputElement;
+ await act(async () => {
+ const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
+ setValue?.call(nameInput, 'Renamed Team');
+ nameInput.dispatchEvent(new Event('input', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const saveButton = Array.from(host.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Save'
+ ) as HTMLButtonElement;
+ await act(async () => {
+ saveButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(api.teams.updateConfig).toHaveBeenCalledWith('live-team', {
+ name: 'Renamed Team',
+ description: 'desc',
+ color: 'blue',
+ });
+ expect(api.teams.replaceMembers).not.toHaveBeenCalled();
+ expect(api.teams.restartMember).not.toHaveBeenCalled();
+ expect(onSaved).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('surfaces config-only refresh failures without reporting member changes', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
+ vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined);
+ const onSaved = vi.fn(async () => {
+ throw new Error('refresh failed');
+ });
+ const onClose = vi.fn();
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(EditTeamDialog, {
+ open: true,
+ teamName: 'live-team',
+ currentName: 'Current Team',
+ currentDescription: 'desc',
+ currentColor: 'blue',
+ currentMembers: [{ name: 'alice', role: 'Reviewer' }] as any,
+ isTeamAlive: true,
+ projectPath: '/tmp/project',
+ onClose,
+ onChangeLeadRuntime: vi.fn(),
+ onSaved,
+ })
+ );
+ await Promise.resolve();
+ });
+
+ const nameInput = host.querySelector('#edit-team-name') as HTMLInputElement;
+ await act(async () => {
+ const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
+ setValue?.call(nameInput, 'Renamed Team');
+ nameInput.dispatchEvent(new Event('input', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ const saveButton = Array.from(host.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Save'
+ ) as HTMLButtonElement;
+ await act(async () => {
+ saveButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(api.teams.updateConfig).toHaveBeenCalled();
+ expect(api.teams.replaceMembers).not.toHaveBeenCalled();
+ expect(host.textContent).toContain(
+ 'Team settings were saved, but failed to refresh the latest view: refresh failed'
+ );
+ expect(host.textContent).not.toContain('member changes failed');
+ expect(onClose).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
it('removes existing live teammates through the dedicated removeMember path during save', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
@@ -655,6 +789,70 @@ describe('EditTeamDialog', () => {
await Promise.resolve();
});
+ await act(async () => {
+ host
+ .querySelector('[data-testid="change-member-runtime"]')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain('Saving will restart or relaunch this teammate');
+
+ const saveButton = Array.from(host.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Save'
+ );
+
+ await act(async () => {
+ host
+ .querySelector('[data-testid="change-member-role"]')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(api.teams.restartMember).toHaveBeenCalledWith('live-team', 'alice');
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('does not call generic restart for live OpenCode teammate edits handled by replaceMembers', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
+ vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined);
+ vi.mocked(api.teams.restartMember).mockResolvedValue(undefined);
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(EditTeamDialog, {
+ open: true,
+ teamName: 'live-team',
+ currentName: 'Current Team',
+ currentDescription: 'desc',
+ currentColor: 'blue',
+ currentMembers: [
+ { name: 'alice', role: 'Reviewer', providerId: 'opencode', model: 'openrouter/a' },
+ ] as any,
+ isTeamAlive: true,
+ projectPath: '/tmp/project',
+ onClose: vi.fn(),
+ onChangeLeadRuntime: vi.fn(),
+ onSaved: vi.fn(),
+ })
+ );
+ await Promise.resolve();
+ });
+
await act(async () => {
host
.querySelector('[data-testid="change-member-role"]')
@@ -666,12 +864,85 @@ describe('EditTeamDialog', () => {
(button) => button.textContent === 'Save'
);
+ await act(async () => {
+ host
+ .querySelector('[data-testid="change-member-role"]')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
- expect(api.teams.restartMember).toHaveBeenCalledWith('live-team', 'alice');
+ expect(api.teams.replaceMembers).toHaveBeenCalledWith(
+ 'live-team',
+ expect.objectContaining({
+ members: expect.arrayContaining([
+ expect.objectContaining({ name: 'alice', providerId: 'opencode' }),
+ ]),
+ })
+ );
+ expect(api.teams.restartMember).not.toHaveBeenCalled();
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ });
+
+ it('blocks live primary-owned teammate edits in mixed OpenCode teams before saving', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
+ vi.mocked(api.teams.replaceMembers).mockResolvedValue(undefined);
+ vi.mocked(api.teams.restartMember).mockResolvedValue(undefined);
+
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ await act(async () => {
+ root.render(
+ React.createElement(EditTeamDialog, {
+ open: true,
+ teamName: 'live-team',
+ currentName: 'Current Team',
+ currentDescription: 'desc',
+ currentColor: 'blue',
+ currentMembers: [
+ { name: 'bob', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.2' },
+ { name: 'alice', role: 'Reviewer', providerId: 'opencode', model: 'openrouter/a' },
+ ] as any,
+ leadMember: { name: 'team-lead', role: 'Team Lead', providerId: 'codex' } as any,
+ isTeamAlive: true,
+ projectPath: '/tmp/project',
+ onClose: vi.fn(),
+ onChangeLeadRuntime: vi.fn(),
+ onSaved: vi.fn(),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ host
+ .querySelector('[data-testid="change-member-role"]')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
+ expect(host.textContent).toContain(
+ 'Live edits/removals for primary-owned teammates in mixed OpenCode teams'
+ );
+
+ const saveButton = Array.from(host.querySelectorAll('button')).find(
+ (button) => button.textContent === 'Save'
+ ) as HTMLButtonElement | undefined;
+ expect(saveButton?.disabled).toBe(true);
+ expect(api.teams.updateConfig).not.toHaveBeenCalled();
+ expect(api.teams.replaceMembers).not.toHaveBeenCalled();
+ expect(api.teams.restartMember).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
@@ -818,6 +1089,13 @@ describe('EditTeamDialog', () => {
(button) => button.textContent === 'Save'
);
+ await act(async () => {
+ host
+ .querySelector('[data-testid="change-member-role"]')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
@@ -876,6 +1154,13 @@ describe('EditTeamDialog', () => {
await Promise.resolve();
});
+ await act(async () => {
+ host
+ .querySelector('[data-testid="change-member-role"]')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ await Promise.resolve();
+ });
+
await act(async () => {
saveButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
diff --git a/test/renderer/components/team/dialogs/editTeamRuntimeChanges.test.ts b/test/renderer/components/team/dialogs/editTeamRuntimeChanges.test.ts
index f55edf4b..ad57eb47 100644
--- a/test/renderer/components/team/dialogs/editTeamRuntimeChanges.test.ts
+++ b/test/renderer/components/team/dialogs/editTeamRuntimeChanges.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
+ buildEditTeamMemberRosterSnapshot,
buildEditTeamSourceSnapshot,
getLiveRosterIdentityChanges,
getMembersRequiringRuntimeRestart,
@@ -209,6 +210,57 @@ describe('getMembersRequiringRuntimeRestart', () => {
expect(refreshed).toBe(base);
});
+ it('matches equivalent current and built roster snapshots', () => {
+ const current = buildEditTeamMemberRosterSnapshot([
+ {
+ name: 'alice',
+ role: 'Reviewer',
+ providerId: 'codex',
+ providerBackendId: 'api',
+ model: 'gpt-5.4-mini',
+ effort: 'medium',
+ status: 'online',
+ } as any,
+ ]);
+
+ const built = buildEditTeamMemberRosterSnapshot([
+ {
+ name: 'alice',
+ role: 'Reviewer',
+ providerId: 'codex',
+ model: 'gpt-5.4-mini',
+ effort: 'medium',
+ },
+ ]);
+
+ expect(built).toBe(current);
+ });
+
+ it('keeps provider backend and fast mode in the edit roster snapshot', () => {
+ const base = buildEditTeamMemberRosterSnapshot([
+ {
+ name: 'alice',
+ role: 'Reviewer',
+ providerId: 'codex',
+ model: 'gpt-5.4-mini',
+ fastMode: 'inherit',
+ } as any,
+ ]);
+
+ const changed = buildEditTeamMemberRosterSnapshot([
+ {
+ name: 'alice',
+ role: 'Reviewer',
+ providerId: 'codex',
+ providerBackendId: 'codex-native',
+ model: 'gpt-5.4-mini',
+ fastMode: 'off',
+ } as any,
+ ]);
+
+ expect(changed).not.toBe(base);
+ });
+
it('keeps worktree isolation in the edit source snapshot', () => {
const sharedWorkspace = buildEditTeamSourceSnapshot({
name: 'Team A',
diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts
index 6022a9c0..84166381 100644
--- a/test/renderer/components/team/messages/MessagesPanel.test.ts
+++ b/test/renderer/components/team/messages/MessagesPanel.test.ts
@@ -14,6 +14,7 @@ const storeState = {
sendMessageDebugDetails: null as OpenCodeRuntimeDeliveryDebugDetails | null,
lastSendMessageResult: null as unknown,
clearSendMessageRuntimeDiagnostics: vi.fn(),
+ refreshSendMessageRuntimeDeliveryStatus: vi.fn().mockResolvedValue(undefined),
teams: [],
openTeamTab: vi.fn(),
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
@@ -191,6 +192,7 @@ function makeMessage(overrides: Partial = {}): InboxMessage {
describe('MessagesPanel idle summary invariants', () => {
afterEach(() => {
document.body.innerHTML = '';
+ vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -204,6 +206,7 @@ describe('MessagesPanel idle summary invariants', () => {
storeState.sendCrossTeamMessage.mockClear();
storeState.openTeamTab.mockClear();
storeState.clearSendMessageRuntimeDiagnostics.mockClear();
+ storeState.refreshSendMessageRuntimeDeliveryStatus.mockClear();
storeState.loadOlderTeamMessages.mockClear();
storeState.refreshTeamMessagesHead.mockClear();
storeState.sendingMessage = false;
@@ -606,6 +609,80 @@ describe('MessagesPanel idle summary invariants', () => {
});
});
+ it('refreshes pending OpenCode runtime diagnostics after send timeout', async () => {
+ vi.useFakeTimers();
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const root = createRoot(host);
+
+ storeState.sendMessageWarning =
+ 'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.';
+ storeState.sendMessageDebugDetails = {
+ messageId: 'user-send',
+ providerId: 'opencode',
+ delivered: true,
+ responsePending: true,
+ responseState: 'pending',
+ ledgerStatus: 'accepted',
+ acceptanceUnknown: false,
+ reason: 'assistant_response_pending',
+ diagnostics: ['assistant_response_pending'],
+ };
+
+ await act(async () => {
+ storeState.teamMessagesByName['atlas-hq'] = {
+ canonicalMessages: [
+ makeMessage({
+ messageId: 'user-send',
+ from: 'user',
+ to: 'tom',
+ source: 'user_sent',
+ timestamp: '2026-04-08T12:00:00.000Z',
+ text: 'Тут?',
+ }),
+ ],
+ optimisticMessages: [],
+ feedRevision: 'rev-1',
+ nextCursor: null,
+ hasMore: false,
+ lastFetchedAt: Date.now(),
+ loadingHead: false,
+ loadingOlder: false,
+ headHydrated: true,
+ };
+ root.render(
+ React.createElement(MessagesPanel, {
+ teamName: 'atlas-hq',
+ position: 'sidebar',
+ onPositionChange: vi.fn(),
+ members: [],
+ tasks: [],
+ timeWindow: null,
+ pendingRepliesByMember: {},
+ onPendingReplyChange: vi.fn(),
+ })
+ );
+ await Promise.resolve();
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(15_000);
+ await Promise.resolve();
+ });
+
+ expect(storeState.refreshSendMessageRuntimeDeliveryStatus).toHaveBeenCalledWith(
+ 'atlas-hq',
+ 'user-send'
+ );
+
+ await act(async () => {
+ root.unmount();
+ await Promise.resolve();
+ });
+ vi.useRealTimers();
+ });
+
it('renders the bottom-sheet composer before the status block so input stays pinned near the header', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts
index 12ad5420..4d3c0f6a 100644
--- a/test/renderer/store/teamSlice.test.ts
+++ b/test/renderer/store/teamSlice.test.ts
@@ -34,6 +34,7 @@ const hoisted = vi.hoisted(() => ({
restoreTeam: vi.fn(),
permanentlyDeleteTeam: vi.fn(),
sendMessage: vi.fn(),
+ getOpenCodeRuntimeDeliveryStatus: vi.fn(),
retryFailedOpenCodeSecondaryLanes: vi.fn(),
restartMember: vi.fn(),
skipMemberForLaunch: vi.fn(),
@@ -70,6 +71,7 @@ vi.mock('@renderer/api', () => ({
restoreTeam: hoisted.restoreTeam,
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
sendMessage: hoisted.sendMessage,
+ getOpenCodeRuntimeDeliveryStatus: hoisted.getOpenCodeRuntimeDeliveryStatus,
retryFailedOpenCodeSecondaryLanes: hoisted.retryFailedOpenCodeSecondaryLanes,
restartMember: hoisted.restartMember,
skipMemberForLaunch: hoisted.skipMemberForLaunch,
@@ -309,6 +311,7 @@ describe('teamSlice actions', () => {
feedRevision: 'rev-1',
});
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
+ hoisted.getOpenCodeRuntimeDeliveryStatus.mockResolvedValue(null);
hoisted.requestReview.mockResolvedValue(undefined);
hoisted.updateKanban.mockResolvedValue(undefined);
hoisted.createTeam.mockResolvedValue({ runId: 'run-1' });
@@ -487,6 +490,58 @@ describe('teamSlice actions', () => {
});
});
+ it('updates pending OpenCode runtime diagnostics when delivery becomes terminal', async () => {
+ const store = createSliceStore();
+ hoisted.sendMessage.mockResolvedValue({
+ deliveredToInbox: true,
+ messageId: 'm-opencode-pending',
+ runtimeDelivery: {
+ providerId: 'opencode',
+ attempted: true,
+ delivered: true,
+ responsePending: true,
+ responseState: 'pending',
+ ledgerStatus: 'accepted',
+ acceptanceUnknown: false,
+ reason: 'assistant_response_pending',
+ diagnostics: ['assistant_response_pending'],
+ },
+ });
+ hoisted.getOpenCodeRuntimeDeliveryStatus.mockResolvedValue({
+ messageId: 'm-opencode-pending',
+ providerId: 'opencode',
+ attempted: true,
+ delivered: false,
+ responsePending: false,
+ responseState: 'empty_assistant_turn',
+ ledgerStatus: 'failed_terminal',
+ acceptanceUnknown: false,
+ reason: 'empty_assistant_turn',
+ diagnostics: ['empty_assistant_turn'],
+ });
+
+ await store.getState().sendTeamMessage('my-team', {
+ member: 'bob',
+ text: 'hello',
+ });
+ await store
+ .getState()
+ .refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
+
+ expect(store.getState().sendMessageWarning).toBe(
+ 'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
+ );
+ expect(store.getState().sendMessageDebugDetails).toMatchObject({
+ messageId: 'm-opencode-pending',
+ delivered: false,
+ responsePending: false,
+ responseState: 'empty_assistant_turn',
+ ledgerStatus: 'failed_terminal',
+ reason: 'empty_assistant_turn',
+ diagnostics: ['empty_assistant_turn'],
+ });
+ });
+
it('clears OpenCode runtime diagnostics only for the matching message id', async () => {
const store = createSliceStore();
hoisted.sendMessage.mockResolvedValue({