feat: add task logging functionality and enhance team management
- Introduced TEAM_GET_LOGS_FOR_TASK IPC channel to retrieve session logs related to specific tasks. - Implemented handleGetLogsForTask function to validate inputs and fetch logs for a given task. - Updated TeamMemberLogsFinder to include findLogsForTask method for session log retrieval based on task ID. - Enhanced UI components to support displaying logs for tasks, improving task management and visibility. - Updated related services and components to accommodate the new logging functionality. These changes aim to enhance task tracking and improve collaboration within teams by providing access to relevant session logs.
This commit is contained in:
parent
6bcc525ace
commit
55f7611b14
24 changed files with 412 additions and 90 deletions
|
|
@ -8,6 +8,7 @@ import {
|
|||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_LAUNCH,
|
||||
|
|
@ -101,6 +102,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
|
||||
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
||||
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
||||
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
|
||||
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
|
||||
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
||||
|
|
@ -128,6 +130,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_ALIVE_LIST);
|
||||
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
||||
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
|
||||
ipcMain.removeHandler(TEAM_START_TASK);
|
||||
|
|
@ -763,6 +766,24 @@ async function handleGetMemberLogs(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleGetLogsForTask(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown
|
||||
): Promise<IpcResult<MemberLogSummary[]>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const vTask = validateTaskId(taskId);
|
||||
if (!vTask.valid) {
|
||||
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||
}
|
||||
return wrapTeamHandler('getLogsForTask', () =>
|
||||
getTeamMemberLogsFinder().findLogsForTask(vTeam.value!, vTask.value!)
|
||||
);
|
||||
}
|
||||
|
||||
function getMemberStatsComputer(): MemberStatsComputer {
|
||||
if (!memberStatsComputer) {
|
||||
throw new Error('Member stats computer is not initialized');
|
||||
|
|
|
|||
|
|
@ -274,12 +274,15 @@ function createTask(paths, flags) {
|
|||
const taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
||||
if (fs.existsSync(taskPath)) die('Task already exists: ' + String(nextId));
|
||||
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
|
||||
|
||||
const task = {
|
||||
id: nextId,
|
||||
subject,
|
||||
description: String(description || subject),
|
||||
activeForm: activeForm ? String(activeForm) : undefined,
|
||||
owner,
|
||||
createdBy: from || undefined,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
|
|
@ -419,7 +422,7 @@ function printHelp() {
|
|||
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
|
||||
' node teamctl.js task complete <id> [--team <team>]',
|
||||
' node teamctl.js task start <id> [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
|
||||
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
||||
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
||||
' node teamctl.js kanban clear <id> [--team <team>]',
|
||||
|
|
|
|||
|
|
@ -244,6 +244,7 @@ export class TeamDataService {
|
|||
subject: request.subject,
|
||||
description,
|
||||
owner: request.owner,
|
||||
createdBy: 'user',
|
||||
status: shouldStart ? 'in_progress' : 'pending',
|
||||
blocks: [],
|
||||
blockedBy,
|
||||
|
|
|
|||
|
|
@ -102,6 +102,68 @@ export class TeamMemberLogsFinder {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns session logs that reference the given task (TaskCreate, TaskUpdate, comments, etc.).
|
||||
*/
|
||||
async findLogsForTask(teamName: string, taskId: string): Promise<MemberLogSummary[]> {
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) return [];
|
||||
|
||||
const { projectDir, projectId, config, sessionIds, knownMembers } = discovery;
|
||||
const results: MemberLogSummary[] = [];
|
||||
const leadMemberName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
|
||||
if (config.leadSessionId) {
|
||||
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
|
||||
try {
|
||||
await fs.access(leadJsonl);
|
||||
if (await this.fileMentionsTaskId(leadJsonl, taskId)) {
|
||||
const leadSummary = await this.parseLeadSessionSummary(
|
||||
leadJsonl,
|
||||
projectId,
|
||||
config.leadSessionId,
|
||||
leadMemberName
|
||||
);
|
||||
if (leadSummary) results.push(leadSummary);
|
||||
}
|
||||
} catch {
|
||||
// file missing or unreadable
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(subagentsDir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
|
||||
if (file.startsWith('agent-acompact')) continue;
|
||||
const filePath = path.join(subagentsDir, file);
|
||||
if (!(await this.fileMentionsTaskId(filePath, taskId))) continue;
|
||||
const attribution = await this.attributeSubagent(filePath, knownMembers);
|
||||
if (!attribution) continue;
|
||||
const summary = await this.parseSubagentSummary(
|
||||
filePath,
|
||||
projectId,
|
||||
sessionId,
|
||||
file,
|
||||
attribution.detectedMember,
|
||||
knownMembers
|
||||
);
|
||||
if (summary) results.push(summary);
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort(
|
||||
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns absolute paths to all JSONL files belonging to the specified member.
|
||||
* Uses the same discovery logic as findMemberLogs but collects file paths.
|
||||
|
|
@ -149,16 +211,12 @@ export class TeamMemberLogsFinder {
|
|||
return paths;
|
||||
}
|
||||
|
||||
private async discoverMemberFiles(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<{
|
||||
private async discoverProjectSessions(teamName: string): Promise<{
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
|
||||
sessionIds: string[];
|
||||
knownMembers: Set<string>;
|
||||
isLeadMember: boolean;
|
||||
} | null> {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (!config?.projectPath) {
|
||||
|
|
@ -171,11 +229,6 @@ export class TeamMemberLogsFinder {
|
|||
const baseDir = extractBaseDir(projectId);
|
||||
const projectDir = path.join(getProjectsBasePath(), baseDir);
|
||||
|
||||
const leadMemberName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
|
||||
|
||||
// Collect all known session IDs: current lead + history
|
||||
const knownSessionIds = new Set<string>();
|
||||
if (config.leadSessionId) {
|
||||
knownSessionIds.add(config.leadSessionId);
|
||||
|
|
@ -190,17 +243,14 @@ export class TeamMemberLogsFinder {
|
|||
|
||||
let sessionIds: string[];
|
||||
if (knownSessionIds.size > 0) {
|
||||
// Verify each known session dir exists, fall back to full scan if none exist
|
||||
const verified: string[] = [];
|
||||
for (const sid of knownSessionIds) {
|
||||
const sidDir = path.join(projectDir, sid);
|
||||
try {
|
||||
const stat = await fs.stat(sidDir);
|
||||
if (stat.isDirectory()) {
|
||||
verified.push(sid);
|
||||
}
|
||||
if (stat.isDirectory()) verified.push(sid);
|
||||
} catch {
|
||||
// dir doesn't exist, skip
|
||||
// dir doesn't exist
|
||||
}
|
||||
}
|
||||
sessionIds = verified.length > 0 ? verified : await this.listSessionDirs(projectDir);
|
||||
|
|
@ -217,26 +267,69 @@ export class TeamMemberLogsFinder {
|
|||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
for (const member of metaMembers) {
|
||||
const normalized = member.name.trim().toLowerCase();
|
||||
if (normalized.length > 0) {
|
||||
knownMembers.add(normalized);
|
||||
}
|
||||
if (normalized.length > 0) knownMembers.add(normalized);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort enrichment.
|
||||
// best-effort
|
||||
}
|
||||
try {
|
||||
const inboxMembers = await this.inboxReader.listInboxNames(teamName);
|
||||
for (const memberNameFromInbox of inboxMembers) {
|
||||
const normalized = memberNameFromInbox.trim().toLowerCase();
|
||||
if (normalized.length > 0) {
|
||||
knownMembers.add(normalized);
|
||||
}
|
||||
for (const name of inboxMembers) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (normalized.length > 0) knownMembers.add(normalized);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort enrichment.
|
||||
// best-effort
|
||||
}
|
||||
|
||||
return { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember };
|
||||
return { projectDir, projectId, config, sessionIds, knownMembers };
|
||||
}
|
||||
|
||||
private async discoverMemberFiles(
|
||||
teamName: string,
|
||||
memberName: string
|
||||
): Promise<{
|
||||
projectDir: string;
|
||||
projectId: string;
|
||||
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
|
||||
sessionIds: string[];
|
||||
knownMembers: Set<string>;
|
||||
isLeadMember: boolean;
|
||||
} | null> {
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) return null;
|
||||
const { config, knownMembers } = discovery;
|
||||
const leadMemberName =
|
||||
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
|
||||
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
|
||||
return { ...discovery, isLeadMember };
|
||||
}
|
||||
|
||||
private async fileMentionsTaskId(filePath: string, taskId: string): Promise<boolean> {
|
||||
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const patterns = [
|
||||
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
|
||||
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
|
||||
new RegExp(`#${escaped}\\b`),
|
||||
];
|
||||
try {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
for await (const line of rl) {
|
||||
for (const re of patterns) {
|
||||
if (re.test(line)) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async listSessionDirs(projectDir: string): Promise<string[]> {
|
||||
|
|
|
|||
|
|
@ -263,16 +263,25 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
|
||||
const leadName =
|
||||
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
|
||||
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||
You are "${leadName}", the team lead.
|
||||
|
||||
Goal: Provision a Claude Code agent team with live teammates.
|
||||
|
||||
${userPromptBlock}
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite — use TaskCreate for tasks.
|
||||
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
||||
- Do NOT shut down, terminate, or clean up the team or its members.
|
||||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
|
||||
Task board operations — use teamctl.js via Bash:
|
||||
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"
|
||||
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
|
|
@ -287,8 +296,10 @@ Steps (execute in this exact order):
|
|||
|
||||
${taskProtocol}"
|
||||
|
||||
3) After spawning all members, output a short summary.
|
||||
${userPromptBlock}
|
||||
3) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
|
||||
4) After all steps, output a short summary.
|
||||
|
||||
Members:
|
||||
${members}
|
||||
`;
|
||||
|
|
@ -304,16 +315,24 @@ function buildLaunchPrompt(
|
|||
: '';
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
|
||||
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
|
||||
return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||
You are "${leadName}", the team lead.
|
||||
|
||||
Goal: Reconnect with existing team "${request.teamName}".
|
||||
|
||||
${userPromptBlock}
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite — use TaskCreate for tasks.
|
||||
- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN).
|
||||
- Do NOT shut down, terminate, or clean up the team or its members.
|
||||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
|
||||
Task board operations — use teamctl.js via Bash:
|
||||
- Create task: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task create --subject "..." --description "..." --owner "<actual-member-name>" --notify --from "${leadName}"
|
||||
- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task set-status <id> <pending|in_progress|completed|deleted>
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
|
|
@ -329,8 +348,10 @@ Steps (execute in this exact order):
|
|||
|
||||
${taskProtocol}"
|
||||
|
||||
4) After spawning all members, output a short summary.
|
||||
${userPromptBlock}
|
||||
4) If user instructions above mention tasks or work for members — create each task via teamctl.js (see "Task board operations"). The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.
|
||||
|
||||
5) After all steps, output a short summary.
|
||||
|
||||
Members:
|
||||
${membersBlock}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -242,6 +242,9 @@ export const TEAM_CREATE_CONFIG = 'team:createConfig';
|
|||
/** Get member subagent logs */
|
||||
export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs';
|
||||
|
||||
/** Get session logs that reference a task */
|
||||
export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask';
|
||||
|
||||
/** Update team config (name, description) */
|
||||
export const TEAM_UPDATE_CONFIG = 'team:updateConfig';
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_LAUNCH,
|
||||
|
|
@ -572,6 +573,9 @@ const electronAPI: ElectronAPI = {
|
|||
getMemberLogs: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_MEMBER_LOGS, teamName, memberName);
|
||||
},
|
||||
getLogsForTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_LOGS_FOR_TASK, teamName, taskId);
|
||||
},
|
||||
getMemberStats: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -692,6 +692,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
console.warn('[HttpAPIClient] getMemberLogs is not available in browser mode');
|
||||
return [];
|
||||
},
|
||||
getLogsForTask: async () => {
|
||||
return [];
|
||||
},
|
||||
getMemberStats: async () => {
|
||||
console.warn('[HttpAPIClient] getMemberStats is not available in browser mode');
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string
|
|||
'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' },
|
||||
'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' },
|
||||
'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' },
|
||||
'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' },
|
||||
'user-message': { bg: 'rgba(249, 115, 22, 0.15)', text: '#fb923c', label: 'User' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string
|
|||
'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' },
|
||||
'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' },
|
||||
'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' },
|
||||
'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' },
|
||||
'user-message': { bg: 'rgba(249, 115, 22, 0.15)', text: '#fb923c', label: 'User' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return (
|
||||
<div className="size-full overflow-auto p-4">
|
||||
<div className="mb-4 h-10 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||||
<TeamProvisioningBanner teamName={teamName} />
|
||||
<div className="space-y-3">
|
||||
<div className="h-24 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||||
<div className="h-48 animate-pulse rounded-md bg-[var(--color-surface-raised)]" />
|
||||
|
|
@ -611,6 +612,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
<ActivityTimeline
|
||||
messages={filteredMessages}
|
||||
members={data.members}
|
||||
onMemberClick={setSelectedMember}
|
||||
onCreateTaskFromMessage={(subject, description) => {
|
||||
openCreateTaskDialog(subject, description);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -98,10 +98,14 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
fetchTeams,
|
||||
openTeamTab,
|
||||
deleteTeam,
|
||||
selectedProjectId,
|
||||
projects,
|
||||
globalTasks,
|
||||
fetchAllTasks,
|
||||
viewMode,
|
||||
repositoryGroups,
|
||||
selectedRepositoryId,
|
||||
selectedWorktreeId,
|
||||
activeProjectId,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
teams: s.teams,
|
||||
|
|
@ -110,10 +114,14 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
fetchTeams: s.fetchTeams,
|
||||
openTeamTab: s.openTeamTab,
|
||||
deleteTeam: s.deleteTeam,
|
||||
selectedProjectId: s.selectedProjectId,
|
||||
projects: s.projects,
|
||||
globalTasks: s.globalTasks,
|
||||
fetchAllTasks: s.fetchAllTasks,
|
||||
viewMode: s.viewMode,
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
selectedRepositoryId: s.selectedRepositoryId,
|
||||
selectedWorktreeId: s.selectedWorktreeId,
|
||||
activeProjectId: s.activeProjectId,
|
||||
}))
|
||||
);
|
||||
const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
|
||||
|
|
@ -144,11 +152,23 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
};
|
||||
}, [electronMode, teams]);
|
||||
|
||||
const selectedProjectPath = useMemo(() => {
|
||||
if (!selectedProjectId) return null;
|
||||
const project = projects.find((p) => p.id === selectedProjectId);
|
||||
const currentProjectPath = useMemo(() => {
|
||||
if (viewMode === 'grouped') {
|
||||
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
|
||||
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId);
|
||||
const path = worktree?.path ?? null;
|
||||
return path ? normalizePath(path) : null;
|
||||
}
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
return project ? normalizePath(project.path) : null;
|
||||
}, [selectedProjectId, projects]);
|
||||
}, [
|
||||
viewMode,
|
||||
repositoryGroups,
|
||||
selectedRepositoryId,
|
||||
selectedWorktreeId,
|
||||
projects,
|
||||
activeProjectId,
|
||||
]);
|
||||
|
||||
const filteredTeams = useMemo<TeamSummary[]>(() => {
|
||||
let result = teams;
|
||||
|
|
@ -163,10 +183,10 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
);
|
||||
}
|
||||
|
||||
if (selectedProjectPath) {
|
||||
if (currentProjectPath) {
|
||||
const matches = (t: TeamSummary): boolean => {
|
||||
if (t.projectPath && normalizePath(t.projectPath) === selectedProjectPath) return true;
|
||||
return t.projectPathHistory?.some((p) => normalizePath(p) === selectedProjectPath) ?? false;
|
||||
if (t.projectPath && normalizePath(t.projectPath) === currentProjectPath) return true;
|
||||
return t.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? false;
|
||||
};
|
||||
result = [...result].sort((a, b) => {
|
||||
const aMatch = matches(a) ? 0 : 1;
|
||||
|
|
@ -176,7 +196,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
}
|
||||
|
||||
return result;
|
||||
}, [teams, searchQuery, selectedProjectPath]);
|
||||
}, [teams, searchQuery, currentProjectPath]);
|
||||
|
||||
const handleDeleteTeam = useCallback(
|
||||
(teamName: string, e: React.MouseEvent) => {
|
||||
|
|
@ -249,6 +269,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
provisioningError={provisioningError}
|
||||
existingTeamNames={teams.map((t) => t.teamName)}
|
||||
initialData={copyData ?? undefined}
|
||||
defaultProjectPath={currentProjectPath}
|
||||
onClose={() => {
|
||||
setShowCreateDialog(false);
|
||||
setCopyData(null);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ interface ActivityItemProps {
|
|||
message: InboxMessage;
|
||||
memberRole?: string;
|
||||
memberColor?: string;
|
||||
recipientColor?: string;
|
||||
onMemberNameClick?: (memberName: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
}
|
||||
|
|
@ -124,10 +126,13 @@ export const ActivityItem = ({
|
|||
message,
|
||||
memberRole,
|
||||
memberColor,
|
||||
recipientColor,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
}: ActivityItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||
const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null;
|
||||
const formattedRole = formatAgentRole(memberRole);
|
||||
|
||||
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
||||
|
|
@ -217,17 +222,35 @@ export const ActivityItem = ({
|
|||
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||
)}
|
||||
|
||||
{/* Name badge */}
|
||||
<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`,
|
||||
}}
|
||||
>
|
||||
{message.from}
|
||||
</span>
|
||||
{/* Name badge — clickable to open member popup */}
|
||||
{onMemberNameClick ? (
|
||||
<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={(e) => {
|
||||
e.stopPropagation();
|
||||
onMemberNameClick(message.from);
|
||||
}}
|
||||
>
|
||||
{message.from}
|
||||
</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`,
|
||||
}}
|
||||
>
|
||||
{message.from}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Role */}
|
||||
{formattedRole ? (
|
||||
|
|
@ -254,10 +277,25 @@ export const ActivityItem = ({
|
|||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Recipient */}
|
||||
{/* Recipient — clickable to open member popup */}
|
||||
{message.to && message.to !== message.from ? (
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
→ {message.to}
|
||||
<span className="text-[10px]">
|
||||
<span style={{ color: CARD_ICON_MUTED }}>→ </span>
|
||||
{onMemberNameClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{ color: recipientColors?.text ?? CARD_ICON_MUTED }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMemberNameClick(message.to!);
|
||||
}}
|
||||
>
|
||||
{message.to}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ color: recipientColors?.text ?? CARD_ICON_MUTED }}>{message.to}</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface ActivityTimelineProps {
|
|||
members?: ResolvedTeamMember[];
|
||||
onCreateTaskFromMessage?: (subject: string, description: string) => void;
|
||||
onReplyToMessage?: (message: InboxMessage) => void;
|
||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||
}
|
||||
|
||||
export const ActivityTimeline = ({
|
||||
|
|
@ -14,17 +15,27 @@ export const ActivityTimeline = ({
|
|||
members,
|
||||
onCreateTaskFromMessage,
|
||||
onReplyToMessage,
|
||||
onMemberClick,
|
||||
}: ActivityTimelineProps): React.JSX.Element => {
|
||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||
if (members) {
|
||||
for (const m of members) {
|
||||
memberInfo.set(m.name, {
|
||||
const info = {
|
||||
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
|
||||
color: m.color,
|
||||
});
|
||||
};
|
||||
memberInfo.set(m.name, info);
|
||||
if (m.agentType && m.agentType !== m.name) {
|
||||
memberInfo.set(m.agentType, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemberNameClick = (name: string): void => {
|
||||
const member = members?.find((m) => m.name === name || m.agentType === name);
|
||||
if (member) onMemberClick?.(member);
|
||||
};
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||
|
|
@ -38,12 +49,15 @@ export const ActivityTimeline = ({
|
|||
<div className="space-y-1">
|
||||
{messages.slice(0, 200).map((message, index) => {
|
||||
const info = memberInfo.get(message.from);
|
||||
const recipientInfo = message.to ? memberInfo.get(message.to) : undefined;
|
||||
return (
|
||||
<ActivityItem
|
||||
key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`}
|
||||
message={message}
|
||||
memberRole={info?.role}
|
||||
memberColor={info?.color}
|
||||
recipientColor={recipientInfo?.color}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -61,6 +62,7 @@ interface CreateTeamDialogProps {
|
|||
provisioningError: string | null;
|
||||
existingTeamNames: string[];
|
||||
initialData?: TeamCopyData;
|
||||
defaultProjectPath?: string | null;
|
||||
onClose: () => void;
|
||||
onCreate: (request: TeamCreateRequest) => Promise<void>;
|
||||
onOpenTeam: (teamName: string, projectPath?: string) => void;
|
||||
|
|
@ -230,6 +232,7 @@ export const CreateTeamDialog = ({
|
|||
provisioningError,
|
||||
existingTeamNames,
|
||||
initialData,
|
||||
defaultProjectPath,
|
||||
onClose,
|
||||
onCreate,
|
||||
onOpenTeam,
|
||||
|
|
@ -420,8 +423,15 @@ export const CreateTeamDialog = ({
|
|||
if (selectedProjectPath || projects.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (defaultProjectPath) {
|
||||
const match = projects.find((p) => normalizePath(p.path) === defaultProjectPath);
|
||||
if (match) {
|
||||
setSelectedProjectPath(match.path);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSelectedProjectPath(projects[0].path);
|
||||
}, [cwdMode, projects, selectedProjectPath]);
|
||||
}, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,14 @@ import {
|
|||
} from '@renderer/components/ui/dialog';
|
||||
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, FileText, User } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeftFromLine,
|
||||
ArrowRightFromLine,
|
||||
Clock,
|
||||
FileText,
|
||||
PenLine,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||
|
||||
|
|
@ -92,6 +99,12 @@ export const TaskDetailDialog = ({
|
|||
{currentTask.owner ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
{currentTask.createdBy ? (
|
||||
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
||||
<PenLine size={12} />
|
||||
<span className="text-[var(--color-text-secondary)]">{currentTask.createdBy}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{currentTask.createdAt
|
||||
? (() => {
|
||||
const date = new Date(currentTask.createdAt);
|
||||
|
|
@ -204,18 +217,12 @@ export const TaskDetailDialog = ({
|
|||
{/* Separator */}
|
||||
<div className="border-t border-[var(--color-border)]" />
|
||||
|
||||
{/* Session Logs */}
|
||||
{/* Session Logs — sessions that reference this task */}
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]">
|
||||
Execution Logs
|
||||
</h4>
|
||||
{currentTask.owner ? (
|
||||
<MemberLogsTab teamName={teamName} memberName={currentTask.owner} />
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-[var(--color-text-muted)]">
|
||||
Assign a member to see execution logs
|
||||
</p>
|
||||
)}
|
||||
<MemberLogsTab teamName={teamName} taskId={currentTask.id} />
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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';
|
||||
|
|
@ -7,6 +8,7 @@ import type { ResolvedTeamMember } from '@shared/types';
|
|||
|
||||
interface MemberCardProps {
|
||||
member: ResolvedTeamMember;
|
||||
memberColor: string;
|
||||
isTeamAlive?: boolean;
|
||||
onClick?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
|
|
@ -15,6 +17,7 @@ interface MemberCardProps {
|
|||
|
||||
export const MemberCard = ({
|
||||
member,
|
||||
memberColor,
|
||||
isTeamAlive,
|
||||
onClick,
|
||||
onSendMessage,
|
||||
|
|
@ -22,10 +25,15 @@ export const MemberCard = ({
|
|||
}: MemberCardProps): React.JSX.Element => {
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive);
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]"
|
||||
style={{
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
backgroundColor: colors.badge,
|
||||
}}
|
||||
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
|
||||
import { MemberCard } from './MemberCard';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
|
@ -27,10 +29,11 @@ export const MemberList = ({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{members.map((member) => (
|
||||
{members.map((member, index) => (
|
||||
<MemberCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
memberColor={member.color ?? getMemberColor(index)}
|
||||
isTeamAlive={isTeamAlive}
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,15 @@ import type { MemberLogSummary } from '@shared/types';
|
|||
|
||||
interface MemberLogsTabProps {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
memberName?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): React.JSX.Element => {
|
||||
export const MemberLogsTab = ({
|
||||
teamName,
|
||||
memberName,
|
||||
taskId,
|
||||
}: MemberLogsTabProps): React.JSX.Element => {
|
||||
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -36,7 +41,10 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
|
|||
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await api.teams.getMemberLogs(teamName, memberName);
|
||||
const result =
|
||||
taskId != null
|
||||
? await api.teams.getLogsForTask(teamName, taskId)
|
||||
: await api.teams.getMemberLogs(teamName, memberName ?? '');
|
||||
if (!cancelled) {
|
||||
setLogs(result);
|
||||
}
|
||||
|
|
@ -54,7 +62,7 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, memberName]);
|
||||
}, [teamName, memberName, taskId]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
async (log: MemberLogSummary) => {
|
||||
|
|
@ -112,7 +120,9 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
|
|||
<FileText size={20} className="mx-auto mb-2 opacity-40" />
|
||||
No logs found
|
||||
<p className="mt-1 text-[10px] opacity-60">
|
||||
This member has no recorded session activity yet
|
||||
{taskId != null
|
||||
? 'No session activity for this task yet'
|
||||
: 'This member has no recorded session activity yet'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,22 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
textareaRef: internalRef,
|
||||
});
|
||||
|
||||
// Sync backdrop font with textarea computed font to prevent caret drift.
|
||||
// Chromium UA stylesheet may apply different font-family / letter-spacing
|
||||
// to <textarea> vs <div>, and sub-pixel differences accumulate over text length.
|
||||
React.useLayoutEffect(() => {
|
||||
const textarea = internalRef.current;
|
||||
const backdrop = backdropRef.current;
|
||||
if (!textarea || !backdrop) return;
|
||||
const cs = window.getComputedStyle(textarea);
|
||||
backdrop.style.font = cs.font;
|
||||
backdrop.style.letterSpacing = cs.letterSpacing;
|
||||
backdrop.style.wordSpacing = cs.wordSpacing;
|
||||
backdrop.style.textIndent = cs.textIndent;
|
||||
backdrop.style.textTransform = cs.textTransform;
|
||||
backdrop.style.tabSize = cs.tabSize;
|
||||
}, [value]); // re-sync when value changes (textarea may reflow)
|
||||
|
||||
// --- Mention overlay ---
|
||||
const hasMentionOverlay = suggestions.length > 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -28,16 +28,16 @@
|
|||
--highlight-text-inactive: #fef08a;
|
||||
--highlight-ring: #facc15;
|
||||
|
||||
/* User chat bubble — cool slate */
|
||||
/* User chat bubble — orange (Claude Code) */
|
||||
--chat-user-bg: #1c1d26;
|
||||
--chat-user-text: #94a3b8;
|
||||
--chat-user-border: rgba(148, 163, 184, 0.1);
|
||||
--chat-user-shadow: 0 1px 0 0 rgba(99, 102, 241, 0.04);
|
||||
--chat-user-border: rgba(249, 115, 22, 0.2);
|
||||
--chat-user-shadow: 0 1px 0 0 rgba(249, 115, 22, 0.06);
|
||||
|
||||
/* User bubble inline tags */
|
||||
--chat-user-tag-bg: rgba(148, 163, 184, 0.08);
|
||||
--chat-user-tag-text: #e2e8f0;
|
||||
--chat-user-tag-border: rgba(148, 163, 184, 0.12);
|
||||
--chat-user-tag-bg: rgba(249, 115, 22, 0.12);
|
||||
--chat-user-tag-text: #fb923c;
|
||||
--chat-user-tag-border: rgba(249, 115, 22, 0.2);
|
||||
|
||||
/* Tool items */
|
||||
--tool-item-name: #e2e8f0;
|
||||
|
|
@ -218,16 +218,16 @@
|
|||
--highlight-text-inactive: #422006;
|
||||
--highlight-ring: #ca8a04;
|
||||
|
||||
/* User chat bubble - Warm neutral, clearly visible */
|
||||
/* User chat bubble - orange (Claude Code) */
|
||||
--chat-user-bg: #eae9e6;
|
||||
--chat-user-text: #5a5955;
|
||||
--chat-user-border: #d5d3cf;
|
||||
--chat-user-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
|
||||
--chat-user-border: rgba(249, 115, 22, 0.35);
|
||||
--chat-user-shadow: 0 1px 2px 0 rgba(249, 115, 22, 0.08);
|
||||
|
||||
/* User bubble inline tags - Warm neutral */
|
||||
--chat-user-tag-bg: rgba(0, 0, 0, 0.05);
|
||||
--chat-user-tag-text: #3a3935;
|
||||
--chat-user-tag-border: rgba(0, 0, 0, 0.08);
|
||||
/* User bubble inline tags */
|
||||
--chat-user-tag-bg: rgba(249, 115, 22, 0.12);
|
||||
--chat-user-tag-text: #c2410c;
|
||||
--chat-user-tag-border: rgba(249, 115, 22, 0.25);
|
||||
|
||||
/* Tool items - Warm high contrast */
|
||||
--tool-item-name: #1c1b19;
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ export interface TeamsAPI {
|
|||
aliveList: () => Promise<string[]>;
|
||||
createConfig: (request: TeamCreateConfigRequest) => Promise<void>;
|
||||
getMemberLogs: (teamName: string, memberName: string) => Promise<MemberLogSummary[]>;
|
||||
getLogsForTask: (teamName: string, taskId: string) => Promise<MemberLogSummary[]>;
|
||||
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
|
||||
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
||||
getAllTasks: () => Promise<GlobalTask[]>;
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export interface TeamTask {
|
|||
description?: string;
|
||||
activeForm?: string;
|
||||
owner?: string;
|
||||
createdBy?: string;
|
||||
status: TeamTaskStatus;
|
||||
blocks?: string[];
|
||||
blockedBy?: string[];
|
||||
|
|
|
|||
|
|
@ -121,4 +121,46 @@ describe('TeamDataService', () => {
|
|||
expect.objectContaining({ projectPath: '/Users/dev/my-project' })
|
||||
);
|
||||
});
|
||||
|
||||
it('creates task with status pending when startImmediately is false', async () => {
|
||||
const createTaskMock = vi.fn(async () => undefined);
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [] })),
|
||||
} as never,
|
||||
{
|
||||
getNextTaskId: vi.fn(async () => '2'),
|
||||
getTasks: vi.fn(async () => []),
|
||||
} as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{
|
||||
createTask: createTaskMock,
|
||||
addBlocksEntry: vi.fn(async () => undefined),
|
||||
} as never,
|
||||
{
|
||||
resolveMembers: vi.fn(() => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
garbageCollect: vi.fn(async () => undefined),
|
||||
} as never
|
||||
);
|
||||
|
||||
const result = await service.createTask('my-team', {
|
||||
subject: 'Review main file',
|
||||
owner: 'alice',
|
||||
startImmediately: false,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.objectContaining({ status: 'pending', owner: 'alice' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue