fix(team): clear delivered runtime message warnings
This commit is contained in:
parent
b26f0b05ba
commit
068e473d2d
12 changed files with 386 additions and 46 deletions
|
|
@ -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(', ')}` : '';
|
||||
|
|
|
|||
|
|
@ -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<TeamProviderId>(['codex', 'opencode']);
|
||||
|
||||
function canShowLeadContextUi(providerId: TeamProviderId | undefined): boolean {
|
||||
return providerId === undefined || !LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS.has(providerId);
|
||||
}
|
||||
|
||||
function buildMemberSpawnStatusMap(
|
||||
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
|
||||
): Map<string, MemberSpawnStatusEntry> | 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<TeamProviderId | undefined>(() => {
|
||||
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 ? (
|
||||
<LeadContextWatcher
|
||||
teamName={teamName}
|
||||
tabId={tabId}
|
||||
|
|
@ -2080,7 +2105,7 @@ export const TeamDetailView = ({
|
|||
sessions={sessions}
|
||||
sessionsLoading={sessionsLoading}
|
||||
/>
|
||||
);
|
||||
) : 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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -545,7 +545,7 @@ export const SendMessageDialog = ({
|
|||
</button>
|
||||
}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{sendError ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
|
|
@ -557,16 +557,18 @@ export const SendMessageDialog = ({
|
|||
debugDetails={sendDebugDetails}
|
||||
/>
|
||||
) : null}
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -935,18 +935,20 @@ export const MessageComposer = ({
|
|||
isCompactLayout ? (
|
||||
compactFooterNotice
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{compactFooterNotice}
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
{remaining < 200 ? (
|
||||
<span
|
||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||
>
|
||||
{remaining} chars left
|
||||
</span>
|
||||
) : null}
|
||||
{draft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TimelineItem | null>(() => {
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<string, TeamProvisioningProgress>;
|
||||
/** Synthetic TeamSummary snapshots for teams currently being provisioned (before config.json exists). */
|
||||
|
|
@ -4023,6 +4024,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue