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_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
|
TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_GET_SAVED_REQUEST,
|
TEAM_GET_SAVED_REQUEST,
|
||||||
TEAM_GET_TASK_ACTIVITY,
|
TEAM_GET_TASK_ACTIVITY,
|
||||||
|
|
@ -189,6 +190,7 @@ import type {
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
|
OpenCodeRuntimeDeliveryStatus,
|
||||||
RetryFailedOpenCodeSecondaryLanesResult,
|
RetryFailedOpenCodeSecondaryLanesResult,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
|
@ -686,6 +688,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
|
||||||
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
|
||||||
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
|
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_MESSAGES_PAGE, handleGetMessagesPage);
|
||||||
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
ipcMain.handle(TEAM_GET_MEMBER_ACTIVITY_META, handleGetMemberActivityMeta);
|
||||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||||
|
|
@ -771,6 +774,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
|
||||||
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
|
||||||
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS);
|
||||||
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
ipcMain.removeHandler(TEAM_GET_MESSAGES_PAGE);
|
||||||
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
ipcMain.removeHandler(TEAM_GET_MEMBER_ACTIVITY_META);
|
||||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
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(
|
async function handleCreateTask(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
@ -3899,9 +3927,16 @@ async function handleRestartMember(
|
||||||
if (!validatedMemberName.valid) {
|
if (!validatedMemberName.valid) {
|
||||||
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('restartMember', async () =>
|
return wrapTeamHandler('restartMember', async () => {
|
||||||
getTeamProvisioningService().restartMember(validatedTeamName.value!, validatedMemberName.value!)
|
try {
|
||||||
);
|
await getTeamProvisioningService().restartMember(
|
||||||
|
validatedTeamName.value!,
|
||||||
|
validatedMemberName.value!
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
getTeamDataService().invalidateMessageFeed(validatedTeamName.value!);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRetryFailedOpenCodeSecondaryLanes(
|
async function handleRetryFailedOpenCodeSecondaryLanes(
|
||||||
|
|
@ -4297,6 +4332,7 @@ async function handleReplaceMembers(
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
await teamDataService.replaceMembers(tn, { members });
|
await teamDataService.replaceMembers(tn, { members });
|
||||||
|
teamDataService.invalidateMessageFeed(tn);
|
||||||
|
|
||||||
if (!isTeamAlive) {
|
if (!isTeamAlive) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,7 @@ import type {
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
|
OpenCodeRuntimeDeliveryStatus,
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
|
|
@ -3577,7 +3578,8 @@ function buildMemberSpawnPrompt(
|
||||||
member: TeamCreateRequest['members'][number],
|
member: TeamCreateRequest['members'][number],
|
||||||
displayName: string,
|
displayName: string,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string
|
leadName: string,
|
||||||
|
options?: { restart?: boolean }
|
||||||
): string {
|
): string {
|
||||||
const role = member.role?.trim() || 'team member';
|
const role = member.role?.trim() || 'team member';
|
||||||
const providerLine =
|
const providerLine =
|
||||||
|
|
@ -3591,10 +3593,13 @@ function buildMemberSpawnPrompt(
|
||||||
const workflowBlock = member.workflow?.trim()
|
const workflowBlock = member.workflow?.trim()
|
||||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
|
? `\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(
|
const actionModeProtocol = protocols.buildActionModeProtocolText(
|
||||||
protocols.MEMBER_DELEGATE_DESCRIPTION
|
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()}
|
${getAgentLanguageInstruction()}
|
||||||
Your FIRST action: call MCP tool member_briefing with:
|
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[] {
|
private createOpenCodeRuntimeDeliveryPorts(): RuntimeDeliveryDestinationPort[] {
|
||||||
const userMessagesPort: RuntimeDeliveryDestinationPort = {
|
const userMessagesPort: RuntimeDeliveryDestinationPort = {
|
||||||
kind: 'user_sent_messages',
|
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: {
|
private async launchDirectTmuxMemberRestart(input: {
|
||||||
run: ProvisioningRun;
|
run: ProvisioningRun;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -12165,7 +12254,8 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
input.displayName,
|
input.displayName,
|
||||||
input.teamName,
|
input.teamName,
|
||||||
input.leadName
|
input.leadName,
|
||||||
|
{ restart: true }
|
||||||
);
|
);
|
||||||
const bootstrapExpectedAfter = nowIso();
|
const bootstrapExpectedAfter = nowIso();
|
||||||
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
|
const runtimeArgsPlan = await this.buildTeamRuntimeLaunchArgsPlan({
|
||||||
|
|
@ -13167,6 +13257,17 @@ export class TeamProvisioningService {
|
||||||
this.clearMemberSpawnToolTracking(run, memberName);
|
this.clearMemberSpawnToolTracking(run, memberName);
|
||||||
run.pendingMemberRestarts.delete(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);
|
await this.launchSingleMixedSecondaryLane(run, laneState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,9 @@ export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
|
||||||
/** Send inbox message to team member */
|
/** Send inbox message to team member */
|
||||||
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
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 */
|
/** Paginated messages for timeline/messages panel */
|
||||||
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
export const TEAM_GET_MESSAGES_PAGE = 'team:getMessagesPage';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ import {
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
|
TEAM_GET_OPENCODE_RUNTIME_DELIVERY_STATUS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_GET_SAVED_REQUEST,
|
TEAM_GET_SAVED_REQUEST,
|
||||||
TEAM_GET_TASK_ACTIVITY,
|
TEAM_GET_TASK_ACTIVITY,
|
||||||
|
|
@ -281,6 +282,7 @@ import type {
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
|
OpenCodeRuntimeDeliveryStatus,
|
||||||
ProjectBranchChangeEvent,
|
ProjectBranchChangeEvent,
|
||||||
RejectResult,
|
RejectResult,
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
|
|
@ -934,6 +936,13 @@ const electronAPI: ElectronAPI = {
|
||||||
sendMessage: async (teamName: string, request: SendMessageRequest) => {
|
sendMessage: async (teamName: string, request: SendMessageRequest) => {
|
||||||
return invokeIpcWithResult<SendMessageResult>(TEAM_SEND_MESSAGE, teamName, request);
|
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 (
|
getMessagesPage: async (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { cursor?: string | null; limit?: number }
|
options?: { cursor?: string | null; limit?: number }
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
NotificationsAPI,
|
NotificationsAPI,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
|
OpenCodeRuntimeDeliveryStatus,
|
||||||
PaginatedSessionsResult,
|
PaginatedSessionsResult,
|
||||||
Project,
|
Project,
|
||||||
RepositoryGroup,
|
RepositoryGroup,
|
||||||
|
|
@ -794,6 +795,12 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<SendMessageResult> => {
|
): Promise<SendMessageResult> => {
|
||||||
throw new Error('Team messaging is not available in browser mode');
|
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 () => {
|
getMessagesPage: async () => {
|
||||||
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
|
return { messages: [], nextCursor: null, hasMore: false, feedRevision: 'empty' };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -381,6 +381,7 @@ const PassiveIdlePeerSummaryRow = ({
|
||||||
|
|
||||||
const BootstrapSystemRow = ({
|
const BootstrapSystemRow = ({
|
||||||
teamName,
|
teamName,
|
||||||
|
eventKind,
|
||||||
senderName,
|
senderName,
|
||||||
recipientName,
|
recipientName,
|
||||||
runtime,
|
runtime,
|
||||||
|
|
@ -390,6 +391,7 @@ const BootstrapSystemRow = ({
|
||||||
onMemberNameClick,
|
onMemberNameClick,
|
||||||
}: {
|
}: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
eventKind: 'start' | 'restart';
|
||||||
senderName: string;
|
senderName: string;
|
||||||
recipientName: string;
|
recipientName: string;
|
||||||
runtime?: string;
|
runtime?: string;
|
||||||
|
|
@ -397,34 +399,41 @@ const BootstrapSystemRow = ({
|
||||||
recipientColor?: string;
|
recipientColor?: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
onMemberNameClick?: (memberName: string) => void;
|
onMemberNameClick?: (memberName: string) => void;
|
||||||
}): React.JSX.Element => (
|
}): React.JSX.Element => {
|
||||||
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.82 }}>
|
const isRestart = eventKind === 'restart';
|
||||||
<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">
|
return (
|
||||||
start
|
<div className="flex items-center gap-2 px-3 py-2" style={{ opacity: 0.82 }}>
|
||||||
</span>
|
<span
|
||||||
<MemberBadge
|
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${
|
||||||
name={senderName}
|
isRestart ? 'bg-amber-500/12 text-amber-300' : 'bg-sky-500/12 text-sky-300'
|
||||||
color={senderColor}
|
}`}
|
||||||
teamName={teamName}
|
>
|
||||||
hideAvatar
|
{isRestart ? 'restart' : 'start'}
|
||||||
onClick={onMemberNameClick}
|
</span>
|
||||||
/>
|
<MemberBadge
|
||||||
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
name={senderName}
|
||||||
<MemberBadge
|
color={senderColor}
|
||||||
name={recipientName}
|
teamName={teamName}
|
||||||
color={recipientColor}
|
hideAvatar
|
||||||
teamName={teamName}
|
onClick={onMemberNameClick}
|
||||||
hideAvatar
|
/>
|
||||||
onClick={onMemberNameClick}
|
<MoveRight size={10} style={{ color: CARD_ICON_MUTED }} className="shrink-0" />
|
||||||
/>
|
<MemberBadge
|
||||||
<span className="min-w-0 flex-1 truncate text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
name={recipientName}
|
||||||
{runtime || 'Starting teammate'}
|
color={recipientColor}
|
||||||
</span>
|
teamName={teamName}
|
||||||
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
hideAvatar
|
||||||
{timestamp}
|
onClick={onMemberNameClick}
|
||||||
</span>
|
/>
|
||||||
</div>
|
<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 = ({
|
const BootstrapAcknowledgementRow = ({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -921,6 +930,7 @@ export const ActivityItem = memo(
|
||||||
return (
|
return (
|
||||||
<BootstrapSystemRow
|
<BootstrapSystemRow
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
eventKind={bootstrapDisplay.eventKind}
|
||||||
senderName={senderName}
|
senderName={senderName}
|
||||||
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
|
recipientName={bootstrapDisplay.teammateName ?? message.to ?? 'teammate'}
|
||||||
runtime={bootstrapDisplay.runtime}
|
runtime={bootstrapDisplay.runtime}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildEditTeamMemberRosterSnapshot,
|
||||||
buildEditTeamSourceSnapshot,
|
buildEditTeamSourceSnapshot,
|
||||||
getLiveRosterIdentityChanges,
|
getLiveRosterIdentityChanges,
|
||||||
getMemberRuntimeContractKey,
|
getMemberRuntimeContractKey,
|
||||||
|
|
@ -254,6 +255,27 @@ export const EditTeamDialog = ({
|
||||||
new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
|
new Map(builtMembers.map((member) => [member.name.trim().toLowerCase(), member] as const)),
|
||||||
[builtMembers]
|
[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 effectiveMembersToRestart = useMemo(() => {
|
||||||
const retryMembers = Object.entries(membersPendingRestartRetry).flatMap(
|
const retryMembers = Object.entries(membersPendingRestartRetry).flatMap(
|
||||||
([normalizedName, expectedRuntimeContractKey]) => {
|
([normalizedName, expectedRuntimeContractKey]) => {
|
||||||
|
|
@ -270,10 +292,35 @@ export const EditTeamDialog = ({
|
||||||
new Set(
|
new Set(
|
||||||
[...membersToRestart, ...retryMembers]
|
[...membersToRestart, ...retryMembers]
|
||||||
.map((memberName) => memberName.trim())
|
.map((memberName) => memberName.trim())
|
||||||
|
.filter((memberName) => {
|
||||||
|
const nextMember = builtMembersByName.get(memberName.toLowerCase());
|
||||||
|
return nextMember?.providerId !== 'opencode';
|
||||||
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}, [builtMembersByName, membersPendingRestartRetry, membersToRestart]);
|
}, [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(
|
const liveIdentityChanges = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isTeamAlive
|
isTeamAlive
|
||||||
|
|
@ -289,6 +336,34 @@ export const EditTeamDialog = ({
|
||||||
() => (isTeamAlive ? liveIdentityChanges.removed : []),
|
() => (isTeamAlive ? liveIdentityChanges.removed : []),
|
||||||
[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(
|
const hasNewLiveTeammates = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()),
|
isTeamAlive && members.some((member) => !member.removedAt && !member.originalName?.trim()),
|
||||||
|
|
@ -296,7 +371,7 @@ export const EditTeamDialog = ({
|
||||||
);
|
);
|
||||||
const memberWarningById = useMemo(() => {
|
const memberWarningById = useMemo(() => {
|
||||||
const restartNames = new Set(
|
const restartNames = new Set(
|
||||||
effectiveMembersToRestart.map((memberName) => memberName.trim().toLowerCase())
|
liveRuntimeRefreshMemberNames.map((memberName) => memberName.trim().toLowerCase())
|
||||||
);
|
);
|
||||||
if (restartNames.size === 0) {
|
if (restartNames.size === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -309,7 +384,7 @@ export const EditTeamDialog = ({
|
||||||
: null,
|
: null,
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}, [effectiveMembersToRestart, members]);
|
}, [liveRuntimeRefreshMemberNames, members]);
|
||||||
|
|
||||||
const handleSave = (): void => {
|
const handleSave = (): void => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|
@ -359,12 +434,19 @@ export const EditTeamDialog = ({
|
||||||
);
|
);
|
||||||
return;
|
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);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSaveOutcomeError(null);
|
setSaveOutcomeError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let configSaved = false;
|
let configSaved = false;
|
||||||
let membersSaved = false;
|
let membersSaved = false;
|
||||||
|
let refreshAfterSaveAttempted = false;
|
||||||
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
|
let committedMembersForSnapshot: ResolvedTeamMember[] = currentMembers;
|
||||||
try {
|
try {
|
||||||
await api.teams.updateConfig(teamName, {
|
await api.teams.updateConfig(teamName, {
|
||||||
|
|
@ -373,14 +455,17 @@ export const EditTeamDialog = ({
|
||||||
color,
|
color,
|
||||||
});
|
});
|
||||||
configSaved = true;
|
configSaved = true;
|
||||||
for (const removedMemberName of liveRemovedExistingMembers) {
|
if (hasMemberRosterChanges) {
|
||||||
await api.teams.removeMember(teamName, removedMemberName);
|
for (const removedMemberName of liveRemovedExistingMembers) {
|
||||||
committedMembersForSnapshot = applyRemovedMembersToSnapshot(committedMembersForSnapshot, [
|
await api.teams.removeMember(teamName, removedMemberName);
|
||||||
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({
|
pendingCommittedSourceSnapshotRef.current = buildEditTeamSourceSnapshot({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
|
|
@ -409,6 +494,7 @@ export const EditTeamDialog = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshAfterSaveAttempted = true;
|
||||||
await Promise.resolve(onSaved());
|
await Promise.resolve(onSaved());
|
||||||
if (restartFailures.length === 0) {
|
if (restartFailures.length === 0) {
|
||||||
setMembersPendingRestartRetry({});
|
setMembersPendingRestartRetry({});
|
||||||
|
|
@ -445,6 +531,12 @@ export const EditTeamDialog = ({
|
||||||
color: color.trim(),
|
color: color.trim(),
|
||||||
members: committedMembersForSnapshot,
|
members: committedMembersForSnapshot,
|
||||||
});
|
});
|
||||||
|
if (refreshAfterSaveAttempted) {
|
||||||
|
setSaveOutcomeError(
|
||||||
|
`Team settings were saved, but failed to refresh the latest view: ${message}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let refreshErrorDetail: string | null = null;
|
let refreshErrorDetail: string | null = null;
|
||||||
try {
|
try {
|
||||||
await Promise.resolve(onSaved());
|
await Promise.resolve(onSaved());
|
||||||
|
|
@ -601,12 +693,19 @@ export const EditTeamDialog = ({
|
||||||
changes or stop the team first.
|
changes or stop the team first.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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">
|
<p className="text-xs text-amber-300">
|
||||||
Saving will restart{' '}
|
Saving will restart or relaunch{' '}
|
||||||
{effectiveMembersToRestart.length === 1 ? 'this teammate' : 'these teammates'} to
|
{liveRuntimeRefreshMemberNames.length === 1 ? 'this teammate' : 'these teammates'} to
|
||||||
apply role, workflow, worktree isolation, provider, model, or effort changes:{' '}
|
apply role, workflow, worktree isolation, provider, model, or effort changes:{' '}
|
||||||
{effectiveMembersToRestart.join(', ')}.
|
{liveRuntimeRefreshMemberNames.join(', ')}.
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -662,7 +761,8 @@ export const EditTeamDialog = ({
|
||||||
isTeamProvisioning ||
|
isTeamProvisioning ||
|
||||||
!name.trim() ||
|
!name.trim() ||
|
||||||
hasDuplicateMembers ||
|
hasDuplicateMembers ||
|
||||||
Boolean(invalidMemberNamesError)
|
Boolean(invalidMemberNamesError) ||
|
||||||
|
unsupportedLiveMixedPrimaryMutationNames.length > 0
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
|
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||||
|
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
import type {
|
import type {
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
TeamFastMode,
|
||||||
|
TeamProviderBackendId,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
TeamProvisioningMemberInput,
|
TeamProvisioningMemberInput,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
@ -127,18 +130,22 @@ function normalizeEditableMemberSnapshot(member: {
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
|
providerBackendId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
isolation?: string;
|
isolation?: string;
|
||||||
|
fastMode?: string;
|
||||||
removedAt?: number | string | null;
|
removedAt?: number | string | null;
|
||||||
}): {
|
}): {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
providerId?: TeamProviderId;
|
providerId?: TeamProviderId;
|
||||||
|
providerBackendId?: TeamProviderBackendId;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
isolation?: 'worktree';
|
isolation?: 'worktree';
|
||||||
|
fastMode?: TeamFastMode;
|
||||||
} | null {
|
} | null {
|
||||||
if (member.removedAt) {
|
if (member.removedAt) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -147,24 +154,42 @@ function normalizeEditableMemberSnapshot(member: {
|
||||||
if (!name || name.toLowerCase() === 'team-lead') {
|
if (!name || name.toLowerCase() === 'team-lead') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const runtime = normalizeRestartSensitiveMemberContract(member);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.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: {
|
export function buildEditTeamSourceSnapshot(params: {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
color: string;
|
color: string;
|
||||||
members: readonly ResolvedTeamMember[];
|
members: readonly ResolvedTeamMember[];
|
||||||
}): string {
|
}): string {
|
||||||
const members = params.members
|
const members = JSON.parse(buildEditTeamMemberRosterSnapshot(params.members)) as unknown;
|
||||||
.map(normalizeEditableMemberSnapshot)
|
|
||||||
.filter((member): member is NonNullable<typeof member> => member !== null)
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
name: params.name.trim(),
|
name: params.name.trim(),
|
||||||
|
|
|
||||||
|
|
@ -215,8 +215,20 @@ export const MemberDetailDialog = ({
|
||||||
const effectiveLaunchInfoMessage = openCodeBootstrapStalled
|
const effectiveLaunchInfoMessage = openCodeBootstrapStalled
|
||||||
? OPENCODE_BOOTSTRAP_STALLED_MESSAGE
|
? OPENCODE_BOOTSTRAP_STALLED_MESSAGE
|
||||||
: undefined;
|
: undefined;
|
||||||
const restartButtonLabel =
|
const isOpenCodeMember = member?.providerId === 'opencode';
|
||||||
openCodeNoRuntimeEvidence || openCodeRelaunchActionable ? 'Relaunch OpenCode' : 'Restart';
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !member) {
|
if (!open || !member) {
|
||||||
|
|
@ -380,37 +392,35 @@ export const MemberDetailDialog = ({
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{onRestartMember &&
|
{canRestartFromDialog && (
|
||||||
!isLeadMember(member) &&
|
<Button
|
||||||
(isTeamAlive || isTeamProvisioning) &&
|
variant="outline"
|
||||||
runtimeEntry?.restartable !== false && (
|
size="sm"
|
||||||
<Button
|
className="gap-1.5"
|
||||||
variant="outline"
|
disabled={restarting || restartInFlight}
|
||||||
size="sm"
|
onClick={async () => {
|
||||||
className="gap-1.5"
|
setRestartError(null);
|
||||||
disabled={restarting || restartInFlight}
|
setRestarting(true);
|
||||||
onClick={async () => {
|
try {
|
||||||
setRestartError(null);
|
if (!onRestartMember) return;
|
||||||
setRestarting(true);
|
await onRestartMember(member.name);
|
||||||
try {
|
} catch (error) {
|
||||||
await onRestartMember(member.name);
|
setRestartError(
|
||||||
} catch (error) {
|
error instanceof Error ? error.message : 'Failed to restart member'
|
||||||
setRestartError(
|
);
|
||||||
error instanceof Error ? error.message : 'Failed to restart member'
|
} finally {
|
||||||
);
|
setRestarting(false);
|
||||||
} finally {
|
}
|
||||||
setRestarting(false);
|
}}
|
||||||
}
|
>
|
||||||
}}
|
{restarting ? (
|
||||||
>
|
<Loader2 size={14} className="animate-spin" />
|
||||||
{restarting ? (
|
) : (
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<RotateCcw size={14} />
|
||||||
) : (
|
)}
|
||||||
<RotateCcw size={14} />
|
{restartButtonLabel}
|
||||||
)}
|
</Button>
|
||||||
{restartButtonLabel}
|
)}
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
||||||
<MessageSquare size={14} />
|
<MessageSquare size={14} />
|
||||||
Send Message
|
Send Message
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface ActionModeSelectorProps {
|
||||||
value: ActionMode;
|
value: ActionMode;
|
||||||
onChange: (mode: ActionMode) => void;
|
onChange: (mode: ActionMode) => void;
|
||||||
showDelegate: boolean;
|
showDelegate: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_CONFIG: {
|
const MODE_CONFIG: {
|
||||||
|
|
@ -50,6 +51,7 @@ export const ActionModeSelector = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
showDelegate,
|
showDelegate,
|
||||||
|
disabled = false,
|
||||||
}: ActionModeSelectorProps): React.JSX.Element => {
|
}: ActionModeSelectorProps): React.JSX.Element => {
|
||||||
const modes = showDelegate ? MODE_CONFIG : MODE_CONFIG.filter((m) => m.mode !== 'delegate');
|
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',
|
'px-2 py-0.5 text-[10px] font-medium transition-colors',
|
||||||
isFirst && 'rounded-l-full',
|
isFirst && 'rounded-l-full',
|
||||||
isLast && 'rounded-r-full',
|
isLast && 'rounded-r-full',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
isActive
|
isActive
|
||||||
? cfg.activeClass
|
? cfg.activeClass
|
||||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||||
)}
|
)}
|
||||||
|
disabled={disabled}
|
||||||
onClick={() => onChange(cfg.mode)}
|
onClick={() => onChange(cfg.mode)}
|
||||||
>
|
>
|
||||||
{cfg.label}
|
{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 { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
|
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -75,6 +76,24 @@ interface MessageComposerProps {
|
||||||
) => void;
|
) => 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 = ({
|
export const MessageComposer = ({
|
||||||
teamName,
|
teamName,
|
||||||
members,
|
members,
|
||||||
|
|
@ -310,14 +329,18 @@ export const MessageComposer = ({
|
||||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||||
// );
|
// );
|
||||||
const supportsAttachments = isLeadRecipient && !isCrossTeam && !!isTeamAlive;
|
const supportsAttachments = isLeadRecipient && !isCrossTeam && !!isTeamAlive;
|
||||||
const canAttach = supportsAttachments && draft.canAddMore;
|
const canAttach = supportsAttachments && draft.canAddMore && !sending;
|
||||||
const attachmentRestrictionReason = !supportsAttachments
|
const attachmentRestrictionReason = !supportsAttachments
|
||||||
? isCrossTeam
|
? isCrossTeam
|
||||||
? 'File attachments are not supported for cross-team messages'
|
? 'File attachments are not supported for cross-team messages'
|
||||||
: !isLeadRecipient
|
: !isLeadRecipient
|
||||||
? 'Files can only be sent to the team lead'
|
? 'Files can only be sent to the team lead'
|
||||||
: 'Team must be online to attach files'
|
: '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 attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
|
||||||
const slashCommandRestrictionReason = standaloneSlashCommand
|
const slashCommandRestrictionReason = standaloneSlashCommand
|
||||||
? draft.attachments.length > 0
|
? draft.attachments.length > 0
|
||||||
|
|
@ -340,19 +363,32 @@ export const MessageComposer = ({
|
||||||
!slashCommandRestrictionReason &&
|
!slashCommandRestrictionReason &&
|
||||||
(!isCrossTeam || onCrossTeamSend !== undefined);
|
(!isCrossTeam || onCrossTeamSend !== undefined);
|
||||||
|
|
||||||
// Track whether we initiated a send — clear draft only on confirmed success
|
const pendingSendRef = useRef<PendingSendState | null>(null);
|
||||||
const pendingSendRef = useRef(false);
|
|
||||||
|
|
||||||
const handleCycleActionMode = useCallback(() => {
|
const handleCycleActionMode = useCallback(() => {
|
||||||
|
if (sending) return;
|
||||||
const modes: ActionMode[] = canDelegate ? ['do', 'ask', 'delegate'] : ['do', 'ask'];
|
const modes: ActionMode[] = canDelegate ? ['do', 'ask', 'delegate'] : ['do', 'ask'];
|
||||||
const idx = modes.indexOf(actionMode);
|
const idx = modes.indexOf(actionMode);
|
||||||
setActionMode(modes[(idx + 1) % modes.length]);
|
setActionMode(modes[(idx + 1) % modes.length]);
|
||||||
}, [actionMode, canDelegate, setActionMode]);
|
}, [actionMode, canDelegate, sending, setActionMode]);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
dismissMentionsRef.current?.();
|
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 taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions);
|
||||||
const serialized = serializeChipsWithText(trimmed, draft.chips);
|
const serialized = serializeChipsWithText(trimmed, draft.chips);
|
||||||
if (isCrossTeam && selectedTeam && onCrossTeamSend) {
|
if (isCrossTeam && selectedTeam && onCrossTeamSend) {
|
||||||
|
|
@ -377,35 +413,67 @@ export const MessageComposer = ({
|
||||||
onCrossTeamSend,
|
onCrossTeamSend,
|
||||||
isCrossTeam,
|
isCrossTeam,
|
||||||
selectedTeam,
|
selectedTeam,
|
||||||
|
sendDebugDetails,
|
||||||
draft.attachments,
|
draft.attachments,
|
||||||
draft.chips,
|
draft.chips,
|
||||||
draft.text,
|
draft.text,
|
||||||
|
lastResult,
|
||||||
taskSuggestions,
|
taskSuggestions,
|
||||||
|
teamName,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Clear draft only after send completes successfully (sending: true -> false, no error).
|
// Clear once the send starts, not after the IPC finishes. For OpenCode teammates the message
|
||||||
// Layout effect prevents a visible paint where the optimistic message is already in the list
|
// can already be visible from inbox refresh while runtime delivery diagnostics are still pending.
|
||||||
// but the submitted text is still shown in the composer.
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!sending && pendingSendRef.current) {
|
const pending = pendingSendRef.current;
|
||||||
pendingSendRef.current = false;
|
if (!pending) return;
|
||||||
if (!sendError && sendDebugDetails?.delivered !== false) {
|
const isPendingCurrentTeam = pending.teamName === teamName;
|
||||||
draft.clearDraft();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [sending, sendError, sendDebugDetails, draft]);
|
|
||||||
|
|
||||||
const { addFiles: draftAddFiles } = draft;
|
if (sending) {
|
||||||
const handleFileInputChange = useCallback(
|
pending.observedSending = true;
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
if (isPendingCurrentTeam && !pending.optimisticallyCleared) {
|
||||||
const input = e.target;
|
pending.optimisticallyCleared = true;
|
||||||
if (input.files?.length) {
|
draft.hideDraftForPendingSend(pending.snapshot);
|
||||||
void draftAddFiles(input.files);
|
|
||||||
}
|
}
|
||||||
input.value = '';
|
return;
|
||||||
},
|
}
|
||||||
[draftAddFiles]
|
|
||||||
);
|
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(() => {
|
const showFileRestrictionError = useCallback(() => {
|
||||||
setFileRestrictionError(
|
setFileRestrictionError(
|
||||||
|
|
@ -417,6 +485,23 @@ export const MessageComposer = ({
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}, [attachmentRestrictionReason]);
|
}, [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
|
// Cleanup restriction error timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ref = fileRestrictionTimerRef;
|
const ref = fileRestrictionTimerRef;
|
||||||
|
|
@ -448,7 +533,7 @@ export const MessageComposer = ({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
if (!supportsAttachments) {
|
if (!canAttach) {
|
||||||
const files = e.dataTransfer?.files;
|
const files = e.dataTransfer?.files;
|
||||||
if (files?.length) {
|
if (files?.length) {
|
||||||
showFileRestrictionError();
|
showFileRestrictionError();
|
||||||
|
|
@ -457,13 +542,13 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
draftHandleDrop(e);
|
draftHandleDrop(e);
|
||||||
},
|
},
|
||||||
[supportsAttachments, draftHandleDrop, showFileRestrictionError]
|
[canAttach, draftHandleDrop, showFileRestrictionError]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { handlePaste: draftHandlePaste } = draft;
|
const { handlePaste: draftHandlePaste } = draft;
|
||||||
const handlePasteWrapper = useCallback(
|
const handlePasteWrapper = useCallback(
|
||||||
(e: React.ClipboardEvent) => {
|
(e: React.ClipboardEvent) => {
|
||||||
if (!supportsAttachments) {
|
if (!canAttach) {
|
||||||
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
|
const hasFiles = Array.from(e.clipboardData.items).some((item) => item.kind === 'file');
|
||||||
if (hasFiles) {
|
if (hasFiles) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -473,7 +558,7 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
draftHandlePaste(e);
|
draftHandlePaste(e);
|
||||||
},
|
},
|
||||||
[supportsAttachments, draftHandlePaste, showFileRestrictionError]
|
[canAttach, draftHandlePaste, showFileRestrictionError]
|
||||||
);
|
);
|
||||||
|
|
||||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||||
|
|
@ -546,9 +631,11 @@ export const MessageComposer = ({
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
{!isTeamAlive
|
{!isTeamAlive
|
||||||
? 'Team must be online to attach files'
|
? 'Team must be online to attach files'
|
||||||
: !draft.canAddMore
|
: sending
|
||||||
? 'Maximum attachments reached'
|
? 'Wait for current message to finish sending'
|
||||||
: 'Attach files (paste or drag & drop)'}
|
: !draft.canAddMore
|
||||||
|
? 'Maximum attachments reached'
|
||||||
|
: 'Attach files (paste or drag & drop)'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
|
|
@ -850,7 +937,7 @@ export const MessageComposer = ({
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DropZoneOverlay
|
<DropZoneOverlay
|
||||||
active={isDragOver}
|
active={isDragOver}
|
||||||
rejected={!supportsAttachments}
|
rejected={!canAttach}
|
||||||
rejectionReason={attachmentRestrictionReason}
|
rejectionReason={attachmentRestrictionReason}
|
||||||
/>
|
/>
|
||||||
<MentionableTextarea
|
<MentionableTextarea
|
||||||
|
|
@ -893,6 +980,7 @@ export const MessageComposer = ({
|
||||||
value={actionMode}
|
value={actionMode}
|
||||||
onChange={setActionMode}
|
onChange={setActionMode}
|
||||||
showDelegate={canDelegate}
|
showDelegate={canDelegate}
|
||||||
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
cornerAction={
|
cornerAction={
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ const BOTTOM_SHEET_HEADER_HEIGHT = 40;
|
||||||
const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
|
const BOTTOM_SHEET_COLLAPSED_SNAP_INDEX = 1;
|
||||||
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
|
const BOTTOM_SHEET_COMPOSER_SNAP_INDEX = 2;
|
||||||
const BOTTOM_SHEET_FULL_SNAP_INDEX = 4;
|
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 {
|
interface MessagesPanelProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -246,6 +247,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
sendMessageDebugDetails,
|
sendMessageDebugDetails,
|
||||||
lastSendMessageResult,
|
lastSendMessageResult,
|
||||||
clearSendMessageRuntimeDiagnostics,
|
clearSendMessageRuntimeDiagnostics,
|
||||||
|
refreshSendMessageRuntimeDeliveryStatus,
|
||||||
teams,
|
teams,
|
||||||
openTeamTab,
|
openTeamTab,
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -262,6 +264,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
sendMessageDebugDetails: s.sendMessageDebugDetails,
|
sendMessageDebugDetails: s.sendMessageDebugDetails,
|
||||||
lastSendMessageResult: s.lastSendMessageResult,
|
lastSendMessageResult: s.lastSendMessageResult,
|
||||||
clearSendMessageRuntimeDiagnostics: s.clearSendMessageRuntimeDiagnostics,
|
clearSendMessageRuntimeDiagnostics: s.clearSendMessageRuntimeDiagnostics,
|
||||||
|
refreshSendMessageRuntimeDeliveryStatus: s.refreshSendMessageRuntimeDeliveryStatus,
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
openTeamTab: s.openTeamTab,
|
openTeamTab: s.openTeamTab,
|
||||||
messages: selectTeamMessages(s, teamName),
|
messages: selectTeamMessages(s, teamName),
|
||||||
|
|
@ -577,6 +580,28 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
sendMessageRuntimeReplyVisible,
|
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(
|
const handleSend = useCallback(
|
||||||
(
|
(
|
||||||
member: string,
|
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
|
// Clear all
|
||||||
clearDraft: () => void;
|
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;
|
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
|
// Hook
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -98,6 +132,8 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
// Debounce timer
|
// Debounce timer
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingRef = useRef<{ teamName: string; snapshot: ComposerDraftSnapshot } | 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
|
// Keep teamNameRef in sync
|
||||||
useEffect(() => {
|
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(() => {
|
const flushPending = useCallback(() => {
|
||||||
if (timerRef.current != null) {
|
if (timerRef.current != null) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -140,16 +182,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
pending.snapshot.chips.length === 0 &&
|
pending.snapshot.chips.length === 0 &&
|
||||||
pending.snapshot.attachments.length === 0;
|
pending.snapshot.attachments.length === 0;
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
void composerDraftStorage.deleteSnapshot(pending.teamName);
|
void enqueuePersist(() => composerDraftStorage.deleteSnapshot(pending.teamName));
|
||||||
} else {
|
} else {
|
||||||
void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot);
|
void enqueuePersist(() =>
|
||||||
|
composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [enqueuePersist]);
|
||||||
|
|
||||||
const scheduleSave = useCallback(() => {
|
const scheduleSave = useCallback(() => {
|
||||||
const snapshot = buildSnapshot();
|
const snapshot = buildSnapshot();
|
||||||
pendingRef.current = { teamName: teamNameRef.current, snapshot };
|
pendingRef.current = { teamName: teamNameRef.current, snapshot };
|
||||||
|
const persistenceVersion = ++persistenceVersionRef.current;
|
||||||
|
|
||||||
if (timerRef.current != null) {
|
if (timerRef.current != null) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -165,16 +210,18 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
pending.snapshot.text.length === 0 &&
|
pending.snapshot.text.length === 0 &&
|
||||||
pending.snapshot.chips.length === 0 &&
|
pending.snapshot.chips.length === 0 &&
|
||||||
pending.snapshot.attachments.length === 0;
|
pending.snapshot.attachments.length === 0;
|
||||||
if (isEmpty) {
|
const persist = enqueuePersist(() =>
|
||||||
void composerDraftStorage.deleteSnapshot(pending.teamName);
|
isEmpty
|
||||||
if (mountedRef.current) setIsSaved(true);
|
? composerDraftStorage.deleteSnapshot(pending.teamName)
|
||||||
} else {
|
: composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot)
|
||||||
void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot).then(() => {
|
);
|
||||||
if (mountedRef.current) setIsSaved(true);
|
void persist.then(() => {
|
||||||
});
|
if (mountedRef.current && persistenceVersionRef.current === persistenceVersion) {
|
||||||
}
|
setIsSaved(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
}, [buildSnapshot]);
|
}, [buildSnapshot, enqueuePersist]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Apply snapshot to state
|
// Apply snapshot to state
|
||||||
|
|
@ -458,6 +505,19 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
// Clear all
|
// 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(() => {
|
const clearDraft = useCallback(() => {
|
||||||
if (timerRef.current != null) {
|
if (timerRef.current != null) {
|
||||||
clearTimeout(timerRef.current);
|
clearTimeout(timerRef.current);
|
||||||
|
|
@ -476,8 +536,102 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
setAttachmentError(null);
|
setAttachmentError(null);
|
||||||
setIsSaved(false);
|
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 {
|
return {
|
||||||
text,
|
text,
|
||||||
|
|
@ -499,5 +653,8 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult {
|
||||||
isSaved,
|
isSaved,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
|
hideDraftForPendingSend,
|
||||||
|
finalizePendingSendClear,
|
||||||
|
restoreDraft,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export interface ComposerDraftSnapshot {
|
||||||
chips: InlineChip[];
|
chips: InlineChip[];
|
||||||
attachments: AttachmentPayload[];
|
attachments: AttachmentPayload[];
|
||||||
actionMode?: AgentActionMode;
|
actionMode?: AgentActionMode;
|
||||||
|
pendingSendId?: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2367,6 +2367,7 @@ export interface TeamSlice {
|
||||||
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
|
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||||
lastSendMessageResult: SendMessageResult | null;
|
lastSendMessageResult: SendMessageResult | null;
|
||||||
clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => void;
|
clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => void;
|
||||||
|
refreshSendMessageRuntimeDeliveryStatus: (teamName: string, messageId: string) => Promise<void>;
|
||||||
reviewActionError: string | null;
|
reviewActionError: string | null;
|
||||||
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
provisioningRuns: Record<string, TeamProvisioningProgress>;
|
||||||
/** Synthetic TeamSummary snapshots for teams currently being provisioned (before config.json exists). */
|
/** 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 () => {
|
fetchCrossTeamTargets: async () => {
|
||||||
set({ crossTeamTargetsLoading: true });
|
set({ crossTeamTargetsLoading: true });
|
||||||
try {
|
try {
|
||||||
|
|
@ -4677,6 +4702,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
|
await unwrapIpc('team:restartMember', () => api.teams.restartMember(teamName, memberName));
|
||||||
} finally {
|
} finally {
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
|
get().refreshTeamMessagesHead(teamName),
|
||||||
get().fetchMemberSpawnStatuses(teamName),
|
get().fetchMemberSpawnStatuses(teamName),
|
||||||
get().fetchTeamAgentRuntime(teamName),
|
get().fetchTeamAgentRuntime(teamName),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ function buildRuntimeSummary(
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BootstrapPromptDisplay {
|
export interface BootstrapPromptDisplay {
|
||||||
|
eventKind: 'start' | 'restart';
|
||||||
teammateName?: string;
|
teammateName?: string;
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
runtime?: string;
|
runtime?: string;
|
||||||
|
|
@ -144,9 +145,16 @@ export function getBootstrapPromptDisplay(
|
||||||
const model = matchOverrideField(text, 'Model override');
|
const model = matchOverrideField(text, 'Model override');
|
||||||
const effort = matchOverrideField(text, 'Effort override');
|
const effort = matchOverrideField(text, 'Effort override');
|
||||||
const runtime = buildRuntimeSummary(providerId, model, effort);
|
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 displayName = teammateName ? displayMemberName(teammateName) : 'teammate';
|
||||||
const summary = `Starting ${displayName}`;
|
const summary = `${eventKind === 'restart' ? 'Restarting' : 'Starting'} ${displayName}`;
|
||||||
const bodyLines = [`Lead is starting \`${displayName}\` as a teammate.`];
|
const bodyLines = [
|
||||||
|
`Lead is ${eventKind === 'restart' ? 'restarting' : 'starting'} \`${displayName}\` as a teammate.`,
|
||||||
|
];
|
||||||
|
|
||||||
if (runtime) {
|
if (runtime) {
|
||||||
bodyLines.push(`Runtime: ${runtime}`);
|
bodyLines.push(`Runtime: ${runtime}`);
|
||||||
|
|
@ -157,6 +165,7 @@ export function getBootstrapPromptDisplay(
|
||||||
bodyLines.push('Startup instructions are hidden in the UI.');
|
bodyLines.push('Startup instructions are hidden in the UI.');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
eventKind,
|
||||||
teammateName,
|
teammateName,
|
||||||
teamName,
|
teamName,
|
||||||
runtime,
|
runtime,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import type {
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
MessagesPage,
|
MessagesPage,
|
||||||
|
OpenCodeRuntimeDeliveryStatus,
|
||||||
ProjectBranchChangeEvent,
|
ProjectBranchChangeEvent,
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
RetryFailedOpenCodeSecondaryLanesResult,
|
RetryFailedOpenCodeSecondaryLanesResult,
|
||||||
|
|
@ -468,6 +469,10 @@ export interface TeamsAPI {
|
||||||
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
|
||||||
cancelProvisioning: (runId: string) => Promise<void>;
|
cancelProvisioning: (runId: string) => Promise<void>;
|
||||||
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
sendMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
|
||||||
|
getOpenCodeRuntimeDeliveryStatus: (
|
||||||
|
teamName: string,
|
||||||
|
messageId: string
|
||||||
|
) => Promise<OpenCodeRuntimeDeliveryStatus | null>;
|
||||||
getMessagesPage: (
|
getMessagesPage: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
options?: { cursor?: string | null; limit?: number }
|
options?: { cursor?: string | null; limit?: number }
|
||||||
|
|
|
||||||
|
|
@ -736,6 +736,10 @@ export interface SendMessageResult {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OpenCodeRuntimeDeliveryStatus = NonNullable<SendMessageResult['runtimeDelivery']> & {
|
||||||
|
messageId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AddTaskCommentRequest {
|
export interface AddTaskCommentRequest {
|
||||||
text: string;
|
text: string;
|
||||||
attachments?: CommentAttachmentPayload[];
|
attachments?: CommentAttachmentPayload[];
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,7 @@ describe('ipc teams handlers', () => {
|
||||||
addTaskRelationship: vi.fn(async () => undefined),
|
addTaskRelationship: vi.fn(async () => undefined),
|
||||||
removeTaskRelationship: vi.fn(async () => undefined),
|
removeTaskRelationship: vi.fn(async () => undefined),
|
||||||
replaceMembers: vi.fn(async () => undefined),
|
replaceMembers: vi.fn(async () => undefined),
|
||||||
|
invalidateMessageFeed: vi.fn(() => undefined),
|
||||||
createTeamConfig: vi.fn(async () => undefined),
|
createTeamConfig: vi.fn(async () => undefined),
|
||||||
getSavedRequest: vi.fn(async (): Promise<TeamCreateRequest | null> => null),
|
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 () => {
|
it('removes existing live teammates through the dedicated removeMember path during save', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
|
vi.mocked(api.teams.updateConfig).mockResolvedValue({} as any);
|
||||||
|
|
@ -655,6 +789,70 @@ describe('EditTeamDialog', () => {
|
||||||
await Promise.resolve();
|
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 () => {
|
await act(async () => {
|
||||||
host
|
host
|
||||||
.querySelector('[data-testid="change-member-role"]')
|
.querySelector('[data-testid="change-member-role"]')
|
||||||
|
|
@ -666,12 +864,85 @@ describe('EditTeamDialog', () => {
|
||||||
(button) => button.textContent === 'Save'
|
(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 () => {
|
await act(async () => {
|
||||||
saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
await Promise.resolve();
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
@ -818,6 +1089,13 @@ describe('EditTeamDialog', () => {
|
||||||
(button) => button.textContent === 'Save'
|
(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 () => {
|
await act(async () => {
|
||||||
saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
@ -876,6 +1154,13 @@ describe('EditTeamDialog', () => {
|
||||||
await Promise.resolve();
|
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 () => {
|
await act(async () => {
|
||||||
saveButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
saveButton()?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
buildEditTeamMemberRosterSnapshot,
|
||||||
buildEditTeamSourceSnapshot,
|
buildEditTeamSourceSnapshot,
|
||||||
getLiveRosterIdentityChanges,
|
getLiveRosterIdentityChanges,
|
||||||
getMembersRequiringRuntimeRestart,
|
getMembersRequiringRuntimeRestart,
|
||||||
|
|
@ -209,6 +210,57 @@ describe('getMembersRequiringRuntimeRestart', () => {
|
||||||
expect(refreshed).toBe(base);
|
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', () => {
|
it('keeps worktree isolation in the edit source snapshot', () => {
|
||||||
const sharedWorkspace = buildEditTeamSourceSnapshot({
|
const sharedWorkspace = buildEditTeamSourceSnapshot({
|
||||||
name: 'Team A',
|
name: 'Team A',
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const storeState = {
|
||||||
sendMessageDebugDetails: null as OpenCodeRuntimeDeliveryDebugDetails | null,
|
sendMessageDebugDetails: null as OpenCodeRuntimeDeliveryDebugDetails | null,
|
||||||
lastSendMessageResult: null as unknown,
|
lastSendMessageResult: null as unknown,
|
||||||
clearSendMessageRuntimeDiagnostics: vi.fn(),
|
clearSendMessageRuntimeDiagnostics: vi.fn(),
|
||||||
|
refreshSendMessageRuntimeDeliveryStatus: vi.fn().mockResolvedValue(undefined),
|
||||||
teams: [],
|
teams: [],
|
||||||
openTeamTab: vi.fn(),
|
openTeamTab: vi.fn(),
|
||||||
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
|
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -191,6 +192,7 @@ function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
||||||
describe('MessagesPanel idle summary invariants', () => {
|
describe('MessagesPanel idle summary invariants', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -204,6 +206,7 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
storeState.sendCrossTeamMessage.mockClear();
|
storeState.sendCrossTeamMessage.mockClear();
|
||||||
storeState.openTeamTab.mockClear();
|
storeState.openTeamTab.mockClear();
|
||||||
storeState.clearSendMessageRuntimeDiagnostics.mockClear();
|
storeState.clearSendMessageRuntimeDiagnostics.mockClear();
|
||||||
|
storeState.refreshSendMessageRuntimeDeliveryStatus.mockClear();
|
||||||
storeState.loadOlderTeamMessages.mockClear();
|
storeState.loadOlderTeamMessages.mockClear();
|
||||||
storeState.refreshTeamMessagesHead.mockClear();
|
storeState.refreshTeamMessagesHead.mockClear();
|
||||||
storeState.sendingMessage = false;
|
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 () => {
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const hoisted = vi.hoisted(() => ({
|
||||||
restoreTeam: vi.fn(),
|
restoreTeam: vi.fn(),
|
||||||
permanentlyDeleteTeam: vi.fn(),
|
permanentlyDeleteTeam: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
|
getOpenCodeRuntimeDeliveryStatus: vi.fn(),
|
||||||
retryFailedOpenCodeSecondaryLanes: vi.fn(),
|
retryFailedOpenCodeSecondaryLanes: vi.fn(),
|
||||||
restartMember: vi.fn(),
|
restartMember: vi.fn(),
|
||||||
skipMemberForLaunch: vi.fn(),
|
skipMemberForLaunch: vi.fn(),
|
||||||
|
|
@ -70,6 +71,7 @@ vi.mock('@renderer/api', () => ({
|
||||||
restoreTeam: hoisted.restoreTeam,
|
restoreTeam: hoisted.restoreTeam,
|
||||||
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
|
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
|
||||||
sendMessage: hoisted.sendMessage,
|
sendMessage: hoisted.sendMessage,
|
||||||
|
getOpenCodeRuntimeDeliveryStatus: hoisted.getOpenCodeRuntimeDeliveryStatus,
|
||||||
retryFailedOpenCodeSecondaryLanes: hoisted.retryFailedOpenCodeSecondaryLanes,
|
retryFailedOpenCodeSecondaryLanes: hoisted.retryFailedOpenCodeSecondaryLanes,
|
||||||
restartMember: hoisted.restartMember,
|
restartMember: hoisted.restartMember,
|
||||||
skipMemberForLaunch: hoisted.skipMemberForLaunch,
|
skipMemberForLaunch: hoisted.skipMemberForLaunch,
|
||||||
|
|
@ -309,6 +311,7 @@ describe('teamSlice actions', () => {
|
||||||
feedRevision: 'rev-1',
|
feedRevision: 'rev-1',
|
||||||
});
|
});
|
||||||
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
|
hoisted.sendMessage.mockResolvedValue({ deliveredToInbox: true, messageId: 'm1' });
|
||||||
|
hoisted.getOpenCodeRuntimeDeliveryStatus.mockResolvedValue(null);
|
||||||
hoisted.requestReview.mockResolvedValue(undefined);
|
hoisted.requestReview.mockResolvedValue(undefined);
|
||||||
hoisted.updateKanban.mockResolvedValue(undefined);
|
hoisted.updateKanban.mockResolvedValue(undefined);
|
||||||
hoisted.createTeam.mockResolvedValue({ runId: 'run-1' });
|
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 () => {
|
it('clears OpenCode runtime diagnostics only for the matching message id', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
hoisted.sendMessage.mockResolvedValue({
|
hoisted.sendMessage.mockResolvedValue({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue