feat: implement member spawn status tracking and online marking

- Added functionality to track member spawn statuses during team provisioning, including marking members as online when their first inbox message arrives.
- Introduced new IPC channels and handlers for fetching member spawn statuses.
- Enhanced TeamProvisioningService to manage spawn status updates and emit events for changes.
- Updated UI components to reflect member spawn statuses, improving visibility of member activity during provisioning.
- Added CSS animations for member spawn effects to enhance user experience.
This commit is contained in:
iliya 2026-03-10 13:16:38 +02:00
parent d5c02fc61d
commit c6e7757f42
27 changed files with 662 additions and 170 deletions

View file

@ -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) => {

View file

@ -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<IpcResult<Record<string, MemberSpawnStatusEntry>>> {
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

View file

@ -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<string, MemberSpawnStatusEntry> {
const runId = this.activeByTeam.get(teamName);
if (!runId) return {};
const run = this.runs.get(runId);
if (!run) return {};
const result: Record<string, MemberSpawnStatusEntry> = {};
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<string, unknown>[]): 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<string, unknown>;
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<string, unknown>[]): 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;

View file

@ -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';

View file

@ -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<LeadContextUsage | null>(TEAM_LEAD_CONTEXT, teamName);
},
getMemberSpawnStatuses: async (teamName: string) => {
return invokeIpcWithResult<Record<string, MemberSpawnStatusEntry>>(
TEAM_MEMBER_SPAWN_STATUSES,
teamName
);
},
softDeleteTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
},

View file

@ -826,6 +826,9 @@ export class HttpAPIClient implements ElectronAPI {
getLeadContext: async () => {
return null;
},
getMemberSpawnStatuses: async () => {
return {};
},
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op
},

View file

@ -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;

View file

@ -100,10 +100,10 @@ export const SessionContextHeader = ({
~{formatTokens(totalTokens)}
</span>
</div>
{/* Total Session tokens (if provided) */}
{/* Total Input tokens (if provided) */}
{totalSessionTokens !== undefined && totalSessionTokens > 0 && (
<div>
<span style={{ color: COLOR_TEXT_MUTED }}>Total: </span>
<span style={{ color: COLOR_TEXT_MUTED }}>Input: </span>
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
{formatTokens(totalSessionTokens)}
</span>

View file

@ -544,8 +544,8 @@ export const TokenUsageDisplay = ({
incl. CLAUDE.md ×{claudeMdStats.accumulatedCount}
</span>
<span className="tabular-nums">
{totalTokens > 0
? ((claudeMdStats.totalEstimatedTokens / totalTokens) * 100).toFixed(1)
{totalInputTokens > 0
? ((claudeMdStats.totalEstimatedTokens / totalInputTokens) * 100).toFixed(1)
: '0.0'}
%
</span>

View file

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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: <MessageSquare size={12} className="shrink-0" />,
thinking: <Brain size={12} className="shrink-0" />,
tool: <Wrench size={12} className="shrink-0" />,
} 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 (
<span className="flex min-w-0 items-center gap-1.5 opacity-70">
<span className="shrink-0" style={{ color: 'var(--tool-item-muted)' }}>
{PREVIEW_ICONS[preview.type]}
</span>
<span className="shrink-0 text-[11px] font-medium" style={{ color: 'var(--tool-item-name)' }}>
{preview.label}
</span>
{summaryText && (
<>
<span className="text-[11px]" style={{ color: 'var(--tool-item-muted)' }}>
-
</span>
<span
className="min-w-0 truncate text-[11px]"
style={{ color: 'var(--tool-item-summary)' }}
>
{summaryText}
</span>
</>
)}
</span>
);
};
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 ? (
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : 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={<Terminal size={14} />}
badge={badge}
headerExtra={headerExtra}
defaultOpen
headerExtra={
<>
{online ? (
<span
className="pointer-events-none relative inline-flex size-2 shrink-0"
title="Updating"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null}
{lastLogPreview ? <LogPreviewInline preview={lastLogPreview} /> : null}
</>
}
defaultOpen={false}
// Prevent scroll anchoring from "pulling" the parent container when logs update.
contentClassName="pt-0 [overflow-anchor:none]"
>

View file

@ -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 (
<div
ref={(el) => {
@ -360,15 +359,15 @@ export const CliLogsRichView = ({
});
}}
>
{hasContent ? (
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{cliLogsTail}
</pre>
) : (
<p className="p-3 text-center text-[11px] italic text-[var(--color-text-muted)]">
Waiting for CLI output...
</p>
)}
<div className="flex items-center gap-2 p-3">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-[var(--color-text-muted)] opacity-40" />
<span className="relative inline-flex size-2 rounded-full bg-[var(--color-text-muted)]" />
</span>
<span className="text-[11px] text-[var(--color-text-muted)]">
Waiting for response...
</span>
</div>
{footer}
</div>
);

View file

@ -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<string, MemberSpawnStatusEntry> → Map<string, MemberSpawnEntry>
const memberSpawnStatusMap = useMemo(() => {
if (!memberSpawnStatuses) return undefined;
const map = new Map<string, { status: MemberSpawnStatusEntry['status']; error?: string }>();
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<MessagesFilterState>({
@ -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]}

View file

@ -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;

View file

@ -533,6 +533,64 @@ export const TaskDetailDialog = ({
</div>
) : null}
{/* Related tasks (explicit) */}
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
<Link2 size={12} />
Related tasks
</div>
{relatedIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
return (
<button
key={`related:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
{relatedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
return (
<button
key={`related-by:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask ? formatTaskDisplayLabel(depTask) : `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
</div>
) : null}
{/* Description */}
<CollapsibleTeamSection
title="Description"
@ -882,68 +940,6 @@ export const TaskDetailDialog = ({
</div>
) : null}
{/* Related tasks (explicit) */}
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
<Link2 size={12} />
Related tasks
</div>
{relatedIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
{relatedIds.map((id) => {
const depTask = taskMap.get(id);
return (
<button
key={`related:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
{relatedByIds.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
{relatedByIds.map((id) => {
const depTask = taskMap.get(id);
return (
<button
key={`related-by:${currentTask.id}:${id}`}
type="button"
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
title={
depTask
? `${formatTaskDisplayLabel(depTask)}: ${depTask.subject}`
: `#${deriveTaskDisplayId(id)}`
}
onClick={() => handleDependencyClick(id)}
>
{depTask
? formatTaskDisplayLabel(depTask)
: `#${deriveTaskDisplayId(id)}`}
</button>
);
})}
</div>
) : null}
</div>
) : null}
{/* Review info */}
{kanbanTaskState ? (
<div className="flex items-center gap-2">

View file

@ -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 (
<div className={isRemoved ? 'rounded opacity-50' : 'rounded'}>
<div
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
>
<div
className="group relative cursor-pointer rounded px-2 py-1.5"
style={{
@ -136,11 +165,28 @@ export const MemberCard = ({
</span>
) : null;
})()}
{presenceLabel === 'connecting' && !isRemoved ? (
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label="connecting"
/>
{presenceLabel === 'connecting' || spawnStatus === 'spawning' ? (
!isRemoved ? (
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label="connecting"
/>
) : null
) : spawnStatus === 'error' ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1">
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
<Badge
variant="secondary"
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
>
{presenceLabel}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
</Tooltip>
) : (
<Badge
variant="secondary"

View file

@ -4,13 +4,24 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { LeadActivityState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
import type {
LeadActivityState,
MemberSpawnStatus,
ResolvedTeamMember,
TeamTaskWithKanban,
} from '@shared/types';
export interface MemberSpawnEntry {
status: MemberSpawnStatus;
error?: string;
}
interface MemberListProps {
members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
taskMap?: Map<string, TeamTaskWithKanban>;
pendingRepliesByMember?: Record<string, number>;
memberSpawnStatuses?: Map<string, MemberSpawnEntry>;
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 (
<MemberCard
key={member.name}
@ -78,6 +91,8 @@ export const MemberList = ({
reviewTask={isRemoved ? null : reviewTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : spawnEntry?.error}
onOpenTask={
!isRemoved && (currentTask ?? reviewTask)
? () => onOpenTask?.((currentTask ?? reviewTask)!)

View file

@ -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,

View file

@ -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') {

View file

@ -74,6 +74,7 @@ import type {
KanbanColumnId,
LeadActivityState,
LeadContextUsage,
MemberSpawnStatusEntry,
SendMessageRequest,
SendMessageResult,
TaskComment,
@ -286,6 +287,9 @@ export interface TeamSlice {
provisioningStartedAtFloorByTeam: Record<string, string>;
leadActivityByTeam: Record<string, LeadActivityState>;
leadContextByTeam: Record<string, LeadContextUsage>;
/** Per-team per-member spawn statuses during team provisioning/launch. */
memberSpawnStatusesByTeam: Record<string, Record<string, MemberSpawnStatusEntry>>;
fetchMemberSpawnStatuses: (teamName: string) => Promise<void>;
activeProvisioningRunId: string | null;
provisioningError: string | null;
clearProvisioningError: () => void;
@ -461,9 +465,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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.

View file

@ -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`;
}

View file

@ -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<MemberSpawnStatus, string> = {
offline: 'bg-zinc-600',
spawning: 'bg-amber-400 animate-pulse',
online: 'bg-emerald-400',
error: 'bg-red-400',
};
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
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<TeamTaskStatus, { bg: string; text: string }> = {
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-400' },

View file

@ -51,9 +51,6 @@ export function formatCrossTeamText(
export const CROSS_TEAM_PREFIX_RE =
/^\[Cross-team from (?<from>[^\]|]+?) \| depth:(?<depth>\d+)(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
export const CROSS_TEAM_REPLY_PREFIX_RE =
/^\[Cross-team reply(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\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 ────────────────────────────────────────────────────

View file

@ -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<void>;
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
getLeadContext: (teamName: string) => Promise<LeadContextUsage | null>;
getMemberSpawnStatuses: (teamName: string) => Promise<Record<string, MemberSpawnStatusEntry>>;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>;
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
@ -542,9 +544,7 @@ export interface TeamsAPI {
export interface CrossTeamAPI {
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
listTargets: (
excludeTeam?: string
) => Promise<
listTargets: (excludeTeam?: string) => Promise<
{
teamName: string;
displayName: string;

View file

@ -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;

View file

@ -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<string, string> }).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: 'Ответ',
},
},

View file

@ -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,

View file

@ -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');
});
});