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:
iliya 2026-02-23 11:37:31 +02:00
parent 6bcc525ace
commit 55f7611b14
24 changed files with 412 additions and 90 deletions

View file

@ -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');

View file

@ -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>]',

View file

@ -244,6 +244,7 @@ export class TeamDataService {
subject: request.subject,
description,
owner: request.owner,
createdBy: 'user',
status: shouldStart ? 'in_progress' : 'pending',
blocks: [],
blockedBy,

View file

@ -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[]> {

View file

@ -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}
`;

View file

@ -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';

View file

@ -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);
},

View file

@ -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 {

View file

@ -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' },
};
// =============================================================================

View file

@ -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' },
};
// =============================================================================

View file

@ -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);
}}

View file

@ -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);

View file

@ -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 }}>
&rarr; {message.to}
<span className="text-[10px]">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </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}

View file

@ -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}
/>

View file

@ -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();

View file

@ -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>

View file

@ -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}

View file

@ -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)}

View file

@ -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>
);

View file

@ -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;

View file

@ -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;

View file

@ -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[]>;

View file

@ -60,6 +60,7 @@ export interface TeamTask {
description?: string;
activeForm?: string;
owner?: string;
createdBy?: string;
status: TeamTaskStatus;
blocks?: string[];
blockedBy?: string[];

View file

@ -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' })
);
});
});