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
368f175db0
commit
75a354abcd
19 changed files with 802 additions and 94 deletions
|
|
@ -155,6 +155,19 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
mainWindow.webContents.send(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);
|
||||
teamChangeCleanup = () => context.fileWatcher.off('team-change', teamChangeHandler);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_STOP,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
|
|
@ -100,6 +101,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
|
||||
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
|
||||
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
|
||||
ipcMain.handle(TEAM_STOP, handleStopTeam);
|
||||
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
||||
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_ALIVE);
|
||||
ipcMain.removeHandler(TEAM_ALIVE_LIST);
|
||||
ipcMain.removeHandler(TEAM_STOP);
|
||||
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
||||
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
||||
|
|
@ -181,6 +184,13 @@ async function handleGetData(
|
|||
return wrapTeamHandler('getData', async () => {
|
||||
const data = await getTeamDataService().getTeamData(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 };
|
||||
});
|
||||
}
|
||||
|
|
@ -821,6 +831,19 @@ async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<st
|
|||
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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
|
|||
|
|
@ -445,6 +445,9 @@ let cachedProbeResult: CachedProbeResult | null = null;
|
|||
export class TeamProvisioningService {
|
||||
private readonly runs = new Map<string, ProvisioningRun>();
|
||||
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(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -1095,6 +1098,119 @@ export class TeamProvisioningService {
|
|||
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.
|
||||
*/
|
||||
|
|
@ -1112,6 +1228,96 @@ export class TeamProvisioningService {
|
|||
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.
|
||||
* Extracts assistant text for progress reporting and detects turn completion.
|
||||
|
|
@ -1186,6 +1392,9 @@ export class TeamProvisioningService {
|
|||
});
|
||||
run.onProgress(progress);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1224,6 +1433,9 @@ export class TeamProvisioningService {
|
|||
run.onProgress(progress);
|
||||
// NOTE: do NOT remove from activeByTeam — process stays alive
|
||||
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.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 */
|
||||
export const TEAM_ALIVE_LIST = 'team:aliveList';
|
||||
export const TEAM_STOP = 'team:stop';
|
||||
|
||||
/** Create team config without provisioning CLI */
|
||||
export const TEAM_CREATE_CONFIG = 'team:createConfig';
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_STOP,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
|
|
@ -567,6 +568,9 @@ const electronAPI: ElectronAPI = {
|
|||
aliveList: async () => {
|
||||
return invokeIpcWithResult<string[]>(TEAM_ALIVE_LIST);
|
||||
},
|
||||
stop: async (teamName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_STOP, teamName);
|
||||
},
|
||||
createConfig: async (request: TeamCreateConfigRequest) => {
|
||||
return invokeIpcWithResult<void>(TEAM_CREATE_CONFIG, request);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -685,6 +685,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
aliveList: async (): Promise<string[]> => {
|
||||
return [];
|
||||
},
|
||||
stop: async (): Promise<void> => {
|
||||
throw new Error('Team stop is not available in browser mode');
|
||||
},
|
||||
createConfig: async (): Promise<void> => {
|
||||
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 { ActivityTimeline } from './activity/ActivityTimeline';
|
||||
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
|
||||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||
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 [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||||
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
|
||||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||
open: false,
|
||||
defaultSubject: '',
|
||||
|
|
@ -309,6 +311,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
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 => {
|
||||
setCreateTaskDialog({
|
||||
open: true,
|
||||
|
|
@ -504,6 +524,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
<MemberList
|
||||
members={data.members}
|
||||
memberTaskCounts={memberTaskCounts}
|
||||
taskMap={taskMap}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
isTeamAlive={data.isAlive}
|
||||
onMemberClick={setSelectedMember}
|
||||
onSendMessage={(member) => {
|
||||
|
|
@ -514,6 +536,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onAssignTask={(member) => {
|
||||
openCreateTaskDialog('', '', member.name);
|
||||
}}
|
||||
onOpenTask={(task) => setSelectedTask(task)}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
@ -677,6 +700,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</div>
|
||||
}
|
||||
>
|
||||
<PendingRepliesBlock
|
||||
members={data.members}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
onMemberClick={setSelectedMember}
|
||||
/>
|
||||
<ActiveTasksBlock
|
||||
members={data.members}
|
||||
tasks={data.tasks}
|
||||
|
|
@ -703,6 +731,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
open={requestChangesTaskId !== null}
|
||||
teamName={teamName}
|
||||
taskId={requestChangesTaskId}
|
||||
members={data?.members ?? []}
|
||||
onCancel={() => setRequestChangesTaskId(null)}
|
||||
onSubmit={(comment) => {
|
||||
if (!requestChangesTaskId) {
|
||||
|
|
@ -791,7 +820,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sendError={sendMessageError}
|
||||
lastResult={lastSendMessageResult}
|
||||
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={() => {
|
||||
setSendDialogOpen(false);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
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 { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||
|
|
@ -243,6 +243,20 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
[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(() => {
|
||||
if (!electronMode) {
|
||||
return;
|
||||
|
|
@ -430,6 +444,24 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<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>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const ActiveTasksBlock = ({
|
|||
return (
|
||||
<div className="mb-3 space-y-1.5">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Сейчас в работе
|
||||
In progress
|
||||
</p>
|
||||
{working.map((member) => {
|
||||
const taskId = member.currentTaskId!;
|
||||
|
|
@ -88,7 +88,7 @@ export const ActiveTasksBlock = ({
|
|||
className="min-w-0 flex-1 truncate text-[10px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
выполняет
|
||||
working on
|
||||
</span>
|
||||
{task &&
|
||||
(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 ? (
|
||||
<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="flex items-center gap-1">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ export const LaunchTeamDialog = ({
|
|||
|
||||
<div className="space-y-4">
|
||||
<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="flex items-center gap-1">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -8,13 +10,18 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
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 { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
interface ReviewDialogProps {
|
||||
open: boolean;
|
||||
teamName: string;
|
||||
taskId: string | null;
|
||||
members: ResolvedTeamMember[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (comment?: string) => void;
|
||||
}
|
||||
|
|
@ -23,6 +30,7 @@ export const ReviewDialog = ({
|
|||
open,
|
||||
teamName,
|
||||
taskId,
|
||||
members,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ReviewDialogProps): React.JSX.Element => {
|
||||
|
|
@ -31,6 +39,17 @@ export const ReviewDialog = ({
|
|||
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 => {
|
||||
onCancel();
|
||||
};
|
||||
|
|
@ -58,16 +77,20 @@ export const ReviewDialog = ({
|
|||
|
||||
<div className="grid gap-2 py-2">
|
||||
<Label htmlFor="review-comment">Comment (optional)</Label>
|
||||
<Textarea
|
||||
<MentionableTextarea
|
||||
id="review-comment"
|
||||
className="min-h-[110px] text-xs"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
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>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -132,27 +132,31 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
const displayText = reply ? reply.replyText : comment.text;
|
||||
const needsExpandCollapse = displayText.includes('\n');
|
||||
const expanded = expandedCommentIds.has(comment.id);
|
||||
const collapsedHeight = 'max-h-[120px]';
|
||||
const showCollapsed = needsExpandCollapse && !expanded;
|
||||
const showExpandedButton = needsExpandCollapse && expanded;
|
||||
return (
|
||||
<div className="relative text-xs">
|
||||
<div
|
||||
className={
|
||||
expanded ? undefined : `relative ${collapsedHeight} overflow-hidden`
|
||||
showCollapsed ? `relative ${collapsedHeight} overflow-hidden` : undefined
|
||||
}
|
||||
>
|
||||
{reply ? (
|
||||
<ReplyQuoteBlock
|
||||
reply={reply}
|
||||
bodyMaxHeight={expanded ? undefined : 'max-h-56'}
|
||||
bodyMaxHeight={needsExpandCollapse && !expanded ? 'max-h-56' : undefined}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownViewer
|
||||
content={comment.text}
|
||||
maxHeight={expanded ? undefined : collapsedHeight}
|
||||
maxHeight={needsExpandCollapse && !expanded ? collapsedHeight : undefined}
|
||||
/>
|
||||
)}
|
||||
{!expanded && (
|
||||
{showCollapsed && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-14"
|
||||
|
|
@ -167,25 +171,25 @@ export const TaskCommentsSection = ({
|
|||
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)]"
|
||||
onClick={() => toggleCommentExpanded(comment.id)}
|
||||
title="Развернуть"
|
||||
title="Expand"
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
Развернуть
|
||||
Expand
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{expanded && (
|
||||
{showExpandedButton && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<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)]"
|
||||
onClick={() => toggleCommentExpanded(comment.id)}
|
||||
title="Свернуть"
|
||||
title="Collapse"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
Свернуть
|
||||
Collapse
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,19 @@ import { Badge } from '@renderer/components/ui/badge';
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
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 { ResolvedTeamMember } from '@shared/types';
|
||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
interface MemberCardProps {
|
||||
member: ResolvedTeamMember;
|
||||
memberColor: string;
|
||||
taskCounts?: TaskStatusCounts | null;
|
||||
isTeamAlive?: boolean;
|
||||
currentTask?: TeamTask | null;
|
||||
isAwaitingReply?: boolean;
|
||||
onOpenTask?: () => void;
|
||||
onClick?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
onAssignTask?: () => void;
|
||||
|
|
@ -22,6 +25,9 @@ export const MemberCard = ({
|
|||
memberColor,
|
||||
taskCounts,
|
||||
isTeamAlive,
|
||||
currentTask,
|
||||
isAwaitingReply,
|
||||
onOpenTask,
|
||||
onClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
|
|
@ -40,7 +46,7 @@ export const MemberCard = ({
|
|||
return (
|
||||
<div className="rounded">
|
||||
<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={{
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
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="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name)}
|
||||
alt={member.name}
|
||||
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}`}
|
||||
aria-label={member.status}
|
||||
/>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{member.name}
|
||||
</span>
|
||||
{(() => {
|
||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
return roleLabel ? (
|
||||
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
|
||||
{roleLabel}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
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?.();
|
||||
}}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(member.name)}
|
||||
alt={member.name}
|
||||
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}`}
|
||||
aria-label={member.status}
|
||||
/>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{member.name}
|
||||
</span>
|
||||
{(() => {
|
||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
return roleLabel ? (
|
||||
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
|
||||
{roleLabel}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
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}
|
||||
>
|
||||
<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?.();
|
||||
}}
|
||||
{presenceLabel}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||
>
|
||||
<ListPlus size={13} />
|
||||
</button>
|
||||
{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} />
|
||||
</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>
|
||||
|
||||
{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
|
||||
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 type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
interface MemberListProps {
|
||||
members: ResolvedTeamMember[];
|
||||
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
||||
taskMap?: Map<string, TeamTask>;
|
||||
pendingRepliesByMember?: Record<string, number>;
|
||||
isTeamAlive?: boolean;
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||
onOpenTask?: (task: TeamTask) => void;
|
||||
}
|
||||
|
||||
export const MemberList = ({
|
||||
members,
|
||||
memberTaskCounts,
|
||||
taskMap,
|
||||
pendingRepliesByMember,
|
||||
isTeamAlive,
|
||||
onMemberClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
onOpenTask,
|
||||
}: MemberListProps): React.JSX.Element => {
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
|
|
@ -32,18 +38,26 @@ export const MemberList = ({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{members.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
memberColor={member.color ?? getMemberColor(index)}
|
||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||
isTeamAlive={isTeamAlive}
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
onAssignTask={() => onAssignTask?.(member)}
|
||||
/>
|
||||
))}
|
||||
{members.map((member, index) => {
|
||||
const currentTask =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||
return (
|
||||
<MemberCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
memberColor={member.color ?? getMemberColor(index)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -359,6 +359,7 @@ export interface TeamsAPI {
|
|||
processSend: (teamName: string, message: string) => Promise<void>;
|
||||
processAlive: (teamName: string) => Promise<boolean>;
|
||||
aliveList: () => Promise<string[]>;
|
||||
stop: (teamName: string) => Promise<void>;
|
||||
createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
|
||||
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
|
||||
getLogsForTask: (
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
TEAM_PROCESS_SEND: 'team:processSend',
|
||||
TEAM_PROCESS_ALIVE: 'team:processAlive',
|
||||
TEAM_ALIVE_LIST: 'team:aliveList',
|
||||
TEAM_STOP: 'team:stop',
|
||||
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
|
||||
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
|
||||
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
|
||||
|
|
@ -31,6 +32,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
|
||||
import {
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_STOP,
|
||||
TEAM_CANCEL_PROVISIONING,
|
||||
TEAM_CREATE,
|
||||
TEAM_CREATE_CONFIG,
|
||||
|
|
@ -108,6 +110,7 @@ describe('ipc teams handlers', () => {
|
|||
sendMessageToTeam: vi.fn(async () => undefined),
|
||||
isTeamAlive: vi.fn(() => true),
|
||||
getAliveTeams: vi.fn(() => ['my-team']),
|
||||
stopTeam: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -135,6 +138,7 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
||||
expect(handlers.has(TEAM_PROCESS_ALIVE)).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_GET_MEMBER_LOGS)).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_ALIVE)).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_GET_MEMBER_LOGS)).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