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:
parent
d5c02fc61d
commit
c6e7757f42
27 changed files with 662 additions and 170 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)!)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: 'Ответ',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue