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);
|
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||||
if (match && teamDataService) {
|
if (match && teamDataService) {
|
||||||
const inboxName = match[1];
|
const inboxName = match[1];
|
||||||
|
|
||||||
|
// Mark member as online when their first inbox message arrives (spawn tracking).
|
||||||
|
teamProvisioningService.markMemberOnlineFromInbox(teamName, inboxName);
|
||||||
|
|
||||||
void teamDataService
|
void teamDataService
|
||||||
.getLeadMemberName(teamName)
|
.getLeadMemberName(teamName)
|
||||||
.then((leadName) => {
|
.then((leadName) => {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LEAD_CONTEXT,
|
TEAM_LEAD_CONTEXT,
|
||||||
|
TEAM_MEMBER_SPAWN_STATUSES,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_PERMANENTLY_DELETE,
|
TEAM_PERMANENTLY_DELETE,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
|
|
@ -109,6 +110,7 @@ import type {
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
|
|
@ -253,6 +255,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||||
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
||||||
|
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
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_KILL_PROCESS);
|
||||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||||
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
||||||
|
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
||||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
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(
|
async function handleStopTeam(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import {
|
||||||
CROSS_TEAM_SOURCE,
|
CROSS_TEAM_SOURCE,
|
||||||
CROSS_TEAM_SENT_SOURCE,
|
CROSS_TEAM_SENT_SOURCE,
|
||||||
parseCrossTeamPrefix,
|
parseCrossTeamPrefix,
|
||||||
parseCrossTeamReplyPrefix,
|
|
||||||
stripCrossTeamPrefix,
|
stripCrossTeamPrefix,
|
||||||
} from '@shared/constants/crossTeam';
|
} from '@shared/constants/crossTeam';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
|
|
@ -59,6 +58,8 @@ import type {
|
||||||
CrossTeamSendResult,
|
CrossTeamSendResult,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
|
MemberSpawnStatus,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
|
|
@ -240,6 +241,11 @@ interface ProvisioningRun {
|
||||||
pendingPostCompactReminder: boolean;
|
pendingPostCompactReminder: boolean;
|
||||||
postCompactReminderInFlight: boolean;
|
postCompactReminderInFlight: boolean;
|
||||||
suppressPostCompactReminderOutput: 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';
|
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||||
|
|
@ -1269,6 +1275,15 @@ export class TeamProvisioningService {
|
||||||
return false;
|
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 {
|
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||||
try {
|
try {
|
||||||
createController({
|
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 CONTEXT_EMIT_THROTTLE_MS = 2000;
|
||||||
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
|
private static readonly LEAD_TEXT_EMIT_THROTTLE_MS = 2000;
|
||||||
|
|
||||||
|
|
@ -1961,6 +2015,7 @@ export class TeamProvisioningService {
|
||||||
pendingPostCompactReminder: false,
|
pendingPostCompactReminder: false,
|
||||||
postCompactReminderInFlight: false,
|
postCompactReminderInFlight: false,
|
||||||
suppressPostCompactReminderOutput: false,
|
suppressPostCompactReminderOutput: false,
|
||||||
|
memberSpawnStatuses: new Map(),
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -2284,6 +2339,7 @@ export class TeamProvisioningService {
|
||||||
pendingPostCompactReminder: false,
|
pendingPostCompactReminder: false,
|
||||||
postCompactReminderInFlight: false,
|
postCompactReminderInFlight: false,
|
||||||
suppressPostCompactReminderOutput: false,
|
suppressPostCompactReminderOutput: false,
|
||||||
|
memberSpawnStatuses: new Map(),
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
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
|
* 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.
|
* 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 {
|
private captureSendMessages(run: ProvisioningRun, content: Record<string, unknown>[]): void {
|
||||||
for (const part of content) {
|
for (const part of content) {
|
||||||
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
|
if (part.type !== 'tool_use' || part.name !== 'SendMessage') continue;
|
||||||
|
|
@ -3153,13 +3244,12 @@ export class TeamProvisioningService {
|
||||||
localRecipientNames
|
localRecipientNames
|
||||||
);
|
);
|
||||||
if (crossTeamRecipient && this.crossTeamSender) {
|
if (crossTeamRecipient && this.crossTeamSender) {
|
||||||
const explicitReplyMeta = parseCrossTeamReplyPrefix(cleanContent);
|
|
||||||
const inferredReplyMeta = this.resolveCrossTeamReplyMetadata(
|
const inferredReplyMeta = this.resolveCrossTeamReplyMetadata(
|
||||||
run.teamName,
|
run.teamName,
|
||||||
crossTeamRecipient.teamName
|
crossTeamRecipient.teamName
|
||||||
);
|
);
|
||||||
const crossTeamMeta = parseCrossTeamPrefix(cleanContent);
|
const crossTeamMeta = parseCrossTeamPrefix(cleanContent);
|
||||||
const replyMeta = explicitReplyMeta ?? inferredReplyMeta;
|
const replyMeta = inferredReplyMeta;
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
const messageId = `lead-sendmsg-${run.runId}-${Date.now()}`;
|
const messageId = `lead-sendmsg-${run.runId}-${Date.now()}`;
|
||||||
|
|
||||||
|
|
@ -3171,14 +3261,9 @@ export class TeamProvisioningService {
|
||||||
summary,
|
summary,
|
||||||
messageId,
|
messageId,
|
||||||
timestamp,
|
timestamp,
|
||||||
conversationId:
|
conversationId: crossTeamMeta?.conversationId ?? replyMeta?.conversationId,
|
||||||
explicitReplyMeta?.conversationId ??
|
|
||||||
crossTeamMeta?.conversationId ??
|
|
||||||
replyMeta?.conversationId,
|
|
||||||
replyToConversationId:
|
replyToConversationId:
|
||||||
explicitReplyMeta?.replyToConversationId ??
|
|
||||||
replyMeta?.replyToConversationId ??
|
replyMeta?.replyToConversationId ??
|
||||||
explicitReplyMeta?.conversationId ??
|
|
||||||
crossTeamMeta?.conversationId ??
|
crossTeamMeta?.conversationId ??
|
||||||
replyMeta?.conversationId,
|
replyMeta?.conversationId,
|
||||||
})
|
})
|
||||||
|
|
@ -3200,14 +3285,9 @@ export class TeamProvisioningService {
|
||||||
: summary || strippedCrossTeamContent,
|
: summary || strippedCrossTeamContent,
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
source: 'cross_team_sent',
|
source: 'cross_team_sent',
|
||||||
conversationId:
|
conversationId: crossTeamMeta?.conversationId ?? replyMeta?.conversationId,
|
||||||
explicitReplyMeta?.conversationId ??
|
|
||||||
crossTeamMeta?.conversationId ??
|
|
||||||
replyMeta?.conversationId,
|
|
||||||
replyToConversationId:
|
replyToConversationId:
|
||||||
explicitReplyMeta?.replyToConversationId ??
|
|
||||||
replyMeta?.replyToConversationId ??
|
replyMeta?.replyToConversationId ??
|
||||||
explicitReplyMeta?.conversationId ??
|
|
||||||
crossTeamMeta?.conversationId ??
|
crossTeamMeta?.conversationId ??
|
||||||
replyMeta?.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.
|
// Capture SendMessage tool_use blocks from assistant output.
|
||||||
// Works in both pre-ready and post-ready phases so outbound runtime messages
|
// 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.
|
// 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 inboxNameSetLower = new Set(allInboxNames.map((n) => n.toLowerCase()));
|
||||||
const inboxNames = allInboxNames
|
const inboxNames = allInboxNames
|
||||||
.filter((name) => name !== 'team-lead' && name !== 'user')
|
.filter((name) => name !== 'team-lead' && name !== 'user')
|
||||||
|
.filter((name) => !this.isCrossTeamPseudoRecipientName(name))
|
||||||
|
.filter((name) => !this.looksLikeQualifiedExternalRecipientName(name))
|
||||||
.filter((name) => {
|
.filter((name) => {
|
||||||
const match = /^(.+)-(\d+)$/.exec(name);
|
const match = /^(.+)-(\d+)$/.exec(name);
|
||||||
if (!match?.[1] || !match[2]) return true;
|
if (!match?.[1] || !match[2]) return true;
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,9 @@ export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
||||||
/** Get lead process context window usage */
|
/** Get lead process context window usage */
|
||||||
export const TEAM_LEAD_CONTEXT = 'team:leadContext';
|
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) */
|
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,7 @@ import {
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LEAD_CONTEXT,
|
TEAM_LEAD_CONTEXT,
|
||||||
|
TEAM_MEMBER_SPAWN_STATUSES,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_PERMANENTLY_DELETE,
|
TEAM_PERMANENTLY_DELETE,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
|
|
@ -214,6 +215,7 @@ import type {
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
RejectResult,
|
RejectResult,
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
|
|
@ -904,6 +906,12 @@ const electronAPI: ElectronAPI = {
|
||||||
getLeadContext: async (teamName: string) => {
|
getLeadContext: async (teamName: string) => {
|
||||||
return invokeIpcWithResult<LeadContextUsage | null>(TEAM_LEAD_CONTEXT, teamName);
|
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) => {
|
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -826,6 +826,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getLeadContext: async () => {
|
getLeadContext: async () => {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
getMemberSpawnStatuses: async () => {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||||
// Not available via HTTP client — no-op
|
// Not available via HTTP client — no-op
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -168,7 +168,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
const stats = sessionContextStats.get(targetAiGroupId);
|
const stats = sessionContextStats.get(targetAiGroupId);
|
||||||
const injections = stats?.accumulatedInjections ?? [];
|
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;
|
let totalTokens: number | undefined;
|
||||||
const targetItem = conversation.items.find(
|
const targetItem = conversation.items.find(
|
||||||
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
(item) => item.type === 'ai' && item.group.id === targetAiGroupId
|
||||||
|
|
@ -181,7 +182,6 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
||||||
const usage = msg.usage;
|
const usage = msg.usage;
|
||||||
totalTokens =
|
totalTokens =
|
||||||
(usage.input_tokens ?? 0) +
|
(usage.input_tokens ?? 0) +
|
||||||
(usage.output_tokens ?? 0) +
|
|
||||||
(usage.cache_read_input_tokens ?? 0) +
|
(usage.cache_read_input_tokens ?? 0) +
|
||||||
(usage.cache_creation_input_tokens ?? 0);
|
(usage.cache_creation_input_tokens ?? 0);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -100,10 +100,10 @@ export const SessionContextHeader = ({
|
||||||
~{formatTokens(totalTokens)}
|
~{formatTokens(totalTokens)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Total Session tokens (if provided) */}
|
{/* Total Input tokens (if provided) */}
|
||||||
{totalSessionTokens !== undefined && totalSessionTokens > 0 && (
|
{totalSessionTokens !== undefined && totalSessionTokens > 0 && (
|
||||||
<div>
|
<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 }}>
|
<span className="font-medium tabular-nums" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||||
{formatTokens(totalSessionTokens)}
|
{formatTokens(totalSessionTokens)}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -544,8 +544,8 @@ export const TokenUsageDisplay = ({
|
||||||
incl. CLAUDE.md ×{claudeMdStats.accumulatedCount}
|
incl. CLAUDE.md ×{claudeMdStats.accumulatedCount}
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{totalTokens > 0
|
{totalInputTokens > 0
|
||||||
? ((claudeMdStats.totalEstimatedTokens / totalTokens) * 100).toFixed(1)
|
? ((claudeMdStats.totalEstimatedTokens / totalInputTokens) * 100).toFixed(1)
|
||||||
: '0.0'}
|
: '0.0'}
|
||||||
%
|
%
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
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 { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover';
|
||||||
import { CliLogsRichView } from './CliLogsRichView';
|
import { CliLogsRichView } from './CliLogsRichView';
|
||||||
|
|
@ -31,6 +31,140 @@ function isRecent(updatedAt: string | undefined): boolean {
|
||||||
return Date.now() - t <= ONLINE_WINDOW_MS;
|
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 {
|
function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
|
||||||
// We want to feed CliLogsRichView the exact format it expects:
|
// We want to feed CliLogsRichView the exact format it expects:
|
||||||
// - marker lines: "[stdout]" / "[stderr]"
|
// - marker lines: "[stdout]" / "[stderr]"
|
||||||
|
|
@ -54,18 +188,26 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let content = line;
|
||||||
if (line.startsWith('[stdout] ')) {
|
if (line.startsWith('[stdout] ')) {
|
||||||
pushMarker('stdout');
|
pushMarker('stdout');
|
||||||
out.push(line.slice('[stdout] '.length));
|
content = line.slice('[stdout] '.length);
|
||||||
continue;
|
} else if (line.startsWith('[stderr] ')) {
|
||||||
}
|
|
||||||
if (line.startsWith('[stderr] ')) {
|
|
||||||
pushMarker('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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
out.push(line);
|
if (content !== line) {
|
||||||
|
// Already stripped prefix above
|
||||||
|
out.push(content);
|
||||||
|
} else {
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
|
|
@ -377,12 +519,10 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
const badge = data.total > 0 ? data.total : undefined;
|
const badge = data.total > 0 ? data.total : undefined;
|
||||||
const showMoreVisible = data.hasMore || loadingMore;
|
const showMoreVisible = data.hasMore || loadingMore;
|
||||||
|
|
||||||
const headerExtra = online ? (
|
const lastLogPreview = useMemo(
|
||||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
|
() => (data.lines.length > 0 ? extractLastLogPreview(data.lines) : null),
|
||||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
[data.lines]
|
||||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
);
|
||||||
</span>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const filteredText = useMemo(() => {
|
const filteredText = useMemo(() => {
|
||||||
if (data.lines.length === 0) return '';
|
if (data.lines.length === 0) return '';
|
||||||
|
|
@ -433,8 +573,21 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
title="Claude logs"
|
title="Claude logs"
|
||||||
icon={<Terminal size={14} />}
|
icon={<Terminal size={14} />}
|
||||||
badge={badge}
|
badge={badge}
|
||||||
headerExtra={headerExtra}
|
headerExtra={
|
||||||
defaultOpen
|
<>
|
||||||
|
{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.
|
// Prevent scroll anchoring from "pulling" the parent container when logs update.
|
||||||
contentClassName="pt-0 [overflow-anchor:none]"
|
contentClassName="pt-0 [overflow-anchor:none]"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -338,8 +338,7 @@ export const CliLogsRichView = ({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
// cliLogsTail has data but no parseable assistant messages — show raw text fallback
|
// No parseable assistant messages yet — show waiting state
|
||||||
const hasContent = cliLogsTail.trim().length > 0;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|
@ -360,15 +359,15 @@ export const CliLogsRichView = ({
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasContent ? (
|
<div className="flex items-center gap-2 p-3">
|
||||||
<pre className="p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
|
<span className="relative flex size-2">
|
||||||
{cliLogsTail}
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-[var(--color-text-muted)] opacity-40" />
|
||||||
</pre>
|
<span className="relative inline-flex size-2 rounded-full bg-[var(--color-text-muted)]" />
|
||||||
) : (
|
</span>
|
||||||
<p className="p-3 text-center text-[11px] italic text-[var(--color-text-muted)]">
|
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
Waiting for CLI output...
|
Waiting for response...
|
||||||
</p>
|
</span>
|
||||||
)}
|
</div>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,12 @@ import type { MessagesFilterState } from './messages/MessagesFilterPopover';
|
||||||
import type { ContextInjection } from '@renderer/types/contextInjection';
|
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||||
import type { Session } from '@renderer/types/data';
|
import type { Session } from '@renderer/types/data';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
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';
|
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||||
|
|
||||||
interface TeamDetailViewProps {
|
interface TeamDetailViewProps {
|
||||||
|
|
@ -234,6 +239,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
clearProvisioningError,
|
clearProvisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivityByTeam,
|
leadActivityByTeam,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
fetchMemberSpawnStatuses,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
|
|
@ -277,6 +284,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||||
),
|
),
|
||||||
leadActivityByTeam: s.leadActivityByTeam,
|
leadActivityByTeam: s.leadActivityByTeam,
|
||||||
|
memberSpawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
|
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
|
|
@ -311,6 +320,23 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
}
|
}
|
||||||
}, [isTeamProvisioning]);
|
}, [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 [kanbanSearch, setKanbanSearch] = useState('');
|
||||||
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
|
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
|
||||||
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
|
||||||
|
|
@ -1189,6 +1215,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
memberTaskCounts={memberTaskCounts}
|
memberTaskCounts={memberTaskCounts}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
|
memberSpawnStatuses={memberSpawnStatusMap}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
leadActivity={leadActivityByTeam[teamName]}
|
leadActivity={leadActivityByTeam[teamName]}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import {
|
||||||
CROSS_TEAM_SENT_SOURCE,
|
CROSS_TEAM_SENT_SOURCE,
|
||||||
CROSS_TEAM_SOURCE,
|
CROSS_TEAM_SOURCE,
|
||||||
parseCrossTeamPrefix,
|
parseCrossTeamPrefix,
|
||||||
parseCrossTeamReplyPrefix,
|
|
||||||
stripCrossTeamPrefix,
|
stripCrossTeamPrefix,
|
||||||
} from '@shared/constants/crossTeam';
|
} from '@shared/constants/crossTeam';
|
||||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||||
|
|
@ -325,10 +324,6 @@ export const ActivityItem = ({
|
||||||
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
|
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
|
||||||
|
|
||||||
const parsedCrossTeamPrefix = useMemo(() => parseCrossTeamPrefix(message.text), [message.text]);
|
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 qualifiedRecipient = useMemo(() => parseQualifiedRecipient(message.to), [message.to]);
|
||||||
const crossTeamSentTarget = useMemo(
|
const crossTeamSentTarget = useMemo(
|
||||||
() => getCrossTeamSentTarget(message.to, teamName, localMemberNames),
|
() => getCrossTeamSentTarget(message.to, teamName, localMemberNames),
|
||||||
|
|
@ -339,10 +334,7 @@ export const ActivityItem = ({
|
||||||
[message.to]
|
[message.to]
|
||||||
);
|
);
|
||||||
const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null;
|
const isCrossTeam = message.source === CROSS_TEAM_SOURCE || parsedCrossTeamPrefix !== null;
|
||||||
const isCrossTeamSent =
|
const isCrossTeamSent = message.source === CROSS_TEAM_SENT_SOURCE || crossTeamSentTarget !== null;
|
||||||
message.source === CROSS_TEAM_SENT_SOURCE ||
|
|
||||||
parsedCrossTeamReplyPrefix !== null ||
|
|
||||||
crossTeamSentTarget !== null;
|
|
||||||
const isCrossTeamAny = isCrossTeam || isCrossTeamSent;
|
const isCrossTeamAny = isCrossTeam || isCrossTeamSent;
|
||||||
const crossTeamOrigin = useMemo(() => {
|
const crossTeamOrigin = useMemo(() => {
|
||||||
if (!isCrossTeam) return null;
|
if (!isCrossTeam) return null;
|
||||||
|
|
|
||||||
|
|
@ -533,6 +533,64 @@ export const TaskDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 */}
|
{/* Description */}
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
title="Description"
|
title="Description"
|
||||||
|
|
@ -882,68 +940,6 @@ export const TaskDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 */}
|
{/* Review info */}
|
||||||
{kanbanTaskState ? (
|
{kanbanTaskState ? (
|
||||||
<div className="flex items-center gap-2">
|
<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 { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||||
import { useTheme } from '@renderer/hooks/useTheme';
|
import { useTheme } from '@renderer/hooks/useTheme';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
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 { 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 { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
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 {
|
interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
|
|
@ -23,6 +33,8 @@ interface MemberCardProps {
|
||||||
reviewTask?: TeamTaskWithKanban | null;
|
reviewTask?: TeamTaskWithKanban | null;
|
||||||
isAwaitingReply?: boolean;
|
isAwaitingReply?: boolean;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnError?: string;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSendMessage?: () => void;
|
onSendMessage?: () => void;
|
||||||
|
|
@ -40,6 +52,8 @@ export const MemberCard = ({
|
||||||
reviewTask,
|
reviewTask,
|
||||||
isAwaitingReply,
|
isAwaitingReply,
|
||||||
isRemoved,
|
isRemoved,
|
||||||
|
spawnStatus,
|
||||||
|
spawnError,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onClick,
|
onClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
|
@ -50,8 +64,21 @@ export const MemberCard = ({
|
||||||
// const leadContext = useStore((s) =>
|
// const leadContext = useStore((s) =>
|
||||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||||
// );
|
// );
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
const dotClass = getSpawnAwareDotClass(
|
||||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
member,
|
||||||
|
spawnStatus,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
|
leadActivity
|
||||||
|
);
|
||||||
|
const presenceLabel = getSpawnAwarePresenceLabel(
|
||||||
|
member,
|
||||||
|
spawnStatus,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
|
leadActivity
|
||||||
|
);
|
||||||
|
const spawnCardClass = isTeamProvisioning ? getSpawnCardClass(spawnStatus) : '';
|
||||||
const colors = getTeamColorSet(memberColor);
|
const colors = getTeamColorSet(memberColor);
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
const pending = taskCounts?.pending ?? 0;
|
const pending = taskCounts?.pending ?? 0;
|
||||||
|
|
@ -68,7 +95,9 @@ export const MemberCard = ({
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isRemoved ? 'rounded opacity-50' : 'rounded'}>
|
<div
|
||||||
|
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="group relative cursor-pointer rounded px-2 py-1.5"
|
className="group relative cursor-pointer rounded px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -136,11 +165,28 @@ export const MemberCard = ({
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
{presenceLabel === 'connecting' && !isRemoved ? (
|
{presenceLabel === 'connecting' || spawnStatus === 'spawning' ? (
|
||||||
<Loader2
|
!isRemoved ? (
|
||||||
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
<Loader2
|
||||||
aria-label="connecting"
|
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
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,24 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
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 {
|
interface MemberListProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
||||||
taskMap?: Map<string, TeamTaskWithKanban>;
|
taskMap?: Map<string, TeamTaskWithKanban>;
|
||||||
pendingRepliesByMember?: Record<string, number>;
|
pendingRepliesByMember?: Record<string, number>;
|
||||||
|
memberSpawnStatuses?: Map<string, MemberSpawnEntry>;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
|
|
@ -25,6 +36,7 @@ export const MemberList = ({
|
||||||
memberTaskCounts,
|
memberTaskCounts,
|
||||||
taskMap,
|
taskMap,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
|
memberSpawnStatuses,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
|
@ -65,6 +77,7 @@ export const MemberList = ({
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||||
|
const spawnEntry = memberSpawnStatuses?.get(member.name);
|
||||||
return (
|
return (
|
||||||
<MemberCard
|
<MemberCard
|
||||||
key={member.name}
|
key={member.name}
|
||||||
|
|
@ -78,6 +91,8 @@ export const MemberList = ({
|
||||||
reviewTask={isRemoved ? null : reviewTask}
|
reviewTask={isRemoved ? null : reviewTask}
|
||||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||||
isRemoved={isRemoved}
|
isRemoved={isRemoved}
|
||||||
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
|
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||||
onOpenTask={
|
onOpenTask={
|
||||||
!isRemoved && (currentTask ?? reviewTask)
|
!isRemoved && (currentTask ?? reviewTask)
|
||||||
? () => onOpenTask?.((currentTask ?? reviewTask)!)
|
? () => onOpenTask?.((currentTask ?? reviewTask)!)
|
||||||
|
|
|
||||||
|
|
@ -778,6 +778,28 @@ body {
|
||||||
filter: none;
|
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 {
|
:root.light .skeleton-card::after {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
|
|
|
||||||
|
|
@ -400,6 +400,12 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
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.
|
// 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.
|
// This keeps the refresh lightweight and prevents one noisy team from starving another.
|
||||||
if (event.type === 'lead-message') {
|
if (event.type === 'lead-message') {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
@ -286,6 +287,9 @@ export interface TeamSlice {
|
||||||
provisioningStartedAtFloorByTeam: Record<string, string>;
|
provisioningStartedAtFloorByTeam: Record<string, string>;
|
||||||
leadActivityByTeam: Record<string, LeadActivityState>;
|
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||||
leadContextByTeam: Record<string, LeadContextUsage>;
|
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;
|
activeProvisioningRunId: string | null;
|
||||||
provisioningError: string | null;
|
provisioningError: string | null;
|
||||||
clearProvisioningError: () => void;
|
clearProvisioningError: () => void;
|
||||||
|
|
@ -461,9 +465,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
provisioningStartedAtFloorByTeam: {},
|
provisioningStartedAtFloorByTeam: {},
|
||||||
leadActivityByTeam: {},
|
leadActivityByTeam: {},
|
||||||
leadContextByTeam: {},
|
leadContextByTeam: {},
|
||||||
|
memberSpawnStatusesByTeam: {},
|
||||||
activeProvisioningRunId: null,
|
activeProvisioningRunId: null,
|
||||||
provisioningError: null,
|
provisioningError: null,
|
||||||
clearProvisioningError: () => set({ 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,
|
kanbanFilterQuery: null,
|
||||||
globalTaskDetail: null,
|
globalTaskDetail: null,
|
||||||
pendingMemberProfile: null,
|
pendingMemberProfile: null,
|
||||||
|
|
@ -1277,6 +1296,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (progress.state === 'ready' || progress.state === 'disconnected') {
|
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();
|
void get().fetchTeams();
|
||||||
// If the user already opened the team tab, reload team data now that
|
// If the user already opened the team tab, reload team data now that
|
||||||
// config.json is guaranteed to exist.
|
// config.json is guaranteed to exist.
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,5 @@ export function formatPercentOfTotal(
|
||||||
): string | null {
|
): string | null {
|
||||||
const pct = computePercentOfTotal(visibleTokens, totalSessionTokens);
|
const pct = computePercentOfTotal(visibleTokens, totalSessionTokens);
|
||||||
if (pct === null) return null;
|
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 {
|
import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
|
MemberSpawnStatus,
|
||||||
MemberStatus,
|
MemberStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamReviewState,
|
TeamReviewState,
|
||||||
|
|
@ -60,6 +61,76 @@ export function getPresenceLabel(
|
||||||
return member.currentTaskId ? 'working' : 'idle';
|
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 }> = {
|
export const TASK_STATUS_STYLES: Record<TeamTaskStatus, { bg: string; text: string }> = {
|
||||||
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
|
pending: { bg: 'bg-zinc-500/15', text: 'text-zinc-400' },
|
||||||
in_progress: { bg: 'bg-blue-500/15', text: 'text-blue-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 =
|
export const CROSS_TEAM_PREFIX_RE =
|
||||||
/^\[Cross-team from (?<from>[^\]|]+?) \| depth:(?<depth>\d+)(?: \| conversation:(?<conversationId>[^\]|]+))?(?: \| replyTo:(?<replyToConversationId>[^\]|]+))?\]\n?/;
|
/^\[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. */
|
/** Parse metadata from a cross-team prefix line. */
|
||||||
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
|
export function parseCrossTeamPrefix(text: string): ParsedCrossTeamPrefix | null {
|
||||||
const match = text.match(CROSS_TEAM_PREFIX_RE);
|
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). */
|
/** Strip the cross-team prefix from message text (for UI display). */
|
||||||
export function stripCrossTeamPrefix(text: string): string {
|
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 ────────────────────────────────────────────────────
|
// ── Source discriminators ────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import type {
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
|
@ -484,6 +485,7 @@ export interface TeamsAPI {
|
||||||
killProcess: (teamName: string, pid: number) => Promise<void>;
|
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||||
getLeadContext: (teamName: string) => Promise<LeadContextUsage | null>;
|
getLeadContext: (teamName: string) => Promise<LeadContextUsage | null>;
|
||||||
|
getMemberSpawnStatuses: (teamName: string) => Promise<Record<string, MemberSpawnStatusEntry>>;
|
||||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||||
|
|
@ -542,9 +544,7 @@ export interface TeamsAPI {
|
||||||
|
|
||||||
export interface CrossTeamAPI {
|
export interface CrossTeamAPI {
|
||||||
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
|
send: (request: CrossTeamSendRequest) => Promise<CrossTeamSendResult>;
|
||||||
listTargets: (
|
listTargets: (excludeTeam?: string) => Promise<
|
||||||
excludeTeam?: string
|
|
||||||
) => Promise<
|
|
||||||
{
|
{
|
||||||
teamName: string;
|
teamName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,15 @@ export interface SendMessageResult {
|
||||||
|
|
||||||
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
|
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 type KanbanColumnId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
||||||
|
|
||||||
export interface KanbanTaskState {
|
export interface KanbanTaskState {
|
||||||
|
|
@ -410,11 +419,28 @@ export interface LeadContextUsage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamChangeEvent {
|
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;
|
teamName: string;
|
||||||
detail?: 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 {
|
export interface TeamClaudeLogsQuery {
|
||||||
/** Offset in lines from the newest log line (0 = newest). */
|
/** Offset in lines from the newest log line (0 = newest). */
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ interface RunLike {
|
||||||
cancelRequested: boolean;
|
cancelRequested: boolean;
|
||||||
provisioningOutputParts: string[];
|
provisioningOutputParts: string[];
|
||||||
request: { members: { name: string; role?: string }[] };
|
request: { members: { name: string; role?: string }[] };
|
||||||
|
activeCrossTeamReplyHints?: Array<{ toTeam: string; conversationId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -143,6 +144,7 @@ function attachRun(
|
||||||
cancelRequested: false,
|
cancelRequested: false,
|
||||||
provisioningOutputParts: [],
|
provisioningOutputParts: [],
|
||||||
request: { members: [{ name: 'team-lead', role: 'Team Lead' }] },
|
request: { members: [{ name: 'team-lead', role: 'Team Lead' }] },
|
||||||
|
activeCrossTeamReplyHints: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
|
(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.setTeamChangeEmitter(emitter);
|
||||||
service.setCrossTeamSender(crossTeamSender);
|
service.setCrossTeamSender(crossTeamSender);
|
||||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||||
|
run.activeCrossTeamReplyHints = [{ toTeam: 'team-best', conversationId: 'conv-123' }];
|
||||||
|
|
||||||
callHandleStreamJsonMessage(service, run, {
|
callHandleStreamJsonMessage(service, run, {
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
|
|
@ -441,7 +444,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
input: {
|
input: {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
recipient: 'team-best.user',
|
recipient: 'team-best.user',
|
||||||
content: '[Cross-team reply | conversation:conv-123] Привет!',
|
content: 'Привет!',
|
||||||
summary: 'Ответ',
|
summary: 'Ответ',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,26 @@ describe('TeamProvisioningService (launch roster discovery)', () => {
|
||||||
expect(result.members.map((m: { name: string }) => m.name)).toEqual(['dev', 'dev-1']);
|
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 () => {
|
it('inbox fallback keeps suffixed name if base is absent', async () => {
|
||||||
const svc = new TeamProvisioningService(
|
const svc = new TeamProvisioningService(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { parseCrossTeamPrefix, stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
||||||
parseCrossTeamPrefix,
|
|
||||||
parseCrossTeamReplyPrefix,
|
|
||||||
stripCrossTeamPrefix,
|
|
||||||
} from '@shared/constants/crossTeam';
|
|
||||||
|
|
||||||
describe('crossTeam protocol helpers', () => {
|
describe('crossTeam protocol helpers', () => {
|
||||||
it('parses canonical cross-team prefix metadata', () => {
|
it('parses canonical cross-team prefix metadata', () => {
|
||||||
|
|
@ -20,21 +16,9 @@ describe('crossTeam protocol helpers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses manual cross-team reply prefix metadata', () => {
|
it('strips canonical prefix from UI text', () => {
|
||||||
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', () => {
|
|
||||||
expect(stripCrossTeamPrefix('[Cross-team from a.b | depth:0 | conversation:conv-1]\nHello')).toBe(
|
expect(stripCrossTeamPrefix('[Cross-team from a.b | depth:0 | conversation:conv-1]\nHello')).toBe(
|
||||||
'Hello'
|
'Hello'
|
||||||
);
|
);
|
||||||
expect(stripCrossTeamPrefix('[Cross-team reply | conversation:conv-1]\nHello')).toBe('Hello');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue