feat: add/remove member, mesage box, improve ui...

This commit is contained in:
iliya 2026-02-24 14:05:50 +02:00 committed by Илия
parent 25b740c134
commit aa49ce0ccc
74 changed files with 4174 additions and 867 deletions

View file

@ -12,7 +12,7 @@
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp; <a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp; <a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases"><img src="https://img.shields.io/github/downloads/777genius/claude_agent_teams_ui/total?style=flat-square&color=green" alt="Downloads" /></a>&nbsp; <a href="https://github.com/777genius/claude_agent_teams_ui/releases"><img src="https://img.shields.io/github/downloads/777genius/claude_agent_teams_ui/total?style=flat-square&color=green" alt="Downloads" /></a>&nbsp;
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows%20%7C%20Docker-lightgrey?style=flat-square" alt="Platform" /> <img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform" />
</p> </p>
<br /> <br />
@ -26,9 +26,6 @@
</a>&nbsp;&nbsp; </a>&nbsp;&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"> <a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
<img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" /> <img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" />
</a>&nbsp;&nbsp;
<a href="#docker--standalone-deployment">
<img src="https://img.shields.io/badge/Docker-Deploy-2496ED?logo=docker&logoColor=white&style=flat" alt="Deploy with Docker" height="30" />
</a> </a>
</p> </p>
@ -58,87 +55,11 @@
| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open | | **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open |
| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). | | **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). |
| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" | | **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
| **Docker** | `docker compose up` | Open `http://localhost:3456`. See [Docker / Standalone Deployment](#docker--standalone-deployment) for details. |
The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login. The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login.
--- ---
## What the CLI Hides vs. What Claude Agent Teams UI Shows
| What you see in the terminal | What Claude Agent Teams UI shows you |
|------------------------------|-------------------------------|
| `Read 3 files` | Exact file paths, syntax-highlighted content with line numbers |
| `Searched for 1 pattern` | The regex pattern, every matching file, and the matched lines |
| `Edited 2 files` | Inline diffs with added/removed highlighting per file |
| A three-segment context bar | Per-turn token attribution across 7 categories — CLAUDE.md breakdown, skills, @-mentions, tool I/O, thinking, teams, user text — with compaction visualization showing how context fills, compresses, and refills |
| Subagent output interleaved with the main thread | Isolated execution trees per agent, expandable inline with their own metrics |
| Teammate messages buried in session logs | Color-coded teammate cards with name, message, and full team lifecycle visibility |
| Critical events mixed into normal output | Trigger-filtered notification inbox for `.env` access, payment-related file paths, execution errors, and high token usage |
| `--verbose` JSON dump | Structured, filterable, navigable interface — no noise |
---
## Docker / Standalone Deployment
Run Claude Agent Teams UI without Electron — in Docker, on a remote server, or anywhere Node.js runs.
### Quick Start (Docker Compose)
```bash
docker compose up
```
Open `http://localhost:3456` in your browser.
### Quick Start (Docker)
```bash
docker build -t claude-agent-teams-ui .
docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
```
### Quick Start (Node.js)
```bash
pnpm install
pnpm standalone:build
node dist-standalone/index.cjs
```
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_ROOT` | `~/.claude` | Path to the `.claude` data directory |
| `HOST` | `0.0.0.0` | Bind address |
| `PORT` | `3456` | Listen port |
| `CORS_ORIGIN` | `*` (standalone) | CORS origin policy (`*`, specific origin, or comma-separated list) |
### Notes
- **Real-time updates may be slower than Electron.** The Electron app uses native file system watchers with IPC for instant updates. The Docker/standalone server uses SSE (Server-Sent Events) over HTTP, which may introduce slight delays when sessions are actively being written to.
- **Custom Claude root path.** If your `.claude` directory is not at `~/.claude`, update the volume mount to point to the correct location:
```bash
# Example: Claude root at /home/user/custom-claude-dir
docker run -p 3456:3456 -v /home/user/custom-claude-dir:/data/.claude:ro claude-agent-teams-ui
# Or with docker compose, set the CLAUDE_DIR env variable:
CLAUDE_DIR=/home/user/custom-claude-dir docker compose up
```
### Security-Focused Deployment
The standalone server has **zero** outbound network calls. For maximum isolation:
```bash
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
```
See [SECURITY.md](SECURITY.md) for a full audit of network activity.
---
## Development ## Development
<details> <details>
@ -184,6 +105,12 @@ pnpm dist # macOS + Windows + Linux
--- ---
## TODO
- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
---
## Contributing ## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md). See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md).

View file

@ -14,7 +14,12 @@ import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; import {
type ClaudeMdFileInfo,
readAgentConfigs,
readAllClaudeMdFiles,
readDirectoryClaudeMd,
} from '../services';
import { validateFilePath } from '../utils/pathValidation'; import { validateFilePath } from '../utils/pathValidation';
import { countTokens } from '../utils/tokenizer'; import { countTokens } from '../utils/tokenizer';

View file

@ -1,4 +1,7 @@
import { randomUUID } from 'node:crypto';
import { import {
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT, TEAM_ADD_TASK_COMMENT,
TEAM_ALIVE_LIST, TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING, TEAM_CANCEL_PROVISIONING,
@ -7,10 +10,12 @@ import {
TEAM_CREATE_TASK, TEAM_CREATE_TASK,
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_LOGS_FOR_TASK, TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LIST, TEAM_LIST,
TEAM_PREPARE_PROVISIONING, TEAM_PREPARE_PROVISIONING,
@ -18,22 +23,35 @@ import {
TEAM_PROCESS_SEND, TEAM_PROCESS_SEND,
TEAM_PROVISIONING_PROGRESS, TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS, TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_START_TASK, TEAM_START_TASK,
TEAM_STOP, TEAM_STOP,
TEAM_UPDATE_CONFIG, TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS, TEAM_UPDATE_TASK_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels'; } from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { NotificationManager } from '../services/infrastructure/NotificationManager';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards'; import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards';
/** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */
const notifiedRateLimitKeys = new Set<string>();
const RATE_LIMIT_KEYS_MAX = 500;
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import type { import type {
MemberStatsComputer, MemberStatsComputer,
TeamDataService, TeamDataService,
@ -41,9 +59,13 @@ import type {
TeamProvisioningService, TeamProvisioningService,
} from '../services'; } from '../services';
import type { import type {
AttachmentFileData,
AttachmentMeta,
AttachmentPayload,
CreateTaskRequest, CreateTaskRequest,
GlobalTask, GlobalTask,
IpcResult, IpcResult,
KanbanColumnId,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
SendMessageRequest, SendMessageRequest,
@ -67,11 +89,63 @@ import type {
const logger = createLogger('IPC:teams'); const logger = createLogger('IPC:teams');
/**
* Check messages for rate limit indicators and fire native notifications for new ones.
*/
function checkRateLimitMessages(
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
teamName: string,
teamDisplayName: string,
projectPath?: string
): void {
for (const msg of messages) {
if (msg.from === 'user') continue;
if (!isRateLimitMessage(msg.text)) continue;
// Prefix key with teamName to avoid collisions across teams
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
const key = `${teamName}:${rawKey}`;
if (notifiedRateLimitKeys.has(key)) continue;
notifiedRateLimitKeys.add(key);
// Prevent unbounded memory growth
if (notifiedRateLimitKeys.size > RATE_LIMIT_KEYS_MAX) {
const first = notifiedRateLimitKeys.values().next().value!;
notifiedRateLimitKeys.delete(first);
}
void NotificationManager.getInstance()
.addError({
id: randomUUID(),
timestamp: Date.now(),
sessionId: `team:${teamName}`,
projectId: teamName,
filePath: '',
source: 'rate-limit',
message: `[${msg.from}] ${msg.text.slice(0, 200)}`,
triggerColor: 'red',
triggerName: 'Rate Limit',
context: {
projectName: teamDisplayName,
cwd: projectPath,
},
})
.catch(() => undefined);
}
}
let teamDataService: TeamDataService | null = null; let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null; let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null; let memberStatsComputer: MemberStatsComputer | null = null;
const attachmentStore = new TeamAttachmentStore();
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
const MAX_ATTACHMENTS = 5;
const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total
export function initializeTeamHandlers( export function initializeTeamHandlers(
service: TeamDataService, service: TeamDataService,
provisioningService: TeamProvisioningService, provisioningService: TeamProvisioningService,
@ -96,7 +170,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask); ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview); ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban); ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
ipcMain.handle(TEAM_UPDATE_KANBAN_COLUMN_ORDER, handleUpdateKanbanColumnOrder);
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus); ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam); ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend); ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive); ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
@ -110,6 +186,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_START_TASK, handleStartTask); ipcMain.handle(TEAM_START_TASK, handleStartTask);
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks); ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
logger.info('Team handlers registered'); logger.info('Team handlers registered');
} }
@ -125,7 +205,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_CREATE_TASK); ipcMain.removeHandler(TEAM_CREATE_TASK);
ipcMain.removeHandler(TEAM_REQUEST_REVIEW); ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN); ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER);
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS); ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
ipcMain.removeHandler(TEAM_DELETE_TEAM); ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_PROCESS_SEND); ipcMain.removeHandler(TEAM_PROCESS_SEND);
ipcMain.removeHandler(TEAM_PROCESS_ALIVE); ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
@ -139,6 +221,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_START_TASK); ipcMain.removeHandler(TEAM_START_TASK);
ipcMain.removeHandler(TEAM_GET_ALL_TASKS); ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
ipcMain.removeHandler(TEAM_ADD_MEMBER);
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
} }
function getTeamDataService(): TeamDataService { function getTeamDataService(): TeamDataService {
@ -169,6 +255,23 @@ async function wrapTeamHandler<T>(
} }
} }
async function handleGetProjectBranch(
_event: IpcMainInvokeEvent,
projectPath: unknown
): Promise<IpcResult<string | null>> {
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
return { success: false, error: 'projectPath must be a non-empty string' };
}
try {
const branch = await gitIdentityResolver.getBranch(projectPath.trim());
return { success: true, data: branch };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[teams:getProjectBranch] ${message}`);
return { success: false, error: message };
}
}
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> { async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
return wrapTeamHandler('list', () => getTeamDataService().listTeams()); return wrapTeamHandler('list', () => getTeamDataService().listTeams());
} }
@ -203,8 +306,12 @@ async function handleGetData(
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined); void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
} }
const displayName = data.config.name || tn;
const projectPath = data.config.projectPath;
const live = provisioning.getLiveLeadProcessMessages(tn); const live = provisioning.getLiveLeadProcessMessages(tn);
if (live.length === 0) { if (live.length === 0) {
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive } }; return { success: true, data: { ...data, isAlive } };
} }
@ -244,6 +351,7 @@ async function handleGetData(
} }
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
checkRateLimitMessages(merged, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive, messages: merged } }; return { success: true, data: { ...data, isAlive, messages: merged } };
} }
@ -387,6 +495,7 @@ async function validateProvisioningRequest(
members, members,
cwd, cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
}, },
}; };
} }
@ -447,12 +556,17 @@ async function handleLaunchTeam(
return { success: false, error: 'prompt must be a string' }; return { success: false, error: 'prompt must be a string' };
} }
if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a string' };
}
return wrapTeamHandler('launch', () => return wrapTeamHandler('launch', () =>
getTeamProvisioningService().launchTeam( getTeamProvisioningService().launchTeam(
{ {
teamName: validatedTeamName.value!, teamName: validatedTeamName.value!,
cwd, cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
}, },
(progress) => { (progress) => {
try { try {
@ -526,6 +640,76 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved'); return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved');
} }
async function handleGetAttachments(
_event: IpcMainInvokeEvent,
teamName: unknown,
messageId: unknown
): Promise<IpcResult<AttachmentFileData[]>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
if (typeof messageId !== 'string' || messageId.trim().length === 0) {
return { success: false, error: 'messageId must be a non-empty string' };
}
const safeMessageId = messageId.trim();
if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) {
return { success: false, error: 'Invalid messageId' };
}
return wrapTeamHandler('getAttachments', () =>
attachmentStore.getAttachments(vTeam.value!, safeMessageId)
);
}
function validateAttachments(
attachments: unknown
): { valid: true; value: AttachmentPayload[] } | { valid: false; error: string } {
if (!Array.isArray(attachments)) {
return { valid: false, error: 'attachments must be an array' };
}
if (attachments.length > MAX_ATTACHMENTS) {
return { valid: false, error: `Maximum ${MAX_ATTACHMENTS} attachments allowed` };
}
let totalSize = 0;
const result: AttachmentPayload[] = [];
for (const att of attachments) {
if (!att || typeof att !== 'object') {
return { valid: false, error: 'Invalid attachment entry' };
}
const a = att as Partial<AttachmentPayload>;
if (typeof a.id !== 'string' || typeof a.filename !== 'string') {
return { valid: false, error: 'Attachment must have id and filename' };
}
if (typeof a.data !== 'string' || typeof a.mimeType !== 'string') {
return { valid: false, error: 'Attachment must have data and mimeType' };
}
if (typeof a.size !== 'number' || a.size <= 0) {
return { valid: false, error: 'Attachment must have a positive size' };
}
if (!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)) {
return { valid: false, error: `Unsupported attachment type: ${a.mimeType}` };
}
if (a.size > MAX_ATTACHMENT_SIZE) {
return { valid: false, error: `Attachment "${a.filename}" exceeds 10MB limit` };
}
// Sanity check: base64 data should be roughly 4/3 of the reported binary size
const estimatedBinarySize = Math.ceil(a.data.length * 0.75);
if (estimatedBinarySize > MAX_ATTACHMENT_SIZE * 1.1) {
return { valid: false, error: `Attachment "${a.filename}" data exceeds size limit` };
}
totalSize += a.size;
result.push({
id: a.id,
filename: a.filename,
data: a.data,
mimeType: a.mimeType,
size: a.size,
});
}
if (totalSize > MAX_TOTAL_ATTACHMENT_SIZE) {
return { valid: false, error: 'Total attachment size exceeds 20MB limit' };
}
return { valid: true, value: result };
}
async function handleSendMessage( async function handleSendMessage(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,
@ -558,24 +742,98 @@ async function handleSendMessage(
} }
} }
let validatedAttachments: AttachmentPayload[] | undefined;
if (
payload.attachments !== undefined &&
Array.isArray(payload.attachments) &&
payload.attachments.length > 0
) {
const attResult = validateAttachments(payload.attachments);
if (!attResult.valid) {
return { success: false, error: attResult.error };
}
validatedAttachments = attResult.value;
}
return wrapTeamHandler('sendMessage', async () => { return wrapTeamHandler('sendMessage', async () => {
const tn = validatedTeamName.value!; const tn = validatedTeamName.value!;
const provisioning = getTeamProvisioningService();
const isAlive = provisioning.isTeamAlive(tn);
const leadName = await getTeamDataService().getLeadMemberName(tn);
const memberName = validatedMember.value!;
const isLeadRecipient = leadName !== null && memberName === leadName;
// Attachments only supported for live lead (stdin content blocks)
if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) {
throw new Error(
'Attachments are only supported when sending to the team lead while the team is online'
);
}
// Smart routing: lead + alive → stdin direct, else → inbox
if (isLeadRecipient && isAlive) {
try {
await provisioning.sendMessageToTeam(tn, payload.text!, validatedAttachments);
const result = await getTeamDataService().sendDirectToLead(
tn,
leadName,
payload.text!,
payload.summary
);
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
size: a.size,
}));
// Save attachment binary data to disk (best-effort)
if (validatedAttachments?.length && result.messageId) {
void attachmentStore
.saveAttachments(tn, result.messageId, validatedAttachments)
.catch((e) => logger.warn(`Failed to save attachments: ${e}`));
}
provisioning.pushLiveLeadProcessMessage(tn, {
from: 'user',
to: leadName,
text: payload.text!,
timestamp: new Date().toISOString(),
read: true,
summary: payload.summary,
messageId: result.messageId,
source: 'user_sent',
attachments: attachmentMeta,
});
return result;
} catch (stdinError) {
// Stdin failed (process died between check and write)
// If attachments were requested, fail rather than silently dropping them
if (validatedAttachments?.length) {
throw new Error(
'Failed to deliver message with attachments: team process became unavailable'
);
}
logger.warn('stdin fallback for ' + tn + ': ' + String(stdinError));
// Fallback to inbox path for text-only messages
}
}
// Inbox path: offline lead or regular members (no attachment support)
const result = await getTeamDataService().sendMessage(tn, { const result = await getTeamDataService().sendMessage(tn, {
member: validatedMember.value!, member: memberName,
text: payload.text!, text: payload.text!,
summary: payload.summary, summary: payload.summary,
from: payload.from, from: payload.from,
}); });
// Best-effort: if messaging the lead while process is alive, relay immediately (no UI dependency). // Best-effort relay for lead via inbox
try { if (isLeadRecipient && isAlive) {
const provisioning = getTeamProvisioningService(); void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
if (provisioning.isTeamAlive(tn)) {
// Avoid reading unrelated inboxes; relayLeadInboxMessages will no-op when nothing new exists.
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
}
} catch {
// ignore
} }
return result; return result;
@ -705,6 +963,44 @@ async function handleUpdateKanban(
}); });
} }
const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved'];
function validateKanbanColumnId(
value: unknown
): { valid: true; value: KanbanColumnId } | { valid: false; error: string } {
if (typeof value !== 'string' || !KANBAN_COLUMN_IDS.includes(value as KanbanColumnId)) {
return { valid: false, error: `columnId must be one of: ${KANBAN_COLUMN_IDS.join(', ')}` };
}
return { valid: true, value: value as KanbanColumnId };
}
async function handleUpdateKanbanColumnOrder(
_event: IpcMainInvokeEvent,
teamName: unknown,
columnId: unknown,
orderedTaskIds: unknown
): Promise<IpcResult<void>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
const validatedColumnId = validateKanbanColumnId(columnId);
if (!validatedColumnId.valid) {
return { success: false, error: validatedColumnId.error ?? 'Invalid columnId' };
}
if (!Array.isArray(orderedTaskIds)) {
return { success: false, error: 'orderedTaskIds must be an array' };
}
const ids = orderedTaskIds.filter((id): id is string => typeof id === 'string');
return wrapTeamHandler('updateKanbanColumnOrder', () =>
getTeamDataService().updateKanbanColumnOrder(
validatedTeamName.value!,
validatedColumnId.value,
ids
)
);
}
const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed']; const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed'];
async function handleUpdateTaskStatus( async function handleUpdateTaskStatus(
@ -736,6 +1032,31 @@ async function handleUpdateTaskStatus(
); );
} }
async function handleUpdateTaskOwner(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
owner: unknown
): Promise<IpcResult<void>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
const validatedTaskId = validateTaskId(taskId);
if (!validatedTaskId.valid) {
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
}
if (owner !== null && (typeof owner !== 'string' || owner.length === 0)) {
return { success: false, error: 'owner must be a non-empty string or null' };
}
return wrapTeamHandler('updateTaskOwner', () =>
getTeamDataService().updateTaskOwner(validatedTeamName.value!, validatedTaskId.value!, owner)
);
}
async function handleProcessSend( async function handleProcessSend(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,
@ -946,6 +1267,47 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks()); return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
} }
async function handleAddMember(
_event: IpcMainInvokeEvent,
teamName: unknown,
payload: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload' };
}
const { name, role } = payload as { name?: unknown; role?: unknown };
const vName = validateMemberName(name);
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
if (role !== undefined && typeof role !== 'string') {
return { success: false, error: 'role must be a string' };
}
return wrapTeamHandler('addMember', () =>
getTeamDataService().addMember(vTeam.value!, {
name: vName.value!,
role: role,
})
);
}
async function handleRemoveMember(
_event: IpcMainInvokeEvent,
teamName: unknown,
memberName: unknown
): Promise<IpcResult<void>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
const vMember = validateMemberName(memberName);
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
return wrapTeamHandler('removeMember', () =>
getTeamDataService().removeMember(vTeam.value!, vMember.value!)
);
}
async function handleAddTaskComment( async function handleAddTaskComment(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,

View file

@ -181,6 +181,7 @@ export interface GeneralConfig {
theme: 'dark' | 'light' | 'system'; theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session'; defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null; claudeRootPath: string | null;
agentLanguage: string;
} }
export interface DisplayConfig { export interface DisplayConfig {
@ -248,6 +249,7 @@ const DEFAULT_CONFIG: AppConfig = {
theme: 'dark', theme: 'dark',
defaultTab: 'dashboard', defaultTab: 'dashboard',
claudeRootPath: null, claudeRootPath: null,
agentLanguage: 'system',
}, },
display: { display: {
showTimestamps: true, showTimestamps: true,

View file

@ -921,7 +921,7 @@ export class FileWatcher extends EventEmitter {
} }
// Classify only the paths we care about in iteration 02. // Classify only the paths we care about in iteration 02.
if (normalized.includes('inboxes')) { if (normalized.includes('inboxes') || relative === 'sentMessages.json') {
const event: TeamChangeEvent = { const event: TeamChangeEvent = {
type: 'inbox', type: 'inbox',
teamName, teamName,

View file

@ -28,7 +28,10 @@ function parseFrontmatter(content: string): Record<string, string> {
const key = line.slice(0, colonIdx).trim(); const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim(); let value = line.slice(colonIdx + 1).trim();
// Strip surrounding quotes // Strip surrounding quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1); value = value.slice(1, -1);
} }
if (key) result[key] = value; if (key) result[key] = value;
@ -40,9 +43,7 @@ function parseFrontmatter(content: string): Record<string, string> {
* Read agent config files from a project's `.claude/agents/` directory. * Read agent config files from a project's `.claude/agents/` directory.
* Returns a map of agent name config (with optional color). * Returns a map of agent name config (with optional color).
*/ */
export async function readAgentConfigs( export async function readAgentConfigs(projectRoot: string): Promise<Record<string, AgentConfig>> {
projectRoot: string
): Promise<Record<string, AgentConfig>> {
const agentsDir = path.join(projectRoot, '.claude', 'agents'); const agentsDir = path.join(projectRoot, '.claude', 'agents');
const result: Record<string, AgentConfig> = {}; const result: Record<string, AgentConfig> = {};

View file

@ -0,0 +1,86 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
const logger = createLogger('Service:TeamAttachmentStore');
const ATTACHMENTS_DIR = 'attachments';
export class TeamAttachmentStore {
private getDir(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR);
}
private getFilePath(teamName: string, messageId: string): string {
return path.join(this.getDir(teamName), `${messageId}.json`);
}
async saveAttachments(
teamName: string,
messageId: string,
attachments: AttachmentPayload[]
): Promise<void> {
if (attachments.length === 0) return;
const fileData: AttachmentFileData[] = attachments.map((a) => ({
id: a.id,
data: a.data,
mimeType: a.mimeType,
}));
await atomicWriteAsync(this.getFilePath(teamName, messageId), JSON.stringify(fileData));
logger.debug(
`[${teamName}] Saved ${attachments.length} attachment(s) for message ${messageId}`
);
}
async getAttachments(teamName: string, messageId: string): Promise<AttachmentFileData[]> {
const filePath = this.getFilePath(teamName, messageId);
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return [];
}
if (!Array.isArray(parsed)) {
return [];
}
const result: AttachmentFileData[] = [];
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const row = item as Partial<AttachmentFileData>;
if (
typeof row.id !== 'string' ||
typeof row.data !== 'string' ||
typeof row.mimeType !== 'string'
) {
continue;
}
result.push({
id: row.id,
data: row.data,
mimeType: row.mimeType,
});
}
return result;
}
}

View file

@ -61,10 +61,15 @@ export class TeamConfigReader {
} }
} }
const removedNames = new Set<string>();
try { try {
const metaMembers = await this.membersMetaStore.getMembers(entry.name); const metaMembers = await this.membersMetaStore.getMembers(entry.name);
for (const member of metaMembers) { for (const member of metaMembers) {
addMember(member); if (member.removedAt) {
removedNames.add(member.name.trim());
} else {
addMember(member);
}
} }
} catch { } catch {
logger.debug(`Failed to read members.meta.json for team: ${entry.name}`); logger.debug(`Failed to read members.meta.json for team: ${entry.name}`);
@ -86,6 +91,10 @@ export class TeamConfigReader {
// Inbox folder may not exist yet. // Inbox folder may not exist yet.
} }
for (const name of removedNames) {
memberMap.delete(name);
}
const memberCount = memberMap.size; const memberCount = memberMap.size;
const members = Array.from(memberMap.values()); const members = Array.from(memberMap.values());
summaries.push({ summaries.push({

View file

@ -8,10 +8,13 @@ import {
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { randomUUID } from 'crypto';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as readline from 'readline'; import * as readline from 'readline';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller'; import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
import { TeamConfigReader } from './TeamConfigReader'; import { TeamConfigReader } from './TeamConfigReader';
@ -20,21 +23,26 @@ import { TeamInboxWriter } from './TeamInboxWriter';
import { TeamKanbanManager } from './TeamKanbanManager'; import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver'; import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter'; import { TeamTaskWriter } from './TeamTaskWriter';
import type { import type {
AddMemberRequest,
CreateTaskRequest, CreateTaskRequest,
GlobalTask, GlobalTask,
InboxMessage, InboxMessage,
KanbanColumnId,
KanbanState, KanbanState,
KanbanTaskState, KanbanTaskState,
ResolvedTeamMember,
SendMessageRequest, SendMessageRequest,
SendMessageResult, SendMessageResult,
TaskComment, TaskComment,
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamData, TeamData,
TeamMember,
TeamSummary, TeamSummary,
TeamTask, TeamTask,
TeamTaskStatus, TeamTaskStatus,
@ -57,7 +65,8 @@ export class TeamDataService {
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(), private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(), private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller(), private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
) {} ) {}
async listTeams(): Promise<TeamSummary[]> { async listTeams(): Promise<TeamSummary[]> {
@ -162,12 +171,22 @@ export class TeamDataService {
const leadTexts = await this.extractLeadSessionTexts(config); const leadTexts = await this.extractLeadSessionTexts(config);
if (leadTexts.length > 0) { if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts]; messages = [...messages, ...leadTexts];
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
} }
} catch { } catch {
warnings.push('Lead session texts failed to load'); warnings.push('Lead session texts failed to load');
} }
try {
const sentMessages = await this.sentMessagesStore.readMessages(teamName);
if (sentMessages.length > 0) {
messages = [...messages, ...sentMessages];
}
} catch {
warnings.push('Sent messages failed to load');
}
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = []; let metaMembers: TeamConfig['members'] = [];
try { try {
metaMembers = await this.membersMetaStore.getMembers(teamName); metaMembers = await this.membersMetaStore.getMembers(teamName);
@ -211,6 +230,9 @@ export class TeamDataService {
messages messages
); );
// Enrich members with git branch when it differs from lead's branch
await this.enrichMemberBranches(members, config);
// Auto-sync: create comments from task-related inbox messages // Auto-sync: create comments from task-related inbox messages
if (tasksLoaded && messages.length > 0) { if (tasksLoaded && messages.length > 0) {
try { try {
@ -241,6 +263,84 @@ export class TeamDataService {
}; };
} }
/**
* Enriches members with gitBranch when their cwd differs from the lead's.
* Mutates members in-place for efficiency (called right after resolveMembers).
*/
private async enrichMemberBranches(
members: ResolvedTeamMember[],
config: TeamConfig
): Promise<void> {
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
const leadEntry = config.members?.find((m) => m.name === 'team-lead');
const leadCwd = leadEntry?.cwd ?? config.projectPath;
if (!leadCwd) return;
let leadBranch: string | null = null;
try {
leadBranch = await gitIdentityResolver.getBranch(leadCwd);
} catch {
// Lead cwd may not be a git repo — skip enrichment entirely
return;
}
await Promise.all(
members.map(async (member) => {
if (!member.cwd || member.cwd === leadCwd) return;
try {
const branch = await gitIdentityResolver.getBranch(member.cwd);
if (branch && branch !== leadBranch) {
// eslint-disable-next-line no-param-reassign -- intentional in-place enrichment
member.gitBranch = branch;
}
} catch {
// Member cwd may not be a git repo — skip silently
}
})
);
}
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
const members = await this.membersMetaStore.getMembers(teamName);
const existing = members.find((m) => m.name.toLowerCase() === request.name.toLowerCase());
if (existing) {
if (existing.removedAt) {
throw new Error(`Name "${request.name}" was previously used by a removed member`);
}
throw new Error(`Member "${request.name}" already exists`);
}
const newMember: TeamMember = {
name: request.name,
role: request.role?.trim() || undefined,
agentType: 'general-purpose',
color: getMemberColor(members.filter((m) => !m.removedAt).length),
joinedAt: Date.now(),
};
members.push(newMember);
await this.membersMetaStore.writeMembers(teamName, members);
}
async removeMember(teamName: string, memberName: string): Promise<void> {
const members = await this.membersMetaStore.getMembers(teamName);
const member = members.find((m) => m.name === memberName);
if (!member) {
throw new Error(`Member "${memberName}" not found`);
}
if (member.removedAt) {
throw new Error(`Member "${memberName}" is already removed`);
}
if (member.agentType === 'team-lead') {
throw new Error('Cannot remove team lead');
}
member.removedAt = Date.now();
await this.membersMetaStore.writeMembers(teamName, members);
}
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> { async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> {
const nextId = await this.taskReader.getNextTaskId(teamName); const nextId = await this.taskReader.getNextTaskId(teamName);
@ -364,6 +464,10 @@ export class TeamDataService {
await this.taskWriter.updateStatus(teamName, taskId, status); await this.taskWriter.updateStatus(teamName, taskId, status);
} }
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
await this.taskWriter.updateOwner(teamName, taskId, owner);
}
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> { async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
const comment = await this.taskWriter.addComment(teamName, taskId, text); const comment = await this.taskWriter.addComment(teamName, taskId, text);
@ -398,6 +502,54 @@ export class TeamDataService {
return this.inboxWriter.sendMessage(teamName, request); return this.inboxWriter.sendMessage(teamName, request);
} }
async sendDirectToLead(
teamName: string,
leadName: string,
text: string,
summary?: string
): Promise<SendMessageResult> {
const messageId = randomUUID();
const msg: InboxMessage = {
from: 'user',
to: leadName,
text,
timestamp: new Date().toISOString(),
read: true,
summary,
messageId,
source: 'user_sent',
};
await this.sentMessagesStore.appendMessage(teamName, msg);
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
}
async getLeadMemberName(teamName: string): Promise<string | null> {
try {
const config = await this.configReader.getConfig(teamName);
// Check config.json members first (Claude Code-created teams)
if (config?.members?.length) {
const lead = config.members.find(
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
);
if (lead?.name) return lead.name;
}
// Fallback: check members.meta.json (UI-created teams)
const metaMembers = await this.membersMetaStore.getMembers(teamName);
if (metaMembers.length > 0) {
const lead = metaMembers.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead');
if (lead?.name) return lead.name;
return metaMembers[0]?.name ?? null;
}
// Last resort: check config.json first member
return config?.members?.[0]?.name ?? null;
} catch {
return null;
}
}
async requestReview(teamName: string, taskId: string): Promise<void> { async requestReview(teamName: string, taskId: string): Promise<void> {
await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' }); await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' });
@ -630,4 +782,12 @@ export class TeamDataService {
throw error; throw error;
} }
} }
async updateKanbanColumnOrder(
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
): Promise<void> {
await this.kanbanManager.updateColumnOrder(teamName, columnId, orderedTaskIds);
}
} }

View file

@ -5,10 +5,12 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite'; import { atomicWriteAsync } from './atomicWrite';
import type { KanbanState, UpdateKanbanPatch } from '@shared/types'; import type { KanbanColumnId, KanbanState, UpdateKanbanPatch } from '@shared/types';
const logger = createLogger('Service:TeamKanbanManager'); const logger = createLogger('Service:TeamKanbanManager');
const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved'];
function createDefaultState(teamName: string): KanbanState { function createDefaultState(teamName: string): KanbanState {
return { return {
teamName, teamName,
@ -21,6 +23,23 @@ function isValidColumn(value: unknown): value is 'review' | 'approved' {
return value === 'review' || value === 'approved'; return value === 'review' || value === 'approved';
} }
function sanitizeColumnOrder(raw: unknown): KanbanState['columnOrder'] | undefined {
if (!raw || typeof raw !== 'object') {
return undefined;
}
const result: NonNullable<KanbanState['columnOrder']> = {};
for (const colId of KANBAN_COLUMN_IDS) {
const arr = (raw as Record<string, unknown>)[colId];
if (Array.isArray(arr)) {
const ids = arr.filter((id): id is string => typeof id === 'string');
if (ids.length > 0) {
result[colId] = ids;
}
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
export class TeamKanbanManager { export class TeamKanbanManager {
async getState(teamName: string): Promise<KanbanState> { async getState(teamName: string): Promise<KanbanState> {
const statePath = this.getStatePath(teamName); const statePath = this.getStatePath(teamName);
@ -72,9 +91,25 @@ export class TeamKanbanManager {
? parsed.reviewers.filter((r): r is string => typeof r === 'string' && r.trim().length > 0) ? parsed.reviewers.filter((r): r is string => typeof r === 'string' && r.trim().length > 0)
: [], : [],
tasks: sanitizedTasks, tasks: sanitizedTasks,
columnOrder: sanitizeColumnOrder(parsed.columnOrder),
}; };
} }
async updateColumnOrder(
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
): Promise<void> {
const state = await this.getState(teamName);
const columnOrder = { ...state.columnOrder };
if (orderedTaskIds.length > 0) {
columnOrder[columnId] = orderedTaskIds;
} else {
delete columnOrder[columnId];
}
await this.writeState(teamName, { ...state, columnOrder });
}
async updateTask(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> { async updateTask(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
const state = await this.getState(teamName); const state = await this.getState(teamName);
@ -106,12 +141,32 @@ export class TeamKanbanManager {
} }
} }
let columnOrderChanged = false;
if (state.columnOrder) {
const cleaned: NonNullable<KanbanState['columnOrder']> = {};
for (const [colId, ids] of Object.entries(state.columnOrder)) {
const valid = ids.filter((id) => validTaskIds.has(id));
if (valid.length > 0) {
cleaned[colId as KanbanColumnId] = valid;
}
if (valid.length !== ids.length) {
columnOrderChanged = true;
}
}
if (columnOrderChanged) {
state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
}
}
const after = Object.keys(state.tasks).length; const after = Object.keys(state.tasks).length;
if (before === after) { const tasksChanged = before !== after;
if (!tasksChanged && !columnOrderChanged) {
return; return;
} }
logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`); if (tasksChanged) {
logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`);
}
await this.writeState(teamName, state); await this.writeState(teamName, state);
} }
@ -125,6 +180,9 @@ export class TeamKanbanManager {
teamName, teamName,
reviewers: state.reviewers, reviewers: state.reviewers,
tasks: state.tasks, tasks: state.tasks,
...(state.columnOrder && Object.keys(state.columnOrder).length > 0
? { columnOrder: state.columnOrder }
: {}),
}; };
await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2)); await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2));
} }

View file

@ -40,7 +40,7 @@ export class TeamMemberResolver {
const configMemberMap = new Map< const configMemberMap = new Map<
string, string,
{ agentType?: string; role?: string; color?: string } { agentType?: string; role?: string; color?: string; cwd?: string }
>(); >();
if (Array.isArray(config.members)) { if (Array.isArray(config.members)) {
for (const m of config.members) { for (const m of config.members) {
@ -49,12 +49,16 @@ export class TeamMemberResolver {
agentType: m.agentType, agentType: m.agentType,
role: m.role, role: m.role,
color: m.color, color: m.color,
cwd: m.cwd,
}); });
} }
} }
} }
const metaMemberMap = new Map<string, { agentType?: string; role?: string; color?: string }>(); const metaMemberMap = new Map<
string,
{ agentType?: string; role?: string; color?: string; removedAt?: number }
>();
if (Array.isArray(metaMembers)) { if (Array.isArray(metaMembers)) {
for (const member of metaMembers) { for (const member of metaMembers) {
if (typeof member?.name === 'string' && member.name.trim() !== '') { if (typeof member?.name === 'string' && member.name.trim() !== '') {
@ -62,6 +66,7 @@ export class TeamMemberResolver {
agentType: member.agentType, agentType: member.agentType,
role: member.role, role: member.role,
color: member.color, color: member.color,
removedAt: member.removedAt,
}); });
} }
} }
@ -89,6 +94,8 @@ export class TeamMemberResolver {
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color, color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
agentType: configMember?.agentType ?? metaMember?.agentType, agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role, role: configMember?.role ?? metaMember?.role,
cwd: configMember?.cwd,
removedAt: metaMember?.removedAt,
}); });
} }

View file

@ -24,6 +24,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined, joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined,
agentId: typeof member.agentId === 'string' ? member.agentId : undefined, agentId: typeof member.agentId === 'string' ? member.agentId : undefined,
removedAt: typeof member.removedAt === 'number' ? member.removedAt : undefined,
}; };
} }

View file

@ -1,4 +1,5 @@
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */ /* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import { import {
encodePath, encodePath,
extractBaseDir, extractBaseDir,
@ -8,9 +9,11 @@ import {
getTasksBasePath, getTasksBasePath,
getTeamsBasePath, getTeamsBasePath,
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { execFile, spawn } from 'child_process'; import { execFile, spawn } from 'child_process';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { app } from 'electron';
import * as fs from 'fs'; import * as fs from 'fs';
import * as os from 'os'; import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
@ -22,6 +25,7 @@ import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader'; import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import type { import type {
InboxMessage, InboxMessage,
@ -42,7 +46,7 @@ const VERIFY_POLL_MS = 500;
const STDERR_RING_LIMIT = 64 * 1024; const STDERR_RING_LIMIT = 64 * 1024;
const STDOUT_RING_LIMIT = 64 * 1024; const STDOUT_RING_LIMIT = 64 * 1024;
const LOG_PROGRESS_THROTTLE_MS = 300; const LOG_PROGRESS_THROTTLE_MS = 300;
const UI_LOGS_TAIL_LIMIT = 8000; const UI_LOGS_TAIL_LIMIT = 128 * 1024;
const SHELL_ENV_TIMEOUT_MS = 12000; const SHELL_ENV_TIMEOUT_MS = 12000;
const CLI_PREPARE_TIMEOUT_MS = 10000; const CLI_PREPARE_TIMEOUT_MS = 10000;
const PREFLIGHT_TIMEOUT_MS = 30000; const PREFLIGHT_TIMEOUT_MS = 30000;
@ -120,6 +124,11 @@ interface ProvisioningRun {
rejectOnce: (error: string) => void; rejectOnce: (error: string) => void;
timeoutHandle: NodeJS.Timeout; timeoutHandle: NodeJS.Timeout;
} | null; } | null;
/**
* Accumulates assistant text for direct userlead messages (no relay capture active).
* Flushed to liveLeadProcessMessages on result.success.
*/
directReplyParts: string[];
} }
type ProvisioningAuthSource = type ProvisioningAuthSource =
@ -279,11 +288,20 @@ function buildTaskStatusProtocol(teamName: string): string {
Failure to follow this protocol means the task board will show incorrect status.`; Failure to follow this protocol means the task board will show incorrect status.`;
} }
function getAgentLanguageInstruction(): string {
const config = ConfigManager.getInstance().getConfig();
const langCode = config.general.agentLanguage || 'system';
const systemLocale = app.getLocale();
const languageName = resolveLanguageName(langCode, systemLocale);
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
}
function buildProvisioningPrompt(request: TeamCreateRequest): string { function buildProvisioningPrompt(request: TeamCreateRequest): string {
const displayName = request.displayName?.trim() || request.teamName; const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description'; const description = request.description?.trim() || 'No description';
const members = buildMembersPrompt(request.members); const members = buildMembersPrompt(request.members);
const taskProtocol = buildTaskStatusProtocol(request.teamName); const taskProtocol = buildTaskStatusProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const userPromptBlock = request.prompt?.trim() const userPromptBlock = request.prompt?.trim()
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: ''; : '';
@ -296,6 +314,8 @@ You are "${leadName}", the team lead.
Goal: Provision a Claude Code agent team with live teammates. Goal: Provision a Claude Code agent team with live teammates.
${userPromptBlock} ${userPromptBlock}
${languageInstruction}
Constraints: Constraints:
- Do NOT call TeamDelete under any circumstances. - Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite. - Do NOT use TodoWrite.
@ -327,7 +347,8 @@ Steps (execute in this exact order):
- subagent_type: "general-purpose" - subagent_type: "general-purpose"
- prompt: - prompt:
You are {name}, a {role} on team "${displayName}" (${request.teamName}). You are {name}, a {role} on team "${displayName}" (${request.teamName}).
Introduce yourself briefly (name and role) and confirm you are ready use the language that matches the project's CLAUDE.md or the user's locale. ${languageInstruction}
Introduce yourself briefly (name and role) and confirm you are ready.
Then wait for task assignments. Then wait for task assignments.
${taskProtocol} ${taskProtocol}
@ -352,6 +373,7 @@ function buildLaunchPrompt(
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: ''; : '';
const taskProtocol = buildTaskStatusProtocol(request.teamName); const taskProtocol = buildTaskStatusProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
@ -360,6 +382,8 @@ You are "${leadName}", the team lead.
Goal: Reconnect with existing team "${request.teamName}". Goal: Reconnect with existing team "${request.teamName}".
${userPromptBlock} ${userPromptBlock}
${languageInstruction}
Constraints: Constraints:
- Do NOT call TeamDelete under any circumstances. - Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite. - Do NOT use TodoWrite.
@ -392,7 +416,8 @@ Steps (execute in this exact order):
- subagent_type: "general-purpose" - subagent_type: "general-purpose"
- prompt: - prompt:
You are {name}, a {role} on team "${request.teamName}". You are {name}, a {role} on team "${request.teamName}".
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready use the language that matches the project's CLAUDE.md or the user's locale. ${languageInstruction}
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready.
Then resume any pending work you own (if any) and wait for new assignments. Then resume any pending work you own (if any) and wait for new assignments.
${taskProtocol} ${taskProtocol}
@ -501,7 +526,8 @@ export class TeamProvisioningService {
constructor( constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(), private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
) {} ) {}
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void { setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
@ -668,6 +694,7 @@ export class TeamProvisioningService {
isLaunch: false, isLaunch: false,
fsPhase: 'waiting_config', fsPhase: 'waiting_config',
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [],
progress: { progress: {
runId, runId,
teamName: request.teamName, teamName: request.teamName,
@ -706,6 +733,7 @@ export class TeamProvisioningService {
'--disallowedTools', '--disallowedTools',
'TeamDelete,TodoWrite', 'TeamDelete,TodoWrite',
'--dangerously-skip-permissions', '--dangerously-skip-permissions',
...(request.model ? ['--model', request.model] : []),
], ],
{ {
cwd: request.cwd, cwd: request.cwd,
@ -936,6 +964,7 @@ export class TeamProvisioningService {
isLaunch: true, isLaunch: true,
fsPhase: 'waiting_members', fsPhase: 'waiting_members',
leadRelayCapture: null, leadRelayCapture: null,
directReplyParts: [],
progress: { progress: {
runId, runId,
teamName: request.teamName, teamName: request.teamName,
@ -984,6 +1013,9 @@ export class TeamProvisioningService {
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity` `[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
); );
} }
if (request.model) {
launchArgs.push('--model', request.model);
}
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say // New sessions: CLI creates its own ID. No --resume with synthetic name — docs say
// --resume is for existing sessions and may show an interactive picker if not found. // --resume is for existing sessions and may show an interactive picker if not found.
@ -1140,7 +1172,11 @@ export class TeamProvisioningService {
* Send a message to the team's lead process via stream-json stdin. * Send a message to the team's lead process via stream-json stdin.
* The lead will receive it as a new user turn and can delegate to teammates. * The lead will receive it as a new user turn and can delegate to teammates.
*/ */
async sendMessageToTeam(teamName: string, message: string): Promise<void> { async sendMessageToTeam(
teamName: string,
message: string,
attachments?: { data: string; mimeType: string }[]
): Promise<void> {
const runId = this.activeByTeam.get(teamName); const runId = this.activeByTeam.get(teamName);
if (!runId) { if (!runId) {
throw new Error(`No active process for team "${teamName}"`); throw new Error(`No active process for team "${teamName}"`);
@ -1149,11 +1185,26 @@ export class TeamProvisioningService {
if (!run?.child?.stdin?.writable) { if (!run?.child?.stdin?.writable) {
throw new Error(`Team "${teamName}" process stdin is not writable`); throw new Error(`Team "${teamName}" process stdin is not writable`);
} }
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
if (attachments?.length) {
for (const att of attachments) {
contentBlocks.push({
type: 'image',
source: {
type: 'base64',
media_type: att.mimeType,
data: att.data,
},
});
}
}
const payload = JSON.stringify({ const payload = JSON.stringify({
type: 'user', type: 'user',
message: { message: {
role: 'user', role: 'user',
content: [{ type: 'text', text: message }], content: contentBlocks,
}, },
}); });
run.child.stdin.write(payload + '\n'); run.child.stdin.write(payload + '\n');
@ -1273,6 +1324,8 @@ export class TeamProvisioningService {
}, },
}; };
run.leadRelayCapture = capture; run.leadRelayCapture = capture;
// Clear any direct reply parts — relay capture takes priority
run.directReplyParts = [];
}); });
try { try {
@ -1442,7 +1495,7 @@ export class TeamProvisioningService {
return next; return next;
} }
private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void { pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
const MAX = 100; const MAX = 100;
const list = this.liveLeadProcessMessages.get(teamName) ?? []; const list = this.liveLeadProcessMessages.get(teamName) ?? [];
list.push(message); list.push(message);
@ -1512,6 +1565,9 @@ export class TeamProvisioningService {
capture.resolveOnce(combined); capture.resolveOnce(combined);
}, capture.idleMs); }, capture.idleMs);
} }
} else if (run.provisioningComplete) {
// Accumulate assistant text for direct user→lead messages (no relay capture).
run.directReplyParts.push(text);
} }
} }
} }
@ -1532,6 +1588,35 @@ export class TeamProvisioningService {
const capture = run.leadRelayCapture; const capture = run.leadRelayCapture;
const combined = capture.textParts.join('').trim(); const combined = capture.textParts.join('').trim();
capture.resolveOnce(combined); capture.resolveOnce(combined);
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
// Flush accumulated assistant reply from direct user→lead message
const replyText = run.directReplyParts.join('').trim();
run.directReplyParts = [];
const leadName =
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
'team-lead';
if (replyText.length > 0) {
const replyMsg: InboxMessage = {
from: leadName,
to: 'user',
text: replyText,
timestamp: nowIso(),
read: true,
summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText,
messageId: `lead-direct-${run.runId}-${Date.now()}`,
source: 'lead_process',
};
this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
// Persist to disk so replies survive app restart
void this.sentMessagesStore
.appendMessage(run.teamName, replyMsg)
.catch(() => undefined);
this.teamChangeEmitter?.({
type: 'inbox',
teamName: run.teamName,
detail: 'lead-direct-reply',
});
}
} }
if (!run.provisioningComplete) { if (!run.provisioningComplete) {
void this.handleProvisioningTurnComplete(run); void this.handleProvisioningTurnComplete(run);

View file

@ -0,0 +1,75 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { InboxMessage } from '@shared/types';
const MAX_MESSAGES = 200;
export class TeamSentMessagesStore {
private getFilePath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'sentMessages.json');
}
async readMessages(teamName: string): Promise<InboxMessage[]> {
const filePath = this.getFilePath(teamName);
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return [];
}
if (!Array.isArray(parsed)) {
return [];
}
const messages: InboxMessage[] = [];
for (const item of parsed) {
if (!item || typeof item !== 'object') continue;
const row = item as Partial<InboxMessage>;
if (
typeof row.from !== 'string' ||
typeof row.text !== 'string' ||
typeof row.timestamp !== 'string'
) {
continue;
}
messages.push({
from: row.from,
to: typeof row.to === 'string' ? row.to : undefined,
text: row.text,
timestamp: row.timestamp,
read: typeof row.read === 'boolean' ? row.read : true,
summary: typeof row.summary === 'string' ? row.summary : undefined,
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
source: 'user_sent',
});
}
return messages;
}
async appendMessage(teamName: string, message: InboxMessage): Promise<void> {
const existing = await this.readMessages(teamName);
existing.push(message);
// Trim to MAX_MESSAGES (keep newest)
const trimmed = existing.length > MAX_MESSAGES ? existing.slice(-MAX_MESSAGES) : existing;
await atomicWriteAsync(this.getFilePath(teamName), JSON.stringify(trimmed, null, 2));
}
}

View file

@ -98,6 +98,7 @@ export class TeamTaskReader {
description: typeof parsed.description === 'string' ? parsed.description : undefined, description: typeof parsed.description === 'string' ? parsed.description : undefined,
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
parsed.status as TeamTask['status'] parsed.status as TeamTask['status']
) )

View file

@ -118,6 +118,30 @@ export class TeamTaskWriter {
}); });
} }
async updateOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
if (owner) {
task.owner = owner;
} else {
delete task.owner;
}
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async addComment( async addComment(
teamName: string, teamName: string,
taskId: string, taskId: string,

View file

@ -1,6 +1,7 @@
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
export { MemberStatsComputer } from './MemberStatsComputer'; export { MemberStatsComputer } from './MemberStatsComputer';
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller'; export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
export { TeamAttachmentStore } from './TeamAttachmentStore';
export { TeamConfigReader } from './TeamConfigReader'; export { TeamConfigReader } from './TeamConfigReader';
export { TeamDataService } from './TeamDataService'; export { TeamDataService } from './TeamDataService';
export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxReader } from './TeamInboxReader';
@ -10,5 +11,6 @@ export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMemberResolver } from './TeamMemberResolver';
export { TeamMembersMetaStore } from './TeamMembersMetaStore'; export { TeamMembersMetaStore } from './TeamMembersMetaStore';
export { TeamProvisioningService } from './TeamProvisioningService'; export { TeamProvisioningService } from './TeamProvisioningService';
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
export { TeamTaskReader } from './TeamTaskReader'; export { TeamTaskReader } from './TeamTaskReader';
export { TeamTaskWriter } from './TeamTaskWriter'; export { TeamTaskWriter } from './TeamTaskWriter';

View file

@ -191,6 +191,9 @@ export const TEAM_GET_DATA = 'team:getData';
/** Update team kanban state */ /** Update team kanban state */
export const TEAM_UPDATE_KANBAN = 'team:updateKanban'; export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
/** Update kanban column task order (drag-and-drop within column) */
export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
/** Send inbox message to team member */ /** Send inbox message to team member */
export const TEAM_SEND_MESSAGE = 'team:sendMessage'; export const TEAM_SEND_MESSAGE = 'team:sendMessage';
@ -230,6 +233,9 @@ export const TEAM_CREATE_TASK = 'team:createTask';
/** Update task status directly (pending/in_progress/completed) */ /** Update task status directly (pending/in_progress/completed) */
export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus'; export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
/** Update task owner (reassign) */
export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner';
/** Delete a team and its associated task directory */ /** Delete a team and its associated task directory */
export const TEAM_DELETE_TEAM = 'team:deleteTeam'; export const TEAM_DELETE_TEAM = 'team:deleteTeam';
@ -260,3 +266,15 @@ export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';
/** Add a comment to a task */ /** Add a comment to a task */
export const TEAM_ADD_TASK_COMMENT = 'team:addTaskComment'; export const TEAM_ADD_TASK_COMMENT = 'team:addTaskComment';
/** Get current git branch for a project path (live read from .git/HEAD) */
export const TEAM_GET_PROJECT_BRANCH = 'team:getProjectBranch';
/** Add a new member to an existing team */
export const TEAM_ADD_MEMBER = 'team:addMember';
/** Soft-delete a team member */
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
/** Get attachment data for a message */
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';

View file

@ -18,6 +18,7 @@ import {
SSH_SAVE_LAST_CONNECTION, SSH_SAVE_LAST_CONNECTION,
SSH_STATUS, SSH_STATUS,
SSH_TEST, SSH_TEST,
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT, TEAM_ADD_TASK_COMMENT,
TEAM_ALIVE_LIST, TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING, TEAM_CANCEL_PROVISIONING,
@ -27,10 +28,12 @@ import {
TEAM_CREATE_TASK, TEAM_CREATE_TASK,
TEAM_DELETE_TEAM, TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS, TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DATA, TEAM_GET_DATA,
TEAM_GET_LOGS_FOR_TASK, TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS, TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LIST, TEAM_LIST,
TEAM_PREPARE_PROVISIONING, TEAM_PREPARE_PROVISIONING,
@ -38,12 +41,15 @@ import {
TEAM_PROCESS_SEND, TEAM_PROCESS_SEND,
TEAM_PROVISIONING_PROGRESS, TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS, TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_START_TASK, TEAM_START_TASK,
TEAM_STOP, TEAM_STOP,
TEAM_UPDATE_CONFIG, TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS, TEAM_UPDATE_TASK_STATUS,
UPDATER_CHECK, UPDATER_CHECK,
UPDATER_DOWNLOAD, UPDATER_DOWNLOAD,
@ -84,7 +90,9 @@ import {
} from './constants/ipcChannels'; } from './constants/ipcChannels';
import type { import type {
AddMemberRequest,
AppConfig, AppConfig,
AttachmentFileData,
ClaudeRootFolderSelection, ClaudeRootFolderSelection,
ClaudeRootInfo, ClaudeRootInfo,
ContextInfo, ContextInfo,
@ -93,6 +101,7 @@ import type {
GlobalTask, GlobalTask,
HttpServerStatus, HttpServerStatus,
IpcResult, IpcResult,
KanbanColumnId,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
NotificationTrigger, NotificationTrigger,
@ -553,9 +562,24 @@ const electronAPI: ElectronAPI = {
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => { updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_KANBAN, teamName, taskId, patch); return invokeIpcWithResult<void>(TEAM_UPDATE_KANBAN, teamName, taskId, patch);
}, },
updateKanbanColumnOrder: async (
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
) => {
return invokeIpcWithResult<void>(
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
teamName,
columnId,
orderedTaskIds
);
},
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => { updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status); return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
}, },
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner);
},
startTask: async (teamName: string, taskId: string) => { startTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId); return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
}, },
@ -601,6 +625,18 @@ const electronAPI: ElectronAPI = {
addTaskComment: async (teamName: string, taskId: string, text: string) => { addTaskComment: async (teamName: string, taskId: string, text: string) => {
return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, text); return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, text);
}, },
addMember: async (teamName: string, request: AddMemberRequest) => {
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
},
removeMember: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
},
getProjectBranch: async (projectPath: string) => {
return invokeIpcWithResult<string | null>(TEAM_GET_PROJECT_BRANCH, projectPath);
},
getAttachments: async (teamName: string, messageId: string) => {
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on( ipcRenderer.on(
TEAM_CHANGE, TEAM_CHANGE,

View file

@ -8,6 +8,7 @@
import type { import type {
AppConfig, AppConfig,
AttachmentFileData,
ClaudeMdFileInfo, ClaudeMdFileInfo,
ClaudeRootFolderSelection, ClaudeRootFolderSelection,
ClaudeRootInfo, ClaudeRootInfo,
@ -20,6 +21,7 @@ import type {
GlobalTask, GlobalTask,
HttpServerAPI, HttpServerAPI,
HttpServerStatus, HttpServerStatus,
KanbanColumnId,
NotificationsAPI, NotificationsAPI,
NotificationTrigger, NotificationTrigger,
PaginatedSessionsResult, PaginatedSessionsResult,
@ -666,6 +668,13 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => { ): Promise<void> => {
throw new Error('Team kanban is not available in browser mode'); throw new Error('Team kanban is not available in browser mode');
}, },
updateKanbanColumnOrder: async (
_teamName: string,
_columnId: KanbanColumnId,
_orderedTaskIds: string[]
): Promise<void> => {
throw new Error('Team kanban column order is not available in browser mode');
},
updateTaskStatus: async ( updateTaskStatus: async (
_teamName: string, _teamName: string,
_taskId: string, _taskId: string,
@ -673,6 +682,13 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => { ): Promise<void> => {
throw new Error('Team task status update is not available in browser mode'); throw new Error('Team task status update is not available in browser mode');
}, },
updateTaskOwner: async (
_teamName: string,
_taskId: string,
_owner: string | null
): Promise<void> => {
throw new Error('Team task owner update is not available in browser mode');
},
startTask: async (_teamName: string, _taskId: string): Promise<void> => { startTask: async (_teamName: string, _taskId: string): Promise<void> => {
throw new Error('Team start task is not available in browser mode'); throw new Error('Team start task is not available in browser mode');
}, },
@ -726,6 +742,21 @@ export class HttpAPIClient implements ElectronAPI {
addTaskComment: async () => { addTaskComment: async () => {
throw new Error('Task comments are not available in browser mode'); throw new Error('Task comments are not available in browser mode');
}, },
addMember: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
removeMember: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
return null;
},
getAttachments: async (
_teamName: string,
_messageId: string
): Promise<AttachmentFileData[]> => {
return [];
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) => return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent) callback(null, data as TeamChangeEvent)

View file

@ -47,6 +47,8 @@ interface MarkdownViewerProps {
itemId?: string; itemId?: string;
/** When true, shows a copy button (overlay when no label, inline in header when label exists) */ /** When true, shows a copy button (overlay when no label, inline in header when label exists) */
copyable?: boolean; copyable?: boolean;
/** When true, renders without wrapper background/border (for embedding inside cards) */
bare?: boolean;
} }
// ============================================================================= // =============================================================================
@ -277,6 +279,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
label, label,
itemId, itemId,
copyable = false, copyable = false,
bare = false,
}) => { }) => {
// Only subscribe to search store when itemId is provided // Only subscribe to search store when itemId is provided
const { searchQuery, searchMatches, currentSearchIndex } = useStore( const { searchQuery, searchMatches, currentSearchIndex } = useStore(
@ -300,11 +303,15 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
return ( return (
<div <div
className={`min-w-0 overflow-hidden rounded-lg shadow-sm ${copyable && !label ? 'group relative' : ''} ${className}`} className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
style={{ style={
backgroundColor: CODE_BG, bare
border: `1px solid ${CODE_BORDER}`, ? undefined
}} : {
backgroundColor: CODE_BG,
border: `1px solid ${CODE_BORDER}`,
}
}
> >
{/* Copy button overlay (when no label header) */} {/* Copy button overlay (when no label header) */}
{copyable && !label && <CopyButton text={content} />} {copyable && !label && <CopyButton text={content} />}

View file

@ -11,6 +11,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
import { import {
buildTaskCountsByProject, buildTaskCountsByProject,
normalizePath, normalizePath,
@ -256,14 +257,39 @@ const RepositoryCard = ({
// Ghost Card (New Project) // Ghost Card (New Project)
// ============================================================================= // =============================================================================
interface WorktreeMatch {
repoId: string;
worktreeId: string;
}
function findMatchingWorktree(
groups: RepositoryGroup[],
selectedPath: string
): WorktreeMatch | null {
const norm = normalizePath(selectedPath);
for (const repo of groups) {
for (const worktree of repo.worktrees) {
if (normalizePath(worktree.path) === norm) {
return { repoId: repo.id, worktreeId: worktree.id };
}
}
}
return null;
}
const NewProjectCard = (): React.JSX.Element => { const NewProjectCard = (): React.JSX.Element => {
const { repositoryGroups, selectRepository } = useStore( const { repositoryGroups, fetchRepositoryGroups } = useStore(
useShallow((s) => ({ useShallow((s) => ({
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
selectRepository: s.selectRepository, fetchRepositoryGroups: s.fetchRepositoryGroups,
})) }))
); );
const navigateToMatch = (match: WorktreeMatch): void => {
useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId));
void useStore.getState().fetchSessionsInitial(match.worktreeId);
};
const handleClick = async (): Promise<void> => { const handleClick = async (): Promise<void> => {
try { try {
const selectedPaths = await api.config.selectFolders(); const selectedPaths = await api.config.selectFolders();
@ -273,17 +299,23 @@ const NewProjectCard = (): React.JSX.Element => {
const selectedPath = selectedPaths[0]; const selectedPath = selectedPaths[0];
// Match selected path against known repository worktrees // Match selected path against known repository worktrees (normalized comparison)
for (const repo of repositoryGroups) { const match = findMatchingWorktree(repositoryGroups, selectedPath);
for (const worktree of repo.worktrees) { if (match) {
if (worktree.path === selectedPath) { navigateToMatch(match);
selectRepository(repo.id); return;
return;
}
}
} }
// No match found - open the folder in file manager as fallback // No match — refresh repository groups and retry
await fetchRepositoryGroups();
const refreshedGroups = useStore.getState().repositoryGroups;
const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath);
if (matchAfterRefresh) {
navigateToMatch(matchAfterRefresh);
return;
}
// Still no match — open the folder in file manager as fallback
const result = await api.openPath(selectedPath, undefined, true); const result = await api.openPath(selectedPath, undefined, true);
if (!result.success) { if (!result.success) {
logger.error('Failed to open folder:', result.error); logger.error('Failed to open folder:', result.error);

View file

@ -115,49 +115,79 @@ export const Sidebar = (): React.JSX.Element => {
> >
<SidebarHeader /> <SidebarHeader />
{/* Tab bar: Tasks | Sessions */} {/* Tab bar: Tasks | Sessions — tab strip style, filters on the right */}
<div <div
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5" className="flex shrink-0 items-end gap-2 border-b px-3 pt-1"
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
> >
<div className="flex gap-0.5"> <div className="flex flex-1" />
<div className="flex" role="tablist" aria-label="Sidebar view">
<button <button
type="button" type="button"
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${ role="tab"
sidebarTab === 'tasks' aria-selected={sidebarTab === 'tasks'}
? 'bg-surface-raised text-text' aria-controls="sidebar-tasks-panel"
: 'text-text-muted hover:text-text-secondary' id="sidebar-tab-tasks"
className={`relative px-3 py-1.5 text-[11px] font-medium transition-colors ${
sidebarTab === 'tasks' ? 'text-text' : 'text-text-muted hover:text-text-secondary'
}`} }`}
style={
sidebarTab === 'tasks'
? {
borderBottom: '2px solid var(--color-text)',
marginBottom: '-1px',
}
: undefined
}
onClick={() => setSidebarTab('tasks')} onClick={() => setSidebarTab('tasks')}
> >
Tasks Tasks
</button> </button>
<button <button
type="button" type="button"
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${ role="tab"
aria-selected={sidebarTab === 'sessions'}
aria-controls="sidebar-sessions-panel"
id="sidebar-tab-sessions"
className={`relative px-3 py-1.5 text-[11px] font-medium transition-colors ${
sidebarTab === 'sessions' sidebarTab === 'sessions'
? 'bg-surface-raised text-text' ? 'text-text'
: 'text-text-muted hover:text-text-secondary' : 'text-text-muted hover:text-text-secondary'
}`} }`}
style={
sidebarTab === 'sessions'
? {
borderBottom: '2px solid var(--color-text)',
marginBottom: '-1px',
}
: undefined
}
onClick={() => setSidebarTab('sessions')} onClick={() => setSidebarTab('sessions')}
> >
Sessions Sessions
</button> </button>
</div> </div>
{sidebarTab === 'tasks' && ( <div className="flex flex-1 justify-end pb-0.5">
<TaskFiltersPopover {sidebarTab === 'tasks' && (
open={taskFiltersPopoverOpen} <TaskFiltersPopover
onOpenChange={setTaskFiltersPopoverOpen} open={taskFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))} onOpenChange={setTaskFiltersPopoverOpen}
filters={taskFilters} teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
onFiltersChange={setTaskFilters} filters={taskFilters}
onApply={() => {}} onFiltersChange={setTaskFilters}
/> onApply={() => {}}
)} />
)}
</div>
</div> </div>
{/* Content: Tasks list or Sessions list */} {/* Content: Tasks list or Sessions list */}
<div className="min-w-0 flex-1 overflow-hidden"> <div
id={sidebarTab === 'tasks' ? 'sidebar-tasks-panel' : 'sidebar-sessions-panel'}
role="tabpanel"
aria-labelledby={`sidebar-tab-${sidebarTab}`}
className="min-w-0 flex-1 overflow-hidden"
>
{sidebarTab === 'tasks' ? ( {sidebarTab === 'tasks' ? (
<GlobalTaskList <GlobalTaskList
hideHeader hideHeader

View file

@ -128,6 +128,7 @@ export const SettingsView = (): React.JSX.Element | null => {
saving={saving} saving={saving}
onGeneralToggle={handlers.handleGeneralToggle} onGeneralToggle={handlers.handleGeneralToggle}
onThemeChange={handlers.handleThemeChange} onThemeChange={handlers.handleThemeChange}
onLanguageChange={handlers.handleLanguageChange}
/> />
)} )}

View file

@ -30,6 +30,7 @@ export interface SafeConfig {
theme: 'dark' | 'light' | 'system'; theme: 'dark' | 'light' | 'system';
defaultTab: 'dashboard' | 'last-session'; defaultTab: 'dashboard' | 'last-session';
claudeRootPath: string | null; claudeRootPath: string | null;
agentLanguage: string;
}; };
notifications: { notifications: {
enabled: boolean; enabled: boolean;
@ -154,6 +155,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
theme: displayConfig?.general?.theme ?? 'dark', theme: displayConfig?.general?.theme ?? 'dark',
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null, claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
}, },
notifications: { notifications: {
enabled: displayConfig?.notifications?.enabled ?? true, enabled: displayConfig?.notifications?.enabled ?? true,

View file

@ -30,6 +30,7 @@ interface SettingsHandlers {
// General handlers // General handlers
handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void; handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
handleThemeChange: (value: 'dark' | 'light' | 'system') => void; handleThemeChange: (value: 'dark' | 'light' | 'system') => void;
handleLanguageChange: (value: string) => void;
handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void; handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void;
// Notification handlers // Notification handlers
@ -81,6 +82,13 @@ export function useSettingsHandlers({
[updateConfig] [updateConfig]
); );
const handleLanguageChange = useCallback(
(value: string) => {
void updateConfig('general', { agentLanguage: value });
},
[updateConfig]
);
const handleDefaultTabChange = useCallback( const handleDefaultTabChange = useCallback(
(value: 'dashboard' | 'last-session') => { (value: 'dashboard' | 'last-session') => {
void updateConfig('general', { defaultTab: value }); void updateConfig('general', { defaultTab: value });
@ -287,6 +295,7 @@ export function useSettingsHandlers({
theme: 'dark', theme: 'dark',
defaultTab: 'dashboard', defaultTab: 'dashboard',
claudeRootPath: null, claudeRootPath: null,
agentLanguage: 'system',
}, },
display: { display: {
showTimestamps: true, showTimestamps: true,
@ -373,6 +382,7 @@ export function useSettingsHandlers({
return { return {
handleGeneralToggle, handleGeneralToggle,
handleThemeChange, handleThemeChange,
handleLanguageChange,
handleDefaultTabChange, handleDefaultTabChange,
handleNotificationToggle, handleNotificationToggle,
handleSnooze, handleSnooze,

View file

@ -6,8 +6,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api'; import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog'; import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Combobox } from '@renderer/components/ui/combobox';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers'; import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react'; import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components'; import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
@ -28,6 +30,7 @@ interface GeneralSectionProps {
readonly saving: boolean; readonly saving: boolean;
readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void; readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void;
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void; readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
readonly onLanguageChange: (value: string) => void;
} }
export const GeneralSection = ({ export const GeneralSection = ({
@ -35,6 +38,7 @@ export const GeneralSection = ({
saving, saving,
onGeneralToggle, onGeneralToggle,
onThemeChange, onThemeChange,
onLanguageChange,
}: GeneralSectionProps): React.JSX.Element => { }: GeneralSectionProps): React.JSX.Element => {
const [serverStatus, setServerStatus] = useState<HttpServerStatus>({ const [serverStatus, setServerStatus] = useState<HttpServerStatus>({
running: false, running: false,
@ -247,8 +251,59 @@ export const GeneralSection = ({
const isElectron = useMemo(() => isElectronMode(), []); const isElectron = useMemo(() => isElectronMode(), []);
const agentLanguageDescription = useMemo(() => {
const current = safeConfig.general.agentLanguage ?? 'system';
if (current === 'system') {
const browserLang = navigator.language;
const primaryCode = browserLang.includes('-') ? browserLang.split('-')[0] : browserLang;
const detected = resolveLanguageName('system', browserLang);
const detectedFlag = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === primaryCode)?.flag ?? '';
const flagPrefix = detectedFlag ? `${detectedFlag} ` : '';
return `Language for agent communication (detected: ${flagPrefix}${detected})`;
}
return 'Language for agent communication';
}, [safeConfig.general.agentLanguage]);
const languageComboboxOptions = useMemo(
() =>
AGENT_LANGUAGE_OPTIONS.map((opt) => ({
value: opt.value,
label: `${opt.flag} ${opt.label}`,
meta: { flag: opt.flag },
})),
[]
);
const renderLanguageOption = useCallback(
(
option: { value: string; label: string; meta?: Record<string, unknown> },
isSelected: boolean
) => (
<>
<Check className={`mr-2 size-3.5 shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
<span className="text-[var(--color-text)]">{option.label}</span>
</>
),
[]
);
return ( return (
<div> <div>
<SettingsSectionHeader title="Agent Language" />
<SettingRow label="Language" description={agentLanguageDescription}>
<Combobox
options={languageComboboxOptions}
value={safeConfig.general.agentLanguage ?? 'system'}
onValueChange={onLanguageChange}
placeholder="Select language..."
searchPlaceholder="Search language..."
emptyMessage="No language found."
disabled={saving}
className="min-w-[180px]"
renderOption={renderLanguageOption}
/>
</SettingRow>
{isElectron && ( {isElectron && (
<> <>
<SettingsSectionHeader title="Startup" /> <SettingsSectionHeader title="Startup" />

View file

@ -1,12 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import { cn } from '@renderer/lib/utils';
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { import {
@ -235,18 +229,30 @@ export const GlobalTaskList = ({
)} )}
</div> </div>
{/* Grouping mode */} {/* Grouping mode — compact segmented toggle */}
<div className="flex shrink-0 items-center gap-2 px-2 py-1"> <div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span> <span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
<Select value={groupingMode} onValueChange={(v) => setGroupingMode(v as TaskGroupingMode)}> <div
<SelectTrigger className="h-7 min-w-0 flex-1 border-[var(--color-border)] px-2 text-[11px]"> className="bg-surface-raised/60 inline-flex rounded-md p-0.5 text-[11px]"
<SelectValue placeholder="Group by" /> role="group"
</SelectTrigger> aria-label="Group by"
<SelectContent> >
<SelectItem value="project">Project</SelectItem> {(['project', 'time'] as const).map((mode) => (
<SelectItem value="time">Time</SelectItem> <button
</SelectContent> key={mode}
</Select> type="button"
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-2 py-0.5 transition-colors',
groupingMode === mode
? 'bg-surface-raised text-text shadow-sm'
: 'text-text-muted hover:text-text-secondary'
)}
>
{mode === 'project' ? 'Project' : 'Time'}
</button>
))}
</div>
</div> </div>
{/* Content */} {/* Content */}

View file

@ -27,13 +27,15 @@ export const CollapsibleTeamSection = ({
const isOpen = forceOpen ? true : open; const isOpen = forceOpen ? true : open;
return ( return (
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0"> <section className="border-b border-[var(--color-border)] pb-3 last:border-b-0">
<div className="flex items-center"> <div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
<button <button
type="button" type="button"
className="flex flex-1 items-center gap-2 text-left" className="absolute inset-0 z-0 cursor-pointer rounded-md transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={() => setOpen((prev) => !prev)} onClick={() => setOpen((prev) => !prev)}
> aria-label={isOpen ? 'Collapse section' : 'Expand section'}
/>
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 basis-0 items-center gap-2 pl-4">
<ChevronRight <ChevronRight
size={14} size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`} className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
@ -60,8 +62,8 @@ export const CollapsibleTeamSection = ({
{secondaryBadge} {secondaryBadge}
</Badge> </Badge>
)} )}
</button> </div>
{action && <div className="shrink-0">{action}</div>} {action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div> </div>
{isOpen && <div className="mt-2">{children}</div>} {isOpen && <div className="mt-2">{children}</div>}
</section> </section>

View file

@ -0,0 +1,74 @@
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
interface MemberBadgeProps {
name: string;
color?: string;
/** Avatar + badge size variant */
size?: 'sm' | 'md';
onClick?: (name: string) => void;
}
/**
* Reusable member avatar + colored name badge.
* Avatar is rendered OUTSIDE the badge, to the left.
* When onClick is provided, both avatar and badge are clickable as one unit.
*/
export const MemberBadge = ({
name,
color,
size = 'sm',
onClick,
}: MemberBadgeProps): React.JSX.Element => {
const colors = getTeamColorSet(color ?? '');
const avatarSize = size === 'md' ? 32 : 24;
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
const textClass = size === 'md' ? 'text-xs' : 'text-[10px]';
const badgeStyle = {
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
};
const avatar = (
<img
src={agentAvatarUrl(name, avatarSize)}
alt=""
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
loading="lazy"
/>
);
const badge = (
<span
className={`rounded px-1.5 py-0.5 ${textClass} font-medium tracking-wide`}
style={badgeStyle}
>
{name}
</span>
);
if (onClick) {
return (
<button
type="button"
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
onClick={(e) => {
e.stopPropagation();
onClick(name);
}}
>
{avatar}
{badge}
</button>
);
}
return (
<span className="inline-flex items-center gap-1">
{avatar}
{badge}
</span>
);
};

View file

@ -1,7 +1,13 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { Loader2 } from 'lucide-react'; import hljs from 'highlight.js/lib/core';
import json from 'highlight.js/lib/languages/json';
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
hljs.registerLanguage('json', json);
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps'; import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
@ -18,17 +24,93 @@ export interface ProvisioningProgressBlockProps {
loading?: boolean; loading?: boolean;
/** Cancel button label and handler */ /** Cancel button label and handler */
onCancel?: (() => void) | null; onCancel?: (() => void) | null;
/** ISO timestamp when provisioning started */
startedAt?: string;
/** PID of the CLI process */
pid?: number;
/** Tail of CLI logs */
cliLogsTail?: string;
className?: string; className?: string;
} }
function formatElapsed(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
function useElapsedTimer(startedAt?: string): string | null {
const [elapsed, setElapsed] = useState<string | null>(null);
useEffect(() => {
if (!startedAt) return () => setElapsed(null);
const startMs = Date.parse(startedAt);
if (isNaN(startMs)) return () => setElapsed(null);
const tick = (): void => {
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
setElapsed(formatElapsed(seconds));
};
tick();
const id = window.setInterval(tick, 1000);
return () => {
window.clearInterval(id);
};
}, [startedAt]);
if (!startedAt) return null;
return elapsed;
}
function highlightLogsHtml(text: string): string {
return text
.split('\n')
.map((line) => {
const trimmed = line.trimStart();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
return hljs.highlight(line, { language: 'json' }).value;
} catch {
return escapeHtml(line);
}
}
if (line === '[stdout]' || line === '[stderr]') {
return `<span class="hljs-comment">${escapeHtml(line)}</span>`;
}
return escapeHtml(line);
})
.join('\n');
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export const ProvisioningProgressBlock = ({ export const ProvisioningProgressBlock = ({
title, title,
message, message,
currentStepIndex, currentStepIndex,
loading = false, loading = false,
onCancel, onCancel,
startedAt,
pid,
cliLogsTail,
className, className,
}: ProvisioningProgressBlockProps): React.JSX.Element => { }: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt);
const [logsOpen, setLogsOpen] = useState(false);
const logsRef = useRef<HTMLPreElement>(null);
const highlightedHtml = useMemo(
() => (cliLogsTail ? highlightLogsHtml(cliLogsTail) : ''),
[cliLogsTail]
);
useEffect(() => {
if (logsOpen && logsRef.current) {
logsRef.current.scrollTop = logsRef.current.scrollHeight;
}
}, [logsOpen, cliLogsTail]);
return ( return (
<div <div
className={cn( className={cn(
@ -42,6 +124,14 @@ export const ProvisioningProgressBlock = ({
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" /> <Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
) : null} ) : null}
<p className="text-xs font-medium text-[var(--color-text)]">{title}</p> <p className="text-xs font-medium text-[var(--color-text)]">{title}</p>
{elapsed !== null ? (
<span className="text-[10px] tabular-nums text-[var(--color-text-muted)]">
{elapsed}
</span>
) : null}
{pid !== undefined ? (
<span className="text-[10px] text-[var(--color-text-muted)]">PID {pid}</span>
) : null}
</div> </div>
{onCancel ? ( {onCancel ? (
<Button <Button
@ -62,6 +152,7 @@ export const ProvisioningProgressBlock = ({
return ( return (
<div key={step} className="flex items-center gap-1"> <div key={step} className="flex items-center gap-1">
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */}
<Badge <Badge
variant="secondary" variant="secondary"
className={cn( className={cn(
@ -76,6 +167,7 @@ export const ProvisioningProgressBlock = ({
</span> </span>
{STEP_LABELS[step]} {STEP_LABELS[step]}
</Badge> </Badge>
{/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */}
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? ( {index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span> <span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null} ) : null}
@ -83,6 +175,29 @@ export const ProvisioningProgressBlock = ({
); );
})} })}
</div> </div>
{cliLogsTail ? (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setLogsOpen((v) => !v)}
>
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
CLI output
</button>
{logsOpen ? (
<pre
ref={logsRef}
className="hljs mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
// Safe: highlightedHtml is built from hljs.highlight() which only produces
// hljs-* <span> tags, combined with escapeHtml() for non-JSON lines.
// Input is CLI stdout/stderr from a local process, not user-supplied web content.
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
) : null}
</div>
) : null}
</div> </div>
); );
}; };

View file

@ -2,18 +2,28 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react'; import { GitBranch, Pencil, Play, Plus, Search, Trash2, UserPlus, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { ActiveTasksBlock } from './activity/ActiveTasksBlock'; import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
import { ActivityTimeline } from './activity/ActivityTimeline'; import { ActivityTimeline } from './activity/ActivityTimeline';
import { PendingRepliesBlock } from './activity/PendingRepliesBlock'; import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog'; import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
@ -24,6 +34,7 @@ import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList'; import { MemberList } from './members/MemberList';
import { MessageComposer } from './messages/MessageComposer';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover'; import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamProvisioningBanner } from './TeamProvisioningBanner';
@ -78,9 +89,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
defaultOwner: '', defaultOwner: '',
}); });
const [creatingTask, setCreatingTask] = useState(false); const [creatingTask, setCreatingTask] = useState(false);
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined); const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
undefined undefined
@ -102,7 +117,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
projects, projects,
selectTeam, selectTeam,
updateKanban, updateKanban,
updateKanbanColumnOrder,
updateTaskStatus, updateTaskStatus,
updateTaskOwner,
sendTeamMessage, sendTeamMessage,
requestReview, requestReview,
createTeamTask, createTeamTask,
@ -113,6 +130,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError, sendMessageError,
lastSendMessageResult, lastSendMessageResult,
reviewActionError, reviewActionError,
addMember,
removeMember,
launchTeam, launchTeam,
provisioningError, provisioningError,
isTeamProvisioning, isTeamProvisioning,
@ -126,7 +145,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
projects: s.projects, projects: s.projects,
selectTeam: s.selectTeam, selectTeam: s.selectTeam,
updateKanban: s.updateKanban, updateKanban: s.updateKanban,
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
updateTaskStatus: s.updateTaskStatus, updateTaskStatus: s.updateTaskStatus,
updateTaskOwner: s.updateTaskOwner,
sendTeamMessage: s.sendTeamMessage, sendTeamMessage: s.sendTeamMessage,
requestReview: s.requestReview, requestReview: s.requestReview,
createTeamTask: s.createTeamTask, createTeamTask: s.createTeamTask,
@ -137,6 +158,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError: s.sendMessageError, sendMessageError: s.sendMessageError,
lastSendMessageResult: s.lastSendMessageResult, lastSendMessageResult: s.lastSendMessageResult,
reviewActionError: s.reviewActionError, reviewActionError: s.reviewActionError,
addMember: s.addMember,
removeMember: s.removeMember,
launchTeam: s.launchTeam, launchTeam: s.launchTeam,
provisioningError: s.provisioningError, provisioningError: s.provisioningError,
isTeamProvisioning: Object.values(s.provisioningRuns).some( isTeamProvisioning: Object.values(s.provisioningRuns).some(
@ -204,6 +227,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}; };
}, [projectId]); }, [projectId]);
// Resolve lead's git branch from project path
const [leadBranch, setLeadBranch] = useState<string | null>(null);
useEffect(() => {
const projectPath = data?.config.projectPath?.trim();
if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') {
setLeadBranch(null);
return;
}
let cancelled = false;
void api.teams.getProjectBranch(projectPath).then(
(branch) => {
if (!cancelled) setLeadBranch(branch);
},
() => {
if (!cancelled) setLeadBranch(null);
}
);
return () => {
cancelled = true;
};
}, [data?.config.projectPath]);
// Filter sessions to team-only using sessionHistory + leadSessionId // Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessions = useMemo(() => { const teamSessions = useMemo(() => {
const sessionIds = new Set<string>(); const sessionIds = new Set<string>();
@ -313,6 +358,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return filterKanbanTasks(filteredTasks, query); return filterKanbanTasks(filteredTasks, query);
}, [filteredTasks, kanbanSearch]); }, [filteredTasks, kanbanSearch]);
const activeMembers = useMemo(
() => (data?.members ?? []).filter((m) => !m.removedAt),
[data?.members]
);
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
@ -354,12 +404,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}; };
const handleDeleteTeam = useCallback((): void => { const handleDeleteTeam = useCallback((): void => {
const confirmed = window.confirm( setDeleteConfirmOpen(true);
`Delete team "${teamName}"? This action is irreversible. All team data and tasks will be deleted.` }, []);
);
if (!confirmed) { const confirmDeleteTeam = useCallback((): void => {
return; setDeleteConfirmOpen(false);
}
void (async () => { void (async () => {
try { try {
await deleteTeam(teamName); await deleteTeam(teamName);
@ -475,46 +524,76 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
headerColorSet && 'relative z-10' headerColorSet && 'relative z-10'
)} )}
> >
<div className="min-w-0"> <div className="min-w-0 flex-1">
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2> <h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
{data.config.description && (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
{data.config.description}
</p>
)}
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{!data.isAlive ? ( <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <Button
size="sm" variant="ghost"
className="h-7 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" size="sm"
onClick={() => setLaunchDialogOpen(true)} className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
> onClick={() => setEditDialogOpen(true)}
<Play size={12} /> >
Launch <Pencil size={12} />
</Button> </Button>
) : null} </TooltipTrigger>
<Button <TooltipContent side="bottom">Edit team</TooltipContent>
variant="ghost" </Tooltip>
size="sm" <Tooltip>
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]" <TooltipTrigger asChild>
onClick={() => setEditDialogOpen(true)} <Button
> variant="ghost"
<Pencil size={12} /> size="sm"
</Button> className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
<Button onClick={handleDeleteTeam}
variant="ghost" >
size="sm" <Trash2 size={12} />
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300" </Button>
onClick={handleDeleteTeam} </TooltipTrigger>
> <TooltipContent side="bottom">Delete team</TooltipContent>
<Trash2 size={12} /> </Tooltip>
</Button>
</div> </div>
</div> </div>
{(data.config.description || leadBranch) && (
<div
className={cn(
'flex items-center justify-between gap-2',
headerColorSet && 'relative z-10'
)}
>
<p className="min-w-0 truncate text-xs text-[var(--color-text-muted)]">
{data.config.description || ''}
</p>
{leadBranch ? (
<span
className="flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-text-muted)]"
title={leadBranch}
>
<GitBranch size={10} />
<span className="max-w-32 truncate">{leadBranch}</span>
</span>
) : null}
</div>
)}
</div> </div>
{!data.isAlive ? (
<div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<span className="text-xs text-amber-200">Team is offline</span>
<Button
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={() => setLaunchDialogOpen(true)}
>
<Play size={12} />
Launch
</Button>
</div>
) : null}
<TeamProvisioningBanner teamName={teamName} /> <TeamProvisioningBanner teamName={teamName} />
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
@ -528,7 +607,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div> </div>
) : null} ) : null}
<CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen> <CollapsibleTeamSection
title="Team"
badge={activeMembers.length}
defaultOpen
action={
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setAddMemberDialogOpen(true);
}}
>
<UserPlus size={12} />
Member
</Button>
}
>
<MemberList <MemberList
members={data.members} members={data.members}
memberTaskCounts={memberTaskCounts} memberTaskCounts={memberTaskCounts}
@ -581,28 +678,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</Button> </Button>
} }
> >
<div className="relative mb-2">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks… (#id or text)"
value={kanbanSearch}
onChange={(e) => setKanbanSearch(e.target.value)}
className="h-8 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
/>
{kanbanSearch && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setKanbanSearch('')}
>
<X size={14} />
</button>
)}
</div>
<KanbanBoard <KanbanBoard
tasks={kanbanDisplayTasks} tasks={kanbanDisplayTasks}
teamName={teamName} teamName={teamName}
@ -610,8 +685,37 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
filter={kanbanFilter} filter={kanbanFilter}
sessions={teamSessions} sessions={teamSessions}
leadSessionId={data.config.leadSessionId} leadSessionId={data.config.leadSessionId}
members={data.members} members={activeMembers}
onFilterChange={setKanbanFilter} onFilterChange={setKanbanFilter}
toolbarLeft={
<div className="relative">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks… (#id or text)"
value={kanbanSearch}
onChange={(e) => setKanbanSearch(e.target.value)}
className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
/>
{kanbanSearch && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setKanbanSearch('')}
>
<X size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Clear search</TooltipContent>
</Tooltip>
)}
</div>
}
onRequestReview={(taskId) => { onRequestReview={(taskId) => {
void requestReview(teamName, taskId); void requestReview(teamName, taskId);
}} }}
@ -652,6 +756,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onCompleteTask={(taskId) => { onCompleteTask={(taskId) => {
void updateTaskStatus(teamName, taskId, 'completed'); void updateTaskStatus(teamName, taskId, 'completed');
}} }}
onColumnOrderChange={(columnId, orderedTaskIds) => {
void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
}}
onScrollToTask={(taskId) => { onScrollToTask={(taskId) => {
const el = document.querySelector(`[data-task-id="${taskId}"]`); const el = document.querySelector(`[data-task-id="${taskId}"]`);
if (el) { if (el) {
@ -692,23 +799,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onOpenChange={setMessagesFilterOpen} onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter} 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> </div>
} }
> >
<MessageComposer
teamName={teamName}
members={activeMembers}
isTeamAlive={data.isAlive}
sending={sendingMessage}
sendError={sendMessageError}
onSend={(member, text, summary, attachments) => {
const sentAtMs = Date.now();
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => {
setPendingRepliesByMember((prev) => {
if (prev[member] !== sentAtMs) return prev;
const next = { ...prev };
delete next[member];
return next;
});
});
}}
/>
<PendingRepliesBlock <PendingRepliesBlock
members={data.members} members={data.members}
pendingRepliesByMember={pendingRepliesByMember} pendingRepliesByMember={pendingRepliesByMember}
@ -722,6 +834,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
/> />
<ActivityTimeline <ActivityTimeline
messages={filteredMessages} messages={filteredMessages}
teamName={teamName}
members={data.members} members={data.members}
readState={{ readSet, getMessageKey: toMessageKey }} readState={{ readSet, getMessageKey: toMessageKey }}
onMemberClick={setSelectedMember} onMemberClick={setSelectedMember}
@ -786,12 +899,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setSelectedMember(null); setSelectedMember(null);
setSelectedTask(task); setSelectedTask(task);
}} }}
onRemoveMember={() => {
const name = selectedMember?.name;
if (!name) return;
setRemoveMemberConfirm(name);
}}
/> />
<CreateTaskDialog <CreateTaskDialog
open={createTaskDialog.open} open={createTaskDialog.open}
teamName={teamName} teamName={teamName}
members={data.members} members={activeMembers}
tasks={data.tasks} tasks={data.tasks}
isTeamAlive={data.isAlive && !isTeamProvisioning} isTeamAlive={data.isAlive && !isTeamProvisioning}
defaultSubject={createTaskDialog.defaultSubject} defaultSubject={createTaskDialog.defaultSubject}
@ -812,6 +930,81 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onSaved={() => void selectTeam(teamName)} onSaved={() => void selectTeam(teamName)}
/> />
<AddMemberDialog
open={addMemberDialogOpen}
teamName={teamName}
existingNames={data.members.map((m) => m.name)}
adding={addingMemberLoading}
onClose={() => setAddMemberDialogOpen(false)}
onAdd={(name, role) => {
setAddingMemberLoading(true);
void (async () => {
try {
await addMember(teamName, { name, role });
setAddMemberDialogOpen(false);
} catch {
// error shown via store
} finally {
setAddingMemberLoading(false);
}
})();
}}
/>
<Dialog
open={removeMemberConfirm !== null}
onOpenChange={(open) => {
if (!open) setRemoveMemberConfirm(null);
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Remove member</DialogTitle>
<DialogDescription>
Remove &ldquo;{removeMemberConfirm}&rdquo; from the team? Tasks and messages will be
preserved, but this name cannot be reused.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setRemoveMemberConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
const name = removeMemberConfirm;
setRemoveMemberConfirm(null);
setSelectedMember(null);
if (name) void removeMember(teamName, name);
}}
>
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete team</DialogTitle>
<DialogDescription>
Delete team &ldquo;{data.config.name}&rdquo;? This action is irreversible. All team
data and tasks will be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirmOpen(false)}>
Cancel
</Button>
<Button variant="destructive" size="sm" onClick={confirmDeleteTeam}>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LaunchTeamDialog <LaunchTeamDialog
open={launchDialogOpen} open={launchDialogOpen}
teamName={teamName} teamName={teamName}
@ -826,7 +1019,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<SendMessageDialog <SendMessageDialog
open={sendDialogOpen} open={sendDialogOpen}
members={data.members} members={activeMembers}
defaultRecipient={sendDialogRecipient} defaultRecipient={sendDialogRecipient}
quotedMessage={replyQuote} quotedMessage={replyQuote}
sending={sendingMessage} sending={sendingMessage}
@ -860,7 +1053,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
teamName={teamName} teamName={teamName}
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined} kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
taskMap={taskMap} taskMap={taskMap}
members={data?.members ?? []} members={activeMembers}
onClose={() => setSelectedTask(null)} onClose={() => setSelectedTask(null)}
onScrollToTask={(taskId) => { onScrollToTask={(taskId) => {
setSelectedTask(null); setSelectedTask(null);
@ -871,6 +1064,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500); setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
} }
}} }}
onOwnerChange={(taskId, owner) => {
void updateTaskOwner(teamName, taskId, owner);
}}
/> />
</div> </div>
); );

View file

@ -14,7 +14,17 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils'; import { getBaseName } from '@renderer/utils/pathUtils';
import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Square, Trash2 } from 'lucide-react'; import {
CheckCircle,
Clock,
Copy,
FolderOpen,
GitBranch,
Play,
Search,
Square,
Trash2,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
@ -97,6 +107,7 @@ export const TeamListView = (): React.JSX.Element => {
const [copyData, setCopyData] = useState<TeamCopyData | null>(null); const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [aliveTeams, setAliveTeams] = useState<string[]>([]); const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
const { const {
teams, teams,
teamsLoading, teamsLoading,
@ -204,6 +215,49 @@ export const TeamListView = (): React.JSX.Element => {
return result; return result;
}, [teams, searchQuery, currentProjectPath]); }, [teams, searchQuery, currentProjectPath]);
// Live branch/worktree for team project paths (poll so it updates during process)
const projectPathsToPoll = useMemo(() => {
const byKey = new Map<string, string>();
for (const team of filteredTeams) {
const p = team.projectPath?.trim();
if (p) {
const key = normalizePath(p);
if (!byKey.has(key)) byKey.set(key, p);
}
}
return Array.from(byKey.entries());
}, [filteredTeams]);
useEffect(() => {
if (!electronMode || projectPathsToPoll.length === 0) return;
let cancelled = false;
const poll = async (): Promise<void> => {
const next = new Map<string, string | null>();
for (const [pathKey, actualPath] of projectPathsToPoll) {
if (cancelled) return;
try {
const branch = await api.teams.getProjectBranch(actualPath);
if (!cancelled) next.set(pathKey, branch);
} catch {
if (!cancelled) next.set(pathKey, null);
}
}
if (!cancelled && next.size > 0) {
setBranchByPath((prev) => {
const m = new Map(prev);
for (const [k, v] of next) m.set(k, v);
return m;
});
}
};
void poll();
const interval = setInterval(poll, 6000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [electronMode, projectPathsToPoll]);
const handleDeleteTeam = useCallback( const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => { (teamName: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -509,9 +563,25 @@ export const TeamListView = (): React.JSX.Element => {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]"> <div className="mt-2 flex min-h-10 items-start gap-2">
{team.description || 'No description'} <p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
</p> {team.description || 'No description'}
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
if (!branch) return null;
return (
<span
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branch}
>
<GitBranch size={10} />
<span className="max-w-24 truncate">{branch}</span>
</span>
);
})()}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5"> <div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? ( {team.members && team.members.length > 0 ? (
team.members.map((m) => { team.members.map((m) => {

View file

@ -142,6 +142,9 @@ export const TeamProvisioningBanner = ({
message={progress.message} message={progress.message}
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1} currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
loading loading
startedAt={progress.startedAt}
pid={progress.pid}
cliLogsTail={progress.cliLogsTail}
onCancel={ onCancel={
canCancel canCancel
? () => { ? () => {

View file

@ -1,6 +1,9 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { import {
CARD_BG, CARD_BG,
CARD_BORDER_STYLE, CARD_BORDER_STYLE,
@ -16,7 +19,8 @@ import {
} from '@renderer/utils/agentMessageFormatting'; } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks'; import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { Bot, ChevronRight, ListPlus, MessageSquare, Reply } from 'lucide-react'; import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react';
import { ReplyQuoteBlock } from './ReplyQuoteBlock'; import { ReplyQuoteBlock } from './ReplyQuoteBlock';
@ -27,6 +31,7 @@ type StructuredMessage = Record<string, unknown>;
interface ActivityItemProps { interface ActivityItemProps {
message: InboxMessage; message: InboxMessage;
teamName: string;
memberRole?: string; memberRole?: string;
memberColor?: string; memberColor?: string;
recipientColor?: string; recipientColor?: string;
@ -126,6 +131,7 @@ function stripAgentBlocks(text: string): string {
export const ActivityItem = ({ export const ActivityItem = ({
message, message,
teamName,
memberRole, memberRole,
memberColor, memberColor,
recipientColor, recipientColor,
@ -135,7 +141,6 @@ export const ActivityItem = ({
onReply, onReply,
}: ActivityItemProps): React.JSX.Element => { }: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null;
const formattedRole = formatAgentRole(memberRole); const formattedRole = formatAgentRole(memberRole);
const timestamp = Number.isNaN(Date.parse(message.timestamp)) const timestamp = Number.isNaN(Date.parse(message.timestamp))
@ -143,10 +148,13 @@ export const ActivityItem = ({
: new Date(message.timestamp).toLocaleString(); : new Date(message.timestamp).toLocaleString();
const structured = parseStructuredAgentMessage(message.text); const structured = parseStructuredAgentMessage(message.text);
const noiseLabel = structured ? getNoiseLabel(structured) : null; // Only flag agent messages as rate-limited, not user's own quotes
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
// Never collapse rate limit messages as noise — they must be visible
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
// System/automated messages start collapsed // System/automated messages start collapsed (but not rate limits)
const systemLabel = !structured ? getSystemMessageLabel(message.text) : null; const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel); const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text // Strip agent-only blocks from displayed text
@ -184,9 +192,11 @@ export const ActivityItem = ({
<article <article
className="group overflow-hidden rounded-md" className="group overflow-hidden rounded-md"
style={{ style={{
backgroundColor: CARD_BG, backgroundColor: rateLimited ? 'var(--tool-result-error-bg)' : CARD_BG,
border: CARD_BORDER_STYLE, border: rateLimited ? '1px solid var(--tool-result-error-border)' : CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`, borderLeft: rateLimited
? '3px solid var(--tool-result-error-text)'
: `3px solid ${colors.border}`,
}} }}
> >
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */} {/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
@ -224,41 +234,12 @@ export const ActivityItem = ({
/> />
) : null} ) : null}
{message.source === 'lead_session' || message.source === 'lead_process' ? ( {/* Sender avatar + name badge */}
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} /> <MemberBadge
) : ( name={message.from}
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} /> color={memberColor ?? message.color}
)} onClick={onMemberNameClick}
/>
{/* 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 */} {/* Role */}
{formattedRole ? ( {formattedRole ? (
@ -289,60 +270,24 @@ export const ActivityItem = ({
</span> </span>
) : null} ) : null}
{/* Recipient — badge like sender, clickable to open member popup */} {/* Rate limit warning badge */}
{message.to && message.to !== message.from && recipientColors ? ( {rateLimited ? (
<span className="text-[10px]"> <span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </span> <AlertTriangle size={10} />
{onMemberNameClick ? ( Rate Limited
<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> </span>
) : null} ) : null}
{/* Recipient — arrow + avatar + badge */}
{message.to && message.to !== message.from ? (
<>
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
&rarr;
</span>
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
</>
) : null}
{/* Summary */} {/* Summary */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}> <span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText} {summaryText}
@ -351,32 +296,40 @@ export const ActivityItem = ({
{/* Timestamp + reply + create task */} {/* Timestamp + reply + create task */}
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{onReply && ( {onReply && (
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100" <button
style={{ color: CARD_ICON_MUTED }} type="button"
title="Reply to message" className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
onClick={(e) => { style={{ color: CARD_ICON_MUTED }}
e.stopPropagation(); onClick={(e) => {
onReply(message); e.stopPropagation();
}} onReply(message);
> }}
<Reply size={14} /> >
</button> <Reply size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Reply to message</TooltipContent>
</Tooltip>
)} )}
{onCreateTask && ( {onCreateTask && (
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100" <button
style={{ color: CARD_ICON_MUTED }} type="button"
title="Create task from message" className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
onClick={(e) => { style={{ color: CARD_ICON_MUTED }}
e.stopPropagation(); onClick={(e) => {
handleCreateTask(); e.stopPropagation();
}} handleCreateTask();
> }}
<ListPlus size={14} /> >
</button> <ListPlus size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Create task from message</TooltipContent>
</Tooltip>
)} )}
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp} {timestamp}
@ -404,8 +357,20 @@ export const ActivityItem = ({
) : parsedReply ? ( ) : parsedReply ? (
<ReplyQuoteBlock reply={parsedReply} /> <ReplyQuoteBlock reply={parsedReply} />
) : ( ) : (
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable /> <MarkdownViewer
content={displayText ?? message.text}
maxHeight="max-h-56"
copyable
bare
/>
)} )}
{message.attachments?.length && message.messageId ? (
<AttachmentDisplay
teamName={teamName}
messageId={message.messageId}
attachments={message.attachments}
/>
) : null}
</div> </div>
) : null} ) : null}
</article> </article>

View file

@ -8,6 +8,7 @@ import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps { interface ActivityTimelineProps {
messages: InboxMessage[]; messages: InboxMessage[];
teamName: string;
members?: ResolvedTeamMember[]; members?: ResolvedTeamMember[];
/** /**
* When provided, unread is derived from this set and getMessageKey. * When provided, unread is derived from this set and getMessageKey.
@ -25,6 +26,7 @@ const VIEWPORT_THRESHOLD = 0.15;
const MessageRowWithObserver = ({ const MessageRowWithObserver = ({
message, message,
teamName,
memberRole, memberRole,
memberColor, memberColor,
recipientColor, recipientColor,
@ -35,6 +37,7 @@ const MessageRowWithObserver = ({
onVisible, onVisible,
}: { }: {
message: InboxMessage; message: InboxMessage;
teamName: string;
memberRole?: string; memberRole?: string;
memberColor?: string; memberColor?: string;
recipientColor?: string; recipientColor?: string;
@ -78,6 +81,7 @@ const MessageRowWithObserver = ({
<div ref={ref} className="min-h-px"> <div ref={ref} className="min-h-px">
<ActivityItem <ActivityItem
message={message} message={message}
teamName={teamName}
memberRole={memberRole} memberRole={memberRole}
memberColor={memberColor} memberColor={memberColor}
recipientColor={recipientColor} recipientColor={recipientColor}
@ -92,6 +96,7 @@ const MessageRowWithObserver = ({
export const ActivityTimeline = ({ export const ActivityTimeline = ({
messages, messages,
teamName,
members, members,
readState, readState,
onCreateTaskFromMessage, onCreateTaskFromMessage,
@ -161,6 +166,7 @@ export const ActivityTimeline = ({
<MessageRowWithObserver <MessageRowWithObserver
key={messageKey} key={messageKey}
message={message} message={message}
teamName={teamName}
memberRole={info?.role} memberRole={info?.role}
memberColor={info?.color} memberColor={info?.color}
recipientColor={recipientColor} recipientColor={recipientColor}

View file

@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { AttachmentThumbnail } from './AttachmentThumbnail';
import { ImageLightbox } from './ImageLightbox';
import type { AttachmentFileData, AttachmentMeta } from '@shared/types';
interface AttachmentDisplayProps {
teamName: string;
messageId: string;
attachments: AttachmentMeta[];
}
export const AttachmentDisplay = ({
teamName,
messageId,
attachments,
}: AttachmentDisplayProps): React.JSX.Element | null => {
const [state, setState] = useState<{
loaded: AttachmentFileData[];
loading: boolean;
key: string;
}>({ loaded: [], loading: true, key: `${teamName}:${messageId}` });
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const currentKey = `${teamName}:${messageId}`;
// Reset loading state when deps change (React 18+ pattern: derive from props)
if (state.key !== currentKey) {
setState({ loaded: [], loading: true, key: currentKey });
}
useEffect(() => {
let cancelled = false;
void window.electronAPI.teams
.getAttachments(teamName, messageId)
.then((data) => {
if (!cancelled) setState({ loaded: data, loading: false, key: `${teamName}:${messageId}` });
})
.catch(() => {
if (!cancelled) setState((prev) => ({ ...prev, loading: false }));
});
return () => {
cancelled = true;
};
}, [teamName, messageId]);
const { loaded, loading } = state;
if (attachments.length === 0) return null;
if (loading) {
return (
<div className="flex items-center gap-1.5 py-1 text-[11px] text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Loading attachments...
</div>
);
}
// Build lookup for loaded data
const dataById = new Map(loaded.map((d) => [d.id, d]));
const items = attachments
.map((meta) => {
const data = dataById.get(meta.id);
if (!data) return null;
return { meta, dataUrl: `data:${data.mimeType};base64,${data.data}` };
})
.filter(Boolean) as { meta: AttachmentMeta; dataUrl: string }[];
if (items.length === 0) return null;
return (
<>
<div className="mt-1.5 flex flex-wrap gap-2">
{items.map((item, i) => (
<AttachmentThumbnail
key={item.meta.id}
src={item.dataUrl}
alt={item.meta.filename}
size="md"
onClick={() => setLightboxIndex(i)}
/>
))}
</div>
{lightboxIndex !== null && items[lightboxIndex] ? (
<ImageLightbox
src={items[lightboxIndex].dataUrl}
alt={items[lightboxIndex].meta.filename}
open
onClose={() => setLightboxIndex(null)}
/>
) : null}
</>
);
};

View file

@ -0,0 +1,40 @@
import { formatFileSize } from '@renderer/utils/attachmentUtils';
import { X } from 'lucide-react';
import { AttachmentThumbnail } from './AttachmentThumbnail';
import type { AttachmentPayload } from '@shared/types';
interface AttachmentPreviewItemProps {
attachment: AttachmentPayload;
onRemove: (id: string) => void;
}
export const AttachmentPreviewItem = ({
attachment,
onRemove,
}: AttachmentPreviewItemProps): React.JSX.Element => {
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
return (
<div className="group/att relative flex shrink-0 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
{attachment.filename}
</span>
<span className="text-[10px] text-[var(--color-text-muted)]">
{formatFileSize(attachment.size)}
</span>
</div>
<button
type="button"
className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-red-500 text-white opacity-0 transition-opacity group-hover/att:opacity-100"
onClick={() => onRemove(attachment.id)}
aria-label={`Remove ${attachment.filename}`}
>
<X size={10} />
</button>
</div>
);
};

View file

@ -0,0 +1,37 @@
import { AlertCircle } from 'lucide-react';
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
import type { AttachmentPayload } from '@shared/types';
interface AttachmentPreviewListProps {
attachments: AttachmentPayload[];
onRemove: (id: string) => void;
error?: string | null;
}
export const AttachmentPreviewList = ({
attachments,
onRemove,
error,
}: AttachmentPreviewListProps): React.JSX.Element | null => {
if (attachments.length === 0 && !error) return null;
return (
<div className="space-y-1.5 px-1">
{attachments.length > 0 ? (
<div className="flex gap-2 overflow-x-auto py-1">
{attachments.map((att) => (
<AttachmentPreviewItem key={att.id} attachment={att} onRemove={onRemove} />
))}
</div>
) : null}
{error ? (
<div className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5">
<AlertCircle size={13} className="shrink-0 text-red-400" />
<p className="text-[11px] text-red-400">{error}</p>
</div>
) : null}
</div>
);
};

View file

@ -0,0 +1,42 @@
import { cn } from '@renderer/lib/utils';
interface AttachmentThumbnailProps {
src: string;
alt?: string;
size?: 'sm' | 'md' | 'lg';
onClick?: () => void;
}
const sizeClasses: Record<string, string> = {
sm: 'size-12',
md: 'size-20',
lg: 'size-32',
};
export const AttachmentThumbnail = ({
src,
alt = 'attachment',
size = 'md',
onClick,
}: AttachmentThumbnailProps): React.JSX.Element => {
const img = (
<img
src={src}
alt={alt}
className={cn(
'rounded-md border border-[var(--color-border)] object-cover',
sizeClasses[size],
onClick && 'cursor-pointer transition-opacity hover:opacity-80'
)}
draggable={false}
/>
);
if (onClick) {
return (
<button type="button" className="block border-0 bg-transparent p-0" onClick={onClick}>
{img}
</button>
);
}
return img;
};

View file

@ -0,0 +1,18 @@
import { ImagePlus } from 'lucide-react';
interface DropZoneOverlayProps {
active: boolean;
}
export const DropZoneOverlay = ({ active }: DropZoneOverlayProps): React.JSX.Element | null => {
if (!active) return null;
return (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed border-blue-400/60 bg-blue-500/10 backdrop-blur-[1px]">
<div className="flex flex-col items-center gap-1.5 text-blue-400">
<ImagePlus size={24} />
<span className="text-xs font-medium">Drop images here</span>
</div>
</div>
);
};

View file

@ -0,0 +1,58 @@
import { useCallback, useEffect } from 'react';
interface ImageLightboxProps {
src: string;
alt?: string;
open: boolean;
onClose: () => void;
}
export const ImageLightbox = ({
src,
alt = 'Image',
open,
onClose,
}: ImageLightboxProps): React.JSX.Element | null => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose]
);
useEffect(() => {
if (!open) return;
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, handleKeyDown]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm duration-150 animate-in fade-in"
role="dialog"
aria-modal="true"
aria-label={alt}
>
<button
type="button"
className="absolute inset-0 border-0 bg-transparent p-0"
onClick={onClose}
aria-label="Close"
/>
<button
type="button"
className="relative z-10 max-h-[85vh] max-w-[90vw] border-0 bg-transparent p-0"
onClick={(e) => e.stopPropagation()}
>
<img
src={src}
alt={alt}
className="rounded-lg object-contain shadow-2xl"
draggable={false}
/>
</button>
</div>
);
};

View file

@ -0,0 +1,153 @@
import { useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Loader2 } from 'lucide-react';
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
const CUSTOM_ROLE = '__custom__';
const NO_ROLE = '__none__';
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
interface AddMemberDialogProps {
open: boolean;
teamName: string;
existingNames: string[];
onClose: () => void;
onAdd: (name: string, role?: string) => void;
adding?: boolean;
}
export const AddMemberDialog = ({
open,
teamName,
existingNames,
onClose,
onAdd,
adding,
}: AddMemberDialogProps): React.JSX.Element => {
const [name, setName] = useState('');
const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE);
const [customRole, setCustomRole] = useState('');
const [error, setError] = useState<string | null>(null);
const effectiveRole =
roleSelect === CUSTOM_ROLE
? customRole.trim()
: roleSelect === NO_ROLE
? undefined
: roleSelect;
const validate = (): string | null => {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return 'Name is required';
if (trimmed.length < 2) return 'Name must be at least 2 characters';
if (trimmed.length > 30) return 'Name must be at most 30 characters';
if (!NAME_REGEX.test(trimmed))
return 'Name must be lowercase alphanumeric with hyphens (e.g. alice, dev-1)';
if (existingNames.some((n) => n.toLowerCase() === trimmed)) return 'Name is already taken';
return null;
};
const handleSubmit = (): void => {
const err = validate();
if (err) {
setError(err);
return;
}
setError(null);
onAdd(name.trim().toLowerCase(), effectiveRole);
};
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
setName('');
setRoleSelect(NO_ROLE);
setCustomRole('');
setError(null);
onClose();
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>Add a new member to {teamName}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. alice, dev-1"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
autoFocus
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<div className="space-y-2">
<Label>Role (optional)</Label>
<Select value={roleSelect} onValueChange={setRoleSelect}>
<SelectTrigger>
<SelectValue placeholder="No role" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_ROLE}>No role</SelectItem>
{PRESET_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role}
</SelectItem>
))}
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem>
</SelectContent>
</Select>
{roleSelect === CUSTOM_ROLE && (
<Input
placeholder="Custom role"
value={customRole}
onChange={(e) => setCustomRole(e.target.value)}
/>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={adding}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={adding || !name.trim()}>
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -24,6 +24,7 @@ import {
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { AlertTriangle, Search } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTask } from '@shared/types'; import type { ResolvedTeamMember, TeamTask } from '@shared/types';
@ -73,6 +74,8 @@ export const CreateTaskDialog = ({
const [related, setRelated] = useState<string[]>([]); const [related, setRelated] = useState<string[]>([]);
const [startImmediately, setStartImmediately] = useState(true); const [startImmediately, setStartImmediately] = useState(true);
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` }); const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
const [blockedBySearch, setBlockedBySearch] = useState('');
const [relatedSearch, setRelatedSearch] = useState('');
const [prevOpen, setPrevOpen] = useState(false); const [prevOpen, setPrevOpen] = useState(false);
if (open && !prevOpen) { if (open && !prevOpen) {
@ -85,6 +88,8 @@ export const CreateTaskDialog = ({
setRelated([]); setRelated([]);
setStartImmediately(isTeamAlive); setStartImmediately(isTeamAlive);
promptDraft.clearDraft(); promptDraft.clearDraft();
setBlockedBySearch('');
setRelatedSearch('');
} }
if (open !== prevOpen) { if (open !== prevOpen) {
setPrevOpen(open); setPrevOpen(open);
@ -150,6 +155,16 @@ export const CreateTaskDialog = ({
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{!isTeamAlive ? (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" />
<p className="text-xs leading-relaxed text-amber-300">
Team is offline. The task will be added to <strong>TODO</strong> &mdash; launch the
team to start execution.
</p>
</div>
) : null}
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="task-subject">Subject</Label> <Label htmlFor="task-subject">Subject</Label>
@ -265,39 +280,63 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? ( {availableTasks.length > 0 ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Blocked by tasks (optional)</Label> <Label>Blocked by tasks (optional)</Label>
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2"> <div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.map((t) => { {availableTasks.length > 3 ? (
const isSelected = blockedBy.includes(t.id); <div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
return ( <Search
<button size={12}
key={t.id} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
type="button" />
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${ <input
isSelected type="text"
? 'bg-blue-500/15 text-blue-300' placeholder="Search tasks..."
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]' value={blockedBySearch}
}`} onChange={(e) => setBlockedBySearch(e.target.value)}
onClick={() => toggleBlockedBy(t.id)} className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
> />
<span </div>
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${ ) : null}
isSelected <div className="max-h-[108px] overflow-y-auto p-1.5">
? 'border-blue-400 bg-blue-500/30 text-blue-300' {availableTasks
: 'border-[var(--color-border-emphasis)]' .filter(
}`} (t) =>
> !blockedBySearch ||
{isSelected ? '\u2713' : ''} t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
</span> t.id.includes(blockedBySearch)
<Badge )
variant="secondary" .map((t) => {
className="shrink-0 px-1 py-0 text-[10px] font-normal" const isSelected = blockedBy.includes(t.id);
> return (
#{t.id} <button
</Badge> key={t.id}
<span className="truncate">{t.subject}</span> type="button"
</button> className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
); isSelected
})} ? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleBlockedBy(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-blue-400 bg-blue-500/30 text-blue-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
#{t.id}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div> </div>
{blockedBy.length > 0 ? ( {blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300"> <p className="text-[11px] text-yellow-300">
@ -310,39 +349,63 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? ( {availableTasks.length > 0 ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Related tasks (optional)</Label> <Label>Related tasks (optional)</Label>
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2"> <div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.map((t) => { {availableTasks.length > 3 ? (
const isSelected = related.includes(t.id); <div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
return ( <Search
<button size={12}
key={`related:${t.id}`} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
type="button" />
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${ <input
isSelected type="text"
? 'bg-purple-500/15 text-purple-300' placeholder="Search tasks..."
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]' value={relatedSearch}
}`} onChange={(e) => setRelatedSearch(e.target.value)}
onClick={() => toggleRelated(t.id)} className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
> />
<span </div>
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${ ) : null}
isSelected <div className="max-h-[108px] overflow-y-auto p-1.5">
? 'border-purple-400 bg-purple-500/30 text-purple-300' {availableTasks
: 'border-[var(--color-border-emphasis)]' .filter(
}`} (t) =>
> !relatedSearch ||
{isSelected ? '\u2713' : ''} t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
</span> t.id.includes(relatedSearch)
<Badge )
variant="secondary" .map((t) => {
className="shrink-0 px-1 py-0 text-[10px] font-normal" const isSelected = related.includes(t.id);
> return (
#{t.id} <button
</Badge> key={`related:${t.id}`}
<span className="truncate">{t.subject}</span> type="button"
</button> className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
); isSelected
})} ? 'bg-purple-500/15 text-purple-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleRelated(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-purple-400 bg-purple-500/30 text-purple-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
#{t.id}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div> </div>
{related.length > 0 ? ( {related.length > 0 ? (
<p className="text-[11px] text-purple-300"> <p className="text-[11px] text-purple-300">

View file

@ -28,7 +28,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { Check, CheckCircle2, Loader2 } from 'lucide-react'; import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
const TEAM_COLOR_NAMES = [ const TEAM_COLOR_NAMES = [
'blue', 'blue',
@ -173,6 +173,7 @@ function validateRequest(
options?: { requireCwd?: boolean } options?: { requireCwd?: boolean }
): ValidationResult { ): ValidationResult {
const requireCwd = options?.requireCwd ?? true; const requireCwd = options?.requireCwd ?? true;
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) { if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
return { return {
valid: false, valid: false,
@ -261,6 +262,7 @@ export const CreateTeamDialog = ({
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [launchTeam, setLaunchTeam] = useState(true); const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState(''); const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const resetUIState = (): void => { const resetUIState = (): void => {
setLocalError(null); setLocalError(null);
@ -281,6 +283,7 @@ export const CreateTeamDialog = ({
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
setLaunchTeam(true); setLaunchTeam(true);
setSelectedModel('');
resetUIState(); resetUIState();
}; };
@ -460,6 +463,9 @@ export const CreateTeamDialog = ({
[members] [members]
); );
const effectiveModel =
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
const request = useMemo<TeamCreateRequest>( const request = useMemo<TeamCreateRequest>(
() => ({ () => ({
teamName: teamName.trim(), teamName: teamName.trim(),
@ -468,8 +474,9 @@ export const CreateTeamDialog = ({
members: buildMembers(members), members: buildMembers(members),
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: prompt.trim() || undefined, prompt: prompt.trim() || undefined,
model: effectiveModel,
}), }),
[teamName, description, teamColor, members, effectiveCwd, prompt] [teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
); );
const activeError = localError ?? provisioningError; const activeError = localError ?? provisioningError;
@ -571,7 +578,7 @@ export const CreateTeamDialog = ({
} }
}} }}
> >
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle> <DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">
@ -582,17 +589,31 @@ export const CreateTeamDialog = ({
</DialogHeader> </DialogHeader>
{canCreate && launchTeam && prepareState === 'failed' ? ( {canCreate && launchTeam && prepareState === 'failed' ? (
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300"> <div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<p>{prepareMessage ?? 'Failed to prepare environment'}</p> <div className="flex items-start gap-2">
{prepareWarnings.length > 0 ? ( <AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="mt-1 space-y-1"> <div className="min-w-0 space-y-1">
{prepareWarnings.map((warning) => ( <p className="font-medium text-red-300">
<p key={warning} className="text-[11px] text-amber-300"> CLI environment is not available launch is blocked
{warning} </p>
</p> <p className="text-red-300/80">
))} {prepareMessage ?? 'Failed to prepare environment'}
</p>
{prepareWarnings.length > 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
</div>
) : null}
<p className="text-[11px] text-[var(--color-text-muted)]">
Make sure <span className="font-mono">claude</span> CLI is installed and available
in PATH, then reopen this dialog.
</p>
</div> </div>
) : null} </div>
</div> </div>
) : null} ) : null}
@ -796,6 +817,23 @@ export const CreateTeamDialog = ({
</div> </div>
) : null} ) : null}
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{launchTeam ? ( {launchTeam ? (
<div className="space-y-1.5 md:col-span-2"> <div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label> <Label className="text-xs text-[var(--color-text-muted)]">Project</Label>

View file

@ -14,10 +14,17 @@ import {
import { Input } from '@renderer/components/ui/input'; import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { Check, CheckCircle2, Loader2 } from 'lucide-react'; import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { import type {
@ -91,6 +98,7 @@ export const LaunchTeamDialog = ({
const [prepareMessage, setPrepareMessage] = useState<string | null>(null); const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]); const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedModel, setSelectedModel] = useState('');
const resetFormState = (): void => { const resetFormState = (): void => {
setLocalError(null); setLocalError(null);
@ -101,6 +109,7 @@ export const LaunchTeamDialog = ({
setCwdMode('project'); setCwdMode('project');
setSelectedProjectPath(''); setSelectedProjectPath('');
setCustomCwd(''); setCustomCwd('');
setSelectedModel('');
}; };
// Warm up CLI on open // Warm up CLI on open
@ -231,6 +240,7 @@ export const LaunchTeamDialog = ({
teamName, teamName,
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined, prompt: promptDraft.value.trim() || undefined,
model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined,
}); });
resetFormState(); resetFormState();
onClose(); onClose();
@ -252,7 +262,7 @@ export const LaunchTeamDialog = ({
} }
}} }}
> >
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm">Launch Team</DialogTitle> <DialogTitle className="text-sm">Launch Team</DialogTitle>
<DialogDescription className="text-xs"> <DialogDescription className="text-xs">
@ -262,17 +272,31 @@ export const LaunchTeamDialog = ({
</DialogHeader> </DialogHeader>
{prepareState === 'failed' ? ( {prepareState === 'failed' ? (
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300"> <div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<p>{prepareMessage ?? 'Failed to prepare environment'}</p> <div className="flex items-start gap-2">
{prepareWarnings.length > 0 ? ( <AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="mt-1 space-y-1"> <div className="min-w-0 space-y-1">
{prepareWarnings.map((warning) => ( <p className="font-medium text-red-300">
<p key={warning} className="text-[11px] text-amber-300"> CLI environment is not available launch is blocked
{warning} </p>
</p> <p className="text-red-300/80">
))} {prepareMessage ?? 'Failed to prepare environment'}
</p>
{prepareWarnings.length > 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
</div>
) : null}
<p className="text-[11px] text-[var(--color-text-muted)]">
Make sure <span className="font-mono">claude</span> CLI is installed and available
in PATH, then reopen this dialog.
</p>
</div> </div>
) : null} </div>
</div> </div>
) : null} ) : null}
@ -398,6 +422,21 @@ export const LaunchTeamDialog = ({
} }
/> />
</div> </div>
<div className="space-y-1.5">
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
{activeError ? ( {activeError ? (

View file

@ -118,19 +118,24 @@ export const TaskCommentsSection = ({
: formatDistanceToNow(date, { addSuffix: true }); : formatDistanceToNow(date, { addSuffix: true });
})()} })()}
</span> </span>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100" <button
onClick={() => type="button"
setReplyTo({ className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
author: comment.author, onClick={() =>
text: parseMessageReply(comment.text)?.replyText ?? comment.text, setReplyTo({
}) author: comment.author,
} text: parseMessageReply(comment.text)?.replyText ?? comment.text,
> })
<Reply size={11} /> }
Reply >
</button> <Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div> </div>
{(() => { {(() => {
const reply = parseMessageReply(comment.text); const reply = parseMessageReply(comment.text);

View file

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
@ -13,16 +14,23 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage'; import { markAsRead } from '@renderer/services/commentReadStorage';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { import {
agentAvatarUrl,
KANBAN_COLUMN_DISPLAY, KANBAN_COLUMN_DISPLAY,
TASK_STATUS_LABELS, TASK_STATUS_LABELS,
TASK_STATUS_STYLES, TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers'; } from '@renderer/utils/memberHelpers';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine, User } from 'lucide-react'; import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react';
import { TaskCommentsSection } from './TaskCommentsSection'; import { TaskCommentsSection } from './TaskCommentsSection';
@ -37,6 +45,7 @@ interface TaskDetailDialogProps {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
onClose: () => void; onClose: () => void;
onScrollToTask?: (taskId: string) => void; onScrollToTask?: (taskId: string) => void;
onOwnerChange?: (taskId: string, owner: string | null) => void;
} }
export const TaskDetailDialog = ({ export const TaskDetailDialog = ({
@ -48,6 +57,7 @@ export const TaskDetailDialog = ({
members, members,
onClose, onClose,
onScrollToTask, onScrollToTask,
onOwnerChange,
}: TaskDetailDialogProps): React.JSX.Element => { }: TaskDetailDialogProps): React.JSX.Element => {
const currentTask = task ? (taskMap.get(task.id) ?? task) : null; const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
@ -101,10 +111,12 @@ export const TaskDetailDialog = ({
) )
.map((t) => t.id); .map((t) => t.id);
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null; const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange;
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-h-[85vh] min-w-0 overflow-y-auto overflow-x-hidden sm:max-w-4xl"> <DialogContent className="min-w-0 sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal"> <Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
@ -125,31 +137,46 @@ export const TaskDetailDialog = ({
{/* Metadata */} {/* Metadata */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3"> <div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{ownerMember ? ( {canReassign ? (
<div <Select
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1" value={currentTask.owner ?? '__unassigned__'}
style={{ onValueChange={(v) => {
borderLeft: `3px solid ${getTeamColorSet(ownerMember.color ?? '').border}`, onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
backgroundColor: getTeamColorSet(ownerMember.color ?? '').badge,
}} }}
> >
<img <SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
src={agentAvatarUrl(ownerMember.name, 32)} <SelectValue placeholder="Unassigned" />
alt={ownerMember.name} </SelectTrigger>
className="size-6 shrink-0 rounded-full bg-[var(--color-surface-raised)]" <SelectContent>
loading="lazy" <SelectItem value="__unassigned__">Unassigned</SelectItem>
/> {members.map((m) => {
<span className="min-w-0 truncate font-medium text-[var(--color-text)]"> const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
{ownerMember.name} const memberColor = m.color ? getTeamColorSet(m.color) : null;
</span> return (
</div> <SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? (
<span className="text-[var(--color-text-muted)]">({role})</span>
) : null}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
) : currentTask.owner ? (
<MemberBadge name={currentTask.owner} color={ownerMember?.color} size="md" />
) : ( ) : (
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]"> <span className="text-xs text-[var(--color-text-muted)]">&mdash;</span>
<User size={12} />
<span className="text-[var(--color-text-secondary)]">
{currentTask.owner ?? '\u2014'}
</span>
</div>
)} )}
</div> </div>
{currentTask.createdBy ? ( {currentTask.createdBy ? (

View file

@ -1,5 +1,9 @@
import { useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
@ -18,6 +22,7 @@ import { KanbanFilterPopover } from './KanbanFilterPopover';
import { KanbanTaskCard } from './KanbanTaskCard'; import { KanbanTaskCard } from './KanbanTaskCard';
import type { KanbanFilterState } from './KanbanFilterPopover'; import type { KanbanFilterState } from './KanbanFilterPopover';
import type { DragEndEvent } from '@dnd-kit/core';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types'; import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
@ -69,6 +74,10 @@ interface KanbanBoardProps {
onCompleteTask: (taskId: string) => void; onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void; onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void; onTaskClick?: (task: TeamTask) => void;
/** Вызывается после изменения порядка задач в колонке (drag-and-drop). */
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
toolbarLeft?: React.ReactNode;
} }
type KanbanViewMode = 'grid' | 'columns'; type KanbanViewMode = 'grid' | 'columns';
@ -99,6 +108,97 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId
return null; return null;
} }
/** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */
function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): TeamTask[] {
if (!order?.length) {
return columnTasks;
}
const byId = new Map(columnTasks.map((t) => [t.id, t]));
const ordered: TeamTask[] = [];
const seen = new Set<string>();
for (const id of order) {
const task = byId.get(id);
if (task) {
ordered.push(task);
seen.add(id);
}
}
for (const task of columnTasks) {
if (!seen.has(task.id)) {
ordered.push(task);
}
}
return ordered;
}
interface SortableKanbanTaskCardProps {
task: TeamTask;
columnId: KanbanColumnId;
teamName: string;
kanbanState: KanbanState;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
onMoveBackToDone: (taskId: string) => void;
onStartTask: (taskId: string) => void;
onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
}
const SortableKanbanTaskCard = ({
task,
columnId,
teamName,
kanbanState,
taskMap,
members,
onRequestReview,
onApprove,
onRequestChanges,
onMoveBackToDone,
onStartTask,
onCompleteTask,
onScrollToTask,
onTaskClick,
}: SortableKanbanTaskCardProps): React.JSX.Element => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
data: { type: 'kanban-task', columnId, taskId: task.id },
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
// eslint-disable-next-line react/jsx-props-no-spreading -- dnd-kit useSortable requires spreading attributes/listeners
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<KanbanTaskCard
task={task}
teamName={teamName}
columnId={columnId}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
/>
</div>
);
};
export const KanbanBoard = ({ export const KanbanBoard = ({
tasks, tasks,
teamName, teamName,
@ -116,6 +216,8 @@ export const KanbanBoard = ({
onCompleteTask, onCompleteTask,
onScrollToTask, onScrollToTask,
onTaskClick, onTaskClick,
onColumnOrderChange,
toolbarLeft,
}: KanbanBoardProps): React.JSX.Element => { }: KanbanBoardProps): React.JSX.Element => {
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid'); const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
@ -134,6 +236,45 @@ export const KanbanBoard = ({
return result; return result;
}, [tasks, kanbanState]); }, [tasks, kanbanState]);
const groupedOrdered = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>();
for (const column of COLUMNS) {
const columnTasks = grouped.get(column.id) ?? [];
const order = kanbanState.columnOrder?.[column.id];
result.set(column.id, sortColumnTasksByOrder(columnTasks, order));
}
return result;
}, [grouped, kanbanState.columnOrder]);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!onColumnOrderChange || !over || active.id === over.id) {
return;
}
const activeData = active.data.current;
if (activeData?.type !== 'kanban-task') {
return;
}
const columnId = activeData.columnId as KanbanColumnId;
const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
const oldIndex = orderedIds.indexOf(active.id as string);
const newIndex = orderedIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
return;
}
const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
onColumnOrderChange(columnId, newOrder);
},
[onColumnOrderChange, groupedOrdered]
);
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => { const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
if (columnTasks.length === 0) { if (columnTasks.length === 0) {
return ( return (
@ -142,6 +283,32 @@ export const KanbanBoard = ({
</div> </div>
); );
} }
if (onColumnOrderChange) {
const itemIds = columnTasks.map((t) => t.id);
return (
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{columnTasks.map((task) => (
<SortableKanbanTaskCard
key={task.id}
task={task}
columnId={columnId}
teamName={teamName}
kanbanState={kanbanState}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onStartTask={onStartTask}
onCompleteTask={onCompleteTask}
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
/>
))}
</SortableContext>
);
}
return ( return (
<> <>
{columnTasks.map((task) => ( {columnTasks.map((task) => (
@ -153,6 +320,7 @@ export const KanbanBoard = ({
kanbanTaskState={kanbanState.tasks[task.id]} kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0} hasReviewers={kanbanState.reviewers.length > 0}
taskMap={taskMap} taskMap={taskMap}
members={members}
onRequestReview={onRequestReview} onRequestReview={onRequestReview}
onApprove={onApprove} onApprove={onApprove}
onRequestChanges={onRequestChanges} onRequestChanges={onRequestChanges}
@ -167,62 +335,65 @@ export const KanbanBoard = ({
); );
}; };
return ( const boardContent = (
<div> <>
<div className="mb-2 flex items-center justify-end gap-2"> <div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
<KanbanFilterPopover {toolbarLeft != null && <div className="min-w-0 flex-1">{toolbarLeft}</div>}
filter={filter} <div className="flex shrink-0 items-center gap-2">
sessions={sessions} <KanbanFilterPopover
leadSessionId={leadSessionId} filter={filter}
members={members} sessions={sessions}
onFilterChange={onFilterChange} leadSessionId={leadSessionId}
/> members={members}
<div className="inline-flex rounded-md border border-[var(--color-border)]"> onFilterChange={onFilterChange}
<Tooltip> />
<TooltipTrigger asChild> <div className="inline-flex rounded-md border border-[var(--color-border)]">
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <Button
className={cn( variant="ghost"
'h-7 rounded-r-none px-2', size="sm"
viewMode === 'grid' className={cn(
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' 'h-7 rounded-r-none px-2',
: 'text-[var(--color-text-muted)]' viewMode === 'grid'
)} ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
onClick={() => setViewMode('grid')} : 'text-[var(--color-text-muted)]'
aria-label="Grid view" )}
> onClick={() => setViewMode('grid')}
<LayoutGrid size={14} /> aria-label="Grid view"
</Button> >
</TooltipTrigger> <LayoutGrid size={14} />
<TooltipContent side="bottom">Grid view</TooltipContent> </Button>
</Tooltip> </TooltipTrigger>
<Tooltip> <TooltipContent side="bottom">Grid view</TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<Button <Tooltip>
variant="ghost" <TooltipTrigger asChild>
size="sm" <Button
className={cn( variant="ghost"
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2', size="sm"
viewMode === 'columns' className={cn(
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]' 'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
: 'text-[var(--color-text-muted)]' viewMode === 'columns'
)} ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
onClick={() => setViewMode('columns')} : 'text-[var(--color-text-muted)]'
aria-label="Columns view" )}
> onClick={() => setViewMode('columns')}
<Columns3 size={14} /> aria-label="Columns view"
</Button> >
</TooltipTrigger> <Columns3 size={14} />
<TooltipContent side="bottom">Columns view</TooltipContent> </Button>
</Tooltip> </TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div>
</div> </div>
</div> </div>
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{COLUMNS.map((column) => { {COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];
return ( return (
<KanbanColumn <KanbanColumn
@ -241,7 +412,7 @@ export const KanbanBoard = ({
) : ( ) : (
<div className="flex gap-3 overflow-x-auto pb-2"> <div className="flex gap-3 overflow-x-auto pb-2">
{COLUMNS.map((column) => { {COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? []; const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id]; const accent = COLUMN_ACCENTS[column.id];
return ( return (
<div key={column.id} className="w-64 shrink-0"> <div key={column.id} className="w-64 shrink-0">
@ -259,6 +430,16 @@ export const KanbanBoard = ({
})} })}
</div> </div>
)} )}
</div> </>
); );
if (onColumnOrderChange) {
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
{boardContent}
</DndContext>
);
}
return boardContent;
}; };

View file

@ -1,10 +1,11 @@
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge'; import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react'; import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
import type { KanbanColumnId, KanbanTaskState, TeamTask } from '@shared/types'; import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
interface KanbanTaskCardProps { interface KanbanTaskCardProps {
task: TeamTask; task: TeamTask;
@ -13,6 +14,7 @@ interface KanbanTaskCardProps {
kanbanTaskState?: KanbanTaskState; kanbanTaskState?: KanbanTaskState;
hasReviewers: boolean; hasReviewers: boolean;
taskMap: Map<string, TeamTask>; taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void; onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void; onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void; onRequestChanges: (taskId: string) => void;
@ -63,6 +65,7 @@ export const KanbanTaskCard = ({
kanbanTaskState: _kanbanTaskState, kanbanTaskState: _kanbanTaskState,
hasReviewers, hasReviewers,
taskMap, taskMap,
members,
onRequestReview, onRequestReview,
onApprove, onApprove,
onRequestChanges, onRequestChanges,
@ -96,23 +99,21 @@ export const KanbanTaskCard = ({
} }
}} }}
> >
<div className="mb-2 flex items-start justify-between gap-2"> <div className="mb-2 flex items-center gap-1">
<div> <Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
<div className="mb-1 flex items-center gap-1"> #{task.id}
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal"> </Badge>
#{task.id} {task.owner ? (
</Badge> <MemberBadge
<UnreadCommentsBadge name={task.owner}
unreadCount={unreadCount} color={members.find((m) => m.name === task.owner)?.color}
totalCount={task.comments?.length ?? 0} />
/> ) : null}
</div> <h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
<h5 className="text-sm font-medium text-[var(--color-text)]">{task.subject}</h5> {task.subject}
</div> </h5>
</div> </div>
<p className="mb-2 text-xs text-[var(--color-text-muted)]">Owner: {task.owner ?? '\u2014'}</p>
{hasBlockedBy ? ( {hasBlockedBy ? (
<div className="mb-2 flex flex-wrap items-center gap-1"> <div className="mb-2 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-300"> <span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-300">
@ -147,112 +148,118 @@ export const KanbanTaskCard = ({
</div> </div>
) : null} ) : null}
{columnId === 'todo' ? ( <div className="flex items-center gap-2">
<div className="flex gap-2"> <div className="flex flex-1 flex-wrap gap-2">
<Button {columnId === 'todo' ? (
variant="outline" <>
size="sm" <Button
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300" variant="outline"
aria-label={`Start task ${task.id}`} size="sm"
onClick={(e) => { className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
e.stopPropagation(); aria-label={`Start task ${task.id}`}
onStartTask(task.id); onClick={(e) => {
}} e.stopPropagation();
> onStartTask(task.id);
<Play size={12} /> }}
Start >
</Button> <Play size={12} />
<Button Start
variant="outline" </Button>
size="sm" <Button
className="gap-1" variant="outline"
aria-label={`Complete task ${task.id}`} size="sm"
onClick={(e) => { className="gap-1"
e.stopPropagation(); aria-label={`Complete task ${task.id}`}
onCompleteTask(task.id); onClick={(e) => {
}} e.stopPropagation();
> onCompleteTask(task.id);
<CheckCircle2 size={12} /> }}
Complete >
</Button> <CheckCircle2 size={12} />
</div> Complete
) : null} </Button>
</>
{columnId === 'in_progress' ? (
<Button
variant="outline"
size="sm"
className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onCompleteTask(task.id);
}}
>
<CheckCircle2 size={12} />
Complete
</Button>
) : null}
{columnId === 'done' ? (
<Button
variant="outline"
size="sm"
aria-label={`Request review for task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onRequestReview(task.id);
}}
>
Request Review
</Button>
) : null}
{columnId === 'review' ? (
<div className="space-y-2">
{!hasReviewers ? (
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
) : null} ) : null}
<div className="flex gap-2">
{columnId === 'in_progress' ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
aria-label={`Approve task ${task.id}`} className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onApprove(task.id); onCompleteTask(task.id);
}} }}
> >
Approve <CheckCircle2 size={12} />
Complete
</Button> </Button>
<Button ) : null}
variant="destructive"
size="sm"
aria-label={`Request changes for task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onRequestChanges(task.id);
}}
>
Request Changes
</Button>
</div>
</div>
) : null}
{columnId === 'approved' ? ( {columnId === 'done' ? (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
aria-label={`Move task ${task.id} back to done`} aria-label={`Request review for task ${task.id}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onMoveBackToDone(task.id); onRequestReview(task.id);
}} }}
> >
Move back to DONE Request Review
</Button> </Button>
) : null} ) : null}
{columnId === 'review' ? (
<div className="space-y-2">
{!hasReviewers ? (
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
) : null}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
aria-label={`Approve task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
}}
>
Approve
</Button>
<Button
variant="destructive"
size="sm"
aria-label={`Request changes for task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onRequestChanges(task.id);
}}
>
Request Changes
</Button>
</div>
</div>
) : null}
{columnId === 'approved' ? (
<Button
variant="outline"
size="sm"
aria-label={`Move task ${task.id} back to done`}
onClick={(e) => {
e.stopPropagation();
onMoveBackToDone(task.id);
}}
>
Move back to DONE
</Button>
) : null}
</div>
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
</div>
</div> </div>
); );
}; };

View file

@ -1,8 +1,9 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import { ListPlus, Loader2, MessageSquare } from 'lucide-react'; import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@ -15,6 +16,7 @@ interface MemberCardProps {
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
currentTask?: TeamTaskWithKanban | null; currentTask?: TeamTaskWithKanban | null;
isAwaitingReply?: boolean; isAwaitingReply?: boolean;
isRemoved?: boolean;
onOpenTask?: () => void; onOpenTask?: () => void;
onClick?: () => void; onClick?: () => void;
onSendMessage?: () => void; onSendMessage?: () => void;
@ -29,6 +31,7 @@ export const MemberCard = ({
isTeamProvisioning, isTeamProvisioning,
currentTask, currentTask,
isAwaitingReply, isAwaitingReply,
isRemoved,
onOpenTask, onOpenTask,
onClick, onClick,
onSendMessage, onSendMessage,
@ -41,14 +44,12 @@ export const MemberCard = ({
const inProgress = taskCounts?.inProgress ?? 0; const inProgress = taskCounts?.inProgress ?? 0;
const completed = taskCounts?.completed ?? 0; const completed = taskCounts?.completed ?? 0;
const totalTasks = pending + inProgress + completed; const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0; const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
const progressPercent = Math.round(completedRatio * 100);
return ( return (
<div className="rounded"> <div className={isRemoved ? 'rounded opacity-50' : 'rounded'}>
<div <div
className="group relative cursor-pointer rounded-t px-2 py-1.5" className="group relative cursor-pointer rounded px-2 py-1.5"
style={{ style={{
borderLeft: `3px solid ${colors.border}`, borderLeft: `3px solid ${colors.border}`,
backgroundColor: colors.badge, backgroundColor: colors.badge,
@ -64,7 +65,7 @@ export const MemberCard = ({
} }
}} }}
> >
<div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" /> <div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="relative shrink-0"> <div className="relative shrink-0">
<img <img
@ -80,6 +81,12 @@ export const MemberCard = ({
</div> </div>
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm"> <div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
<span className="shrink-0 font-medium text-[var(--color-text)]">{member.name}</span> <span className="shrink-0 font-medium text-[var(--color-text)]">{member.name}</span>
{member.gitBranch ? (
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<GitBranch size={10} />
{member.gitBranch}
</span>
) : null}
{currentTask ? ( {currentTask ? (
<> <>
<Loader2 <Loader2
@ -133,55 +140,72 @@ export const MemberCard = ({
})()} })()}
<Badge <Badge
variant="secondary" variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]" className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined} title={
isRemoved
? 'This member has been removed'
: member.currentTaskId
? `Current task: ${member.currentTaskId}`
: undefined
}
> >
{presenceLabel} {isRemoved ? 'removed' : presenceLabel}
</Badge> </Badge>
<Badge <div
variant="secondary" className="shrink-0"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none" title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
> >
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'} <Badge
</Badge> variant="secondary"
<div className="flex shrink-0 items-center gap-0.5"> className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
<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} /> {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
</button> </Badge>
<button {totalTasks > 0 && (
type="button" <div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]" <div
title="Assign task" className="h-full rounded-full bg-emerald-500 transition-all duration-500"
onClick={(e) => { style={{ width: `${progressPercent}%` }}
e.stopPropagation(); />
onAssignTask?.(); </div>
}} )}
>
<ListPlus size={13} />
</button>
</div> </div>
{!isRemoved && (
<div className="flex shrink-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
onSendMessage?.();
}}
>
<MessageSquare size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Send message</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
onAssignTask?.();
}}
>
<Plus size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Assign task</TooltipContent>
</Tooltip>
</div>
)}
</div> </div>
</div> </div>
<div
className="h-px 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> </div>
); );
}; };

View file

@ -1,12 +1,12 @@
import { useMemo } from 'react'; import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'; import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { BarChart3, FileText, ListPlus, MessageSquare } from 'lucide-react'; import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
import { MemberDetailHeader } from './MemberDetailHeader'; import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats'; import { MemberDetailStats, type MemberDetailTab } from './MemberDetailStats';
import { MemberLogsTab } from './MemberLogsTab'; import { MemberLogsTab } from './MemberLogsTab';
import { MemberMessagesTab } from './MemberMessagesTab'; import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab'; import { MemberStatsTab } from './MemberStatsTab';
@ -26,6 +26,7 @@ interface MemberDetailDialogProps {
onSendMessage: () => void; onSendMessage: () => void;
onAssignTask: () => void; onAssignTask: () => void;
onTaskClick: (task: TeamTaskWithKanban) => void; onTaskClick: (task: TeamTaskWithKanban) => void;
onRemoveMember?: () => void;
} }
export const MemberDetailDialog = ({ export const MemberDetailDialog = ({
@ -40,6 +41,7 @@ export const MemberDetailDialog = ({
onSendMessage, onSendMessage,
onAssignTask, onAssignTask,
onTaskClick, onTaskClick,
onRemoveMember,
}: MemberDetailDialogProps): React.JSX.Element | null => { }: MemberDetailDialogProps): React.JSX.Element | null => {
const memberTasks = useMemo( const memberTasks = useMemo(
() => (member ? tasks.filter((t) => t.owner === member.name) : []), () => (member ? tasks.filter((t) => t.owner === member.name) : []),
@ -61,28 +63,37 @@ export const MemberDetailDialog = ({
[memberTasks] [memberTasks]
); );
const [activeTab, setActiveTab] = useState<MemberDetailTab>('tasks');
if (!member) return null; if (!member) return null;
return ( return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}> <Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent className="min-w-0 overflow-hidden sm:max-w-4xl"> <DialogContent className="min-w-0 sm:max-w-4xl">
<DialogHeader> <div className="flex items-start gap-4">
<MemberDetailHeader <DialogHeader className="shrink-0">
member={member} <MemberDetailHeader
isTeamAlive={isTeamAlive} member={member}
isTeamProvisioning={isTeamProvisioning} isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
/>
</DialogHeader>
<MemberDetailStats
totalTasks={memberTasks.length}
inProgressTasks={inProgressTasks}
completedTasks={completedTasks}
messageCount={memberMessages.length}
lastActiveAt={member.lastActiveAt}
onTabChange={setActiveTab}
/> />
</DialogHeader> </div>
<MemberDetailStats <Tabs
totalTasks={memberTasks.length} value={activeTab}
inProgressTasks={inProgressTasks} onValueChange={(v) => setActiveTab(v as MemberDetailTab)}
completedTasks={completedTasks} className="min-w-0 overflow-hidden"
messageCount={memberMessages.length} >
lastActiveAt={member.lastActiveAt}
/>
<Tabs defaultValue="tasks">
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger value="tasks" className="flex-1 gap-1.5"> <TabsTrigger value="tasks" className="flex-1 gap-1.5">
Tasks Tasks
@ -113,7 +124,7 @@ export const MemberDetailDialog = ({
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} /> <MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
</TabsContent> </TabsContent>
<TabsContent value="messages"> <TabsContent value="messages">
<MemberMessagesTab messages={memberMessages} /> <MemberMessagesTab messages={memberMessages} teamName={teamName} />
</TabsContent> </TabsContent>
<TabsContent value="stats"> <TabsContent value="stats">
<MemberStatsTab teamName={teamName} memberName={member.name} /> <MemberStatsTab teamName={teamName} memberName={member.name} />
@ -124,14 +135,33 @@ export const MemberDetailDialog = ({
</Tabs> </Tabs>
<DialogFooter> <DialogFooter>
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}> {member.removedAt ? (
<MessageSquare size={14} /> <span className="text-xs text-[var(--color-text-muted)]">
Send Message Removed {new Date(member.removedAt).toLocaleDateString()}
</Button> </span>
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}> ) : (
<ListPlus size={14} /> <>
Assign Task <Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
</Button> <MessageSquare size={14} />
Send Message
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}>
<ListPlus size={14} />
Assign Task
</Button>
{onRemoveMember && member.agentType !== 'team-lead' && (
<Button
variant="outline"
size="sm"
className="gap-1.5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={onRemoveMember}
>
<UserMinus size={14} />
Remove
</Button>
)}
</>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -1,28 +1,49 @@
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs';
interface MemberDetailStatsProps { interface MemberDetailStatsProps {
totalTasks: number; totalTasks: number;
inProgressTasks: number; inProgressTasks: number;
completedTasks: number; completedTasks: number;
messageCount: number; messageCount: number;
lastActiveAt: string | null; lastActiveAt: string | null;
onTabChange?: (tab: MemberDetailTab) => void;
} }
const baseClasses =
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1.5';
const clickableClasses =
'cursor-pointer transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-overlay)]';
const StatBlock = ({ const StatBlock = ({
label, label,
value, value,
sub, sub,
onClick,
}: { }: {
label: string; label: string;
value: string | number; value: string | number;
sub?: string; sub?: string;
}): React.JSX.Element => ( onClick?: () => void;
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2"> }): React.JSX.Element => {
<p className="text-lg font-semibold text-[var(--color-text)]">{value}</p> const classes = onClick ? `${baseClasses} ${clickableClasses}` : baseClasses;
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p> const content = (
{sub && <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">{sub}</p>} <>
</div> <p className="text-base font-semibold leading-tight text-[var(--color-text)]">{value}</p>
); <p className="text-[10px] text-[var(--color-text-muted)]">{label}</p>
{sub && <p className="mt-0.5 text-[9px] text-[var(--color-text-muted)]">{sub}</p>}
</>
);
if (onClick) {
return (
<button type="button" className={classes} onClick={onClick}>
{content}
</button>
);
}
return <div className={classes}>{content}</div>;
};
export const MemberDetailStats = ({ export const MemberDetailStats = ({
totalTasks, totalTasks,
@ -30,21 +51,35 @@ export const MemberDetailStats = ({
completedTasks, completedTasks,
messageCount, messageCount,
lastActiveAt, lastActiveAt,
onTabChange,
}: MemberDetailStatsProps): React.JSX.Element => { }: MemberDetailStatsProps): React.JSX.Element => {
const lastActive = lastActiveAt const lastActive = lastActiveAt
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true }) ? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
: '—'; : '—';
return ( return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> <div className="grid min-w-0 flex-1 grid-cols-4 gap-1.5">
<StatBlock <StatBlock
label="Tasks" label="Tasks"
value={totalTasks} value={totalTasks}
sub={inProgressTasks > 0 ? `in progress: ${inProgressTasks}` : undefined} sub={inProgressTasks > 0 ? `in progress: ${inProgressTasks}` : undefined}
onClick={onTabChange ? () => onTabChange('tasks') : undefined}
/>
<StatBlock
label="Completed"
value={completedTasks}
onClick={onTabChange ? () => onTabChange('tasks') : undefined}
/>
<StatBlock
label="Messages"
value={messageCount}
onClick={onTabChange ? () => onTabChange('messages') : undefined}
/>
<StatBlock
label="Activity"
value={lastActive}
onClick={onTabChange ? () => onTabChange('logs') : undefined}
/> />
<StatBlock label="Completed" value={completedTasks} />
<StatBlock label="Messages" value={messageCount} />
<StatBlock label="Activity" value={lastActive} />
</div> </div>
); );
}; };

View file

@ -4,10 +4,12 @@ import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay'; import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup'; import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer'; import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer'; import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Bot, ChevronDown } from 'lucide-react'; import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
import type { EnhancedChunk } from '@renderer/types/data'; import type { EnhancedChunk } from '@renderer/types/data';
import type { AIGroup, UserGroup } from '@renderer/types/groups'; import type { AIGroup, UserGroup } from '@renderer/types/groups';
@ -88,31 +90,66 @@ export const MemberExecutionLog = ({
); );
}; };
/** Extract agent-only instruction blocks and human-visible text from a message. */
function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[] } {
const agentInfo: string[] = [];
const regex = createAgentBlockRegex();
let m: RegExpExecArray | null;
while ((m = regex.exec(raw)) !== null) {
const content = m[0]
.replace(/^```info_for_agent\n?/, '')
.replace(/\n?```$/, '')
.trim();
if (content) agentInfo.push(content);
}
const humanText = raw.replace(createAgentBlockRegex(), '').trim();
return { humanText, agentInfo };
}
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => { const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
const text = group.content.rawText ?? group.content.text ?? ''; const text = group.content.rawText ?? group.content.text ?? '';
if (!text.trim()) { const { humanText, agentInfo } = useMemo(() => splitAgentBlocks(text), [text]);
const [agentInfoOpen, setAgentInfoOpen] = useState(false);
if (!humanText && agentInfo.length === 0) {
return ( return (
<div className="flex min-w-0 justify-end"> <div className="py-1 text-[10px] text-[var(--color-text-muted)]">
<div className="min-w-0 max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--color-border)] bg-[var(--chat-user-bg)] px-4 py-3"> {format(group.timestamp, 'h:mm:ss a')} (empty)
<div className="text-[10px] text-[var(--color-text-muted)]">
{format(group.timestamp, 'h:mm:ss a')}
</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">(empty)</div>
</div>
</div> </div>
); );
} }
return ( return (
<div className="flex min-w-0 justify-end"> <div className="min-w-0 space-y-1 overflow-hidden py-1">
<div className="min-w-0 max-w-full rounded-2xl rounded-br-sm border border-[var(--chat-user-border)] bg-[var(--chat-user-bg)] px-4 py-3"> <div className="text-[10px] text-[var(--color-text-muted)]">
<div className="text-right text-[10px] text-[var(--color-text-muted)]"> {format(group.timestamp, 'h:mm:ss a')}
{format(group.timestamp, 'h:mm:ss a')}
</div>
<div className="mt-2 min-w-0 break-words text-sm text-[var(--chat-user-text)]">
<MarkdownViewer content={text} copyable />
</div>
</div> </div>
{humanText && (
<div className="min-w-0 overflow-x-auto break-words text-xs text-[var(--chat-user-text)]">
<MarkdownViewer content={humanText} copyable />
</div>
)}
{agentInfo.length > 0 && (
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<button
type="button"
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setAgentInfoOpen((v) => !v)}
>
<ChevronRight
size={10}
className={`shrink-0 transition-transform ${agentInfoOpen ? 'rotate-90' : ''}`}
/>
<Bot size={10} className="shrink-0" />
Agent instructions
</button>
{agentInfoOpen && (
<pre className="overflow-x-auto border-t border-[var(--color-border)] px-2 py-1.5 text-[10px] leading-relaxed text-[var(--color-text-muted)]">
{agentInfo.join('\n\n')}
</pre>
)}
</div>
)}
</div> </div>
); );
}; };
@ -149,23 +186,28 @@ const AIExecutionGroup = ({
return ( return (
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}> <div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
{hasToggleContent ? ( {hasToggleContent ? (
<button <Tooltip>
type="button" <TooltipTrigger asChild>
className="flex w-full items-center gap-2 text-left" <button
onClick={onToggleExpanded} type="button"
aria-expanded={expanded} className="flex w-full items-center gap-2 text-left"
> onClick={onToggleExpanded}
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" /> aria-expanded={expanded}
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]"> >
Claude <Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
</span> <span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]"> Claude
{enhanced.itemsSummary} </span>
</span> <span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
<ChevronDown {enhanced.itemsSummary}
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`} </span>
/> <ChevronDown
</button> className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{expanded ? 'Collapse' : 'Expand'}</TooltipContent>
</Tooltip>
) : null} ) : null}
{hasToggleContent && expanded ? ( {hasToggleContent && expanded ? (

View file

@ -30,6 +30,9 @@ export const MemberList = ({
onAssignTask, onAssignTask,
onOpenTask, onOpenTask,
}: MemberListProps): React.JSX.Element => { }: MemberListProps): React.JSX.Element => {
const activeMembers = members.filter((m) => !m.removedAt);
const removedMembers = members.filter((m) => m.removedAt);
if (members.length === 0) { if (members.length === 0) {
return ( return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]"> <div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
@ -38,29 +41,46 @@ export const MemberList = ({
); );
} }
const renderCard = (
member: ResolvedTeamMember,
index: number,
isRemoved: boolean
): React.JSX.Element => {
const currentTask =
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
return (
<MemberCard
key={member.name}
member={member}
memberColor={member.color ?? getMemberColor(index)}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning}
currentTask={isRemoved ? null : currentTask}
isAwaitingReply={isRemoved ? false : awaitingReply}
isRemoved={isRemoved}
onOpenTask={currentTask && !isRemoved ? () => onOpenTask?.(currentTask) : undefined}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
/>
);
};
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
{members.map((member, index) => { {activeMembers.map((member, index) => renderCard(member, index, false))}
const currentTask = {removedMembers.length > 0 && (
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; <>
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]); <div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
return ( Removed ({removedMembers.length})
<MemberCard </div>
key={member.name} {removedMembers.map((member, index) =>
member={member} renderCard(member, activeMembers.length + index, true)
memberColor={member.color ?? getMemberColor(index)} )}
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())} </>
isTeamAlive={isTeamAlive} )}
isTeamProvisioning={isTeamProvisioning}
currentTask={currentTask}
isAwaitingReply={awaitingReply}
onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined}
onClick={() => onMemberClick?.(member)}
onSendMessage={() => onSendMessage?.(member)}
onAssignTask={() => onAssignTask?.(member)}
/>
);
})}
</div> </div>
); );
}; };

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDuration } from '@renderer/utils/formatters'; import { formatDuration } from '@renderer/utils/formatters';
import { import {
AlertCircle, AlertCircle,
@ -207,35 +208,40 @@ const LogCard = ({
return ( return (
<div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"> <div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
<button <Tooltip>
className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]" <TooltipTrigger asChild>
onClick={onToggle} <button
> className="flex w-full min-w-0 items-center gap-2 overflow-hidden px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
{expanded ? ( onClick={onToggle}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" /> >
) : ( {expanded ? (
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" /> <ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
)} ) : (
<div className="min-w-0 flex-1 overflow-hidden"> <ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<div className="truncate text-[var(--color-text)]" title={log.description}>
{log.description}
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<Clock size={10} />
{timeAgo}
</span>
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
<span className="flex items-center gap-1">
<MessageSquare size={10} />
{log.messageCount}
</span>
{log.isOngoing && (
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
)} )}
</div> <div className="min-w-0 flex-1 overflow-hidden">
</div> <div className="truncate text-[var(--color-text)]" title={log.description}>
</button> {log.description}
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<Clock size={10} />
{timeAgo}
</span>
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
<span className="flex items-center gap-1">
<MessageSquare size={10} />
{log.messageCount}
</span>
{log.isOngoing && (
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
)}
</div>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{expanded ? 'Hide details' : 'Show details'}</TooltipContent>
</Tooltip>
{expanded && ( {expanded && (
<div className="border-t border-[var(--color-border)] px-3 py-2"> <div className="border-t border-[var(--color-border)] px-3 py-2">

View file

@ -4,6 +4,7 @@ import type { InboxMessage } from '@shared/types';
interface MemberMessagesTabProps { interface MemberMessagesTabProps {
messages: InboxMessage[]; messages: InboxMessage[];
teamName: string;
onCreateTask?: (subject: string, description: string) => void; onCreateTask?: (subject: string, description: string) => void;
} }
@ -11,6 +12,7 @@ const MAX_MESSAGES = 100;
export const MemberMessagesTab = ({ export const MemberMessagesTab = ({
messages, messages,
teamName,
onCreateTask, onCreateTask,
}: MemberMessagesTabProps): React.JSX.Element => { }: MemberMessagesTabProps): React.JSX.Element => {
const displayMessages = messages.slice(0, MAX_MESSAGES); const displayMessages = messages.slice(0, MAX_MESSAGES);
@ -26,7 +28,12 @@ export const MemberMessagesTab = ({
return ( return (
<div className="max-h-[320px] space-y-2 overflow-y-auto"> <div className="max-h-[320px] space-y-2 overflow-y-auto">
{displayMessages.map((msg, idx) => ( {displayMessages.map((msg, idx) => (
<ActivityItem key={msg.messageId ?? idx} message={msg} onCreateTask={onCreateTask} /> <ActivityItem
key={msg.messageId ?? idx}
message={msg}
teamName={teamName}
onCreateTask={onCreateTask}
/>
))} ))}
</div> </div>
); );

View file

@ -0,0 +1,328 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useAttachments } from '@renderer/hooks/useAttachments';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { cn } from '@renderer/lib/utils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { AlertCircle, Check, ChevronDown, Paperclip, Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types';
interface MessageComposerProps {
teamName: string;
members: ResolvedTeamMember[];
isTeamAlive?: boolean;
sending: boolean;
sendError: string | null;
onSend: (
recipient: string,
text: string,
summary?: string,
attachments?: AttachmentPayload[]
) => void;
}
const MAX_MESSAGE_LENGTH = 4000;
export const MessageComposer = ({
teamName,
members,
isTeamAlive,
sending,
sendError,
onSend,
}: MessageComposerProps): React.JSX.Element => {
const [recipient, setRecipient] = useState<string>(() => {
const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
return lead?.name ?? members[0]?.name ?? '';
});
const [recipientOpen, setRecipientOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const draft = useDraftPersistence({ key: `compose:${teamName}` });
const {
attachments,
error: attachmentError,
canAddMore,
addFiles,
removeAttachment,
clearAttachments,
handlePaste,
handleDrop,
} = useAttachments();
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: m.color,
})),
[members]
);
const trimmed = draft.value.trim();
const canSend =
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
const selectedMember = members.find((m) => m.name === recipient);
const selectedColorSet = selectedMember?.color ? getTeamColorSet(selectedMember.color) : null;
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
const handleSend = useCallback(() => {
if (!canSend) return;
const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed;
onSend(recipient, trimmed, autoSummary, attachments.length > 0 ? attachments : undefined);
draft.clearDraft();
clearAttachments();
}, [canSend, recipient, trimmed, onSend, draft, attachments, clearAttachments]);
const handleKeyDownCapture = useCallback(
(e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleSend();
}
},
[handleSend]
);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target;
if (input.files?.length) {
void addFiles(input.files);
}
input.value = '';
},
[addFiles]
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current += 1;
if (dragCounterRef.current === 1) setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current -= 1;
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0;
setIsDragOver(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDropWrapper = useCallback(
(e: React.DragEvent) => {
dragCounterRef.current = 0;
setIsDragOver(false);
if (canAttach) handleDrop(e);
},
[canAttach, handleDrop]
);
const handlePasteWrapper = useCallback(
(e: React.ClipboardEvent) => {
if (canAttach) handlePaste(e);
},
[canAttach, handlePaste]
);
const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
return (
<div
className="relative mb-3 p-3"
role="group"
onKeyDownCapture={handleKeyDownCapture}
onDragEnter={canAttach ? handleDragEnter : undefined}
onDragLeave={canAttach ? handleDragLeave : undefined}
onDragOver={canAttach ? handleDragOver : undefined}
onDrop={canAttach ? handleDropWrapper : undefined}
onPaste={canAttach ? handlePasteWrapper : undefined}
>
<DropZoneOverlay active={isDragOver && !!canAttach} />
<div className="mb-2 flex items-center gap-2">
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
>
{selectedColorSet ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: selectedColorSet.border }}
/>
) : (
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
)}
<span
className="max-w-[120px] truncate font-medium"
style={selectedColorSet ? { color: selectedColorSet.text } : undefined}
>
{recipient || 'Select...'}
</span>
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-56 p-1.5">
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{members.map((m) => {
const colorSet = m.color ? getTeamColorSet(m.color) : null;
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const isSelected = m.name === recipient;
return (
<button
key={m.name}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
isSelected && 'bg-[var(--color-surface-raised)]'
)}
onClick={() => {
setRecipient(m.name);
setRecipientOpen(false);
}}
>
{colorSet ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
) : (
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
)}
<span
className="min-w-0 truncate font-medium"
style={colorSet ? { color: colorSet.text } : undefined}
>
{m.name}
</span>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
{isLeadRecipient ? (
<>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
className="hidden"
onChange={handleFileInputChange}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 rounded p-1 transition-colors',
canAttach
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] opacity-40'
)}
disabled={!canAttach}
onClick={() => fileInputRef.current?.click()}
>
<Paperclip size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{!isTeamAlive
? 'Team must be online to attach images'
: !canAddMore
? 'Maximum attachments reached'
: 'Attach images (paste or drag & drop)'}
</TooltipContent>
</Tooltip>
</>
) : null}
{!isTeamAlive ? (
<span className="ml-auto text-[10px] text-amber-400">Team offline</span>
) : null}
</div>
<AttachmentPreviewList
attachments={attachments}
onRemove={removeAttachment}
error={attachmentError}
/>
<MentionableTextarea
id={`compose-${teamName}`}
placeholder={`Write a message... (${getModifierKeyName()}+Enter to send)`}
value={draft.value}
onValueChange={draft.setValue}
suggestions={mentionSuggestions}
minRows={2}
maxRows={6}
maxLength={MAX_MESSAGE_LENGTH}
disabled={sending}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSend}
onClick={handleSend}
>
<Send size={12} />
Send
</button>
}
footerRight={
<div className="flex items-center gap-2">
{sendError ? (
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
<AlertCircle size={10} className="shrink-0" />
{sendError}
</span>
) : null}
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
);
};

View file

@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Filter } from 'lucide-react'; import { Filter } from 'lucide-react';
import type { InboxMessage } from '@shared/types'; import type { InboxMessage } from '@shared/types';
@ -93,22 +94,26 @@ export const MessagesFilterPopover = ({
return ( return (
<Popover open={open} onOpenChange={onOpenChange}> <Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <PopoverTrigger asChild>
size="sm" <Button
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]" variant="ghost"
aria-label="Filter messages" size="sm"
title="Filter" className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
> aria-label="Filter messages"
<Filter size={14} /> >
{activeCount > 0 && ( <Filter size={14} />
<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 > 0 && (
{activeCount} <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">
</span> {activeCount}
)} </span>
</Button> )}
</PopoverTrigger> </Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter messages</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0"> <PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3"> <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)]"> <p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">

View file

@ -35,6 +35,7 @@ const DialogContent = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg', 'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
className className
)} )}
{...props} {...props}

View file

@ -18,7 +18,7 @@ const PopoverContent = React.forwardRef<
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'z-50 w-72 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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', 'z-50 max-h-[min(80vh,24rem)] min-h-0 w-72 overflow-y-auto overflow-x-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 className
)} )}
{...props} {...props}

View file

@ -0,0 +1,135 @@
import { useCallback, useState } from 'react';
import {
fileToAttachmentPayload,
MAX_FILES,
MAX_TOTAL_SIZE,
validateAttachment,
} from '@renderer/utils/attachmentUtils';
import type { AttachmentPayload } from '@shared/types';
interface UseAttachmentsReturn {
attachments: AttachmentPayload[];
error: string | null;
totalSize: number;
canAddMore: boolean;
addFiles: (files: FileList | File[]) => Promise<void>;
removeAttachment: (id: string) => void;
clearAttachments: () => void;
handlePaste: (event: React.ClipboardEvent) => void;
handleDrop: (event: React.DragEvent) => void;
}
export function useAttachments(): UseAttachmentsReturn {
const [attachments, setAttachments] = useState<AttachmentPayload[]>([]);
const [error, setError] = useState<string | null>(null);
const totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE;
const addFiles = useCallback(async (files: FileList | File[]) => {
setError(null);
const fileArray = Array.from(files);
if (fileArray.length === 0) return;
let batchSize = 0;
let valid = true;
for (const file of fileArray) {
const validation = validateAttachment(file);
if (!validation.valid) {
setError(validation.error);
valid = false;
break;
}
batchSize += file.size;
}
if (!valid) return;
const newPayloads: AttachmentPayload[] = [];
for (const file of fileArray) {
try {
const payload = await fileToAttachmentPayload(file);
newPayloads.push(payload);
} catch {
setError(`Failed to read file: ${file.name}`);
valid = false;
break;
}
}
if (!valid) return;
setAttachments((prev) => {
if (prev.length + newPayloads.length > MAX_FILES) {
setError(`Maximum ${MAX_FILES} attachments allowed`);
return prev;
}
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
setError('Total attachment size exceeds 20MB limit');
return prev;
}
return [...prev, ...newPayloads];
});
}, []);
const removeAttachment = useCallback((id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
setError(null);
}, []);
const clearAttachments = useCallback(() => {
setAttachments([]);
setError(null);
}, []);
const handlePaste = useCallback(
(event: React.ClipboardEvent) => {
const items = event.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length > 0) {
event.preventDefault();
void addFiles(imageFiles);
}
},
[addFiles]
);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
const files = event.dataTransfer?.files;
if (!files?.length) return;
const allFiles = Array.from(files);
const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length > 0) {
void addFiles(imageFiles);
} else if (allFiles.length > 0) {
setError('Only image files are supported');
}
},
[addFiles]
);
return {
attachments,
error,
totalSize,
canAddMore,
addFiles,
removeAttachment,
clearAttachments,
handlePaste,
handleDrop,
};
}

View file

@ -206,6 +206,13 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
// Mark the notification as read // Mark the notification as read
void state.markNotificationRead(error.id); void state.markNotificationRead(error.id);
// Team rate-limit notifications: open the team tab instead of a session tab
if (error.source === 'rate-limit' && error.sessionId.startsWith('team:')) {
const teamName = error.sessionId.slice('team:'.length);
state.openTeamTab(teamName, error.context.cwd);
return;
}
// Create the navigation request with a fresh nonce // Create the navigation request with a fresh nonce
const navRequest = createErrorNavigationRequest( const navRequest = createErrorNavigationRequest(
{ {

View file

@ -22,7 +22,7 @@ import {
syncFocusedPaneState, syncFocusedPaneState,
updatePane, updatePane,
} from '../utils/paneHelpers'; } from '../utils/paneHelpers';
import { getFullResetState } from '../utils/stateResetHelpers'; import { getFullResetState, getWorktreeNavigationState } from '../utils/stateResetHelpers';
import type { AppState, SearchNavigationContext } from '../types'; import type { AppState, SearchNavigationContext } from '../types';
import type { PaneLayout } from '@renderer/types/panes'; import type { PaneLayout } from '@renderer/types/panes';
@ -384,8 +384,8 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
(wt) => normalizePath(wt.path) === normalizedTeamPath (wt) => normalizePath(wt.path) === normalizedTeamPath
); );
if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) { if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) {
state.selectRepository(repo.id); set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
state.selectWorktree(matchingWorktree.id); void get().fetchSessionsInitial(matchingWorktree.id);
break; break;
} }
} }

View file

@ -3,12 +3,16 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
const logger = createLogger('teamSlice'); const logger = createLogger('teamSlice');
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { import type {
AddMemberRequest,
CreateTaskRequest, CreateTaskRequest,
GlobalTask, GlobalTask,
KanbanColumnId,
SendMessageRequest, SendMessageRequest,
SendMessageResult, SendMessageResult,
TaskComment, TaskComment,
@ -71,12 +75,20 @@ export interface TeamSlice {
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>; sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
requestReview: (teamName: string, taskId: string) => Promise<void>; requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
updateKanbanColumnOrder: (
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
) => Promise<void>;
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
startTask: (teamName: string, taskId: string) => Promise<void>; startTask: (teamName: string, taskId: string) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
addingComment: boolean; addingComment: boolean;
addCommentError: string | null; addCommentError: string | null;
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>; addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>; deleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>; createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>; launchTeam: (request: TeamLaunchRequest) => Promise<string>;
@ -180,14 +192,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
const state = get(); const state = get();
// Use display name from teams list or selected team data if available
const teamSummary = state.teams.find((t) => t.teamName === teamName);
const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
const allTabs = state.getAllPaneTabs(); const allTabs = state.getAllPaneTabs();
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName); const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (existing) { if (existing) {
state.setActiveTab(existing.id); state.setActiveTab(existing.id);
// Sync label in case display name changed
if (existing.label !== displayName) {
state.updateTabLabel(existing.id, displayName);
}
} else { } else {
state.openTab({ state.openTab({
type: 'team', type: 'team',
label: teamName, label: displayName,
teamName, teamName,
}); });
} }
@ -222,6 +242,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
selectedTeamError: null, selectedTeamError: null,
}); });
// Sync tab label with the team's display name from config
const displayName = data.config.name || teamName;
const allTabs = get().getAllPaneTabs();
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (teamTab && teamTab.label !== displayName) {
get().updateTabLabel(teamTab.id, displayName);
}
// Auto-select the project associated with this team's cwd/projectPath. // Auto-select the project associated with this team's cwd/projectPath.
// Must search both flat projects and grouped repositoryGroups/worktrees // Must search both flat projects and grouped repositoryGroups/worktrees
// because the default viewMode is 'grouped' and flat projects may be empty. // because the default viewMode is 'grouped' and flat projects may be empty.
@ -244,8 +272,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
); );
if (matchingWorktree) { if (matchingWorktree) {
if (state.selectedWorktreeId !== matchingWorktree.id) { if (state.selectedWorktreeId !== matchingWorktree.id) {
state.selectRepository(repo.id); set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
state.selectWorktree(matchingWorktree.id); void get().fetchSessionsInitial(matchingWorktree.id);
} }
break; break;
} }
@ -330,6 +358,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
}, },
updateKanbanColumnOrder: async (
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
) => {
await unwrapIpc('team:updateKanbanColumnOrder', () =>
api.teams.updateKanbanColumnOrder(teamName, columnId, orderedTaskIds)
);
await get().refreshTeamData(teamName);
},
sendTeamMessage: async (teamName: string, request: SendMessageRequest) => { sendTeamMessage: async (teamName: string, request: SendMessageRequest) => {
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null }); set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
try { try {
@ -382,6 +421,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
await get().refreshTeamData(teamName); await get().refreshTeamData(teamName);
}, },
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
await unwrapIpc('team:updateTaskOwner', () =>
api.teams.updateTaskOwner(teamName, taskId, owner)
);
await get().refreshTeamData(teamName);
},
addTaskComment: async (teamName, taskId, text) => { addTaskComment: async (teamName, taskId, text) => {
set({ addingComment: true, addCommentError: null }); set({ addingComment: true, addCommentError: null });
try { try {
@ -398,6 +444,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
}, },
addMember: async (teamName: string, request: AddMemberRequest) => {
await unwrapIpc('team:addMember', () => api.teams.addMember(teamName, request));
await get().refreshTeamData(teamName);
},
removeMember: async (teamName: string, memberName: string) => {
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
await get().refreshTeamData(teamName);
},
deleteTeam: async (teamName: string) => { deleteTeam: async (teamName: string) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
const state = get(); const state = get();

View file

@ -24,6 +24,22 @@ export function getSessionResetState(): Partial<AppState> {
}; };
} }
/**
* Atomically navigate to a specific worktree.
* Use instead of selectRepository() + selectWorktree() to avoid race condition
* (two competing fetchSessionsInitial calls where the stale response can overwrite).
*/
export function getWorktreeNavigationState(repoId: string, worktreeId: string): Partial<AppState> {
return {
selectedRepositoryId: repoId,
selectedWorktreeId: worktreeId,
selectedProjectId: worktreeId,
activeProjectId: worktreeId,
sidebarCollapsed: false,
...getSessionResetState(),
};
}
/** /**
* Full state reset (session + project + repository + conversation). * Full state reset (session + project + repository + conversation).
* Used when closing all tabs or resetting to initial state. * Used when closing all tabs or resetting to initial state.

View file

@ -0,0 +1,52 @@
import type { AttachmentMediaType, AttachmentPayload } from '@shared/types';
export const ALLOWED_MIME_TYPES = new Set<AttachmentMediaType>([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
]);
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export const MAX_FILES = 5;
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB
export function isImageMimeType(type: string): type is AttachmentMediaType {
return ALLOWED_MIME_TYPES.has(type as AttachmentMediaType);
}
export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
if (!isImageMimeType(file.type)) {
return { valid: false, error: `Unsupported file type: ${file.type}` };
}
if (file.size > MAX_FILE_SIZE) {
return { valid: false, error: `File "${file.name}" exceeds 10MB limit` };
}
return { valid: true };
}
export async function fileToAttachmentPayload(file: File): Promise<AttachmentPayload> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
// Strip "data:image/png;base64," prefix to get raw base64
const base64 = dataUrl.split(',')[1] ?? '';
resolve({
id: crypto.randomUUID(),
filename: file.name,
mimeType: file.type as AttachmentMediaType,
size: file.size,
data: base64,
});
};
reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
reader.readAsDataURL(file);
});
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View file

@ -1,7 +1,9 @@
import type { GlobalTask } from '@shared/types'; import type { GlobalTask } from '@shared/types';
export function normalizePath(p: string): string { export function normalizePath(p: string): string {
return p.endsWith('/') ? p.slice(0, -1) : p; let s = p.replace(/\\/g, '/');
while (s.endsWith('/')) s = s.slice(0, -1);
return s.toLowerCase();
} }
export interface TaskStatusCounts { export interface TaskStatusCounts {

View file

@ -14,8 +14,11 @@ import type {
TriggerTestResult, TriggerTestResult,
} from './notifications'; } from './notifications';
import type { import type {
AddMemberRequest,
AttachmentFileData,
CreateTaskRequest, CreateTaskRequest,
GlobalTask, GlobalTask,
KanbanColumnId,
MemberFullStats, MemberFullStats,
MemberLogSummary, MemberLogSummary,
SendMessageRequest, SendMessageRequest,
@ -354,7 +357,13 @@ export interface TeamsAPI {
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>; createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
requestReview: (teamName: string, taskId: string) => Promise<void>; requestReview: (teamName: string, taskId: string) => Promise<void>;
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
updateKanbanColumnOrder: (
teamName: string,
columnId: KanbanColumnId,
orderedTaskIds: string[]
) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<void>; startTask: (teamName: string, taskId: string) => Promise<void>;
processSend: (teamName: string, message: string) => Promise<void>; processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>; processAlive: (teamName: string) => Promise<boolean>;
@ -371,7 +380,11 @@ export interface TeamsAPI {
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>; launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
getAllTasks: () => Promise<GlobalTask[]>; getAllTasks: () => Promise<GlobalTask[]>;
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>; updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>;
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>; addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
getProjectBranch: (projectPath: string) => Promise<string | null>;
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: ( onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void callback: (event: unknown, data: TeamProvisioningProgress) => void

View file

@ -262,6 +262,8 @@ export interface AppConfig {
defaultTab: 'dashboard' | 'last-session'; defaultTab: 'dashboard' | 'last-session';
/** Optional custom Claude root folder (auto-detected when null) */ /** Optional custom Claude root folder (auto-detected when null) */
claudeRootPath: string | null; claudeRootPath: string | null;
/** Agent communication language ('system' = use OS locale) */
agentLanguage: string;
}; };
/** Display and UI settings */ /** Display and UI settings */
display: { display: {

View file

@ -5,6 +5,8 @@ export interface TeamMember {
role?: string; role?: string;
color?: string; color?: string;
joinedAt?: number; joinedAt?: number;
cwd?: string;
removedAt?: number;
} }
export interface TeamConfig { export interface TeamConfig {
@ -80,6 +82,25 @@ export interface TeamTaskWithKanban extends TeamTask {
kanbanColumn?: 'review' | 'approved'; kanbanColumn?: 'review' | 'approved';
} }
export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
export interface AttachmentMeta {
id: string;
filename: string;
mimeType: AttachmentMediaType;
size: number;
}
export interface AttachmentPayload extends AttachmentMeta {
data: string;
}
export interface AttachmentFileData {
id: string;
data: string;
mimeType: AttachmentMediaType;
}
export interface InboxMessage { export interface InboxMessage {
from: string; from: string;
to?: string; to?: string;
@ -89,7 +110,8 @@ export interface InboxMessage {
summary?: string; summary?: string;
color?: string; color?: string;
messageId?: string; messageId?: string;
source?: 'inbox' | 'lead_session' | 'lead_process'; source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
attachments?: AttachmentMeta[];
} }
export interface SendMessageRequest { export interface SendMessageRequest {
@ -97,10 +119,12 @@ export interface SendMessageRequest {
text: string; text: string;
summary?: string; summary?: string;
from?: string; from?: string;
attachments?: AttachmentPayload[];
} }
export interface SendMessageResult { export interface SendMessageResult {
deliveredToInbox: boolean; deliveredToInbox: boolean;
deliveredViaStdin?: boolean;
messageId: string; messageId: string;
} }
@ -119,6 +143,8 @@ export interface KanbanState {
teamName: string; teamName: string;
reviewers: string[]; reviewers: string[];
tasks: Record<string, KanbanTaskState>; tasks: Record<string, KanbanTaskState>;
/** Порядок id задач по колонкам для отображения на канбан-доске (drag-and-drop). */
columnOrder?: Partial<Record<KanbanColumnId, string[]>>;
} }
export type UpdateKanbanPatch = export type UpdateKanbanPatch =
@ -136,6 +162,10 @@ export interface ResolvedTeamMember {
color?: string; color?: string;
agentType?: string; agentType?: string;
role?: string; role?: string;
cwd?: string;
/** Set only when member's git branch differs from the lead's branch. */
gitBranch?: string;
removedAt?: number;
} }
export interface TeamData { export interface TeamData {
@ -153,6 +183,7 @@ export interface TeamLaunchRequest {
teamName: string; teamName: string;
cwd: string; cwd: string;
prompt?: string; prompt?: string;
model?: string;
} }
export interface TeamLaunchResponse { export interface TeamLaunchResponse {
@ -199,6 +230,7 @@ export interface TeamCreateRequest {
members: TeamProvisioningMemberInput[]; members: TeamProvisioningMemberInput[];
cwd: string; cwd: string;
prompt?: string; prompt?: string;
model?: string;
} }
export interface TeamCreateConfigRequest { export interface TeamCreateConfigRequest {
@ -290,3 +322,12 @@ export interface MemberFullStats {
sessionCount: number; sessionCount: number;
computedAt: string; computedAt: string;
} }
export interface AddMemberRequest {
name: string;
role?: string;
}
export interface RemoveMemberRequest {
name: string;
}

View file

@ -0,0 +1,122 @@
/**
* Agent language configuration utilities.
* Pure functions no Electron or DOM dependencies.
*/
export interface AgentLanguageOption {
readonly value: string;
readonly label: string;
readonly flag: string;
}
/** Curated list of language options for agent communication (sorted alphabetically after System). */
export const AGENT_LANGUAGE_OPTIONS: readonly AgentLanguageOption[] = [
{ value: 'system', label: 'System', flag: '\u{1F310}' },
{ value: 'af', label: 'Afrikaans', flag: '\u{1F1FF}\u{1F1E6}' },
{ value: 'am', label: 'Amharic', flag: '\u{1F1EA}\u{1F1F9}' },
{ value: 'ar', label: 'Arabic', flag: '\u{1F1F8}\u{1F1E6}' },
{ value: 'az', label: 'Azerbaijani', flag: '\u{1F1E6}\u{1F1FF}' },
{ value: 'be', label: 'Belarusian', flag: '\u{1F1E7}\u{1F1FE}' },
{ value: 'bg', label: 'Bulgarian', flag: '\u{1F1E7}\u{1F1EC}' },
{ value: 'bn', label: 'Bengali', flag: '\u{1F1E7}\u{1F1E9}' },
{ value: 'bs', label: 'Bosnian', flag: '\u{1F1E7}\u{1F1E6}' },
{ value: 'ca', label: 'Catalan', flag: '\u{1F1EA}\u{1F1F8}' },
{ value: 'cs', label: 'Czech', flag: '\u{1F1E8}\u{1F1FF}' },
{
value: 'cy',
label: 'Welsh',
flag: '\u{1F3F4}\u{E0067}\u{E0062}\u{E0077}\u{E006C}\u{E0073}\u{E007F}',
},
{ value: 'da', label: 'Danish', flag: '\u{1F1E9}\u{1F1F0}' },
{ value: 'de', label: 'German', flag: '\u{1F1E9}\u{1F1EA}' },
{ value: 'el', label: 'Greek', flag: '\u{1F1EC}\u{1F1F7}' },
{ value: 'en', label: 'English', flag: '\u{1F1EC}\u{1F1E7}' },
{ value: 'es', label: 'Spanish', flag: '\u{1F1EA}\u{1F1F8}' },
{ value: 'et', label: 'Estonian', flag: '\u{1F1EA}\u{1F1EA}' },
{ value: 'eu', label: 'Basque', flag: '\u{1F1EA}\u{1F1F8}' },
{ value: 'fa', label: 'Persian', flag: '\u{1F1EE}\u{1F1F7}' },
{ value: 'fi', label: 'Finnish', flag: '\u{1F1EB}\u{1F1EE}' },
{ value: 'fil', label: 'Filipino', flag: '\u{1F1F5}\u{1F1ED}' },
{ value: 'fr', label: 'French', flag: '\u{1F1EB}\u{1F1F7}' },
{ value: 'ga', label: 'Irish', flag: '\u{1F1EE}\u{1F1EA}' },
{ value: 'gl', label: 'Galician', flag: '\u{1F1EA}\u{1F1F8}' },
{ value: 'gu', label: 'Gujarati', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'he', label: 'Hebrew', flag: '\u{1F1EE}\u{1F1F1}' },
{ value: 'hi', label: 'Hindi', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'hr', label: 'Croatian', flag: '\u{1F1ED}\u{1F1F7}' },
{ value: 'hu', label: 'Hungarian', flag: '\u{1F1ED}\u{1F1FA}' },
{ value: 'hy', label: 'Armenian', flag: '\u{1F1E6}\u{1F1F2}' },
{ value: 'id', label: 'Indonesian', flag: '\u{1F1EE}\u{1F1E9}' },
{ value: 'is', label: 'Icelandic', flag: '\u{1F1EE}\u{1F1F8}' },
{ value: 'it', label: 'Italian', flag: '\u{1F1EE}\u{1F1F9}' },
{ value: 'ja', label: 'Japanese', flag: '\u{1F1EF}\u{1F1F5}' },
{ value: 'ka', label: 'Georgian', flag: '\u{1F1EC}\u{1F1EA}' },
{ value: 'kk', label: 'Kazakh', flag: '\u{1F1F0}\u{1F1FF}' },
{ value: 'km', label: 'Khmer', flag: '\u{1F1F0}\u{1F1ED}' },
{ value: 'kn', label: 'Kannada', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'ko', label: 'Korean', flag: '\u{1F1F0}\u{1F1F7}' },
{ value: 'lt', label: 'Lithuanian', flag: '\u{1F1F1}\u{1F1F9}' },
{ value: 'lv', label: 'Latvian', flag: '\u{1F1F1}\u{1F1FB}' },
{ value: 'mk', label: 'Macedonian', flag: '\u{1F1F2}\u{1F1F0}' },
{ value: 'ml', label: 'Malayalam', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'mn', label: 'Mongolian', flag: '\u{1F1F2}\u{1F1F3}' },
{ value: 'mr', label: 'Marathi', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'ms', label: 'Malay', flag: '\u{1F1F2}\u{1F1FE}' },
{ value: 'my', label: 'Burmese', flag: '\u{1F1F2}\u{1F1F2}' },
{ value: 'ne', label: 'Nepali', flag: '\u{1F1F3}\u{1F1F5}' },
{ value: 'nl', label: 'Dutch', flag: '\u{1F1F3}\u{1F1F1}' },
{ value: 'no', label: 'Norwegian', flag: '\u{1F1F3}\u{1F1F4}' },
{ value: 'pa', label: 'Punjabi', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'pl', label: 'Polish', flag: '\u{1F1F5}\u{1F1F1}' },
{ value: 'pt', label: 'Portuguese', flag: '\u{1F1E7}\u{1F1F7}' },
{ value: 'ro', label: 'Romanian', flag: '\u{1F1F7}\u{1F1F4}' },
{ value: 'ru', label: 'Russian', flag: '\u{1F1F7}\u{1F1FA}' },
{ value: 'si', label: 'Sinhala', flag: '\u{1F1F1}\u{1F1F0}' },
{ value: 'sk', label: 'Slovak', flag: '\u{1F1F8}\u{1F1F0}' },
{ value: 'sl', label: 'Slovenian', flag: '\u{1F1F8}\u{1F1EE}' },
{ value: 'sq', label: 'Albanian', flag: '\u{1F1E6}\u{1F1F1}' },
{ value: 'sr', label: 'Serbian', flag: '\u{1F1F7}\u{1F1F8}' },
{ value: 'sv', label: 'Swedish', flag: '\u{1F1F8}\u{1F1EA}' },
{ value: 'sw', label: 'Swahili', flag: '\u{1F1F0}\u{1F1EA}' },
{ value: 'ta', label: 'Tamil', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'te', label: 'Telugu', flag: '\u{1F1EE}\u{1F1F3}' },
{ value: 'th', label: 'Thai', flag: '\u{1F1F9}\u{1F1ED}' },
{ value: 'tr', label: 'Turkish', flag: '\u{1F1F9}\u{1F1F7}' },
{ value: 'uk', label: 'Ukrainian', flag: '\u{1F1FA}\u{1F1E6}' },
{ value: 'ur', label: 'Urdu', flag: '\u{1F1F5}\u{1F1F0}' },
{ value: 'uz', label: 'Uzbek', flag: '\u{1F1FA}\u{1F1FF}' },
{ value: 'vi', label: 'Vietnamese', flag: '\u{1F1FB}\u{1F1F3}' },
{ value: 'zh', label: 'Chinese', flag: '\u{1F1E8}\u{1F1F3}' },
{ value: 'zu', label: 'Zulu', flag: '\u{1F1FF}\u{1F1E6}' },
] as const;
/**
* Resolves a language code to a human-readable language name.
*
* - `'system'` resolved from `systemLocale` via `Intl.DisplayNames` (e.g. "English", "Русский")
* - Known BCP-47 code human name via `Intl.DisplayNames`
* - Fallback returns the code itself
*/
export function resolveLanguageName(code: string, systemLocale?: string): string {
const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale ?? 'en') : code;
try {
const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
const name = displayNames.of(effectiveCode);
if (name) {
return name.charAt(0).toUpperCase() + name.slice(1);
}
} catch {
// Intl.DisplayNames not available or invalid code — fall through
}
// Fallback: check our curated list
const option = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === effectiveCode);
return option?.label ?? effectiveCode;
}
/** Extracts primary language subtag from a locale string (e.g. "en-US" → "en"). */
function extractPrimaryLanguage(locale: string): string {
const dash = locale.indexOf('-');
return dash > 0 ? locale.slice(0, dash) : locale;
}

View file

@ -0,0 +1,12 @@
/**
* Detects rate limit messages from Claude.
*/
const RATE_LIMIT_SUBSTRING = "You've hit your limit";
/**
* Returns true if the message text contains the rate limit indicator.
*/
export function isRateLimitMessage(text: string): boolean {
return text.includes(RATE_LIMIT_SUBSTRING);
}

View file

@ -16,7 +16,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_SEND_MESSAGE: 'team:sendMessage', TEAM_SEND_MESSAGE: 'team:sendMessage',
TEAM_REQUEST_REVIEW: 'team:requestReview', TEAM_REQUEST_REVIEW: 'team:requestReview',
TEAM_UPDATE_KANBAN: 'team:updateKanban', TEAM_UPDATE_KANBAN: 'team:updateKanban',
TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder',
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus', TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner',
TEAM_PROCESS_SEND: 'team:processSend', TEAM_PROCESS_SEND: 'team:processSend',
TEAM_PROCESS_ALIVE: 'team:processAlive', TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList', TEAM_ALIVE_LIST: 'team:aliveList',
@ -28,6 +30,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_START_TASK: 'team:startTask', TEAM_START_TASK: 'team:startTask',
TEAM_GET_ALL_TASKS: 'team:getAllTasks', TEAM_GET_ALL_TASKS: 'team:getAllTasks',
TEAM_ADD_TASK_COMMENT: 'team:addTaskComment', TEAM_ADD_TASK_COMMENT: 'team:addTaskComment',
TEAM_ADD_MEMBER: 'team:addMember',
TEAM_REMOVE_MEMBER: 'team:removeMember',
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
})); }));
import { import {
@ -54,8 +60,13 @@ import {
TEAM_START_TASK, TEAM_START_TASK,
TEAM_UPDATE_CONFIG, TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_STATUS, TEAM_UPDATE_TASK_STATUS,
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT, TEAM_ADD_TASK_COMMENT,
TEAM_GET_ATTACHMENTS,
TEAM_GET_PROJECT_BRANCH,
TEAM_REMOVE_MEMBER,
} from '../../../src/preload/constants/ipcChannels'; } from '../../../src/preload/constants/ipcChannels';
import { import {
initializeTeamHandlers, initializeTeamHandlers,
@ -82,6 +93,7 @@ describe('ipc teams handlers', () => {
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })), createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
requestReview: vi.fn(async () => undefined), requestReview: vi.fn(async () => undefined),
updateKanban: vi.fn(async () => undefined), updateKanban: vi.fn(async () => undefined),
updateKanbanColumnOrder: vi.fn(async () => undefined),
updateTaskStatus: vi.fn(async () => undefined), updateTaskStatus: vi.fn(async () => undefined),
startTask: vi.fn(async () => undefined), startTask: vi.fn(async () => undefined),
addTaskComment: vi.fn(async () => ({ addTaskComment: vi.fn(async () => ({
@ -90,6 +102,8 @@ describe('ipc teams handlers', () => {
text: 'test comment', text: 'test comment',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
})), })),
addMember: vi.fn(async () => undefined),
removeMember: vi.fn(async () => undefined),
}; };
const provisioningService = { const provisioningService = {
prepareForProvisioning: vi.fn(async () => ({ prepareForProvisioning: vi.fn(async () => ({
@ -135,6 +149,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true); expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true); expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true); expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(true);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true); expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
expect(handlers.has(TEAM_START_TASK)).toBe(true); expect(handlers.has(TEAM_START_TASK)).toBe(true);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true); expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
@ -148,6 +163,8 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true); expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
}); });
it('returns success false on invalid sendMessage args', async () => { it('returns success false on invalid sendMessage args', async () => {
@ -212,6 +229,7 @@ describe('ipc teams handlers', () => {
it('dedups live lead replies when lead_session already has same text', async () => { it('dedups live lead replies when lead_session already has same text', async () => {
service.getTeamData.mockResolvedValueOnce({ service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team', teamName: 'my-team',
config: { name: 'My Team' },
messages: [ messages: [
{ {
from: 'team-lead', from: 'team-lead',
@ -301,6 +319,64 @@ describe('ipc teams handlers', () => {
}); });
}); });
describe('addMember', () => {
it('calls service on valid input', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: 'alice',
role: 'developer',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(service.addMember).toHaveBeenCalledWith('my-team', {
name: 'alice',
role: 'developer',
});
});
it('rejects invalid team name', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, '../bad', {
name: 'alice',
})) as { success: boolean };
expect(result.success).toBe(false);
});
it('rejects invalid member name', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', {
name: '../bad',
})) as { success: boolean };
expect(result.success).toBe(false);
});
it('rejects missing payload', async () => {
const handler = handlers.get(TEAM_ADD_MEMBER)!;
const result = (await handler({} as never, 'my-team', null)) as { success: boolean };
expect(result.success).toBe(false);
});
});
describe('removeMember', () => {
it('calls service on valid input', async () => {
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
expect(result.success).toBe(true);
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
});
it('rejects invalid team name', async () => {
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
const result = (await handler({} as never, '../bad', 'alice')) as { success: boolean };
expect(result.success).toBe(false);
});
it('rejects invalid member name', async () => {
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean };
expect(result.success).toBe(false);
});
});
describe('createTeam prompt validation', () => { describe('createTeam prompt validation', () => {
it('accepts valid prompt in team create request', async () => { it('accepts valid prompt in team create request', async () => {
const handler = handlers.get(TEAM_CREATE)!; const handler = handlers.get(TEAM_CREATE)!;
@ -342,6 +418,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false); expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false); expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false); expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(false);
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false); expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
expect(handlers.has(TEAM_START_TASK)).toBe(false); expect(handlers.has(TEAM_START_TASK)).toBe(false);
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false); expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
@ -355,5 +432,9 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false); expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(false);
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(false);
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
}); });
}); });