Merge agent_teams_features: skip permissions in headless mode

This commit is contained in:
iliya 2026-02-23 14:18:31 +02:00
commit 3be090add3
39 changed files with 1308 additions and 427 deletions

View file

@ -62,6 +62,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-virtual": "^3.10.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -47,6 +47,9 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-virtual':
specifier: ^3.10.8
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -1294,6 +1297,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@ -6176,6 +6192,26 @@ snapshots:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1

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

@ -203,6 +203,20 @@ export class TeamDataService {
tasks,
messages
);
// Auto-sync: create comments from task-related inbox messages
if (tasksLoaded && messages.length > 0) {
try {
const didSync = await this.syncLinkedComments(teamName, tasks, messages);
if (didSync) {
// Re-read tasks only if new comments were actually written
tasks = await this.taskReader.getTasks(teamName);
}
} catch {
warnings.push('Comment sync from messages failed');
}
}
return {
teamName,
config,
@ -244,6 +258,7 @@ export class TeamDataService {
subject: request.subject,
description,
owner: request.owner,
createdBy: 'user',
status: shouldStart ? 'in_progress' : 'pending',
blocks: [],
blockedBy,
@ -436,6 +451,60 @@ export class TeamDataService {
);
}
/**
* Scans inbox messages for task-related discussions and auto-creates
* linked comments on disk. Uses deterministic comment ID for dedup.
* Returns true if any new comments were synced (caller should re-read tasks).
*/
private async syncLinkedComments(
teamName: string,
tasks: TeamTask[],
messages: InboxMessage[]
): Promise<boolean> {
const TASK_ID_PATTERN = /#(\d+)/g;
let synced = false;
// Dedup broadcasts: same sender + same text → process only once
const processedTexts = new Set<string>();
for (const msg of messages) {
if (!msg.messageId || !msg.summary || msg.from === 'user') continue;
if (msg.source === 'lead_session') continue;
const textKey = `${msg.from}\0${msg.text}`;
if (processedTexts.has(textKey)) continue;
processedTexts.add(textKey);
const matches = msg.summary.matchAll(TASK_ID_PATTERN);
const taskIds = new Set<string>();
for (const match of matches) {
taskIds.add(match[1]);
}
for (const taskId of taskIds) {
const task = tasks.find((t) => t.id === taskId);
if (!task) continue;
const commentId = `msg-${msg.messageId}`;
const existing = task.comments ?? [];
if (existing.some((c) => c.id === commentId)) continue;
try {
await this.taskWriter.addComment(teamName, taskId, msg.text, {
id: commentId,
author: msg.from,
createdAt: msg.timestamp,
});
synced = true;
} catch {
// Best-effort — don't fail getTeamData() on sync errors
}
}
}
return synced;
}
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
if (!config.leadSessionId || !config.projectPath) {
return [];

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

@ -251,6 +251,10 @@ function buildTaskStatusProtocol(teamName: string): string {
5. NEVER skip status updates. A task is NOT done until completed status is written.
6. To reply to a comment on a task:
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment <taskId> --text \\"<your reply>\\" --from \\"<your-name>\\"
7. When discussing a task with a teammate and you have important findings, decisions, blockers, or progress updates record them as a task comment:
node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task comment <taskId> --text \\"<summary of your finding or decision>\\" --from \\"<your-name>\\"
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
8. When sending a message about a specific task, include #<taskId> in your SendMessage summary field for traceability.
Failure to follow this protocol means the task board will show incorrect status.`;
}
@ -263,16 +267,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 +300,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 +319,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 +352,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}
`;
@ -619,6 +644,7 @@ export class TeamProvisioningService {
'user,project,local',
'--disallowedTools',
'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
],
{
cwd: request.cwd,
@ -888,6 +914,7 @@ export class TeamProvisioningService {
'user,project,local',
'--disallowedTools',
'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
];
if (previousSessionId) {
launchArgs.push('--resume', previousSessionId);

View file

@ -117,19 +117,28 @@ export class TeamTaskWriter {
});
}
async addComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
async addComment(
teamName: string,
taskId: string,
text: string,
options?: { id?: string; author?: string; createdAt?: string }
): Promise<TaskComment> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const comment: TaskComment = {
id: randomUUID(),
author: 'user',
id: options?.id ?? randomUUID(),
author: options?.author ?? 'user',
text,
createdAt: new Date().toISOString(),
createdAt: options?.createdAt ?? new Date().toISOString(),
};
await withTaskLock(taskPath, async () => {
const raw = await fs.promises.readFile(taskPath, 'utf8');
const task = JSON.parse(raw) as Record<string, unknown>;
const existing = Array.isArray(task.comments) ? (task.comments as TaskComment[]) : [];
// Dedup by ID — skip if comment with same ID already exists
if (existing.some((c) => c.id === comment.id)) {
return;
}
task.comments = [...existing, comment];
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));

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

@ -1,5 +1,7 @@
import React, { useEffect } from 'react';
import { TooltipProvider } from '@renderer/components/ui/tooltip';
import { ConfirmDialog } from './components/common/ConfirmDialog';
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
import { ErrorBoundary } from './components/common/ErrorBoundary';
@ -43,9 +45,11 @@ export const App = (): React.JSX.Element => {
return (
<ErrorBoundary>
<ContextSwitchOverlay />
<TabbedLayout />
<ConfirmDialog />
<TooltipProvider delayDuration={300}>
<ContextSwitchOverlay />
<TabbedLayout />
<ConfirmDialog />
</TooltipProvider>
</ErrorBoundary>
);
};

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

@ -5,6 +5,7 @@ import { Button } from '@renderer/components/ui/button';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
@ -19,11 +20,13 @@ import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
import type { Session } from '@renderer/types/data';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
@ -134,6 +137,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
);
const [kanbanSearch, setKanbanSearch] = useState('');
const [messagesSearchQuery, setMessagesSearchQuery] = useState('');
const [messagesFilter, setMessagesFilter] = useState<MessagesFilterState>({
from: new Set(),
to: new Set(),
});
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
useEffect(() => {
if (!teamName) {
@ -251,12 +260,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const filteredMessages = useMemo(() => {
if (!data) return [];
if (!timeWindow) return data.messages;
return data.messages.filter((m) => {
const ts = new Date(m.timestamp).getTime();
return ts >= timeWindow.start && ts < timeWindow.end;
});
}, [data, timeWindow]);
let list = data.messages;
if (timeWindow) {
list = list.filter((m) => {
const ts = new Date(m.timestamp).getTime();
return ts >= timeWindow.start && ts < timeWindow.end;
});
}
if (messagesFilter.from.size > 0) {
list = list.filter((m) => m.from?.trim() && messagesFilter.from.has(m.from.trim()));
}
if (messagesFilter.to.size > 0) {
list = list.filter((m) => m.to?.trim() && messagesFilter.to.has(m.to.trim()));
}
const q = messagesSearchQuery.trim().toLowerCase();
if (q) {
list = list.filter((m) => {
const text = (m.text ?? '').toLowerCase();
const summary = (m.summary ?? '').toLowerCase();
const from = (m.from ?? '').toLowerCase();
const to = (m.to ?? '').toLowerCase();
return text.includes(q) || summary.includes(q) || from.includes(q) || to.includes(q);
});
}
return list;
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim();
@ -266,6 +294,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
setCreateTaskDialog({
open: true,
@ -351,6 +381,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)]" />
@ -459,6 +490,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen>
<MemberList
members={data.members}
memberTaskCounts={memberTaskCounts}
isTeamAlive={data.isAlive}
onMemberClick={setSelectedMember}
onSendMessage={(member) => {
@ -592,25 +624,47 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
badge={filteredMessages.length}
defaultOpen
action={
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setSendDialogRecipient(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
>
<MessageSquare size={12} />
Message
</Button>
<div className="flex items-center gap-2 pl-2">
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<input
type="search"
placeholder="Search..."
value={messagesSearchQuery}
onChange={(e) => setMessagesSearchQuery(e.target.value)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
</div>
<MessagesFilterPopover
filter={messagesFilter}
messages={data?.messages ?? []}
open={messagesFilterOpen}
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
/>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setSendDialogRecipient(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
>
<MessageSquare size={12} />
Message
</Button>
</div>
}
>
<ActivityTimeline
messages={filteredMessages}
members={data.members}
onMemberClick={setSelectedMember}
onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description);
}}

View file

@ -4,11 +4,17 @@ import { api, isElectronMode } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
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 { Copy, FolderOpen, Search, Trash2 } from 'lucide-react';
import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
@ -98,10 +104,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 +120,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 +158,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 +189,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 +202,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 +275,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);
@ -356,164 +383,197 @@ export const TeamListView = (): React.JSX.Element => {
}
return (
<div className="size-full overflow-auto p-4">
{renderHeader()}
<TooltipProvider delayDuration={300}>
<div className="size-full overflow-auto p-4">
{renderHeader()}
{filteredTeams.length === 0 && searchQuery.trim() ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot;
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className="group relative cursor-pointer overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
{filteredTeams.length === 0 && searchQuery.trim() ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot;
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className="group relative cursor-pointer overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
}}
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
title="Copy team"
>
<Copy size={14} />
</button>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
title="Delete team"
>
<Trash2 size={14} />
</button>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
}
}}
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
>
{m.name}
</span>
{m.role ? (
<span className="text-[9px] text-[var(--color-text-muted)]">
{m.role}
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<span key={m.name} className="inline-flex items-center gap-1">
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={
memberColor
? {
backgroundColor: memberColor.badge,
color: memberColor.text,
border: `1px solid ${memberColor.border}40`,
}
: undefined
}
>
{m.name}
</span>
) : null}
</span>
);
})
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
if (!tc || (tc.pending === 0 && tc.inProgress === 0 && tc.completed === 0)) {
{m.role ? (
<span className="text-[9px] text-[var(--color-text-muted)]">
{m.role}
</span>
) : null}
</span>
);
})
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return (
<Badge variant="secondary" className="text-[10px] font-normal">
Tasks: 0
</Badge>
<div className="mt-2 w-full space-y-1.5">
<div className="flex items-center gap-2">
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
);
}
})()}
</div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<>
{tc.inProgress > 0 && (
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
{tc.inProgress} active
</span>
)}
{tc.pending > 0 && (
<span className="inline-flex items-center rounded-full bg-yellow-500/15 px-1.5 py-0.5 text-[10px] font-medium text-yellow-400">
{tc.pending} pending
</span>
)}
{tc.completed > 0 && (
<span className="inline-flex items-center rounded-full bg-green-500/15 px-1.5 py-0.5 text-[10px] font-medium text-green-400">
{tc.completed} done
</span>
)}
</>
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && status === 'running' ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
})()}
</div>
{(() => {
const recentPaths = getRecentProjects(team);
if (recentPaths.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{recentPaths.map((p, i) => (
<span key={p} title={p}>
{i === 0 && status === 'running' ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < recentPaths.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
})()}
</div>
</div>
);
})}
</div>
)}
{createDialogElement}
</div>
);
})}
</div>
)}
{createDialogElement}
</div>
</TooltipProvider>
);
};

View file

@ -1,5 +1,6 @@
import { useCallback, useMemo } from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import { formatDistanceToNowStrict } from 'date-fns';
import {
@ -205,29 +206,40 @@ const SessionRow = ({
</button>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
title={isSelected ? 'Remove filter' : 'Filter by this session'}
className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
onToggleFilter();
}}
>
{isSelected ? <FilterX size={12} /> : <Filter size={12} />}
</button>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<ExternalLink size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
onToggleFilter();
}}
>
{isSelected ? <FilterX size={12} /> : <Filter size={12} />}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{isSelected ? 'Remove filter' : 'Filter by this session'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<ExternalLink size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Open session</TooltipContent>
</Tooltip>
</div>
</div>
);

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,57 @@ export const ActivityItem = ({
</span>
) : null}
{/* Recipient */}
{message.to && message.to !== message.from ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
&rarr; {message.to}
{/* Recipient — badge like sender, clickable to open member popup */}
{message.to && message.to !== message.from && recipientColors ? (
<span className="text-[10px]">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </span>
{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: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
>
{message.to}
</span>
)}
</span>
) : message.to && message.to !== message.from ? (
<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: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span style={{ color: CARD_ICON_MUTED }}>{message.to}</span>
)}
</span>
) : null}

View file

@ -1,3 +1,5 @@
import { getMemberColorByName } from '@shared/constants/memberColors';
import { ActivityItem } from './ActivityItem';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
@ -7,6 +9,7 @@ interface ActivityTimelineProps {
members?: ResolvedTeamMember[];
onCreateTaskFromMessage?: (subject: string, description: string) => void;
onReplyToMessage?: (message: InboxMessage) => void;
onMemberClick?: (member: ResolvedTeamMember) => void;
}
export const ActivityTimeline = ({
@ -14,17 +17,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 +51,17 @@ 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;
const recipientColor =
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
return (
<ActivityItem
key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`}
message={message}
memberRole={info?.role}
memberColor={info?.color}
recipientColor={recipientColor}
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,
@ -259,6 +262,15 @@ export const CreateTeamDialog = ({
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const resetUIState = (): void => {
setLocalError(null);
setFieldErrors({});
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
};
const resetFormState = (): void => {
setTeamName('');
descriptionDraft.clearDraft();
@ -268,13 +280,8 @@ export const CreateTeamDialog = ({
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
setLocalError(null);
setFieldErrors({});
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
setLaunchTeam(true);
resetUIState();
};
useEffect(() => {
@ -420,8 +427,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();
@ -552,7 +566,7 @@ export const CreateTeamDialog = ({
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
resetFormState();
resetUIState();
onClose();
}
}}

View file

@ -19,6 +19,7 @@ import {
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
@ -180,13 +181,18 @@ export const SendMessageDialog = ({
{quote ? (
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<button
type="button"
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setQuote(undefined)}
>
<X size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setQuote(undefined)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Remove quote</TooltipContent>
</Tooltip>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to @{quote.from}
</span>

View file

@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store';
@ -156,13 +157,18 @@ export const TaskCommentsSection = ({
{replyTo.text}
</div>
</div>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setReplyTo(null)}
>
<X size={12} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
onClick={() => setReplyTo(null)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Cancel reply</TooltipContent>
</Tooltip>
</div>
) : null}

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,6 +1,7 @@
import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
import { Columns3, LayoutGrid } from 'lucide-react';
@ -138,36 +139,44 @@ export const KanbanBoard = ({
onFilterChange={onFilterChange}
/>
<div className="inline-flex rounded-md border border-[var(--color-border)]">
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-r-none px-2',
viewMode === 'grid'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
title="Grid"
>
<LayoutGrid size={14} />
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
viewMode === 'columns'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('columns')}
aria-label="Columns view"
title="Columns"
>
<Columns3 size={14} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-r-none px-2',
viewMode === 'grid'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
viewMode === 'columns'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Crown, Filter } from 'lucide-react';
import type { Session } from '@renderer/types/data';
@ -57,22 +58,26 @@ export const KanbanFilterPopover = ({
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter tasks"
title="Filter"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter tasks"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter tasks</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0">
{/* Session section */}
<div className="border-b border-[var(--color-border)] p-3">

View file

@ -1,12 +1,16 @@
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 type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberCardProps {
member: ResolvedTeamMember;
memberColor: string;
taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean;
onClick?: () => void;
onSendMessage?: () => void;
@ -15,6 +19,8 @@ interface MemberCardProps {
export const MemberCard = ({
member,
memberColor,
taskCounts,
isTeamAlive,
onClick,
onSendMessage,
@ -22,81 +28,108 @@ export const MemberCard = ({
}: MemberCardProps): React.JSX.Element => {
const dotClass = getMemberDotClass(member, isTeamAlive);
const presenceLabel = getPresenceLabel(member, isTeamAlive);
const colors = getTeamColorSet(memberColor);
const pending = taskCounts?.pending ?? 0;
const inProgress = taskCounts?.inProgress ?? 0;
const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
const progressPercent = Math.round(completedRatio * 100);
return (
<div
className="group flex cursor-pointer items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]"
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
>
<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)]"
<div className="rounded">
<div
className="group relative flex cursor-pointer items-center gap-2.5 rounded-t px-2 py-1.5"
style={{
borderLeft: `3px solid ${colors.border}`,
backgroundColor: colors.badge,
}}
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
>
{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="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}
>
<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>
<div
className="h-0.5 rounded-b bg-[var(--color-border)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
title={`${completed}/${totalTasks} tasks`}
style={{
background: `linear-gradient(to right, #10b981 ${progressPercent}%, var(--color-border) ${progressPercent}%)`,
}}
/>
</div>
);
};

View file

@ -1,9 +1,13 @@
import { getMemberColor } from '@shared/constants/memberColors';
import { MemberCard } from './MemberCard';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
memberTaskCounts?: Map<string, TaskStatusCounts>;
isTeamAlive?: boolean;
onMemberClick?: (member: ResolvedTeamMember) => void;
onSendMessage?: (member: ResolvedTeamMember) => void;
@ -12,6 +16,7 @@ interface MemberListProps {
export const MemberList = ({
members,
memberTaskCounts,
isTeamAlive,
onMemberClick,
onSendMessage,
@ -27,10 +32,12 @@ 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)}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
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,14 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
void (async () => {
try {
const result = await api.teams.getMemberLogs(teamName, memberName);
if (taskId == null && !memberName) {
if (!cancelled) setLogs([]);
return;
}
const result =
taskId != null
? await api.teams.getLogsForTask(teamName, taskId)
: await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) {
setLogs(result);
}
@ -54,7 +66,7 @@ export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): Rea
return () => {
cancelled = true;
};
}, [teamName, memberName]);
}, [teamName, memberName, taskId]);
const handleExpand = useCallback(
async (log: MemberLogSummary) => {
@ -112,7 +124,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

@ -0,0 +1,173 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types';
export interface MessagesFilterState {
from: Set<string>;
to: Set<string>;
}
interface MessagesFilterPopoverProps {
filter: MessagesFilterState;
messages: InboxMessage[];
open: boolean;
onOpenChange: (open: boolean) => void;
onApply: (filter: MessagesFilterState) => void;
}
function collectFromOptions(messages: InboxMessage[]): string[] {
const set = new Set<string>();
for (const m of messages) {
if (m.from?.trim()) set.add(m.from.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function collectToOptions(messages: InboxMessage[]): string[] {
const set = new Set<string>();
for (const m of messages) {
if (m.to?.trim()) set.add(m.to.trim());
}
return Array.from(set).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
export const MessagesFilterPopover = ({
filter,
messages,
open,
onOpenChange,
onApply,
}: MessagesFilterPopoverProps): React.JSX.Element => {
const [draft, setDraft] = useState<MessagesFilterState>({ from: new Set(), to: new Set() });
useEffect(() => {
if (open) {
const next = {
from: new Set(filter.from),
to: new Set(filter.to),
};
const schedule = (): void => setDraft(next);
queueMicrotask(schedule);
}
}, [open, filter.from, filter.to]);
const fromOptions = useMemo(() => collectFromOptions(messages), [messages]);
const toOptions = useMemo(() => collectToOptions(messages), [messages]);
const activeCount = (filter.from.size > 0 ? 1 : 0) + (filter.to.size > 0 ? 1 : 0);
const draftCount = (draft.from.size > 0 ? 1 : 0) + (draft.to.size > 0 ? 1 : 0);
const toggleFrom = (name: string): void => {
setDraft((prev) => {
const next = new Set(prev.from);
if (next.has(name)) next.delete(name);
else next.add(name);
return { ...prev, from: next };
});
};
const toggleTo = (name: string): void => {
setDraft((prev) => {
const next = new Set(prev.to);
if (next.has(name)) next.delete(name);
else next.add(name);
return { ...prev, to: next };
});
};
const handleSave = (): void => {
onApply(draft);
onOpenChange(false);
};
const handleReset = (): void => {
const empty = { from: new Set<string>(), to: new Set<string>() };
setDraft(empty);
onApply(empty);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter messages"
title="Filter"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
From
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{fromOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
fromOptions.map((name) => (
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox
checked={draft.from.has(name)}
onCheckedChange={() => toggleFrom(name)}
/>
{name}
</label>
))
)}
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
To
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{toOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
toOptions.map((name) => (
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
{name}
</label>
))
)}
</div>
</div>
<div className="flex justify-between gap-2 p-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={draftCount === 0}
onClick={handleReset}
>
Reset
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
Save
</Button>
</div>
</PopoverContent>
</Popover>
);
};

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

@ -84,7 +84,7 @@ export const Combobox = ({
</div>
<CommandPrimitive.List
id={listboxId}
className="max-h-72 overflow-y-auto overscroll-contain p-1"
className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
onWheel={(e) => e.stopPropagation()}
>
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
@ -111,7 +111,7 @@ export const Combobox = ({
setOpen(false);
setSearch('');
}}
className="relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-0 pr-2 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]"
>
{renderOption ? (
renderOption(option, isSelected, search)

View file

@ -0,0 +1,32 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@renderer/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipPortal = TooltipPrimitive.Portal;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 max-w-[var(--radix-tooltip-content-available-width)] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger };
/* eslint-enable react/jsx-props-no-spreading -- Standard Radix/shadcn pattern */

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

@ -39,3 +39,18 @@ export function buildTaskCountsByTeam(tasks: GlobalTask[]): Map<string, TaskStat
}
return map;
}
/** Build a map of owner name (lowercase) -> task status counts (ignores deleted). */
export function buildTaskCountsByOwner(
tasks: { owner?: string | null; status: string }[]
): Map<string, TaskStatusCounts> {
const map = new Map<string, TaskStatusCounts>();
for (const task of tasks) {
const owner = task.owner?.trim();
if (!owner || task.status === 'deleted') continue;
const key = owner.toLowerCase();
const counts = map.get(key) ?? { pending: 0, inProgress: 0, completed: 0 };
map.set(key, incrementStatus(counts, task.status));
}
return map;
}

View file

@ -8,3 +8,12 @@ export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'magenta
export function getMemberColor(index: number): string {
return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length];
}
/** Derive a stable fallback color from a member name (position-independent). */
export function getMemberColorByName(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
}
return MEMBER_COLOR_PALETTE[Math.abs(hash) % MEMBER_COLOR_PALETTE.length];
}

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

@ -21,6 +21,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList',
TEAM_GET_MEMBER_LOGS: 'team:getMemberLogs',
TEAM_GET_LOGS_FOR_TASK: 'team:getLogsForTask',
TEAM_GET_MEMBER_STATS: 'team:getMemberStats',
TEAM_UPDATE_CONFIG: 'team:updateConfig',
TEAM_START_TASK: 'team:startTask',
@ -45,6 +46,7 @@ import {
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_GET_ALL_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_START_TASK,
@ -135,6 +137,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ALIVE_LIST)).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);
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true);
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
@ -303,6 +306,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ALIVE_LIST)).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);
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false);
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);

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', createdBy: 'user' })
);
});
});