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,
|
memberName,
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
code: 'runtime_not_found',
|
code: 'runtime_not_found',
|
||||||
label: `${memberName} - no runtime found`,
|
label: `${memberName} - waiting for runtime`,
|
||||||
detail: entry.runtimeDiagnostic,
|
detail: entry.runtimeDiagnostic,
|
||||||
observedAt,
|
observedAt,
|
||||||
});
|
});
|
||||||
|
|
@ -15755,7 +15755,7 @@ export class TeamProvisioningService {
|
||||||
? `${launchSummary.runtimeCandidatePendingCount} process candidates`
|
? `${launchSummary.runtimeCandidatePendingCount} process candidates`
|
||||||
: '',
|
: '',
|
||||||
launchSummary.noRuntimePendingCount
|
launchSummary.noRuntimePendingCount
|
||||||
? `${launchSummary.noRuntimePendingCount} no runtime found`
|
? `${launchSummary.noRuntimePendingCount} waiting for runtime`
|
||||||
: '',
|
: '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : '';
|
const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : '';
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ import type {
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
|
TeamProviderId,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||||
|
|
@ -338,9 +339,17 @@ interface LeadContextBridgeProps {
|
||||||
tabId: string | null;
|
tabId: string | null;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
leadSessionId: string | null;
|
leadSessionId: string | null;
|
||||||
|
leadProviderId?: TeamProviderId;
|
||||||
fallbackProjectRoot?: string;
|
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(
|
function buildMemberSpawnStatusMap(
|
||||||
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
|
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined
|
||||||
): Map<string, MemberSpawnStatusEntry> | undefined {
|
): Map<string, MemberSpawnStatusEntry> | undefined {
|
||||||
|
|
@ -547,6 +556,7 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
tabId,
|
tabId,
|
||||||
projectId,
|
projectId,
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
|
leadProviderId,
|
||||||
fallbackProjectRoot,
|
fallbackProjectRoot,
|
||||||
}: LeadContextBridgeProps): React.JSX.Element | null {
|
}: LeadContextBridgeProps): React.JSX.Element | null {
|
||||||
const {
|
const {
|
||||||
|
|
@ -686,8 +696,15 @@ const LeadContextBridge = memo(function LeadContextBridge({
|
||||||
contextMetrics.contextUsedPercentOfContextWindow ?? leadContextSnapshot?.contextUsedPercent;
|
contextMetrics.contextUsedPercentOfContextWindow ?? leadContextSnapshot?.contextUsedPercent;
|
||||||
return percent === null || percent === undefined ? null : `${percent.toFixed(1)}%`;
|
return percent === null || percent === undefined ? null : `${percent.toFixed(1)}%`;
|
||||||
}, [contextMetrics.contextUsedPercentOfContextWindow, leadContextSnapshot?.contextUsedPercent]);
|
}, [contextMetrics.contextUsedPercentOfContextWindow, leadContextSnapshot?.contextUsedPercent]);
|
||||||
|
const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId);
|
||||||
|
|
||||||
if (!leadSessionId) {
|
useEffect(() => {
|
||||||
|
if (!shouldShowLeadContextUi && isContextPanelVisible) {
|
||||||
|
setContextPanelVisible(false);
|
||||||
|
}
|
||||||
|
}, [isContextPanelVisible, setContextPanelVisible, shouldShowLeadContextUi]);
|
||||||
|
|
||||||
|
if (!leadSessionId || !shouldShowLeadContextUi) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1677,6 +1694,14 @@ export const TeamDetailView = ({
|
||||||
() => activeMembers.filter((m) => !isLeadMember(m)).length,
|
() => activeMembers.filter((m) => !isLeadMember(m)).length,
|
||||||
[activeMembers]
|
[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 taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||||
const taskMapRef = useRef(taskMap);
|
const taskMapRef = useRef(taskMap);
|
||||||
|
|
@ -2068,7 +2093,7 @@ export const TeamDetailView = ({
|
||||||
isThisTabActive={isThisTabActive}
|
isThisTabActive={isThisTabActive}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const leadContextWatcher = (
|
const leadContextWatcher = shouldShowLeadContextUi ? (
|
||||||
<LeadContextWatcher
|
<LeadContextWatcher
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
tabId={tabId}
|
tabId={tabId}
|
||||||
|
|
@ -2080,7 +2105,7 @@ export const TeamDetailView = ({
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
sessionsLoading={sessionsLoading}
|
sessionsLoading={sessionsLoading}
|
||||||
/>
|
/>
|
||||||
);
|
) : null;
|
||||||
|
|
||||||
const renderBody = (): React.JSX.Element => {
|
const renderBody = (): React.JSX.Element => {
|
||||||
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
if ((loading && !data) || (data && data.teamName !== teamName)) {
|
||||||
|
|
@ -2190,6 +2215,7 @@ export const TeamDetailView = ({
|
||||||
tabId={tabId}
|
tabId={tabId}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
leadSessionId={leadSessionId}
|
leadSessionId={leadSessionId}
|
||||||
|
leadProviderId={leadProviderId}
|
||||||
fallbackProjectRoot={data.config.projectPath}
|
fallbackProjectRoot={data.config.projectPath}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -545,7 +545,7 @@ export const SendMessageDialog = ({
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
footerRight={
|
footerRight={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col items-end gap-1">
|
||||||
{sendError ? (
|
{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">
|
<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" />
|
<AlertCircle size={10} className="shrink-0" />
|
||||||
|
|
@ -557,16 +557,18 @@ export const SendMessageDialog = ({
|
||||||
debugDetails={sendDebugDetails}
|
debugDetails={sendDebugDetails}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{remaining < 200 ? (
|
<div className="flex items-center gap-2">
|
||||||
<span
|
{remaining < 200 ? (
|
||||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
<span
|
||||||
>
|
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||||
{remaining} chars left
|
>
|
||||||
</span>
|
{remaining} chars left
|
||||||
) : null}
|
</span>
|
||||||
{textDraft.isSaved ? (
|
) : null}
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
{textDraft.isSaved ? (
|
||||||
) : null}
|
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -935,18 +935,20 @@ export const MessageComposer = ({
|
||||||
isCompactLayout ? (
|
isCompactLayout ? (
|
||||||
compactFooterNotice
|
compactFooterNotice
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col items-end gap-1">
|
||||||
{compactFooterNotice}
|
{compactFooterNotice}
|
||||||
{remaining < 200 ? (
|
<div className="flex items-center gap-2">
|
||||||
<span
|
{remaining < 200 ? (
|
||||||
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
<span
|
||||||
>
|
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||||
{remaining} chars left
|
>
|
||||||
</span>
|
{remaining} chars left
|
||||||
) : null}
|
</span>
|
||||||
{draft.isSaved ? (
|
) : null}
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
{draft.isSaved ? (
|
||||||
) : null}
|
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
|
import { selectTeamMessages } from '@renderer/store/slices/teamSlice';
|
||||||
|
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics';
|
||||||
|
|
@ -38,7 +39,11 @@ import {
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ActivityTimeline, type TimelineViewport } from '../activity/ActivityTimeline';
|
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 { MessageExpandDialog } from '../activity/MessageExpandDialog';
|
||||||
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '../CollapsibleTeamSection';
|
||||||
import {
|
import {
|
||||||
|
|
@ -140,7 +145,9 @@ export function reconcilePendingRepliesByMember(
|
||||||
continue;
|
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);
|
const previous = latestReplyToUserByMember.get(message.from);
|
||||||
if (previous == null || ts > previous) {
|
if (previous == null || ts > previous) {
|
||||||
latestReplyToUserByMember.set(message.from, ts);
|
latestReplyToUserByMember.set(message.from, ts);
|
||||||
|
|
@ -164,6 +171,51 @@ export function reconcilePendingRepliesByMember(
|
||||||
return changed ? next : pendingRepliesByMember;
|
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({
|
export const MessagesPanel = memo(function MessagesPanel({
|
||||||
teamName,
|
teamName,
|
||||||
position,
|
position,
|
||||||
|
|
@ -194,6 +246,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
sendMessageWarning,
|
sendMessageWarning,
|
||||||
sendMessageDebugDetails,
|
sendMessageDebugDetails,
|
||||||
lastSendMessageResult,
|
lastSendMessageResult,
|
||||||
|
clearSendMessageRuntimeDiagnostics,
|
||||||
teams,
|
teams,
|
||||||
openTeamTab,
|
openTeamTab,
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -209,6 +262,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
sendMessageWarning: s.sendMessageWarning,
|
sendMessageWarning: s.sendMessageWarning,
|
||||||
sendMessageDebugDetails: s.sendMessageDebugDetails,
|
sendMessageDebugDetails: s.sendMessageDebugDetails,
|
||||||
lastSendMessageResult: s.lastSendMessageResult,
|
lastSendMessageResult: s.lastSendMessageResult,
|
||||||
|
clearSendMessageRuntimeDiagnostics: s.clearSendMessageRuntimeDiagnostics,
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
openTeamTab: s.openTeamTab,
|
openTeamTab: s.openTeamTab,
|
||||||
messages: selectTeamMessages(s, teamName),
|
messages: selectTeamMessages(s, teamName),
|
||||||
|
|
@ -442,6 +496,14 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
),
|
),
|
||||||
[effectiveMessages]
|
[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
|
// Resolve the expanded item from filtered messages
|
||||||
const expandedItem = useMemo<TimelineItem | null>(() => {
|
const expandedItem = useMemo<TimelineItem | null>(() => {
|
||||||
|
|
@ -507,6 +569,15 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
if (next !== pendingRepliesByMember) onPendingReplyChange(() => next);
|
if (next !== pendingRepliesByMember) onPendingReplyChange(() => next);
|
||||||
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
|
}, [onPendingReplyChange, pendingRepliesByMember, replyCandidateMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sendMessageRuntimeReplyVisible || !sendMessageDebugDetails?.messageId) return;
|
||||||
|
clearSendMessageRuntimeDiagnostics(sendMessageDebugDetails.messageId);
|
||||||
|
}, [
|
||||||
|
clearSendMessageRuntimeDiagnostics,
|
||||||
|
sendMessageDebugDetails?.messageId,
|
||||||
|
sendMessageRuntimeReplyVisible,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
(
|
(
|
||||||
member: string,
|
member: string,
|
||||||
|
|
@ -696,8 +767,8 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
sending={sendingMessage}
|
sending={sendingMessage}
|
||||||
sendError={sendMessageError}
|
sendError={sendMessageError}
|
||||||
sendWarning={sendMessageWarning}
|
sendWarning={effectiveSendMessageWarning}
|
||||||
sendDebugDetails={sendMessageDebugDetails}
|
sendDebugDetails={effectiveSendMessageDebugDetails}
|
||||||
lastResult={lastSendMessageResult}
|
lastResult={lastSendMessageResult}
|
||||||
textareaRef={composerTextareaRef}
|
textareaRef={composerTextareaRef}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
|
@ -883,8 +954,8 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
sending={sendingMessage}
|
sending={sendingMessage}
|
||||||
sendError={sendMessageError}
|
sendError={sendMessageError}
|
||||||
sendWarning={sendMessageWarning}
|
sendWarning={effectiveSendMessageWarning}
|
||||||
sendDebugDetails={sendMessageDebugDetails}
|
sendDebugDetails={effectiveSendMessageDebugDetails}
|
||||||
lastResult={lastSendMessageResult}
|
lastResult={lastSendMessageResult}
|
||||||
textareaRef={composerTextareaRef}
|
textareaRef={composerTextareaRef}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
|
@ -1169,8 +1240,8 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
sending={sendingMessage}
|
sending={sendingMessage}
|
||||||
sendError={sendMessageError}
|
sendError={sendMessageError}
|
||||||
sendWarning={sendMessageWarning}
|
sendWarning={effectiveSendMessageWarning}
|
||||||
sendDebugDetails={sendMessageDebugDetails}
|
sendDebugDetails={effectiveSendMessageDebugDetails}
|
||||||
lastResult={lastSendMessageResult}
|
lastResult={lastSendMessageResult}
|
||||||
textareaRef={composerTextareaRef}
|
textareaRef={composerTextareaRef}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
|
|
|
||||||
|
|
@ -1964,6 +1964,7 @@ export interface TeamSlice {
|
||||||
sendMessageWarning: string | null;
|
sendMessageWarning: string | null;
|
||||||
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
|
sendMessageDebugDetails: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||||
lastSendMessageResult: SendMessageResult | null;
|
lastSendMessageResult: SendMessageResult | null;
|
||||||
|
clearSendMessageRuntimeDiagnostics: (messageId?: string | null) => 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). */
|
||||||
|
|
@ -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 () => {
|
fetchCrossTeamTargets: async () => {
|
||||||
set({ crossTeamTargetsLoading: true });
|
set({ crossTeamTargetsLoading: true });
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ function buildPendingDiagnosticPhrase({
|
||||||
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
|
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
|
||||||
formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate),
|
formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate),
|
||||||
formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
|
formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
|
||||||
formatNamedPendingDiagnostic('No runtime found', groups.noRuntime),
|
formatNamedPendingDiagnostic('Waiting for runtime', groups.noRuntime),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
if (namedParts.length > 0) {
|
if (namedParts.length > 0) {
|
||||||
return namedParts.join(', ');
|
return namedParts.join(', ');
|
||||||
|
|
@ -272,7 +272,7 @@ function buildPendingDiagnosticPhrase({
|
||||||
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
|
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
|
||||||
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
|
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
|
||||||
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
|
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
|
||||||
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'),
|
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'waiting for runtime'),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
|
return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2691,7 +2691,7 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
(svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)
|
(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', () => {
|
it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => {
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ describe('ProvisioningProgressBlock', () => {
|
||||||
memberName: 'tom',
|
memberName: 'tom',
|
||||||
severity: 'warning',
|
severity: 'warning',
|
||||||
code: 'runtime_not_found',
|
code: 'runtime_not_found',
|
||||||
label: 'tom - no runtime found',
|
label: 'tom - waiting for runtime',
|
||||||
detail: 'registered runtime metadata without live process',
|
detail: 'registered runtime metadata without live process',
|
||||||
observedAt: '2026-04-24T12:00:01.000Z',
|
observedAt: '2026-04-24T12:00:01.000Z',
|
||||||
},
|
},
|
||||||
|
|
@ -136,7 +136,7 @@ describe('ProvisioningProgressBlock', () => {
|
||||||
|
|
||||||
expect(host.textContent).toContain('bob - shell only');
|
expect(host.textContent).toContain('bob - shell only');
|
||||||
expect(host.textContent).toContain('tmux pane foreground command is zsh');
|
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('registered runtime metadata without live process');
|
||||||
expect(host.textContent).toContain('jack - process table unavailable');
|
expect(host.textContent).toContain('jack - process table unavailable');
|
||||||
expect(host.textContent).toContain(
|
expect(host.textContent).toContain(
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@ import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics';
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
const storeState = {
|
const storeState = {
|
||||||
sendTeamMessage: vi.fn().mockResolvedValue(undefined),
|
sendTeamMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
sendCrossTeamMessage: vi.fn().mockResolvedValue(undefined),
|
sendCrossTeamMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
sendingMessage: false,
|
sendingMessage: false,
|
||||||
sendMessageError: null,
|
sendMessageError: null as string | null,
|
||||||
sendMessageWarning: null,
|
sendMessageWarning: null as string | null,
|
||||||
sendMessageDebugDetails: null,
|
sendMessageDebugDetails: null as OpenCodeRuntimeDeliveryDebugDetails | null,
|
||||||
lastSendMessageResult: null,
|
lastSendMessageResult: null as unknown,
|
||||||
|
clearSendMessageRuntimeDiagnostics: vi.fn(),
|
||||||
teams: [],
|
teams: [],
|
||||||
openTeamTab: vi.fn(),
|
openTeamTab: vi.fn(),
|
||||||
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
|
loadOlderTeamMessages: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -169,6 +171,7 @@ vi.mock('react-modal-sheet', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
hasVisibleReplyForSendMessageDiagnostics,
|
||||||
MessagesPanel,
|
MessagesPanel,
|
||||||
reconcilePendingRepliesByMember,
|
reconcilePendingRepliesByMember,
|
||||||
} from '@renderer/components/team/messages/MessagesPanel';
|
} from '@renderer/components/team/messages/MessagesPanel';
|
||||||
|
|
@ -200,8 +203,14 @@ describe('MessagesPanel idle summary invariants', () => {
|
||||||
storeState.sendTeamMessage.mockClear();
|
storeState.sendTeamMessage.mockClear();
|
||||||
storeState.sendCrossTeamMessage.mockClear();
|
storeState.sendCrossTeamMessage.mockClear();
|
||||||
storeState.openTeamTab.mockClear();
|
storeState.openTeamTab.mockClear();
|
||||||
|
storeState.clearSendMessageRuntimeDiagnostics.mockClear();
|
||||||
storeState.loadOlderTeamMessages.mockClear();
|
storeState.loadOlderTeamMessages.mockClear();
|
||||||
storeState.refreshTeamMessagesHead.mockClear();
|
storeState.refreshTeamMessagesHead.mockClear();
|
||||||
|
storeState.sendingMessage = false;
|
||||||
|
storeState.sendMessageError = null;
|
||||||
|
storeState.sendMessageWarning = null;
|
||||||
|
storeState.sendMessageDebugDetails = null;
|
||||||
|
storeState.lastSendMessageResult = null;
|
||||||
storeState.teamMessagesByName = {};
|
storeState.teamMessagesByName = {};
|
||||||
sidebarUiState.messagesSearchQuery = '';
|
sidebarUiState.messagesSearchQuery = '';
|
||||||
sidebarUiState.messagesFilter = { from: new Set(), to: new Set(), showNoise: false };
|
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({});
|
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 () => {
|
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');
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('clears OpenCode runtime diagnostics after normal success or send failure', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
hoisted.sendMessage
|
hoisted.sendMessage
|
||||||
|
|
|
||||||
|
|
@ -893,8 +893,8 @@ describe('buildTeamProvisioningPresentation', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(presentation?.compactTitle).toBe('Finishing launch');
|
expect(presentation?.compactTitle).toBe('Finishing launch');
|
||||||
expect(presentation?.compactDetail).toBe('No runtime found: alice, bob');
|
expect(presentation?.compactDetail).toBe('Waiting for runtime: alice, bob');
|
||||||
expect(presentation?.panelMessage).toBe('No runtime found: alice, bob');
|
expect(presentation?.panelMessage).toBe('Waiting for runtime: alice, bob');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('names live pending diagnostics without duplicating permission-blocked teammates', () => {
|
it('names live pending diagnostics without duplicating permission-blocked teammates', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue