feat(team): improve composer persistence flow

This commit is contained in:
777genius 2026-05-05 10:35:33 +03:00
parent 032ddbbe2c
commit b192ed4bae
25 changed files with 2009 additions and 137 deletions

View file

@ -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<IpcResult<OpenCodeRuntimeDeliveryStatus | null>> {
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;

View file

@ -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<OpenCodeRuntimeDeliveryStatus | null> {
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);
}

View file

@ -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';

View file

@ -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<SendMessageResult>(TEAM_SEND_MESSAGE, teamName, request);
},
getOpenCodeRuntimeDeliveryStatus: async (teamName: string, messageId: string) => {
return invokeIpcWithResult<OpenCodeRuntimeDeliveryStatus | null>(
TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS,
teamName,
messageId
);
},
getMessagesPage: async (
teamName: string,
options?: { cursor?: string | null; limit?: number }

View file

@ -35,6 +35,7 @@ import type {
KanbanColumnId,
NotificationsAPI,
NotificationTrigger,
OpenCodeRuntimeDeliveryStatus,
PaginatedSessionsResult,
Project,
RepositoryGroup,
@ -794,6 +795,12 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<SendMessageResult> => {
throw new Error('Team messaging is not available in browser mode');
},
getOpenCodeRuntimeDeliveryStatus: async (
_teamName: string,
_messageId: string
): Promise<OpenCodeRuntimeDeliveryStatus | null> => {
throw new Error('OpenCode runtime delivery status is not available in browser mode');
},
getMessagesPage: async () => {
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
},

View file

@ -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 => (
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.82 }}>
<span className="bg-sky-500/12 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-sky-300">
start
</span>
<MemberBadge
name={senderName}
color={senderColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{runtime || 'Starting teammate'}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
}): React.JSX.Element => {
const isRestart = eventKind === 'restart';
return (
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.82 }}>
<span
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${
isRestart ? 'bg-amber-500/12 text-amber-300' : 'bg-sky-500/12 text-sky-300'
}`}
>
{isRestart ? 'restart' : 'start'}
</span>
<MemberBadge
name={senderName}
color={senderColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
<MemberBadge
name={recipientName}
color={recipientColor}
teamName={teamName}
hideAvatar
onClick={onMemberNameClick}
/>
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{runtime || (isRestart ? 'Restarting teammate' : 'Starting teammate')}
</span>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
);
};
const BootstrapAcknowledgementRow = ({
teamName,
@ -921,6 +930,7 @@ export const ActivityItem = memo(
return (
<BootstrapSystemRow
teamName={teamName}
eventKind={bootstrapDisplay.eventKind}
senderName={senderName}
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
runtime={bootstrapDisplay.runtime}

View file

@ -33,6 +33,7 @@ import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { Loader2 } from 'lucide-react';
import {
buildEditTeamMemberRosterSnapshot,
buildEditTeamSourceSnapshot,
getLiveRosterIdentityChanges,
getMemberRuntimeContractKey,
@ -254,6 +255,27 @@ export const EditTeamDialog = ({
new Map(builtMembers.map((member) => [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.
</p>
) : null}
{isTeamAlive && effectiveMembersToRestart.length > 0 ? (
{unsupportedLiveMixedPrimaryMutationNames.length > 0 ? (
<p className="text-xs text-red-300">
Live edits/removals for primary-owned teammates in mixed OpenCode teams require
stopping and relaunching the team:{' '}
{unsupportedLiveMixedPrimaryMutationNames.join(', ')}.
</p>
) : null}
{isTeamAlive && liveRuntimeRefreshMemberNames.length > 0 ? (
<p className="text-xs text-amber-300">
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(', ')}.
</p>
) : null}
<div>
@ -662,7 +761,8 @@ export const EditTeamDialog = ({
isTeamProvisioning ||
!name.trim() ||
hasDuplicateMembers ||
Boolean(invalidMemberNamesError)
Boolean(invalidMemberNamesError) ||
unsupportedLiveMixedPrimaryMutationNames.length > 0
}
>
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}

View file

@ -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<typeof member> => 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<typeof member> => 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(),

View file

@ -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 = ({
</span>
) : (
<>
{onRestartMember &&
!isLeadMember(member) &&
(isTeamAlive || isTeamProvisioning) &&
runtimeEntry?.restartable !== false && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={restarting || restartInFlight}
onClick={async () => {
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 ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
{restartButtonLabel}
</Button>
)}
{canRestartFromDialog && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={restarting || restartInFlight}
onClick={async () => {
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 ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RotateCcw size={14} />
)}
{restartButtonLabel}
</Button>
)}
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
<MessageSquare size={14} />
Send Message

View file

@ -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}

View file

@ -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<string[]>(() => 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<string, unknown>) => 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<React.ComponentProps<typeof MessageComposer>> = {}): {
host: HTMLDivElement;
render: (next?: Partial<React.ComponentProps<typeof MessageComposer>>) => void;
root: ReturnType<typeof createRoot>;
onSend: ReturnType<typeof vi.fn>;
} {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onSend = vi.fn();
const baseProps: React.ComponentProps<typeof MessageComposer> = {
teamName: 'team-alpha',
members,
isTeamAlive: true,
sending: false,
sendError: null,
sendWarning: null,
sendDebugDetails: null,
lastResult: null,
onSend,
};
const render = (next: Partial<React.ComponentProps<typeof MessageComposer>> = {}): 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();
});
});
});

View file

@ -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<PendingSendState | null>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 = ({
<TooltipContent side="top">
{!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)'}
</TooltipContent>
</Tooltip>
</>
@ -850,7 +937,7 @@ export const MessageComposer = ({
<div className="relative">
<DropZoneOverlay
active={isDragOver}
rejected={!supportsAttachments}
rejected={!canAttach}
rejectionReason={attachmentRestrictionReason}
/>
<MentionableTextarea
@ -893,6 +980,7 @@ export const MessageComposer = ({
value={actionMode}
onChange={setActionMode}
showDelegate={canDelegate}
disabled={sending}
/>
}
cornerAction={

View file

@ -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,

View file

@ -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<typeof useComposerDraft>) => 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<typeof useComposerDraft>;
root: ReturnType<typeof createRoot>;
}> {
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
let latestDraft: ReturnType<typeof useComposerDraft> | 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<void>((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<void>((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<void>((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();
});
});
});

View file

@ -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<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<{ teamName: string; snapshot: ComposerDraftSnapshot } | null>(null);
const persistQueueRef = useRef<Promise<void>>(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<void>): Promise<void> => {
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,
};
}

View file

@ -25,6 +25,7 @@ export interface ComposerDraftSnapshot {
chips: InlineChip[];
attachments: AttachmentPayload[];
actionMode?: AgentActionMode;
pendingSendId?: string;
updatedAt: number;
}

View file

@ -2367,6 +2367,7 @@ export interface TeamSlice {
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
lastSendMessageResult: SendMessageResult | null;
clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => void;
refreshSendMessageRuntimeDeliveryStatus: (teamName: string, messageId: string) => Promise<void>;
reviewActionError: string | null;
provisioningRuns: Record<string, TeamProvisioningProgress>;
/** Synthetic TeamSummary snapshots for teams currently being provisioned (before config.json exists). */
@ -4502,6 +4503,30 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
} finally {
await Promise.allSettled([
get().refreshTeamMessagesHead(teamName),
get().fetchMemberSpawnStatuses(teamName),
get().fetchTeamAgentRuntime(teamName),
]);

View file

@ -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,

View file

@ -58,6 +58,7 @@ import type {
MemberLogSummary,
MemberSpawnStatusesSnapshot,
MessagesPage,
OpenCodeRuntimeDeliveryStatus,
ProjectBranchChangeEvent,
ReplaceMembersRequest,
RetryFailedOpenCodeSecondaryLanesResult,
@ -468,6 +469,10 @@ export interface TeamsAPI {
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
cancelProvisioning: (runId: string) => Promise<void>;
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
getOpenCodeRuntimeDeliveryStatus: (
teamName: string,
messageId: string
) => Promise<OpenCodeRuntimeDeliveryStatus | null>;
getMessagesPage: (
teamName: string,
options?: { cursor?: string | null; limit?: number }

View file

@ -736,6 +736,10 @@ export interface SendMessageResult {
};
}
export type OpenCodeRuntimeDeliveryStatus = NonNullable<SendMessageResult['runtimeDelivery']> & {
messageId: string;
};
export interface AddTaskCommentRequest {
text: string;
attachments?: CommentAttachmentPayload[];

View file

@ -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<TeamCreateRequest | null> => null),
};

View file

@ -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();

View file

@ -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',

View file

@ -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> = {}): 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');

View file

@ -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({