diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5982b831..a9da6df6 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -3506,7 +3506,7 @@ function buildLaunchDiagnosticsFromRun( memberName, severity: 'warning', code: 'runtime_not_found', - label: `${memberName} - no runtime found`, + label: `${memberName} - waiting for runtime`, detail: entry.runtimeDiagnostic, observedAt, }); @@ -15755,7 +15755,7 @@ export class TeamProvisioningService { ? `${launchSummary.runtimeCandidatePendingCount} process candidates` : '', launchSummary.noRuntimePendingCount - ? `${launchSummary.noRuntimePendingCount} no runtime found` + ? `${launchSummary.noRuntimePendingCount} waiting for runtime` : '', ].filter(Boolean); const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : ''; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index e4fe80f7..dc992781 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -138,6 +138,7 @@ import type { TeamAgentRuntimeEntry, TeamCreateRequest, TeamLaunchRequest, + TeamProviderId, TeamTaskWithKanban, } from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; @@ -338,9 +339,17 @@ interface LeadContextBridgeProps { tabId: string | null; projectId: string | null; leadSessionId: string | null; + leadProviderId?: TeamProviderId; fallbackProjectRoot?: string; } +// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet. +const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set(['codex', 'opencode']); + +function canShowLeadContextUi(providerId: TeamProviderId | undefined): boolean { + return providerId === undefined || !LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS.has(providerId); +} + function buildMemberSpawnStatusMap( memberSpawnStatuses: Record | undefined ): Map | undefined { @@ -547,6 +556,7 @@ const LeadContextBridge = memo(function LeadContextBridge({ tabId, projectId, leadSessionId, + leadProviderId, fallbackProjectRoot, }: LeadContextBridgeProps): React.JSX.Element | null { const { @@ -686,8 +696,15 @@ const LeadContextBridge = memo(function LeadContextBridge({ contextMetrics.contextUsedPercentOfContextWindow ?? leadContextSnapshot?.contextUsedPercent; return percent === null || percent === undefined ? null : `${percent.toFixed(1)}%`; }, [contextMetrics.contextUsedPercentOfContextWindow, leadContextSnapshot?.contextUsedPercent]); + const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); - if (!leadSessionId) { + useEffect(() => { + if (!shouldShowLeadContextUi && isContextPanelVisible) { + setContextPanelVisible(false); + } + }, [isContextPanelVisible, setContextPanelVisible, shouldShowLeadContextUi]); + + if (!leadSessionId || !shouldShowLeadContextUi) { return null; } @@ -1677,6 +1694,14 @@ export const TeamDetailView = ({ () => activeMembers.filter((m) => !isLeadMember(m)).length, [activeMembers] ); + const leadProviderId = useMemo(() => { + const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; + if (activeLeadProviderId) return activeLeadProviderId; + const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; + if (configuredLeadProviderId) return configuredLeadProviderId; + return launchParams?.providerId; + }, [activeMembers, data?.config.members, launchParams?.providerId]); + const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const taskMapRef = useRef(taskMap); @@ -2068,7 +2093,7 @@ export const TeamDetailView = ({ isThisTabActive={isThisTabActive} /> ); - const leadContextWatcher = ( + const leadContextWatcher = shouldShowLeadContextUi ? ( - ); + ) : null; const renderBody = (): React.JSX.Element => { if ((loading && !data) || (data && data.teamName !== teamName)) { @@ -2190,6 +2215,7 @@ export const TeamDetailView = ({ tabId={tabId} projectId={projectId} leadSessionId={leadSessionId} + leadProviderId={leadProviderId} fallbackProjectRoot={data.config.projectPath} /> diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 895e1286..7e94716f 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -545,7 +545,7 @@ export const SendMessageDialog = ({ } footerRight={ -
+
{sendError ? ( @@ -557,16 +557,18 @@ export const SendMessageDialog = ({ debugDetails={sendDebugDetails} /> ) : null} - {remaining < 200 ? ( - - {remaining} chars left - - ) : null} - {textDraft.isSaved ? ( - Saved - ) : null} +
+ {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {textDraft.isSaved ? ( + Saved + ) : null} +
} /> diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 0006b505..ebdcbac0 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -935,18 +935,20 @@ export const MessageComposer = ({ isCompactLayout ? ( compactFooterNotice ) : ( -
+
{compactFooterNotice} - {remaining < 200 ? ( - - {remaining} chars left - - ) : null} - {draft.isSaved ? ( - Saved - ) : null} +
+ {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {draft.isSaved ? ( + Saved + ) : null} +
) } diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index feb2db82..dbe67299 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -18,6 +18,7 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { selectTeamMessages } from '@renderer/store/slices/teamSlice'; +import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; @@ -38,7 +39,11 @@ import { import { useShallow } from 'zustand/react/shallow'; import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline'; -import { getThoughtGroupKey, groupTimelineItems } from '../activity/LeadThoughtsGroup'; +import { + getThoughtGroupKey, + groupTimelineItems, + isLeadThought, +} from '../activity/LeadThoughtsGroup'; import { MessageExpandDialog } from '../activity/MessageExpandDialog'; import { CollapsibleTeamSection } from '../CollapsibleTeamSection'; import { @@ -140,7 +145,9 @@ export function reconcilePendingRepliesByMember( continue; } - if (message.to === 'user') { + // Team lead often answers through visible lead thoughts, which do not carry `to: 'user'`. + // Count them as replies so the pending-reply badge clears after the lead responds. + if (message.to === 'user' || isLeadThought(message)) { const previous = latestReplyToUserByMember.get(message.from); if (previous == null || ts > previous) { latestReplyToUserByMember.set(message.from, ts); @@ -164,6 +171,51 @@ export function reconcilePendingRepliesByMember( return changed ? next : pendingRepliesByMember; } +function normalizeMessageParticipant(value: unknown): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +export function hasVisibleReplyForSendMessageDiagnostics( + debugDetails: OpenCodeRuntimeDeliveryDebugDetails | null | undefined, + messages: readonly InboxMessage[] +): boolean { + const messageId = debugDetails?.messageId; + if (!messageId) { + return false; + } + + const sentMessage = messages.find((message) => message.messageId === messageId); + if ( + !sentMessage || + sentMessage.from !== 'user' || + typeof sentMessage.to !== 'string' || + sentMessage.to.length === 0 + ) { + return false; + } + + const recipient = normalizeMessageParticipant(sentMessage.to); + const sentAt = Date.parse(sentMessage.timestamp); + if (!recipient || !Number.isFinite(sentAt)) { + return false; + } + + return messages.some((message) => { + if (message.messageId === sentMessage.messageId) { + return false; + } + if (normalizeMessageParticipant(message.from) !== recipient || message.to !== 'user') { + return false; + } + if (message.relayOfMessageId === messageId) { + return true; + } + + const replyAt = Date.parse(message.timestamp); + return Number.isFinite(replyAt) && replyAt > sentAt; + }); +} + export const MessagesPanel = memo(function MessagesPanel({ teamName, position, @@ -194,6 +246,7 @@ export const MessagesPanel = memo(function MessagesPanel({ sendMessageWarning, sendMessageDebugDetails, lastSendMessageResult, + clearSendMessageRuntimeDiagnostics, teams, openTeamTab, messages, @@ -209,6 +262,7 @@ export const MessagesPanel = memo(function MessagesPanel({ sendMessageWarning: s.sendMessageWarning, sendMessageDebugDetails: s.sendMessageDebugDetails, lastSendMessageResult: s.lastSendMessageResult, + clearSendMessageRuntimeDiagnostics: s.clearSendMessageRuntimeDiagnostics, teams: s.teams, openTeamTab: s.openTeamTab, messages: selectTeamMessages(s, teamName), @@ -442,6 +496,14 @@ export const MessagesPanel = memo(function MessagesPanel({ ), [effectiveMessages] ); + const sendMessageRuntimeReplyVisible = useMemo( + () => hasVisibleReplyForSendMessageDiagnostics(sendMessageDebugDetails, effectiveMessages), + [effectiveMessages, sendMessageDebugDetails] + ); + const effectiveSendMessageWarning = sendMessageRuntimeReplyVisible ? null : sendMessageWarning; + const effectiveSendMessageDebugDetails = sendMessageRuntimeReplyVisible + ? null + : sendMessageDebugDetails; // Resolve the expanded item from filtered messages const expandedItem = useMemo(() => { @@ -507,6 +569,15 @@ export const MessagesPanel = memo(function MessagesPanel({ if (next !== pendingRepliesByMember) onPendingReplyChange(() => next); }, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]); + useEffect(() => { + if (!sendMessageRuntimeReplyVisible || !sendMessageDebugDetails?.messageId) return; + clearSendMessageRuntimeDiagnostics(sendMessageDebugDetails.messageId); + }, [ + clearSendMessageRuntimeDiagnostics, + sendMessageDebugDetails?.messageId, + sendMessageRuntimeReplyVisible, + ]); + const handleSend = useCallback( ( member: string, @@ -696,8 +767,8 @@ export const MessagesPanel = memo(function MessagesPanel({ isTeamAlive={isTeamAlive} sending={sendingMessage} sendError={sendMessageError} - sendWarning={sendMessageWarning} - sendDebugDetails={sendMessageDebugDetails} + sendWarning={effectiveSendMessageWarning} + sendDebugDetails={effectiveSendMessageDebugDetails} lastResult={lastSendMessageResult} textareaRef={composerTextareaRef} onSend={handleSend} @@ -883,8 +954,8 @@ export const MessagesPanel = memo(function MessagesPanel({ isTeamAlive={isTeamAlive} sending={sendingMessage} sendError={sendMessageError} - sendWarning={sendMessageWarning} - sendDebugDetails={sendMessageDebugDetails} + sendWarning={effectiveSendMessageWarning} + sendDebugDetails={effectiveSendMessageDebugDetails} lastResult={lastSendMessageResult} textareaRef={composerTextareaRef} onSend={handleSend} @@ -1169,8 +1240,8 @@ export const MessagesPanel = memo(function MessagesPanel({ isTeamAlive={isTeamAlive} sending={sendingMessage} sendError={sendMessageError} - sendWarning={sendMessageWarning} - sendDebugDetails={sendMessageDebugDetails} + sendWarning={effectiveSendMessageWarning} + sendDebugDetails={effectiveSendMessageDebugDetails} lastResult={lastSendMessageResult} textareaRef={composerTextareaRef} onSend={handleSend} diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index ced4cadc..8e0f52fa 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1964,6 +1964,7 @@ export interface TeamSlice { sendMessageWarning: string | null; sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null; lastSendMessageResult: SendMessageResult | null; + clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => void; reviewActionError: string | null; provisioningRuns: Record; /** Synthetic TeamSummary snapshots for teams currently being provisioned (before config.json exists). */ @@ -4023,6 +4024,21 @@ export const createTeamSlice: StateCreator = (set, } }, + clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => { + set((state) => { + if (messageId && state.sendMessageDebugDetails?.messageId !== messageId) { + return {}; + } + if (!state.sendMessageWarning && !state.sendMessageDebugDetails) { + return {}; + } + return { + sendMessageWarning: null, + sendMessageDebugDetails: null, + }; + }); + }, + fetchCrossTeamTargets: async () => { set({ crossTeamTargetsLoading: true }); try { diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index eae8ac56..a42ec297 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -259,7 +259,7 @@ function buildPendingDiagnosticPhrase({ formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess), formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate), formatNamedPendingDiagnostic('Awaiting permission', groups.permission), - formatNamedPendingDiagnostic('No runtime found', groups.noRuntime), + formatNamedPendingDiagnostic('Waiting for runtime', groups.noRuntime), ].filter(Boolean); if (namedParts.length > 0) { return namedParts.join(', '); @@ -272,7 +272,7 @@ function buildPendingDiagnosticPhrase({ formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'), formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'), formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'), - formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'), + formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'waiting for runtime'), ].filter(Boolean); return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase; } diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index c4b1187c..15c98c4c 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -2691,7 +2691,7 @@ describe('TeamProvisioningService', () => { }); expect( (svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary) - ).toContain('1 no runtime found'); + ).toContain('1 waiting for runtime'); }); it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => { diff --git a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx index aa72b645..68ec66de 100644 --- a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx +++ b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx @@ -102,7 +102,7 @@ describe('ProvisioningProgressBlock', () => { memberName: 'tom', severity: 'warning', code: 'runtime_not_found', - label: 'tom - no runtime found', + label: 'tom - waiting for runtime', detail: 'registered runtime metadata without live process', observedAt: '2026-04-24T12:00:01.000Z', }, @@ -136,7 +136,7 @@ describe('ProvisioningProgressBlock', () => { expect(host.textContent).toContain('bob - shell only'); expect(host.textContent).toContain('tmux pane foreground command is zsh'); - expect(host.textContent).toContain('tom - no runtime found'); + expect(host.textContent).toContain('tom - waiting for runtime'); expect(host.textContent).toContain('registered runtime metadata without live process'); expect(host.textContent).toContain('jack - process table unavailable'); expect(host.textContent).toContain( diff --git a/test/renderer/components/team/messages/MessagesPanel.test.ts b/test/renderer/components/team/messages/MessagesPanel.test.ts index 3ea42df9..6022a9c0 100644 --- a/test/renderer/components/team/messages/MessagesPanel.test.ts +++ b/test/renderer/components/team/messages/MessagesPanel.test.ts @@ -2,16 +2,18 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import type { InboxMessage } from '@shared/types'; const storeState = { sendTeamMessage: vi.fn().mockResolvedValue(undefined), sendCrossTeamMessage: vi.fn().mockResolvedValue(undefined), sendingMessage: false, - sendMessageError: null, - sendMessageWarning: null, - sendMessageDebugDetails: null, - lastSendMessageResult: null, + sendMessageError: null as string | null, + sendMessageWarning: null as string | null, + sendMessageDebugDetails: null as OpenCodeRuntimeDeliveryDebugDetails | null, + lastSendMessageResult: null as unknown, + clearSendMessageRuntimeDiagnostics: vi.fn(), teams: [], openTeamTab: vi.fn(), loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined), @@ -169,6 +171,7 @@ vi.mock('react-modal-sheet', () => ({ })); import { + hasVisibleReplyForSendMessageDiagnostics, MessagesPanel, reconcilePendingRepliesByMember, } from '@renderer/components/team/messages/MessagesPanel'; @@ -200,8 +203,14 @@ describe('MessagesPanel idle summary invariants', () => { storeState.sendTeamMessage.mockClear(); storeState.sendCrossTeamMessage.mockClear(); storeState.openTeamTab.mockClear(); + storeState.clearSendMessageRuntimeDiagnostics.mockClear(); storeState.loadOlderTeamMessages.mockClear(); storeState.refreshTeamMessagesHead.mockClear(); + storeState.sendingMessage = false; + storeState.sendMessageError = null; + storeState.sendMessageWarning = null; + storeState.sendMessageDebugDetails = null; + storeState.lastSendMessageResult = null; storeState.teamMessagesByName = {}; sidebarUiState.messagesSearchQuery = ''; sidebarUiState.messagesFilter = { from: new Set(), to: new Set(), showNoise: false }; @@ -417,6 +426,186 @@ describe('MessagesPanel idle summary invariants', () => { expect(reconcilePendingRepliesByMember({ forge: pendingSentAtMs }, messages)).toEqual({}); }); + it('clears pending replies when the team lead answers through a visible lead thought', () => { + const pendingSentAtMs = Date.parse('2026-04-08T12:00:00.000Z'); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'lead-thought-reply', + from: 'lead', + to: undefined, + source: 'lead_session', + timestamp: '2026-04-08T12:00:05.000Z', + text: 'Да, команда на месте.', + }), + ]; + + expect(reconcilePendingRepliesByMember({ lead: pendingSentAtMs }, messages)).toEqual({}); + }); + + it('keeps pending replies when the lead thought is older than the user message', () => { + const pendingSentAtMs = Date.parse('2026-04-08T12:00:00.000Z'); + const pending = { lead: pendingSentAtMs }; + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'older-lead-thought', + from: 'lead', + to: undefined, + source: 'lead_session', + timestamp: '2026-04-08T11:59:59.000Z', + text: 'Предыдущий статус.', + }), + ]; + + expect(reconcilePendingRepliesByMember(pending, messages)).toBe(pending); + }); + + it('detects a visible OpenCode reply for pending runtime diagnostics', () => { + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'user-send', + from: 'user', + to: 'tom', + source: 'user_sent', + timestamp: '2026-04-08T12:00:00.000Z', + text: 'Тут?', + }), + makeMessage({ + messageId: 'tom-reply', + from: 'tom', + to: 'user', + relayOfMessageId: 'user-send', + timestamp: '2026-04-08T12:00:05.000Z', + text: 'Да, я тут.', + }), + ]; + + expect( + hasVisibleReplyForSendMessageDiagnostics( + { + messageId: 'user-send', + providerId: 'opencode', + delivered: true, + responsePending: true, + responseState: 'pending', + ledgerStatus: 'accepted', + acceptanceUnknown: false, + reason: 'assistant_response_pending', + diagnostics: ['assistant_response_pending'], + }, + messages + ) + ).toBe(true); + }); + + it('does not treat older member messages as OpenCode replies for pending diagnostics', () => { + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'tom-old-reply', + from: 'tom', + to: 'user', + timestamp: '2026-04-08T11:59:59.000Z', + text: 'Предыдущий ответ.', + }), + makeMessage({ + messageId: 'user-send', + from: 'user', + to: 'tom', + source: 'user_sent', + timestamp: '2026-04-08T12:00:00.000Z', + text: 'Тут?', + }), + ]; + + expect( + hasVisibleReplyForSendMessageDiagnostics( + { + messageId: 'user-send', + providerId: 'opencode', + delivered: true, + responsePending: true, + responseState: 'pending', + ledgerStatus: 'accepted', + acceptanceUnknown: false, + reason: 'assistant_response_pending', + diagnostics: ['assistant_response_pending'], + }, + messages + ) + ).toBe(false); + }); + + it('clears stale OpenCode runtime diagnostics once the member reply is visible', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const messages: InboxMessage[] = [ + makeMessage({ + messageId: 'user-send', + from: 'user', + to: 'tom', + source: 'user_sent', + timestamp: '2026-04-08T12:00:00.000Z', + text: 'Тут?', + }), + makeMessage({ + messageId: 'tom-reply', + from: 'tom', + to: 'user', + timestamp: '2026-04-08T12:00:05.000Z', + text: 'Да, я тут.', + }), + ]; + + 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: messages, + 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(); + }); + + expect(storeState.clearSendMessageRuntimeDiagnostics).toHaveBeenCalledWith('user-send'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('renders the bottom-sheet composer before the status block so input stays pinned near the header', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 9f7b2671..b2abffb9 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -328,6 +328,40 @@ describe('teamSlice actions', () => { }); }); + it('clears OpenCode runtime diagnostics only for the matching message id', 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'], + }, + }); + + await store.getState().sendTeamMessage('my-team', { + member: 'bob', + text: 'hello', + }); + + store.getState().clearSendMessageRuntimeDiagnostics('other-message'); + expect(store.getState().sendMessageWarning).toBe( + 'OpenCode runtime delivery is still being checked. Message was saved and will be retried if needed.' + ); + expect(store.getState().sendMessageDebugDetails?.messageId).toBe('m-opencode-pending'); + + store.getState().clearSendMessageRuntimeDiagnostics('m-opencode-pending'); + expect(store.getState().sendMessageWarning).toBeNull(); + expect(store.getState().sendMessageDebugDetails).toBeNull(); + }); + it('clears OpenCode runtime diagnostics after normal success or send failure', async () => { const store = createSliceStore(); hoisted.sendMessage diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 664ddc94..01b292b3 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -893,8 +893,8 @@ describe('buildTeamProvisioningPresentation', () => { }); expect(presentation?.compactTitle).toBe('Finishing launch'); - expect(presentation?.compactDetail).toBe('No runtime found: alice, bob'); - expect(presentation?.panelMessage).toBe('No runtime found: alice, bob'); + expect(presentation?.compactDetail).toBe('Waiting for runtime: alice, bob'); + expect(presentation?.panelMessage).toBe('Waiting for runtime: alice, bob'); }); it('names live pending diagnostics without duplicating permission-blocked teammates', () => {