feat: implement team stop functionality and enhance message relaying
- Added `stopTeam` method in `TeamProvisioningService` to terminate running processes for teams, improving resource management. - Introduced IPC channel `TEAM_STOP` to handle stop requests from the renderer process. - Enhanced message relaying for team leads by implementing `relayLeadInboxMessages`, ensuring timely communication of unread messages. - Updated UI components to support stopping teams and display pending replies, enhancing user experience during team management.
This commit is contained in:
parent
d60ac09925
commit
54bd589209
19 changed files with 802 additions and 94 deletions
|
|
@ -155,6 +155,19 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
mainWindow.webContents.send(TEAM_CHANGE, event);
|
mainWindow.webContents.send(TEAM_CHANGE, event);
|
||||||
}
|
}
|
||||||
httpServer?.broadcast('team-change', event);
|
httpServer?.broadcast('team-change', event);
|
||||||
|
|
||||||
|
// Auto-relay direct messages to live team lead process (no UI dependency).
|
||||||
|
try {
|
||||||
|
if (!event || typeof event !== 'object') return;
|
||||||
|
const row = event as { type?: unknown; teamName?: unknown };
|
||||||
|
if (row.type !== 'inbox') return;
|
||||||
|
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||||
|
const teamName = row.teamName.trim();
|
||||||
|
if (!teamProvisioningService.isTeamAlive(teamName)) return;
|
||||||
|
void teamProvisioningService.relayLeadInboxMessages(teamName).catch(() => undefined);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
};
|
};
|
||||||
context.fileWatcher.on('team-change', teamChangeHandler);
|
context.fileWatcher.on('team-change', teamChangeHandler);
|
||||||
teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler);
|
teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
|
TEAM_STOP,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
|
|
@ -100,6 +101,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
|
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
|
||||||
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
|
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
|
||||||
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
|
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
|
||||||
|
ipcMain.handle(TEAM_STOP, handleStopTeam);
|
||||||
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
|
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
|
||||||
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
||||||
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
||||||
|
|
@ -128,6 +130,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_PROCESS_SEND);
|
ipcMain.removeHandler(TEAM_PROCESS_SEND);
|
||||||
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
|
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
|
||||||
ipcMain.removeHandler(TEAM_ALIVE_LIST);
|
ipcMain.removeHandler(TEAM_ALIVE_LIST);
|
||||||
|
ipcMain.removeHandler(TEAM_STOP);
|
||||||
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
|
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
|
||||||
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
||||||
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
||||||
|
|
@ -181,6 +184,13 @@ async function handleGetData(
|
||||||
return wrapTeamHandler('getData', async () => {
|
return wrapTeamHandler('getData', async () => {
|
||||||
const data = await getTeamDataService().getTeamData(validated.value!);
|
const data = await getTeamDataService().getTeamData(validated.value!);
|
||||||
const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!);
|
const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!);
|
||||||
|
if (isAlive) {
|
||||||
|
try {
|
||||||
|
await getTeamProvisioningService().relayLeadInboxMessages(validated.value!);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: never fail getData due to relay issues
|
||||||
|
}
|
||||||
|
}
|
||||||
return { ...data, isAlive };
|
return { ...data, isAlive };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -821,6 +831,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
|
||||||
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
|
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStopTeam(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const validated = validateTeamName(teamName);
|
||||||
|
if (!validated.valid) {
|
||||||
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('stop', async () => {
|
||||||
|
getTeamProvisioningService().stopTeam(validated.value!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStartTask(
|
async function handleStartTask(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,9 @@ let cachedProbeResult: CachedProbeResult | null = null;
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
private readonly runs = new Map<string, ProvisioningRun>();
|
private readonly runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly activeByTeam = new Map<string, string>();
|
private readonly activeByTeam = new Map<string, string>();
|
||||||
|
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
|
||||||
|
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
|
||||||
|
private readonly relayedLeadInboxFallbackKeys = new Map<string, Set<string>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
|
|
@ -1095,6 +1098,119 @@ export class TeamProvisioningService {
|
||||||
run.child.stdin.write(payload + '\n');
|
run.child.stdin.write(payload + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relay unread inbox messages addressed to the team lead into the live lead process.
|
||||||
|
*
|
||||||
|
* Why: teammates (and the UI) write to `inboxes/<lead>.json`, but the live lead CLI
|
||||||
|
* process consumes new turns via stream-json stdin. Without relaying, the lead
|
||||||
|
* appears unresponsive to direct messages.
|
||||||
|
*
|
||||||
|
* Returns the number of messages relayed.
|
||||||
|
*/
|
||||||
|
async relayLeadInboxMessages(teamName: string): Promise<number> {
|
||||||
|
const existing = this.leadInboxRelayInFlight.get(teamName);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const work = (async (): Promise<number> => {
|
||||||
|
const runId = this.activeByTeam.get(teamName);
|
||||||
|
if (!runId) return 0;
|
||||||
|
const run = this.runs.get(runId);
|
||||||
|
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
|
||||||
|
if (!run.provisioningComplete) return 0;
|
||||||
|
|
||||||
|
const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set<string>();
|
||||||
|
const relayedFallback = this.relayedLeadInboxFallbackKeys.get(teamName) ?? new Set<string>();
|
||||||
|
|
||||||
|
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null;
|
||||||
|
try {
|
||||||
|
config = await this.configReader.getConfig(teamName);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (!config) return 0;
|
||||||
|
|
||||||
|
const leadName =
|
||||||
|
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||||
|
|
||||||
|
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
||||||
|
try {
|
||||||
|
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unread = leadInboxMessages
|
||||||
|
.filter((m) => {
|
||||||
|
if (m.read) return false;
|
||||||
|
if (typeof m.text !== 'string' || m.text.trim().length === 0) return false;
|
||||||
|
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
|
||||||
|
return !relayedIds.has(m.messageId);
|
||||||
|
}
|
||||||
|
return !relayedFallback.has(`${m.timestamp}\0${m.from}\0${m.text}`);
|
||||||
|
})
|
||||||
|
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||||
|
|
||||||
|
if (unread.length === 0) return 0;
|
||||||
|
|
||||||
|
const MAX_RELAY = 10;
|
||||||
|
const batch = unread.slice(0, MAX_RELAY);
|
||||||
|
|
||||||
|
const message = [
|
||||||
|
`You have new inbox messages addressed to you (team lead "${leadName}").`,
|
||||||
|
`Process them in order (oldest first).`,
|
||||||
|
`If action is required, delegate via task creation (teamctl.js --notify) or SendMessage, and keep responses minimal.`,
|
||||||
|
``,
|
||||||
|
`Messages:`,
|
||||||
|
...batch.flatMap((m, idx) => {
|
||||||
|
const summaryLine = m.summary?.trim() ? `Summary: ${m.summary.trim()}` : null;
|
||||||
|
return [
|
||||||
|
`${idx + 1}) From: ${m.from || 'unknown'}`,
|
||||||
|
` Timestamp: ${m.timestamp}`,
|
||||||
|
...(summaryLine ? [` ${summaryLine}`] : []),
|
||||||
|
` Text:`,
|
||||||
|
...m.text.split('\n').map((line) => ` ${line}`),
|
||||||
|
``,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sendMessageToTeam(teamName, message);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of batch) {
|
||||||
|
if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) {
|
||||||
|
relayedIds.add(m.messageId);
|
||||||
|
} else {
|
||||||
|
relayedFallback.add(`${m.timestamp}\0${m.from}\0${m.text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
|
||||||
|
this.relayedLeadInboxFallbackKeys.set(teamName, this.trimRelayedSet(relayedFallback));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.markInboxMessagesRead(teamName, leadName, batch);
|
||||||
|
} catch {
|
||||||
|
// Best-effort: relay succeeded; marking read failed.
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch.length;
|
||||||
|
})();
|
||||||
|
|
||||||
|
this.leadInboxRelayInFlight.set(teamName, work);
|
||||||
|
try {
|
||||||
|
return await work;
|
||||||
|
} finally {
|
||||||
|
if (this.leadInboxRelayInFlight.get(teamName) === work) {
|
||||||
|
this.leadInboxRelayInFlight.delete(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a team has a live process.
|
* Check if a team has a live process.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1112,6 +1228,96 @@ export class TeamProvisioningService {
|
||||||
return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name));
|
return Array.from(this.activeByTeam.keys()).filter((name) => this.isTeamAlive(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async markInboxMessagesRead(
|
||||||
|
teamName: string,
|
||||||
|
member: string,
|
||||||
|
messages: { messageId?: string; timestamp: string; from: string; text: string }[]
|
||||||
|
): Promise<void> {
|
||||||
|
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${member}.json`);
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.promises.readFile(inboxPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) return;
|
||||||
|
|
||||||
|
const ids = new Set(messages.map((m) => m.messageId).filter((id): id is string => !!id));
|
||||||
|
const fallbackKeys = new Set(
|
||||||
|
messages.filter((m) => !m.messageId).map((m) => `${m.timestamp}\0${m.from}\0${m.text}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const row = item as Record<string, unknown>;
|
||||||
|
const msgId = typeof row.messageId === 'string' ? row.messageId : null;
|
||||||
|
const timestamp = typeof row.timestamp === 'string' ? row.timestamp : null;
|
||||||
|
const from = typeof row.from === 'string' ? row.from : null;
|
||||||
|
const text = typeof row.text === 'string' ? row.text : null;
|
||||||
|
|
||||||
|
const matchesId = msgId ? ids.has(msgId) : false;
|
||||||
|
const matchesFallback =
|
||||||
|
!msgId && timestamp && from && text
|
||||||
|
? fallbackKeys.has(`${timestamp}\0${from}\0${text}`)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (!matchesId && !matchesFallback) continue;
|
||||||
|
|
||||||
|
if (row.read !== true) {
|
||||||
|
row.read = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) return;
|
||||||
|
await atomicWriteAsync(inboxPath, JSON.stringify(parsed, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimRelayedSet(set: Set<string>): Set<string> {
|
||||||
|
const MAX_IDS = 2000;
|
||||||
|
if (set.size <= MAX_IDS) return set;
|
||||||
|
const next = new Set<string>();
|
||||||
|
const tail = Array.from(set).slice(-MAX_IDS);
|
||||||
|
for (const id of tail) next.add(id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the running process for a team. No-op if team is not running.
|
||||||
|
*/
|
||||||
|
stopTeam(teamName: string): void {
|
||||||
|
const runId = this.activeByTeam.get(teamName);
|
||||||
|
if (!runId) {
|
||||||
|
throw new Error(`No active process for team "${teamName}"`);
|
||||||
|
}
|
||||||
|
const run = this.runs.get(runId);
|
||||||
|
if (!run) {
|
||||||
|
this.activeByTeam.delete(teamName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (run.processKilled || run.cancelRequested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run.processKilled = true;
|
||||||
|
run.cancelRequested = true;
|
||||||
|
run.child?.stdin?.end();
|
||||||
|
run.child?.kill();
|
||||||
|
this.cleanupRun(run);
|
||||||
|
logger.info(`[${teamName}] Process stopped by user`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a parsed stream-json message from stdout.
|
* Process a parsed stream-json message from stdout.
|
||||||
* Extracts assistant text for progress reporting and detects turn completion.
|
* Extracts assistant text for progress reporting and detects turn completion.
|
||||||
|
|
@ -1186,6 +1392,9 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`);
|
||||||
|
|
||||||
|
// Pick up any direct messages that arrived before/while reconnecting.
|
||||||
|
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1224,6 +1433,9 @@ export class TeamProvisioningService {
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
// NOTE: do NOT remove from activeByTeam — process stays alive
|
// NOTE: do NOT remove from activeByTeam — process stays alive
|
||||||
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
logger.info(`[${run.teamName}] Provisioning complete. Process alive for subsequent tasks.`);
|
||||||
|
|
||||||
|
// Pick up any direct messages that arrived during provisioning.
|
||||||
|
void this.relayLeadInboxMessages(run.teamName).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1236,6 +1448,9 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
this.stopFilesystemMonitor(run);
|
this.stopFilesystemMonitor(run);
|
||||||
this.activeByTeam.delete(run.teamName);
|
this.activeByTeam.delete(run.teamName);
|
||||||
|
this.leadInboxRelayInFlight.delete(run.teamName);
|
||||||
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
|
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,7 @@ export const TEAM_DELETE_TEAM = 'team:deleteTeam';
|
||||||
|
|
||||||
/** Get list of teams with live CLI processes */
|
/** Get list of teams with live CLI processes */
|
||||||
export const TEAM_ALIVE_LIST = 'team:aliveList';
|
export const TEAM_ALIVE_LIST = 'team:aliveList';
|
||||||
|
export const TEAM_STOP = 'team:stop';
|
||||||
|
|
||||||
/** Create team config without provisioning CLI */
|
/** Create team config without provisioning CLI */
|
||||||
export const TEAM_CREATE_CONFIG = 'team:createConfig';
|
export const TEAM_CREATE_CONFIG = 'team:createConfig';
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
|
TEAM_STOP,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
|
|
@ -567,6 +568,9 @@ const electronAPI: ElectronAPI = {
|
||||||
aliveList: async () => {
|
aliveList: async () => {
|
||||||
return invokeIpcWithResult<string[]>(TEAM_ALIVE_LIST);
|
return invokeIpcWithResult<string[]>(TEAM_ALIVE_LIST);
|
||||||
},
|
},
|
||||||
|
stop: async (teamName: string) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_STOP, teamName);
|
||||||
|
},
|
||||||
createConfig: async (request: TeamCreateConfigRequest) => {
|
createConfig: async (request: TeamCreateConfigRequest) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_CREATE_CONFIG, request);
|
return invokeIpcWithResult<void>(TEAM_CREATE_CONFIG, request);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
aliveList: async (): Promise<string[]> => {
|
aliveList: async (): Promise<string[]> => {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
stop: async (): Promise<void> => {
|
||||||
|
throw new Error('Team stop is not available in browser mode');
|
||||||
|
},
|
||||||
createConfig: async (): Promise<void> => {
|
createConfig: async (): Promise<void> => {
|
||||||
throw new Error('Team config creation is not available in browser mode');
|
throw new Error('Team config creation is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
||||||
import { ActivityTimeline } from './activity/ActivityTimeline';
|
import { ActivityTimeline } from './activity/ActivityTimeline';
|
||||||
|
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
|
||||||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
||||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||||
|
|
@ -67,6 +68,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
|
||||||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||||||
|
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
|
||||||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||||
open: false,
|
open: false,
|
||||||
defaultSubject: '',
|
defaultSubject: '',
|
||||||
|
|
@ -309,6 +311,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
|
|
||||||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data || Object.keys(pendingRepliesByMember).length === 0) return;
|
||||||
|
const next = { ...pendingRepliesByMember };
|
||||||
|
let changed = false;
|
||||||
|
for (const [memberName, sentAtMs] of Object.entries(pendingRepliesByMember)) {
|
||||||
|
const hasReply = data.messages.some((m) => {
|
||||||
|
if (m.from !== memberName) return false;
|
||||||
|
const ts = Date.parse(m.timestamp);
|
||||||
|
return Number.isFinite(ts) && ts > sentAtMs;
|
||||||
|
});
|
||||||
|
if (hasReply) {
|
||||||
|
delete next[memberName];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) setPendingRepliesByMember(next);
|
||||||
|
}, [data, pendingRepliesByMember]);
|
||||||
|
|
||||||
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
|
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
|
||||||
setCreateTaskDialog({
|
setCreateTaskDialog({
|
||||||
open: true,
|
open: true,
|
||||||
|
|
@ -504,6 +524,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
<MemberList
|
<MemberList
|
||||||
members={data.members}
|
members={data.members}
|
||||||
memberTaskCounts={memberTaskCounts}
|
memberTaskCounts={memberTaskCounts}
|
||||||
|
taskMap={taskMap}
|
||||||
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onSendMessage={(member) => {
|
onSendMessage={(member) => {
|
||||||
|
|
@ -514,6 +536,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onAssignTask={(member) => {
|
onAssignTask={(member) => {
|
||||||
openCreateTaskDialog('', '', member.name);
|
openCreateTaskDialog('', '', member.name);
|
||||||
}}
|
}}
|
||||||
|
onOpenTask={(task) => setSelectedTask(task)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
@ -677,6 +700,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<PendingRepliesBlock
|
||||||
|
members={data.members}
|
||||||
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
|
onMemberClick={setSelectedMember}
|
||||||
|
/>
|
||||||
<ActiveTasksBlock
|
<ActiveTasksBlock
|
||||||
members={data.members}
|
members={data.members}
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
|
|
@ -703,6 +731,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
open={requestChangesTaskId !== null}
|
open={requestChangesTaskId !== null}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={requestChangesTaskId}
|
taskId={requestChangesTaskId}
|
||||||
|
members={data?.members ?? []}
|
||||||
onCancel={() => setRequestChangesTaskId(null)}
|
onCancel={() => setRequestChangesTaskId(null)}
|
||||||
onSubmit={(comment) => {
|
onSubmit={(comment) => {
|
||||||
if (!requestChangesTaskId) {
|
if (!requestChangesTaskId) {
|
||||||
|
|
@ -791,7 +820,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
sendError={sendMessageError}
|
sendError={sendMessageError}
|
||||||
lastResult={lastSendMessageResult}
|
lastResult={lastSendMessageResult}
|
||||||
onSend={(member, text, summary) => {
|
onSend={(member, text, summary) => {
|
||||||
void sendTeamMessage(teamName, { member, text, summary });
|
void (async () => {
|
||||||
|
const sentAtMs = Date.now();
|
||||||
|
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||||
|
try {
|
||||||
|
await sendTeamMessage(teamName, { member, text, summary });
|
||||||
|
} catch {
|
||||||
|
setPendingRepliesByMember((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[member];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSendDialogOpen(false);
|
setSendDialogOpen(false);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Trash2 } from 'lucide-react';
|
import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Square, Trash2 } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||||
|
|
@ -243,6 +243,20 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
[teams]
|
[teams]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [stoppingTeamName, setStoppingTeamName] = useState<string | null>(null);
|
||||||
|
const handleStopTeam = useCallback((teamName: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setStoppingTeamName(teamName);
|
||||||
|
void api.teams
|
||||||
|
.stop(teamName)
|
||||||
|
.then(() => {
|
||||||
|
setAliveTeams((prev) => prev.filter((n) => n !== teamName));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setStoppingTeamName(null);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!electronMode) {
|
if (!electronMode) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -430,6 +444,24 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
<StatusBadge status={status} />
|
<StatusBadge status={status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 gap-1">
|
<div className="flex shrink-0 gap-1">
|
||||||
|
{status === 'running' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
|
||||||
|
onClick={(e) => handleStopTeam(team.teamName, e)}
|
||||||
|
disabled={stoppingTeamName === team.teamName}
|
||||||
|
aria-label="Stop team"
|
||||||
|
>
|
||||||
|
<Square size={14} fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const ActiveTasksBlock = ({
|
||||||
return (
|
return (
|
||||||
<div className="mb-3 space-y-1.5">
|
<div className="mb-3 space-y-1.5">
|
||||||
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
Сейчас в работе
|
In progress
|
||||||
</p>
|
</p>
|
||||||
{working.map((member) => {
|
{working.map((member) => {
|
||||||
const taskId = member.currentTaskId!;
|
const taskId = member.currentTaskId!;
|
||||||
|
|
@ -88,7 +88,7 @@ export const ActiveTasksBlock = ({
|
||||||
className="min-w-0 flex-1 truncate text-[10px]"
|
className="min-w-0 flex-1 truncate text-[10px]"
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
>
|
>
|
||||||
выполняет
|
working on
|
||||||
</span>
|
</span>
|
||||||
{task &&
|
{task &&
|
||||||
(onTaskClick ? (
|
(onTaskClick ? (
|
||||||
|
|
|
||||||
105
src/renderer/components/team/activity/PendingRepliesBlock.tsx
Normal file
105
src/renderer/components/team/activity/PendingRepliesBlock.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
interface PendingRepliesBlockProps {
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
|
pendingRepliesByMember: Record<string, number>;
|
||||||
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PendingRepliesBlock = ({
|
||||||
|
members,
|
||||||
|
pendingRepliesByMember,
|
||||||
|
onMemberClick,
|
||||||
|
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||||
|
const pending = Object.entries(pendingRepliesByMember)
|
||||||
|
.map(([name, sentAtMs]) => ({
|
||||||
|
member: members.find((m) => m.name === name) ?? null,
|
||||||
|
name,
|
||||||
|
sentAtMs,
|
||||||
|
}))
|
||||||
|
.filter((p): p is { member: ResolvedTeamMember; name: string; sentAtMs: number } => !!p.member)
|
||||||
|
.sort((a, b) => b.sentAtMs - a.sentAtMs);
|
||||||
|
|
||||||
|
if (pending.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 space-y-1.5">
|
||||||
|
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||||
|
Awaiting replies
|
||||||
|
</p>
|
||||||
|
{pending.map(({ member, sentAtMs }) => {
|
||||||
|
const colors = getTeamColorSet(member.color ?? '');
|
||||||
|
const roleLabel = formatAgentRole(
|
||||||
|
member.role ?? (member.agentType !== 'general-purpose' ? member.agentType : undefined)
|
||||||
|
);
|
||||||
|
const since = formatDistanceToNowStrict(sentAtMs, { addSuffix: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={`pending-reply:${member.name}:${sentAtMs}`}
|
||||||
|
className="overflow-hidden rounded-md"
|
||||||
|
style={{
|
||||||
|
backgroundColor: CARD_BG,
|
||||||
|
border: CARD_BORDER_STYLE,
|
||||||
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
|
<Loader2
|
||||||
|
className="size-3.5 shrink-0 animate-spin"
|
||||||
|
style={{ color: colors.border }}
|
||||||
|
/>
|
||||||
|
{onMemberClick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.badge,
|
||||||
|
color: colors.text,
|
||||||
|
border: `1px solid ${colors.border}40`,
|
||||||
|
}}
|
||||||
|
onClick={() => onMemberClick(member)}
|
||||||
|
title="Open member"
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||||
|
style={{
|
||||||
|
backgroundColor: colors.badge,
|
||||||
|
color: colors.text,
|
||||||
|
border: `1px solid ${colors.border}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{roleLabel ? (
|
||||||
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{roleLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-[10px]"
|
||||||
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
|
title="Message sent, awaiting reply"
|
||||||
|
>
|
||||||
|
awaiting reply
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
|
{since}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -798,7 +798,7 @@ export const CreateTeamDialog = ({
|
||||||
|
|
||||||
{launchTeam ? (
|
{launchTeam ? (
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
|
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ export const LaunchTeamDialog = ({
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
|
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -8,13 +10,18 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { Textarea } from '@renderer/components/ui/textarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
|
||||||
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
interface ReviewDialogProps {
|
interface ReviewDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
taskId: string | null;
|
taskId: string | null;
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (comment?: string) => void;
|
onSubmit: (comment?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +30,7 @@ export const ReviewDialog = ({
|
||||||
open,
|
open,
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
|
members,
|
||||||
onCancel,
|
onCancel,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: ReviewDialogProps): React.JSX.Element => {
|
}: ReviewDialogProps): React.JSX.Element => {
|
||||||
|
|
@ -31,6 +39,17 @@ export const ReviewDialog = ({
|
||||||
enabled: Boolean(teamName && taskId),
|
enabled: Boolean(teamName && taskId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
|
() =>
|
||||||
|
members.map((m) => ({
|
||||||
|
id: m.name,
|
||||||
|
name: m.name,
|
||||||
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
|
color: m.color,
|
||||||
|
})),
|
||||||
|
[members]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCancel = (): void => {
|
const handleCancel = (): void => {
|
||||||
onCancel();
|
onCancel();
|
||||||
};
|
};
|
||||||
|
|
@ -58,16 +77,20 @@ export const ReviewDialog = ({
|
||||||
|
|
||||||
<div className="grid gap-2 py-2">
|
<div className="grid gap-2 py-2">
|
||||||
<Label htmlFor="review-comment">Comment (optional)</Label>
|
<Label htmlFor="review-comment">Comment (optional)</Label>
|
||||||
<Textarea
|
<MentionableTextarea
|
||||||
id="review-comment"
|
id="review-comment"
|
||||||
className="min-h-[110px] text-xs"
|
className="min-h-[110px] text-xs"
|
||||||
value={draft.value}
|
value={draft.value}
|
||||||
|
onValueChange={draft.setValue}
|
||||||
placeholder="Describe what needs to change..."
|
placeholder="Describe what needs to change..."
|
||||||
onChange={(event) => draft.setValue(event.target.value)}
|
suggestions={mentionSuggestions}
|
||||||
|
hintText="Use @ to mention team members"
|
||||||
|
footerRight={
|
||||||
|
draft.isSaved ? (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{draft.isSaved ? (
|
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -132,27 +132,31 @@ export const TaskCommentsSection = ({
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const reply = parseMessageReply(comment.text);
|
const reply = parseMessageReply(comment.text);
|
||||||
|
const displayText = reply ? reply.replyText : comment.text;
|
||||||
|
const needsExpandCollapse = displayText.includes('\n');
|
||||||
const expanded = expandedCommentIds.has(comment.id);
|
const expanded = expandedCommentIds.has(comment.id);
|
||||||
const collapsedHeight = 'max-h-[120px]';
|
const collapsedHeight = 'max-h-[120px]';
|
||||||
|
const showCollapsed = needsExpandCollapse && !expanded;
|
||||||
|
const showExpandedButton = needsExpandCollapse && expanded;
|
||||||
return (
|
return (
|
||||||
<div className="relative text-xs">
|
<div className="relative text-xs">
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
expanded ? undefined : `relative ${collapsedHeight} overflow-hidden`
|
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{reply ? (
|
{reply ? (
|
||||||
<ReplyQuoteBlock
|
<ReplyQuoteBlock
|
||||||
reply={reply}
|
reply={reply}
|
||||||
bodyMaxHeight={expanded ? undefined : 'max-h-56'}
|
bodyMaxHeight={needsExpandCollapse && !expanded ? 'max-h-56' : undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownViewer
|
<MarkdownViewer
|
||||||
content={comment.text}
|
content={comment.text}
|
||||||
maxHeight={expanded ? undefined : collapsedHeight}
|
maxHeight={needsExpandCollapse && !expanded ? collapsedHeight : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!expanded && (
|
{showCollapsed && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
|
||||||
|
|
@ -167,25 +171,25 @@ export const TaskCommentsSection = ({
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||||
onClick={() => toggleCommentExpanded(comment.id)}
|
onClick={() => toggleCommentExpanded(comment.id)}
|
||||||
title="Развернуть"
|
title="Expand"
|
||||||
>
|
>
|
||||||
<ChevronDown size={12} />
|
<ChevronDown size={12} />
|
||||||
Развернуть
|
Expand
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{showExpandedButton && (
|
||||||
<div className="flex justify-center pt-2">
|
<div className="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
className="flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||||
onClick={() => toggleCommentExpanded(comment.id)}
|
onClick={() => toggleCommentExpanded(comment.id)}
|
||||||
title="Свернуть"
|
title="Collapse"
|
||||||
>
|
>
|
||||||
<ChevronUp size={12} />
|
<ChevronUp size={12} />
|
||||||
Свернуть
|
Collapse
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,19 @@ import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
import { ListPlus, MessageSquare } from 'lucide-react';
|
import { ListPlus, Loader2, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
interface MemberCardProps {
|
interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
memberColor: string;
|
memberColor: string;
|
||||||
taskCounts?: TaskStatusCounts | null;
|
taskCounts?: TaskStatusCounts | null;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
|
currentTask?: TeamTask | null;
|
||||||
|
isAwaitingReply?: boolean;
|
||||||
|
onOpenTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSendMessage?: () => void;
|
onSendMessage?: () => void;
|
||||||
onAssignTask?: () => void;
|
onAssignTask?: () => void;
|
||||||
|
|
@ -22,6 +25,9 @@ export const MemberCard = ({
|
||||||
memberColor,
|
memberColor,
|
||||||
taskCounts,
|
taskCounts,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
currentTask,
|
||||||
|
isAwaitingReply,
|
||||||
|
onOpenTask,
|
||||||
onClick,
|
onClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -40,7 +46,7 @@ export const MemberCard = ({
|
||||||
return (
|
return (
|
||||||
<div className="rounded">
|
<div className="rounded">
|
||||||
<div
|
<div
|
||||||
className="group relative flex cursor-pointer items-center gap-2.5 rounded-t px-2 py-1.5"
|
className="group relative cursor-pointer rounded-t px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
backgroundColor: colors.badge,
|
backgroundColor: colors.badge,
|
||||||
|
|
@ -57,66 +63,95 @@ export const MemberCard = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" />
|
<div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" />
|
||||||
<div className="relative shrink-0">
|
<div className="flex items-center gap-2.5">
|
||||||
<img
|
<div className="relative shrink-0">
|
||||||
src={agentAvatarUrl(member.name)}
|
<img
|
||||||
alt={member.name}
|
src={agentAvatarUrl(member.name)}
|
||||||
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
alt={member.name}
|
||||||
loading="lazy"
|
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
|
||||||
/>
|
loading="lazy"
|
||||||
<span
|
/>
|
||||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
<span
|
||||||
aria-label={member.status}
|
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||||
/>
|
aria-label={member.status}
|
||||||
</div>
|
/>
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
|
</div>
|
||||||
{member.name}
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
|
||||||
</span>
|
{member.name}
|
||||||
{(() => {
|
</span>
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
{(() => {
|
||||||
return roleLabel ? (
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
|
return roleLabel ? (
|
||||||
{roleLabel}
|
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
|
||||||
</span>
|
{roleLabel}
|
||||||
) : null;
|
</span>
|
||||||
})()}
|
) : null;
|
||||||
<Badge
|
})()}
|
||||||
variant="secondary"
|
<Badge
|
||||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
variant="secondary"
|
||||||
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||||
>
|
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
||||||
{presenceLabel}
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
|
||||||
>
|
|
||||||
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
|
||||||
</Badge>
|
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
|
||||||
title="Send Message"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSendMessage?.();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MessageSquare size={13} />
|
{presenceLabel}
|
||||||
</button>
|
</Badge>
|
||||||
<button
|
<Badge
|
||||||
type="button"
|
variant="secondary"
|
||||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||||
title="Assign Task"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onAssignTask?.();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ListPlus size={13} />
|
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
||||||
</button>
|
</Badge>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||||
|
title="Send message"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSendMessage?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare size={13} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||||
|
title="Assign task"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAssignTask?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListPlus size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{currentTask ? (
|
||||||
|
<div className="mt-1 flex items-center gap-2 pl-9 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
<Loader2 className="size-3 animate-spin" style={{ color: colors.border }} />
|
||||||
|
<span className="truncate">working on</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
|
style={{ border: `1px solid ${colors.border}40` }}
|
||||||
|
title="Open task"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenTask?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{currentTask.id} {currentTask.subject.slice(0, 36)}
|
||||||
|
{currentTask.subject.length > 36 ? '…' : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!currentTask && isAwaitingReply ? (
|
||||||
|
<div className="mt-1 flex items-center gap-2 pl-9 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
<Loader2 className="size-3 animate-spin" style={{ color: colors.border }} />
|
||||||
|
<span className="truncate">awaiting reply</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="h-0.5 rounded-b bg-[var(--color-border)]"
|
className="h-0.5 rounded-b bg-[var(--color-border)]"
|
||||||
|
|
|
||||||
|
|
@ -3,24 +3,30 @@ import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
interface MemberListProps {
|
interface MemberListProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
||||||
|
taskMap?: Map<string, TeamTask>;
|
||||||
|
pendingRepliesByMember?: Record<string, number>;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||||
|
onOpenTask?: (task: TeamTask) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberList = ({
|
export const MemberList = ({
|
||||||
members,
|
members,
|
||||||
memberTaskCounts,
|
memberTaskCounts,
|
||||||
|
taskMap,
|
||||||
|
pendingRepliesByMember,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
onOpenTask,
|
||||||
}: MemberListProps): React.JSX.Element => {
|
}: MemberListProps): React.JSX.Element => {
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -32,18 +38,26 @@ export const MemberList = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{members.map((member, index) => (
|
{members.map((member, index) => {
|
||||||
<MemberCard
|
const currentTask =
|
||||||
key={member.name}
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
member={member}
|
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||||
memberColor={member.color ?? getMemberColor(index)}
|
return (
|
||||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
<MemberCard
|
||||||
isTeamAlive={isTeamAlive}
|
key={member.name}
|
||||||
onClick={() => onMemberClick?.(member)}
|
member={member}
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
memberColor={member.color ?? getMemberColor(index)}
|
||||||
onAssignTask={() => onAssignTask?.(member)}
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
/>
|
isTeamAlive={isTeamAlive}
|
||||||
))}
|
currentTask={currentTask}
|
||||||
|
isAwaitingReply={awaitingReply}
|
||||||
|
onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||||
|
onClick={() => onMemberClick?.(member)}
|
||||||
|
onSendMessage={() => onSendMessage?.(member)}
|
||||||
|
onAssignTask={() => onAssignTask?.(member)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,7 @@ export interface TeamsAPI {
|
||||||
processSend: (teamName: string, message: string) => Promise<void>;
|
processSend: (teamName: string, message: string) => Promise<void>;
|
||||||
processAlive: (teamName: string) => Promise<boolean>;
|
processAlive: (teamName: string) => Promise<boolean>;
|
||||||
aliveList: () => Promise<string[]>;
|
aliveList: () => Promise<string[]>;
|
||||||
|
stop: (teamName: string) => Promise<void>;
|
||||||
createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
|
createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
|
||||||
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
|
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
|
||||||
getLogsForTask: (
|
getLogsForTask: (
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_PROCESS_SEND: 'team:processSend',
|
TEAM_PROCESS_SEND: 'team:processSend',
|
||||||
TEAM_PROCESS_ALIVE: 'team:processAlive',
|
TEAM_PROCESS_ALIVE: 'team:processAlive',
|
||||||
TEAM_ALIVE_LIST: 'team:aliveList',
|
TEAM_ALIVE_LIST: 'team:aliveList',
|
||||||
|
TEAM_STOP: 'team:stop',
|
||||||
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
|
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
|
||||||
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
|
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
|
||||||
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
|
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
|
||||||
|
|
@ -31,6 +32,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
|
TEAM_STOP,
|
||||||
TEAM_CANCEL_PROVISIONING,
|
TEAM_CANCEL_PROVISIONING,
|
||||||
TEAM_CREATE,
|
TEAM_CREATE,
|
||||||
TEAM_CREATE_CONFIG,
|
TEAM_CREATE_CONFIG,
|
||||||
|
|
@ -108,6 +110,7 @@ describe('ipc teams handlers', () => {
|
||||||
sendMessageToTeam: vi.fn(async () => undefined),
|
sendMessageToTeam: vi.fn(async () => undefined),
|
||||||
isTeamAlive: vi.fn(() => true),
|
isTeamAlive: vi.fn(() => true),
|
||||||
getAliveTeams: vi.fn(() => ['my-team']),
|
getAliveTeams: vi.fn(() => ['my-team']),
|
||||||
|
stopTeam: vi.fn(() => undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -135,6 +138,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
||||||
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(true);
|
||||||
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_STOP)).toBe(true);
|
||||||
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true);
|
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true);
|
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true);
|
||||||
|
|
@ -304,6 +308,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
||||||
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
|
expect(handlers.has(TEAM_PROCESS_ALIVE)).toBe(false);
|
||||||
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
|
expect(handlers.has(TEAM_ALIVE_LIST)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_STOP)).toBe(false);
|
||||||
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false);
|
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false);
|
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false);
|
||||||
|
|
|
||||||
189
test/main/services/team/TeamProvisioningServiceRelay.test.ts
Normal file
189
test/main/services/team/TeamProvisioningServiceRelay.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
let atomicWriteShouldFail = false;
|
||||||
|
|
||||||
|
// Normalize path separators so tests pass on Windows (backslash → forward slash)
|
||||||
|
const norm = (p: string): string => p.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
const readFile = vi.fn(async (filePath: string) => {
|
||||||
|
const data = files.get(norm(filePath));
|
||||||
|
if (data === undefined) {
|
||||||
|
const error = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
|
||||||
|
if (atomicWriteShouldFail) {
|
||||||
|
throw new Error('atomic write failed');
|
||||||
|
}
|
||||||
|
files.set(norm(filePath), data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
readFile,
|
||||||
|
atomicWrite,
|
||||||
|
setAtomicWriteShouldFail: (next: boolean) => {
|
||||||
|
atomicWriteShouldFail = next;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
promises: {
|
||||||
|
readFile: hoisted.readFile,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
|
||||||
|
atomicWriteAsync: hoisted.atomicWrite,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/utils/pathDecoder', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../../../src/main/utils/pathDecoder')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getTeamsBasePath: () => '/mock/teams',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
|
|
||||||
|
function seedConfig(teamName: string): void {
|
||||||
|
hoisted.files.set(
|
||||||
|
`/mock/teams/${teamName}/config.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
name: 'My Team',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedLeadInbox(teamName: string, messages: unknown[]): void {
|
||||||
|
hoisted.files.set(`/mock/teams/${teamName}/inboxes/team-lead.json`, JSON.stringify(messages));
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachAliveRun(
|
||||||
|
service: TeamProvisioningService,
|
||||||
|
teamName: string,
|
||||||
|
opts?: { writable?: boolean }
|
||||||
|
): { writeSpy: ReturnType<typeof vi.fn> } {
|
||||||
|
const runId = 'run-1';
|
||||||
|
const writeSpy = vi.fn();
|
||||||
|
const writable = opts?.writable ?? true;
|
||||||
|
|
||||||
|
(service as unknown as { activeByTeam: Map<string, string> }).activeByTeam.set(teamName, runId);
|
||||||
|
(service as unknown as { runs: Map<string, unknown> }).runs.set(runId, {
|
||||||
|
runId,
|
||||||
|
teamName,
|
||||||
|
child: {
|
||||||
|
stdin: {
|
||||||
|
writable,
|
||||||
|
write: writeSpy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
processKilled: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
provisioningComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { writeSpy };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamProvisioningService relayLeadInboxMessages', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.files.clear();
|
||||||
|
hoisted.readFile.mockClear();
|
||||||
|
hoisted.atomicWrite.mockClear();
|
||||||
|
hoisted.setAtomicWriteShouldFail(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('relays unread lead inbox messages into stdin', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
seedLeadInbox(teamName, [
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
text: 'Please assign this to Alice.',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
summary: 'Need delegation',
|
||||||
|
messageId: 'm-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName);
|
||||||
|
const relayed = await service.relayLeadInboxMessages(teamName);
|
||||||
|
|
||||||
|
expect(relayed).toBe(1);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
|
expect(payload).toContain('"type":"user"');
|
||||||
|
expect(payload).toContain('Please assign this to Alice.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedups by messageId even if markRead fails', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
seedLeadInbox(teamName, [
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
text: 'Ping leader',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
summary: 'Ping',
|
||||||
|
messageId: 'm-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
hoisted.setAtomicWriteShouldFail(true);
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName);
|
||||||
|
|
||||||
|
const first = await service.relayLeadInboxMessages(teamName);
|
||||||
|
const second = await service.relayLeadInboxMessages(teamName);
|
||||||
|
|
||||||
|
expect(first).toBe(1);
|
||||||
|
expect(second).toBe(0);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark as relayed when stdin is not writable', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
seedLeadInbox(teamName, [
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
text: 'Hello',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName, { writable: false });
|
||||||
|
const first = await service.relayLeadInboxMessages(teamName);
|
||||||
|
expect(first).toBe(0);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
(service as unknown as { runs: Map<string, unknown> }).runs.set('run-1', {
|
||||||
|
runId: 'run-1',
|
||||||
|
teamName,
|
||||||
|
child: { stdin: { writable: true, write: writeSpy } },
|
||||||
|
processKilled: false,
|
||||||
|
cancelRequested: false,
|
||||||
|
provisioningComplete: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = await service.relayLeadInboxMessages(teamName);
|
||||||
|
expect(second).toBe(1);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue