diff --git a/src/main/index.ts b/src/main/index.ts index 5c6dc87f..b2c6272a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -463,6 +463,10 @@ function wireFileWatcherEvents(context: ServiceContext): void { const match = /^inboxes\/(.+)\.json$/.exec(detail); if (match && teamDataService) { const inboxName = match[1]; + + // Mark member as online when their first inbox message arrives (spawn tracking). + teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName); + void teamDataService .getLeadMemberName(teamName) .then((leadName) => { diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index b2156cb5..7f79b2e6 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -26,6 +26,7 @@ import { TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, TEAM_LEAD_CONTEXT, + TEAM_MEMBER_SPAWN_STATUSES, TEAM_LIST, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, @@ -109,6 +110,7 @@ import type { LeadContextUsage, MemberFullStats, MemberLogSummary, + MemberSpawnStatusEntry, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -253,6 +255,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess); ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext); + ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses); ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask); ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask); ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); @@ -310,6 +313,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_KILL_PROCESS); ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); ipcMain.removeHandler(TEAM_LEAD_CONTEXT); + ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES); ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); ipcMain.removeHandler(TEAM_RESTORE_TASK); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); @@ -1777,6 +1781,19 @@ async function handleLeadContext( ); } +async function handleMemberSpawnStatuses( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise>> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('memberSpawnStatuses', async () => + getTeamProvisioningService().getMemberSpawnStatuses(validated.value!) + ); +} + async function handleStopTeam( _event: IpcMainInvokeEvent, teamName: unknown diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index ac918478..b1808ad2 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -24,7 +24,6 @@ import { CROSS_TEAM_SOURCE, CROSS_TEAM_SENT_SOURCE, parseCrossTeamPrefix, - parseCrossTeamReplyPrefix, stripCrossTeamPrefix, } from '@shared/constants/crossTeam'; import { getMemberColorByName } from '@shared/constants/memberColors'; @@ -59,6 +58,8 @@ import type { CrossTeamSendResult, InboxMessage, LeadContextUsage, + MemberSpawnStatus, + MemberSpawnStatusEntry, TeamChangeEvent, TeamCreateRequest, TeamCreateResponse, @@ -240,6 +241,11 @@ interface ProvisioningRun { pendingPostCompactReminder: boolean; postCompactReminderInFlight: boolean; suppressPostCompactReminderOutput: boolean; + /** Per-member spawn lifecycle statuses tracked from stream-json output. */ + memberSpawnStatuses: Map< + string, + { status: MemberSpawnStatus; error?: string; updatedAt: string } + >; } type LeadActivityState = 'active' | 'idle' | 'offline'; @@ -1269,6 +1275,15 @@ export class TeamProvisioningService { return false; } + private looksLikeQualifiedExternalRecipientName(name: string): boolean { + const trimmed = name.trim(); + const dot = trimmed.indexOf('.'); + if (dot <= 0 || dot === trimmed.length - 1) return false; + const teamName = trimmed.slice(0, dot).trim(); + const memberName = trimmed.slice(dot + 1).trim(); + return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0; + } + private persistSentMessage(teamName: string, message: InboxMessage): void { try { createController({ @@ -1386,6 +1401,45 @@ export class TeamProvisioningService { }); } + /** + * Update spawn status for a specific team member and emit a change event. + */ + private setMemberSpawnStatus( + run: ProvisioningRun, + memberName: string, + status: MemberSpawnStatus, + error?: string + ): void { + const prev = run.memberSpawnStatuses.get(memberName); + if (prev?.status === status) return; + run.memberSpawnStatuses.set(memberName, { + status, + error, + updatedAt: nowIso(), + }); + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: run.teamName, + detail: memberName, + }); + } + + /** + * Get current member spawn statuses for a team. + * Returns a map of memberName → MemberSpawnStatusEntry. + */ + getMemberSpawnStatuses(teamName: string): Record { + const runId = this.activeByTeam.get(teamName); + if (!runId) return {}; + const run = this.runs.get(runId); + if (!run) return {}; + const result: Record = {}; + for (const [name, entry] of run.memberSpawnStatuses) { + result[name] = { status: entry.status, error: entry.error, updatedAt: entry.updatedAt }; + } + return result; + } + private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000; @@ -1961,6 +2015,7 @@ export class TeamProvisioningService { pendingPostCompactReminder: false, postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, + memberSpawnStatuses: new Map(), progress: { runId, teamName: request.teamName, @@ -2284,6 +2339,7 @@ export class TeamProvisioningService { pendingPostCompactReminder: false, postCompactReminderInFlight: false, suppressPostCompactReminderOutput: false, + memberSpawnStatuses: new Map(), progress: { runId, teamName: request.teamName, @@ -3117,6 +3173,41 @@ export class TeamProvisioningService { * calls directly from stdout, we persist a durable message row under the correct team name so * Messages stays accurate even if Claude's own routing is flaky. */ + /** + * Intercept Task tool_use blocks that spawn team members. + * Sets member spawn status to 'spawning' when the lead issues a Task call with team_name + name. + */ + private captureTeamSpawnEvents(run: ProvisioningRun, content: Record[]): void { + for (const part of content) { + if (part.type !== 'tool_use' || part.name !== 'Task') continue; + const input = part.input; + if (!input || typeof input !== 'object') continue; + const inp = input as Record; + const teamName = typeof inp.team_name === 'string' ? inp.team_name.trim() : ''; + const memberName = typeof inp.name === 'string' ? inp.name.trim() : ''; + if (!teamName || !memberName) continue; + // Only track spawns for this team + if (teamName !== run.teamName) continue; + this.setMemberSpawnStatus(run, memberName, 'spawning'); + } + } + + /** + * Mark a member as online when their first inbox message arrives. + * Called from the inbox change handler. + */ + markMemberOnlineFromInbox(teamName: string, memberName: string): void { + const runId = this.activeByTeam.get(teamName); + if (!runId) return; + const run = this.runs.get(runId); + if (!run) return; + const entry = run.memberSpawnStatuses.get(memberName); + // Only transition spawning → online (not offline → online, to avoid false positives) + if (entry?.status === 'spawning') { + this.setMemberSpawnStatus(run, memberName, 'online'); + } + } + private captureSendMessages(run: ProvisioningRun, content: Record[]): void { for (const part of content) { if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue; @@ -3153,13 +3244,12 @@ export class TeamProvisioningService { localRecipientNames ); if (crossTeamRecipient && this.crossTeamSender) { - const explicitReplyMeta = parseCrossTeamReplyPrefix(cleanContent); const inferredReplyMeta = this.resolveCrossTeamReplyMetadata( run.teamName, crossTeamRecipient.teamName ); const crossTeamMeta = parseCrossTeamPrefix(cleanContent); - const replyMeta = explicitReplyMeta ?? inferredReplyMeta; + const replyMeta = inferredReplyMeta; const timestamp = nowIso(); const messageId = `lead-sendmsg-${run.runId}-${Date.now()}`; @@ -3171,14 +3261,9 @@ export class TeamProvisioningService { summary, messageId, timestamp, - conversationId: - explicitReplyMeta?.conversationId ?? - crossTeamMeta?.conversationId ?? - replyMeta?.conversationId, + conversationId: crossTeamMeta?.conversationId ?? replyMeta?.conversationId, replyToConversationId: - explicitReplyMeta?.replyToConversationId ?? replyMeta?.replyToConversationId ?? - explicitReplyMeta?.conversationId ?? crossTeamMeta?.conversationId ?? replyMeta?.conversationId, }) @@ -3200,14 +3285,9 @@ export class TeamProvisioningService { : summary || strippedCrossTeamContent, messageId: result.messageId, source: 'cross_team_sent', - conversationId: - explicitReplyMeta?.conversationId ?? - crossTeamMeta?.conversationId ?? - replyMeta?.conversationId, + conversationId: crossTeamMeta?.conversationId ?? replyMeta?.conversationId, replyToConversationId: - explicitReplyMeta?.replyToConversationId ?? replyMeta?.replyToConversationId ?? - explicitReplyMeta?.conversationId ?? crossTeamMeta?.conversationId ?? replyMeta?.conversationId, }; @@ -3483,6 +3563,10 @@ export class TeamProvisioningService { } } + // Track member spawn events from Task tool_use blocks with team_name. + // When the lead calls Task(team_name=X, name=Y), it means member Y is being spawned. + this.captureTeamSpawnEvents(run, content ?? []); + // Capture SendMessage tool_use blocks from assistant output. // Works in both pre-ready and post-ready phases so outbound runtime messages // are visible in our team message artifacts even if Claude's own routing drifts. @@ -5607,6 +5691,8 @@ export class TeamProvisioningService { const inboxNameSetLower = new Set(allInboxNames.map((n) => n.toLowerCase())); const inboxNames = allInboxNames .filter((name) => name !== 'team-lead' && name !== 'user') + .filter((name) => !this.isCrossTeamPseudoRecipientName(name)) + .filter((name) => !this.looksLikeQualifiedExternalRecipientName(name)) .filter((name) => { const match = /^(.+)-(\d+)$/.exec(name); if (!match?.[1] || !match[2]) return true; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 3a33b295..95a625ed 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -331,6 +331,9 @@ export const TEAM_LEAD_ACTIVITY = 'team:leadActivity'; /** Get lead process context window usage */ export const TEAM_LEAD_CONTEXT = 'team:leadContext'; +/** Get per-member spawn statuses for a team */ +export const TEAM_MEMBER_SPAWN_STATUSES = 'team:memberSpawnStatuses'; + /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 7c2e87ad..65f08dfa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -97,6 +97,7 @@ import { TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, TEAM_LEAD_CONTEXT, + TEAM_MEMBER_SPAWN_STATUSES, TEAM_LIST, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, @@ -214,6 +215,7 @@ import type { LeadContextUsage, MemberFullStats, MemberLogSummary, + MemberSpawnStatusEntry, NotificationTrigger, RejectResult, ReplaceMembersRequest, @@ -904,6 +906,12 @@ const electronAPI: ElectronAPI = { getLeadContext: async (teamName: string) => { return invokeIpcWithResult(TEAM_LEAD_CONTEXT, teamName); }, + getMemberSpawnStatuses: async (teamName: string) => { + return invokeIpcWithResult>( + TEAM_MEMBER_SPAWN_STATUSES, + teamName + ); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 9bb6be6a..6f1b36de 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -826,6 +826,9 @@ export class HttpAPIClient implements ElectronAPI { getLeadContext: async () => { return null; }, + getMemberSpawnStatuses: async () => { + return {}; + }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 20a6f69a..734f53b1 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -168,7 +168,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const stats = sessionContextStats.get(targetAiGroupId); const injections = stats?.accumulatedInjections ?? []; - // Get total tokens from the target AI group + // Get total INPUT tokens from the target AI group (excluding output tokens, + // since visible context is part of input only) let totalTokens: number | undefined; const targetItem = conversation.items.find( (item) => item.type === 'ai' && item.group.id === targetAiGroupId @@ -181,7 +182,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const usage = msg.usage; totalTokens = (usage.input_tokens ?? 0) + - (usage.output_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0); break; diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx index 352cde15..347f7386 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -100,10 +100,10 @@ export const SessionContextHeader = ({ ~{formatTokens(totalTokens)} - {/* Total Session tokens (if provided) */} + {/* Total Input tokens (if provided) */} {totalSessionTokens !== undefined && totalSessionTokens > 0 && (
- Total: + Input: {formatTokens(totalSessionTokens)} diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx index f2fc1463..04e9ec7d 100644 --- a/src/renderer/components/common/TokenUsageDisplay.tsx +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -544,8 +544,8 @@ export const TokenUsageDisplay = ({ incl. CLAUDE.md ×{claudeMdStats.accumulatedCount} - {totalTokens > 0 - ? ((claudeMdStats.totalEstimatedTokens / totalTokens) * 100).toFixed(1) + {totalInputTokens > 0 + ? ((claudeMdStats.totalEstimatedTokens / totalInputTokens) * 100).toFixed(1) : '0.0'} % diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index 1616706b..6028cb0d 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -4,7 +4,7 @@ import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; -import { Search, Terminal, X } from 'lucide-react'; +import { Brain, MessageSquare, Search, Terminal, Wrench, X } from 'lucide-react'; import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; import { CliLogsRichView } from './CliLogsRichView'; @@ -31,6 +31,140 @@ function isRecent(updatedAt: string | undefined): boolean { return Date.now() - t <= ONLINE_WINDOW_MS; } +/** + * System JSON subtypes that carry no user-facing value in the logs UI. + * These appear at session start before any assistant messages arrive. + */ +const SYSTEM_NOISE_SUBTYPES = new Set(['hook_started', 'hook_response', 'init']); + +/** + * Returns true if the raw JSON string represents a system message + * that should be filtered from the logs view. + */ +function isSystemNoiseLine(jsonStr: string): boolean { + try { + const parsed = JSON.parse(jsonStr); + if (!parsed || typeof parsed !== 'object') return false; + const obj = parsed as Record; + if (obj.type !== 'system') return false; + // Filter known noise subtypes; if no subtype, still filter generic system lines + if (typeof obj.subtype === 'string') { + return SYSTEM_NOISE_SUBTYPES.has(obj.subtype); + } + return true; + } catch { + return false; + } +} + +/** Info about the most recent log item for the header preview. */ +interface LastLogPreview { + type: 'output' | 'thinking' | 'tool'; + label: string; + summary: string; +} + +/** + * Extracts the preview of the most recent log item from newest-first lines. + * Lightweight: only parses until the first usable assistant message is found. + */ +function extractLastLogPreview(linesNewestFirst: string[]): LastLogPreview | null { + for (const rawLine of linesNewestFirst) { + const line = rawLine?.trim(); + if (!line) continue; + // Skip markers + if (line === '[stdout]' || line === '[stderr]') continue; + + // Strip stream prefix + let content = line; + if (line.startsWith('[stdout] ')) content = line.slice('[stdout] '.length); + else if (line.startsWith('[stderr] ')) content = line.slice('[stderr] '.length); + + // Skip system noise + if (content.trimStart().startsWith('{') && isSystemNoiseLine(content)) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + continue; + } + + if (!parsed || typeof parsed !== 'object') continue; + const obj = parsed as Record; + if (obj.type !== 'assistant') continue; + + // Extract content blocks + type ContentBlock = { type: string; text?: string; thinking?: string; name?: string }; + let blocks: ContentBlock[] | null = null; + if (Array.isArray(obj.content)) { + blocks = obj.content as ContentBlock[]; + } else if (obj.message && typeof obj.message === 'object') { + const msg = obj.message as Record; + if (Array.isArray(msg.content)) blocks = msg.content as ContentBlock[]; + } + + if (!blocks || blocks.length === 0) continue; + + // Take the last non-empty block + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { + return { type: 'output', label: 'Output', summary: b.text.trim().replace(/\n+/g, ' ') }; + } + if (b.type === 'thinking' && typeof b.thinking === 'string' && b.thinking.trim()) { + return { + type: 'thinking', + label: 'Thinking', + summary: b.thinking.trim().replace(/\n+/g, ' '), + }; + } + if (b.type === 'tool_use' && typeof b.name === 'string') { + return { type: 'tool', label: b.name, summary: '' }; + } + } + } + return null; +} + +const PREVIEW_ICONS = { + output: , + thinking: , + tool: , +} as const; + +/** + * Compact inline preview of the most recent log item, shown in the section header. + */ +const LogPreviewInline = ({ preview }: { preview: LastLogPreview }): React.JSX.Element => { + const summaryText = + preview.summary.length > 60 ? preview.summary.slice(0, 60) + '...' : preview.summary; + + return ( + + + {PREVIEW_ICONS[preview.type]} + + + {preview.label} + + {summaryText && ( + <> + + - + + + {summaryText} + + + )} + + ); +}; + function normalizeToStreamJsonText(linesNewestFirst: string[]): string { // We want to feed CliLogsRichView the exact format it expects: // - marker lines: "[stdout]" / "[stderr]" @@ -54,18 +188,26 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string { continue; } + let content = line; if (line.startsWith('[stdout] ')) { pushMarker('stdout'); - out.push(line.slice('[stdout] '.length)); - continue; - } - if (line.startsWith('[stderr] ')) { + content = line.slice('[stdout] '.length); + } else if (line.startsWith('[stderr] ')) { pushMarker('stderr'); - out.push(line.slice('[stderr] '.length)); + content = line.slice('[stderr] '.length); + } + + // Skip system noise lines (hook_started, hook_response, init) + if (content.trimStart().startsWith('{') && isSystemNoiseLine(content)) { continue; } - out.push(line); + if (content !== line) { + // Already stripped prefix above + out.push(content); + } else { + out.push(line); + } } return out.join('\n'); @@ -377,12 +519,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J const badge = data.total > 0 ? data.total : undefined; const showMoreVisible = data.hasMore || loadingMore; - const headerExtra = online ? ( - - - - - ) : null; + const lastLogPreview = useMemo( + () => (data.lines.length > 0 ? extractLastLogPreview(data.lines) : null), + [data.lines] + ); const filteredText = useMemo(() => { if (data.lines.length === 0) return ''; @@ -433,8 +573,21 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J title="Claude logs" icon={} badge={badge} - headerExtra={headerExtra} - defaultOpen + headerExtra={ + <> + {online ? ( + + + + + ) : null} + {lastLogPreview ? : null} + + } + defaultOpen={false} // Prevent scroll anchoring from "pulling" the parent container when logs update. contentClassName="pt-0 [overflow-anchor:none]" > diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 3abd0139..65a67f35 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -338,8 +338,7 @@ export const CliLogsRichView = ({ }, []); if (entries.length === 0) { - // cliLogsTail has data but no parseable assistant messages — show raw text fallback - const hasContent = cliLogsTail.trim().length > 0; + // No parseable assistant messages yet — show waiting state return (
{ @@ -360,15 +359,15 @@ export const CliLogsRichView = ({ }); }} > - {hasContent ? ( -
-            {cliLogsTail}
-          
- ) : ( -

- Waiting for CLI output... -

- )} +
+ + + + + + Waiting for response... + +
{footer}
); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index c7d74068..e1f5d078 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -93,7 +93,12 @@ import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; -import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { + InboxMessage, + MemberSpawnStatusEntry, + ResolvedTeamMember, + TeamTaskWithKanban, +} from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { @@ -234,6 +239,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele clearProvisioningError, isTeamProvisioning, leadActivityByTeam, + memberSpawnStatuses, + fetchMemberSpawnStatuses, refreshTeamData, kanbanFilterQuery, clearKanbanFilter, @@ -277,6 +284,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), leadActivityByTeam: s.leadActivityByTeam, + memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, + fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses, refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, @@ -311,6 +320,23 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele } }, [isTeamProvisioning]); + // Fetch initial spawn statuses when provisioning starts + useEffect(() => { + if (isTeamProvisioning && teamName) { + void fetchMemberSpawnStatuses(teamName); + } + }, [isTeamProvisioning, teamName, fetchMemberSpawnStatuses]); + + // Convert Record → Map + const memberSpawnStatusMap = useMemo(() => { + if (!memberSpawnStatuses) return undefined; + const map = new Map(); + for (const [name, entry] of Object.entries(memberSpawnStatuses)) { + map.set(name, { status: entry.status, error: entry.error }); + } + return map.size > 0 ? map : undefined; + }, [memberSpawnStatuses]); + const [kanbanSearch, setKanbanSearch] = useState(''); const [messagesSearchQuery, setMessagesSearchQuery] = useState(''); const [messagesFilter, setMessagesFilter] = useState({ @@ -1189,6 +1215,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele memberTaskCounts={memberTaskCounts} taskMap={taskMap} pendingRepliesByMember={pendingRepliesByMember} + memberSpawnStatuses={memberSpawnStatusMap} isTeamAlive={data.isAlive} isTeamProvisioning={isTeamProvisioning} leadActivity={leadActivityByTeam[teamName]} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 7c35ed10..37d31e87 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -28,7 +28,6 @@ import { CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SOURCE, parseCrossTeamPrefix, - parseCrossTeamReplyPrefix, stripCrossTeamPrefix, } from '@shared/constants/crossTeam'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; @@ -325,10 +324,6 @@ export const ActivityItem = ({ const isExpanded = isManaged ? !collapseState.isCollapsed : true; const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]); - const parsedCrossTeamReplyPrefix = useMemo( - () => parseCrossTeamReplyPrefix(message.text), - [message.text] - ); const qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]); const crossTeamSentTarget = useMemo( () => getCrossTeamSentTarget(message.to, teamName, localMemberNames), @@ -339,10 +334,7 @@ export const ActivityItem = ({ [message.to] ); const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null; - const isCrossTeamSent = - message.source === CROSS_TEAM_SENT_SOURCE || - parsedCrossTeamReplyPrefix !== null || - crossTeamSentTarget !== null; + const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null; const isCrossTeamAny = isCrossTeam || isCrossTeamSent; const crossTeamOrigin = useMemo(() => { if (!isCrossTeam) return null; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 980f8c17..4df33fd1 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -533,6 +533,64 @@ export const TaskDetailDialog = ({
) : null} + {/* Related tasks (explicit) */} + {relatedIds.length > 0 || relatedByIds.length > 0 ? ( +
+
+ + Related tasks +
+ + {relatedIds.length > 0 ? ( +
+ Links + {relatedIds.map((id) => { + const depTask = taskMap.get(id); + return ( + + ); + })} +
+ ) : null} + + {relatedByIds.length > 0 ? ( +
+ Linked from + {relatedByIds.map((id) => { + const depTask = taskMap.get(id); + return ( + + ); + })} +
+ ) : null} +
+ ) : null} + {/* Description */} ) : null} - {/* Related tasks (explicit) */} - {relatedIds.length > 0 || relatedByIds.length > 0 ? ( -
-
- - Related tasks -
- - {relatedIds.length > 0 ? ( -
- Links - {relatedIds.map((id) => { - const depTask = taskMap.get(id); - return ( - - ); - })} -
- ) : null} - - {relatedByIds.length > 0 ? ( -
- Linked from - {relatedByIds.map((id) => { - const depTask = taskMap.get(id); - return ( - - ); - })} -
- ) : null} -
- ) : null} - {/* Review info */} {kanbanTaskState ? (
diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 6598774a..6d9940b6 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -3,14 +3,24 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; import { useTheme } from '@renderer/hooks/useTheme'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; -import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; +import { + agentAvatarUrl, + getSpawnAwareDotClass, + getSpawnAwarePresenceLabel, + getSpawnCardClass, +} from '@renderer/utils/memberHelpers'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; -import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; +import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; import { CurrentTaskIndicator } from './CurrentTaskIndicator'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; -import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { + LeadActivityState, + MemberSpawnStatus, + ResolvedTeamMember, + TeamTaskWithKanban, +} from '@shared/types'; interface MemberCardProps { member: ResolvedTeamMember; @@ -23,6 +33,8 @@ interface MemberCardProps { reviewTask?: TeamTaskWithKanban | null; isAwaitingReply?: boolean; isRemoved?: boolean; + spawnStatus?: MemberSpawnStatus; + spawnError?: string; onOpenTask?: () => void; onClick?: () => void; onSendMessage?: () => void; @@ -40,6 +52,8 @@ export const MemberCard = ({ reviewTask, isAwaitingReply, isRemoved, + spawnStatus, + spawnError, onOpenTask, onClick, onSendMessage, @@ -50,8 +64,21 @@ export const MemberCard = ({ // const leadContext = useStore((s) => // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined // ); - const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); - const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); + const dotClass = getSpawnAwareDotClass( + member, + spawnStatus, + isTeamAlive, + isTeamProvisioning, + leadActivity + ); + const presenceLabel = getSpawnAwarePresenceLabel( + member, + spawnStatus, + isTeamAlive, + isTeamProvisioning, + leadActivity + ); + const spawnCardClass = isTeamProvisioning ? getSpawnCardClass(spawnStatus) : ''; const colors = getTeamColorSet(memberColor); const { isLight } = useTheme(); const pending = taskCounts?.pending ?? 0; @@ -68,7 +95,9 @@ export const MemberCard = ({ : undefined; return ( -
+
) : null; })()} - {presenceLabel === 'connecting' && !isRemoved ? ( - + {presenceLabel === 'connecting' || spawnStatus === 'spawning' ? ( + !isRemoved ? ( + + ) : null + ) : spawnStatus === 'error' ? ( + + + + + + {presenceLabel} + + + + {spawnError ?? 'Spawn failed'} + ) : ( ; taskMap?: Map; pendingRepliesByMember?: Record; + memberSpawnStatuses?: Map; isTeamAlive?: boolean; isTeamProvisioning?: boolean; leadActivity?: LeadActivityState; @@ -25,6 +36,7 @@ export const MemberList = ({ memberTaskCounts, taskMap, pendingRepliesByMember, + memberSpawnStatuses, isTeamAlive, isTeamProvisioning, leadActivity, @@ -65,6 +77,7 @@ export const MemberList = ({ ) ?? null) : null; const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]); + const spawnEntry = memberSpawnStatuses?.get(member.name); return ( onOpenTask?.((currentTask ?? reviewTask)!) diff --git a/src/renderer/index.css b/src/renderer/index.css index 9edf7c1c..25d6ca79 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -778,6 +778,28 @@ body { filter: none; } +/* Member spawn animations */ +@keyframes member-spawn-pulse { + 0%, + 100% { + opacity: 0.7; + } + 50% { + opacity: 0.45; + } +} + +@keyframes member-fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + :root.light .skeleton-card::after { background: linear-gradient( 90deg, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index d1debcd3..5743cafb 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -400,6 +400,12 @@ export function initializeNotificationListeners(): () => void { return; } + // Member spawn status change: fetch updated spawn statuses for the team. + if (event.type === 'member-spawn') { + void useStore.getState().fetchMemberSpawnStatuses(event.teamName); + return; + } + // Live lead-message events: only refresh the visible team detail, not team/task lists. // This keeps the refresh lightweight and prevents one noisy team from starving another. if (event.type === 'lead-message') { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 91fdcd5b..76c54bc8 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -74,6 +74,7 @@ import type { KanbanColumnId, LeadActivityState, LeadContextUsage, + MemberSpawnStatusEntry, SendMessageRequest, SendMessageResult, TaskComment, @@ -286,6 +287,9 @@ export interface TeamSlice { provisioningStartedAtFloorByTeam: Record; leadActivityByTeam: Record; leadContextByTeam: Record; + /** Per-team per-member spawn statuses during team provisioning/launch. */ + memberSpawnStatusesByTeam: Record>; + fetchMemberSpawnStatuses: (teamName: string) => Promise; activeProvisioningRunId: string | null; provisioningError: string | null; clearProvisioningError: () => void; @@ -461,9 +465,24 @@ export const createTeamSlice: StateCreator = (set, provisioningStartedAtFloorByTeam: {}, leadActivityByTeam: {}, leadContextByTeam: {}, + memberSpawnStatusesByTeam: {}, activeProvisioningRunId: null, provisioningError: null, clearProvisioningError: () => set({ provisioningError: null }), + fetchMemberSpawnStatuses: async (teamName: string) => { + if (!api.teams?.getMemberSpawnStatuses) return; + try { + const statuses = await api.teams.getMemberSpawnStatuses(teamName); + set((prev) => ({ + memberSpawnStatusesByTeam: { + ...prev.memberSpawnStatusesByTeam, + [teamName]: statuses, + }, + })); + } catch { + // ignore — spawn statuses are best-effort + } + }, kanbanFilterQuery: null, globalTaskDetail: null, pendingMemberProfile: null, @@ -1277,6 +1296,12 @@ export const createTeamSlice: StateCreator = (set, }); if (progress.state === 'ready' || progress.state === 'disconnected') { + // Clear spawn statuses — provisioning is complete, members now tracked via normal status + set((prev) => { + const next = { ...prev.memberSpawnStatusesByTeam }; + delete next[progress.teamName]; + return { memberSpawnStatusesByTeam: next }; + }); void get().fetchTeams(); // If the user already opened the team tab, reload team data now that // config.json is guaranteed to exist. diff --git a/src/renderer/utils/contextMath.ts b/src/renderer/utils/contextMath.ts index 1fc5e086..83ec3351 100644 --- a/src/renderer/utils/contextMath.ts +++ b/src/renderer/utils/contextMath.ts @@ -23,6 +23,5 @@ export function formatPercentOfTotal( ): string | null { const pct = computePercentOfTotal(visibleTokens, totalSessionTokens); if (pct === null) return null; - return `${pct.toFixed(1)}% of total`; + return `${pct.toFixed(1)}% of input`; } - diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 7d10a8e9..68516529 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -2,6 +2,7 @@ import { getMemberColorByName, MEMBER_COLOR_PALETTE } from '@shared/constants/me import type { LeadActivityState, + MemberSpawnStatus, MemberStatus, ResolvedTeamMember, TeamReviewState, @@ -60,6 +61,76 @@ export function getPresenceLabel( return member.currentTaskId ? 'working' : 'idle'; } +/* ------------------------------------------------------------------ */ +/* Spawn-status-aware helpers for progressive member card appearance */ +/* ------------------------------------------------------------------ */ + +export const SPAWN_DOT_COLORS: Record = { + offline: 'bg-zinc-600', + spawning: 'bg-amber-400 animate-pulse', + online: 'bg-emerald-400', + error: 'bg-red-400', +}; + +export const SPAWN_PRESENCE_LABELS: Record = { + offline: 'offline', + spawning: 'spawning', + online: 'online', + error: 'spawn failed', +}; + +/** + * Returns dot class for a member during provisioning, respecting spawn status. + * Falls back to the existing `getMemberDotClass` when no spawn status is available. + */ +export function getSpawnAwareDotClass( + member: ResolvedTeamMember, + spawnStatus: MemberSpawnStatus | undefined, + isTeamAlive?: boolean, + isTeamProvisioning?: boolean, + leadActivity?: LeadActivityState +): string { + if (spawnStatus && isTeamProvisioning) { + return SPAWN_DOT_COLORS[spawnStatus]; + } + return getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); +} + +/** + * Returns presence label for a member during provisioning, respecting spawn status. + */ +export function getSpawnAwarePresenceLabel( + member: ResolvedTeamMember, + spawnStatus: MemberSpawnStatus | undefined, + isTeamAlive?: boolean, + isTeamProvisioning?: boolean, + leadActivity?: LeadActivityState +): string { + if (spawnStatus && isTeamProvisioning) { + return SPAWN_PRESENCE_LABELS[spawnStatus]; + } + return getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); +} + +/** + * Card container CSS classes based on spawn status (opacity + animation). + * Used by MemberCard wrapper for fade-in transitions. + */ +export function getSpawnCardClass(spawnStatus: MemberSpawnStatus | undefined): string { + switch (spawnStatus) { + case 'offline': + return 'opacity-40'; + case 'spawning': + return 'opacity-70 animate-[member-spawn-pulse_2s_ease-in-out_infinite]'; + case 'online': + return 'animate-[member-fade-in_0.4s_ease-out]'; + case 'error': + return 'opacity-80'; + default: + return ''; + } +} + export const TASK_STATUS_STYLES: Record = { pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' }, in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' }, diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts index c1f160f1..d905a482 100644 --- a/src/shared/constants/crossTeam.ts +++ b/src/shared/constants/crossTeam.ts @@ -51,9 +51,6 @@ export function formatCrossTeamText( export const CROSS_TEAM_PREFIX_RE = /^\[Cross-team from (?[^\]|]+?) \| depth:(?\d+)(?: \| conversation:(?[^\]|]+))?(?: \| replyTo:(?[^\]|]+))?\]\n?/; -export const CROSS_TEAM_REPLY_PREFIX_RE = - /^\[Cross-team reply(?: \| conversation:(?[^\]|]+))?(?: \| replyTo:(?[^\]|]+))?\]\n?/; - /** Parse metadata from a cross-team prefix line. */ export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null { const match = text.match(CROSS_TEAM_PREFIX_RE); @@ -71,19 +68,9 @@ export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null }; } -export function parseCrossTeamReplyPrefix(text: string): CrossTeamPrefixMeta | null { - const match = text.match(CROSS_TEAM_REPLY_PREFIX_RE); - if (!match?.groups) return null; - - return { - conversationId: match.groups.conversationId?.trim() || undefined, - replyToConversationId: match.groups.replyToConversationId?.trim() || undefined, - }; -} - /** Strip the cross-team prefix from message text (for UI display). */ export function stripCrossTeamPrefix(text: string): string { - return text.replace(CROSS_TEAM_PREFIX_RE, '').replace(CROSS_TEAM_REPLY_PREFIX_RE, ''); + return text.replace(CROSS_TEAM_PREFIX_RE, ''); } // ── Source discriminators ──────────────────────────────────────────────────── diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 8b811615..b199e86f 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -50,6 +50,7 @@ import type { LeadContextUsage, MemberFullStats, MemberLogSummary, + MemberSpawnStatusEntry, ReplaceMembersRequest, SendMessageRequest, SendMessageResult, @@ -484,6 +485,7 @@ export interface TeamsAPI { killProcess: (teamName: string, pid: number) => Promise; getLeadActivity: (teamName: string) => Promise; getLeadContext: (teamName: string) => Promise; + getMemberSpawnStatuses: (teamName: string) => Promise>; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; @@ -542,9 +544,7 @@ export interface TeamsAPI { export interface CrossTeamAPI { send: (request: CrossTeamSendRequest) => Promise; - listTargets: ( - excludeTeam?: string - ) => Promise< + listTargets: (excludeTeam?: string) => Promise< { teamName: string; displayName: string; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index f4726123..6d485982 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -300,6 +300,15 @@ export interface SendMessageResult { export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; +/** + * Spawn lifecycle status for a team member during team launch/reconnect. + * - offline: not yet spawned (no Agent tool_use seen) + * - spawning: Agent tool_use sent, awaiting tool_result + * - online: tool_result received, agent is active + * - error: spawn failed (tool_result with error) + */ +export type MemberSpawnStatus = 'offline' | 'spawning' | 'online' | 'error'; + export type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; export interface KanbanTaskState { @@ -410,11 +419,28 @@ export interface LeadContextUsage { } export interface TeamChangeEvent { - type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'lead-message' | 'process'; + type: + | 'config' + | 'inbox' + | 'task' + | 'lead-activity' + | 'lead-context' + | 'lead-message' + | 'process' + | 'member-spawn'; teamName: string; detail?: string; } +/** Per-member spawn status entry, exposed to renderer via IPC. */ +export interface MemberSpawnStatusEntry { + status: MemberSpawnStatus; + /** Error message when status === 'error'. */ + error?: string; + /** ISO timestamp of the last status change. */ + updatedAt: string; +} + export interface TeamClaudeLogsQuery { /** Offset in lines from the newest log line (0 = newest). */ offset?: number; diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index b556f94a..52eb8dda 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -117,6 +117,7 @@ interface RunLike { cancelRequested: boolean; provisioningOutputParts: string[]; request: { members: { name: string; role?: string }[] }; + activeCrossTeamReplyHints?: Array<{ toTeam: string; conversationId: string }>; } /** @@ -143,6 +144,7 @@ function attachRun( cancelRequested: false, provisioningOutputParts: [], request: { members: [{ name: 'team-lead', role: 'Team Lead' }] }, + activeCrossTeamReplyHints: [], }; (service as unknown as { activeByTeam: Map }).activeByTeam.set(teamName, runId); @@ -431,6 +433,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { service.setTeamChangeEmitter(emitter); service.setCrossTeamSender(crossTeamSender); const run = attachRun(service, 'my-team', { provisioningComplete: true }); + run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-123' }]; callHandleStreamJsonMessage(service, run, { type: 'assistant', @@ -441,7 +444,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { input: { type: 'message', recipient: 'team-best.user', - content: '[Cross-team reply | conversation:conv-123] Привет!', + content: 'Привет!', summary: 'Ответ', }, }, diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts index 62f6886d..53e69e78 100644 --- a/test/main/services/team/TeamProvisioningServiceRoster.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts @@ -26,6 +26,26 @@ describe('TeamProvisioningService (launch roster discovery)', () => { expect(result.members.map((m: { name: string }) => m.name)).toEqual(['dev', 'dev-1']); }); + it('inbox fallback ignores cross-team pseudo and qualified external names', async () => { + const svc = new TeamProvisioningService( + {} as never, + { + listInboxNames: vi.fn(async () => [ + 'dev', + 'cross-team:team-alpha-super', + 'cross-team-team-alpha-super', + 'team-alpha-super.user', + ]), + } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', '{}'); + expect(result.source).toBe('inboxes'); + expect(result.members.map((m: { name: string }) => m.name)).toEqual(['dev']); + }); + it('inbox fallback keeps suffixed name if base is absent', async () => { const svc = new TeamProvisioningService( {} as never, diff --git a/test/shared/constants/crossTeam.test.ts b/test/shared/constants/crossTeam.test.ts index 9e51a548..63c0a32f 100644 --- a/test/shared/constants/crossTeam.test.ts +++ b/test/shared/constants/crossTeam.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { - parseCrossTeamPrefix, - parseCrossTeamReplyPrefix, - stripCrossTeamPrefix, -} from '@shared/constants/crossTeam'; +import { parseCrossTeamPrefix, stripCrossTeamPrefix } from '@shared/constants/crossTeam'; describe('crossTeam protocol helpers', () => { it('parses canonical cross-team prefix metadata', () => { @@ -20,21 +16,9 @@ describe('crossTeam protocol helpers', () => { }); }); - it('parses manual cross-team reply prefix metadata', () => { - const parsed = parseCrossTeamReplyPrefix( - '[Cross-team reply | conversation:conv-1 | replyTo:conv-1]\nHello' - ); - - expect(parsed).toEqual({ - conversationId: 'conv-1', - replyToConversationId: 'conv-1', - }); - }); - - it('strips both canonical and reply prefixes from UI text', () => { + it('strips canonical prefix from UI text', () => { expect(stripCrossTeamPrefix('[Cross-team from a.b | depth:0 | conversation:conv-1]\nHello')).toBe( 'Hello' ); - expect(stripCrossTeamPrefix('[Cross-team reply | conversation:conv-1]\nHello')).toBe('Hello'); }); });