feat(team): improve composer persistence flow
This commit is contained in:
parent
032ddbbe2c
commit
b192ed4bae
25 changed files with 2009 additions and 137 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
412
src/renderer/hooks/useComposerDraft.lifecycle.test.tsx
Normal file
412
src/renderer/hooks/useComposerDraft.lifecycle.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export interface ComposerDraftSnapshot {
|
|||
chips: InlineChip[];
|
||||
attachments: AttachmentPayload[];
|
||||
actionMode?: AgentActionMode;
|
||||
pendingSendId?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -736,6 +736,10 @@ export interface SendMessageResult {
|
|||
};
|
||||
}
|
||||
|
||||
export type OpenCodeRuntimeDeliveryStatus = NonNullable<SendMessageResult['runtimeDelivery']> & {
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export interface AddTaskCommentRequest {
|
||||
text: string;
|
||||
attachments?: CommentAttachmentPayload[];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue