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/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;
<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>
<br />
@ -26,9 +26,6 @@
</a>&nbsp;&nbsp;
<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" />
</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>
</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 |
| **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" |
| **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.
---
## 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
<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
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 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 { countTokens } from '../utils/tokenizer';

View file

@ -1,4 +1,7 @@
import { randomUUID } from 'node:crypto';
import {
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
@ -7,10 +10,12 @@ import {
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DATA,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_LAUNCH,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
@ -18,22 +23,35 @@ import {
TEAM_PROCESS_SEND,
TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_START_TASK,
TEAM_STOP,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { NotificationManager } from '../services/infrastructure/NotificationManager';
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
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 {
MemberStatsComputer,
TeamDataService,
@ -41,9 +59,13 @@ import type {
TeamProvisioningService,
} from '../services';
import type {
AttachmentFileData,
AttachmentMeta,
AttachmentPayload,
CreateTaskRequest,
GlobalTask,
IpcResult,
KanbanColumnId,
MemberFullStats,
MemberLogSummary,
SendMessageRequest,
@ -67,11 +89,63 @@ import type {
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 teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | 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(
service: TeamDataService,
provisioningService: TeamProvisioningService,
@ -96,7 +170,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
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_OWNER, handleUpdateTaskOwner);
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
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_GET_ALL_TASKS, handleGetAllTasks);
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');
}
@ -125,7 +205,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_CREATE_TASK);
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER);
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_PROCESS_SEND);
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
@ -139,6 +221,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_START_TASK);
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
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 {
@ -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[]>> {
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
}
@ -203,8 +306,12 @@ async function handleGetData(
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
}
const displayName = data.config.name || tn;
const projectPath = data.config.projectPath;
const live = provisioning.getLiveLeadProcessMessages(tn);
if (live.length === 0) {
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
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));
checkRateLimitMessages(merged, tn, displayName, projectPath);
return { success: true, data: { ...data, isAlive, messages: merged } };
}
@ -387,6 +495,7 @@ async function validateProvisioningRequest(
members,
cwd,
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' };
}
if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a string' };
}
return wrapTeamHandler('launch', () =>
getTeamProvisioningService().launchTeam(
{
teamName: validatedTeamName.value!,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
},
(progress) => {
try {
@ -526,6 +640,76 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
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(
_event: IpcMainInvokeEvent,
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 () => {
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, {
member: validatedMember.value!,
member: memberName,
text: payload.text!,
summary: payload.summary,
from: payload.from,
});
// Best-effort: if messaging the lead while process is alive, relay immediately (no UI dependency).
try {
const provisioning = getTeamProvisioningService();
if (provisioning.isTeamAlive(tn)) {
// Avoid reading unrelated inboxes; relayLeadInboxMessages will no-op when nothing new exists.
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
}
} catch {
// ignore
// Best-effort relay for lead via inbox
if (isLeadRecipient && isAlive) {
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
}
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'];
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(
_event: IpcMainInvokeEvent,
teamName: unknown,
@ -946,6 +1267,47 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<
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(
_event: IpcMainInvokeEvent,
teamName: unknown,

View file

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

View file

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

View file

@ -28,7 +28,10 @@ function parseFrontmatter(content: string): Record<string, string> {
const key = line.slice(0, colonIdx).trim();
let value = line.slice(colonIdx + 1).trim();
// 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);
}
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.
* Returns a map of agent name config (with optional color).
*/
export async function readAgentConfigs(
projectRoot: string
): Promise<Record<string, AgentConfig>> {
export async function readAgentConfigs(projectRoot: string): Promise<Record<string, AgentConfig>> {
const agentsDir = path.join(projectRoot, '.claude', 'agents');
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 {
const metaMembers = await this.membersMetaStore.getMembers(entry.name);
for (const member of metaMembers) {
addMember(member);
if (member.removedAt) {
removedNames.add(member.name.trim());
} else {
addMember(member);
}
}
} catch {
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.
}
for (const name of removedNames) {
memberMap.delete(name);
}
const memberCount = memberMap.size;
const members = Array.from(memberMap.values());
summaries.push({

View file

@ -8,10 +8,13 @@ import {
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger';
import { randomUUID } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
import { atomicWriteAsync } from './atomicWrite';
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
import { TeamConfigReader } from './TeamConfigReader';
@ -20,21 +23,26 @@ import { TeamInboxWriter } from './TeamInboxWriter';
import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
import type {
AddMemberRequest,
CreateTaskRequest,
GlobalTask,
InboxMessage,
KanbanColumnId,
KanbanState,
KanbanTaskState,
ResolvedTeamMember,
SendMessageRequest,
SendMessageResult,
TaskComment,
TeamConfig,
TeamCreateConfigRequest,
TeamData,
TeamMember,
TeamSummary,
TeamTask,
TeamTaskStatus,
@ -57,7 +65,8 @@ export class TeamDataService {
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
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[]> {
@ -162,12 +171,22 @@ export class TeamDataService {
const leadTexts = await this.extractLeadSessionTexts(config);
if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts];
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
}
} catch {
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'] = [];
try {
metaMembers = await this.membersMetaStore.getMembers(teamName);
@ -211,6 +230,9 @@ export class TeamDataService {
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
if (tasksLoaded && messages.length > 0) {
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> {
const nextId = await this.taskReader.getNextTaskId(teamName);
@ -364,6 +464,10 @@ export class TeamDataService {
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> {
const comment = await this.taskWriter.addComment(teamName, taskId, text);
@ -398,6 +502,54 @@ export class TeamDataService {
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> {
await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' });
@ -630,4 +782,12 @@ export class TeamDataService {
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 type { KanbanState, UpdateKanbanPatch } from '@shared/types';
import type { KanbanColumnId, KanbanState, UpdateKanbanPatch } from '@shared/types';
const logger = createLogger('Service:TeamKanbanManager');
const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved'];
function createDefaultState(teamName: string): KanbanState {
return {
teamName,
@ -21,6 +23,23 @@ function isValidColumn(value: unknown): value is 'review' | '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 {
async getState(teamName: string): Promise<KanbanState> {
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)
: [],
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> {
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;
if (before === after) {
const tasksChanged = before !== after;
if (!tasksChanged && !columnOrderChanged) {
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);
}
@ -125,6 +180,9 @@ export class TeamKanbanManager {
teamName,
reviewers: state.reviewers,
tasks: state.tasks,
...(state.columnOrder && Object.keys(state.columnOrder).length > 0
? { columnOrder: state.columnOrder }
: {}),
};
await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2));
}

View file

@ -40,7 +40,7 @@ export class TeamMemberResolver {
const configMemberMap = new Map<
string,
{ agentType?: string; role?: string; color?: string }
{ agentType?: string; role?: string; color?: string; cwd?: string }
>();
if (Array.isArray(config.members)) {
for (const m of config.members) {
@ -49,12 +49,16 @@ export class TeamMemberResolver {
agentType: m.agentType,
role: m.role,
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)) {
for (const member of metaMembers) {
if (typeof member?.name === 'string' && member.name.trim() !== '') {
@ -62,6 +66,7 @@ export class TeamMemberResolver {
agentType: member.agentType,
role: member.role,
color: member.color,
removedAt: member.removedAt,
});
}
}
@ -89,6 +94,8 @@ export class TeamMemberResolver {
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
agentType: configMember?.agentType ?? metaMember?.agentType,
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,
joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : 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 */
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
import {
encodePath,
extractBaseDir,
@ -8,9 +9,11 @@ import {
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { resolveLanguageName } from '@shared/utils/agentLanguage';
import { createLogger } from '@shared/utils/logger';
import { execFile, spawn } from 'child_process';
import { randomUUID } from 'crypto';
import { app } from 'electron';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@ -22,6 +25,7 @@ import { withInboxLock } from './inboxLock';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import type {
InboxMessage,
@ -42,7 +46,7 @@ const VERIFY_POLL_MS = 500;
const STDERR_RING_LIMIT = 64 * 1024;
const STDOUT_RING_LIMIT = 64 * 1024;
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 CLI_PREPARE_TIMEOUT_MS = 10000;
const PREFLIGHT_TIMEOUT_MS = 30000;
@ -120,6 +124,11 @@ interface ProvisioningRun {
rejectOnce: (error: string) => void;
timeoutHandle: NodeJS.Timeout;
} | null;
/**
* Accumulates assistant text for direct userlead messages (no relay capture active).
* Flushed to liveLeadProcessMessages on result.success.
*/
directReplyParts: string[];
}
type ProvisioningAuthSource =
@ -279,11 +288,20 @@ function buildTaskStatusProtocol(teamName: string): string {
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 {
const displayName = request.displayName?.trim() || request.teamName;
const description = request.description?.trim() || 'No description';
const members = buildMembersPrompt(request.members);
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
const userPromptBlock = request.prompt?.trim()
? `\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.
${userPromptBlock}
${languageInstruction}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
@ -327,7 +347,8 @@ Steps (execute in this exact order):
- subagent_type: "general-purpose"
- prompt:
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.
${taskProtocol}
@ -352,6 +373,7 @@ function buildLaunchPrompt(
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
: '';
const taskProtocol = buildTaskStatusProtocol(request.teamName);
const languageInstruction = getAgentLanguageInstruction();
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}".
${userPromptBlock}
${languageInstruction}
Constraints:
- Do NOT call TeamDelete under any circumstances.
- Do NOT use TodoWrite.
@ -392,7 +416,8 @@ Steps (execute in this exact order):
- subagent_type: "general-purpose"
- prompt:
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.
${taskProtocol}
@ -501,7 +526,8 @@ export class TeamProvisioningService {
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
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 {
@ -668,6 +694,7 @@ export class TeamProvisioningService {
isLaunch: false,
fsPhase: 'waiting_config',
leadRelayCapture: null,
directReplyParts: [],
progress: {
runId,
teamName: request.teamName,
@ -706,6 +733,7 @@ export class TeamProvisioningService {
'--disallowedTools',
'TeamDelete,TodoWrite',
'--dangerously-skip-permissions',
...(request.model ? ['--model', request.model] : []),
],
{
cwd: request.cwd,
@ -936,6 +964,7 @@ export class TeamProvisioningService {
isLaunch: true,
fsPhase: 'waiting_members',
leadRelayCapture: null,
directReplyParts: [],
progress: {
runId,
teamName: request.teamName,
@ -984,6 +1013,9 @@ export class TeamProvisioningService {
`[${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
// --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.
* 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);
if (!runId) {
throw new Error(`No active process for team "${teamName}"`);
@ -1149,11 +1185,26 @@ export class TeamProvisioningService {
if (!run?.child?.stdin?.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({
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: message }],
content: contentBlocks,
},
});
run.child.stdin.write(payload + '\n');
@ -1273,6 +1324,8 @@ export class TeamProvisioningService {
},
};
run.leadRelayCapture = capture;
// Clear any direct reply parts — relay capture takes priority
run.directReplyParts = [];
});
try {
@ -1442,7 +1495,7 @@ export class TeamProvisioningService {
return next;
}
private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
const MAX = 100;
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
list.push(message);
@ -1512,6 +1565,9 @@ export class TeamProvisioningService {
capture.resolveOnce(combined);
}, 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 combined = capture.textParts.join('').trim();
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) {
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,
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : 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(
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(
teamName: string,
taskId: string,

View file

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

View file

@ -191,6 +191,9 @@ export const TEAM_GET_DATA = 'team:getData';
/** Update team kanban state */
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 */
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) */
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 */
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 */
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_STATUS,
SSH_TEST,
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
@ -27,10 +28,12 @@ import {
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DATA,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_PROJECT_BRANCH,
TEAM_LAUNCH,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
@ -38,12 +41,15 @@ import {
TEAM_PROCESS_SEND,
TEAM_PROVISIONING_PROGRESS,
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_START_TASK,
TEAM_STOP,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_OWNER,
TEAM_UPDATE_TASK_STATUS,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
@ -84,7 +90,9 @@ import {
} from './constants/ipcChannels';
import type {
AddMemberRequest,
AppConfig,
AttachmentFileData,
ClaudeRootFolderSelection,
ClaudeRootInfo,
ContextInfo,
@ -93,6 +101,7 @@ import type {
GlobalTask,
HttpServerStatus,
IpcResult,
KanbanColumnId,
MemberFullStats,
MemberLogSummary,
NotificationTrigger,
@ -553,9 +562,24 @@ const electronAPI: ElectronAPI = {
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
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) => {
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) => {
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
},
@ -601,6 +625,18 @@ const electronAPI: ElectronAPI = {
addTaskComment: async (teamName: string, taskId: string, text: string) => {
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) => {
ipcRenderer.on(
TEAM_CHANGE,

View file

@ -8,6 +8,7 @@
import type {
AppConfig,
AttachmentFileData,
ClaudeMdFileInfo,
ClaudeRootFolderSelection,
ClaudeRootInfo,
@ -20,6 +21,7 @@ import type {
GlobalTask,
HttpServerAPI,
HttpServerStatus,
KanbanColumnId,
NotificationsAPI,
NotificationTrigger,
PaginatedSessionsResult,
@ -666,6 +668,13 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
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 (
_teamName: string,
_taskId: string,
@ -673,6 +682,13 @@ export class HttpAPIClient implements ElectronAPI {
): Promise<void> => {
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> => {
throw new Error('Team start task is not available in browser mode');
},
@ -726,6 +742,21 @@ export class HttpAPIClient implements ElectronAPI {
addTaskComment: async () => {
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) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)

View file

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

View file

@ -11,6 +11,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
import {
buildTaskCountsByProject,
normalizePath,
@ -256,14 +257,39 @@ const RepositoryCard = ({
// 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 { repositoryGroups, selectRepository } = useStore(
const { repositoryGroups, fetchRepositoryGroups } = useStore(
useShallow((s) => ({
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> => {
try {
const selectedPaths = await api.config.selectFolders();
@ -273,17 +299,23 @@ const NewProjectCard = (): React.JSX.Element => {
const selectedPath = selectedPaths[0];
// Match selected path against known repository worktrees
for (const repo of repositoryGroups) {
for (const worktree of repo.worktrees) {
if (worktree.path === selectedPath) {
selectRepository(repo.id);
return;
}
}
// Match selected path against known repository worktrees (normalized comparison)
const match = findMatchingWorktree(repositoryGroups, selectedPath);
if (match) {
navigateToMatch(match);
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);
if (!result.success) {
logger.error('Failed to open folder:', result.error);

View file

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

View file

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

View file

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

View file

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

View file

@ -6,8 +6,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, isElectronMode } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Combobox } from '@renderer/components/ui/combobox';
import { useStore } from '@renderer/store';
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 { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
@ -28,6 +30,7 @@ interface GeneralSectionProps {
readonly saving: boolean;
readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void;
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
readonly onLanguageChange: (value: string) => void;
}
export const GeneralSection = ({
@ -35,6 +38,7 @@ export const GeneralSection = ({
saving,
onGeneralToggle,
onThemeChange,
onLanguageChange,
}: GeneralSectionProps): React.JSX.Element => {
const [serverStatus, setServerStatus] = useState<HttpServerStatus>({
running: false,
@ -247,8 +251,59 @@ export const GeneralSection = ({
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 (
<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 && (
<>
<SettingsSectionHeader title="Startup" />

View file

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

View file

@ -27,13 +27,15 @@ export const CollapsibleTeamSection = ({
const isOpen = forceOpen ? true : open;
return (
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0">
<div className="flex items-center">
<section className="border-b border-[var(--color-border)] pb-3 last:border-b-0">
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
<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)}
>
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
size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
@ -60,8 +62,8 @@ export const CollapsibleTeamSection = ({
{secondaryBadge}
</Badge>
)}
</button>
{action && <div className="shrink-0">{action}</div>}
</div>
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
</div>
{isOpen && <div className="mt-2">{children}</div>}
</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 { Button } from '@renderer/components/ui/button';
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';
@ -18,17 +24,93 @@ export interface ProvisioningProgressBlockProps {
loading?: boolean;
/** Cancel button label and handler */
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;
}
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 = ({
title,
message,
currentStepIndex,
loading = false,
onCancel,
startedAt,
pid,
cliLogsTail,
className,
}: 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 (
<div
className={cn(
@ -42,6 +124,14 @@ export const ProvisioningProgressBlock = ({
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
) : null}
<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>
{onCancel ? (
<Button
@ -62,6 +152,7 @@ export const ProvisioningProgressBlock = ({
return (
<div key={step} className="flex items-center gap-1">
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */}
<Badge
variant="secondary"
className={cn(
@ -76,6 +167,7 @@ export const ProvisioningProgressBlock = ({
</span>
{STEP_LABELS[step]}
</Badge>
{/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */}
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
) : null}
@ -83,6 +175,29 @@ export const ProvisioningProgressBlock = ({
);
})}
</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>
);
};

View file

@ -2,18 +2,28 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
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 { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
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 { ActiveTasksBlock } from './activity/ActiveTasksBlock';
import { ActivityTimeline } from './activity/ActivityTimeline';
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
import { AddMemberDialog } from './dialogs/AddMemberDialog';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
@ -24,6 +34,7 @@ import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList';
import { MessageComposer } from './messages/MessageComposer';
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
@ -78,9 +89,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
defaultOwner: '',
});
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 [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
undefined
@ -102,7 +117,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
projects,
selectTeam,
updateKanban,
updateKanbanColumnOrder,
updateTaskStatus,
updateTaskOwner,
sendTeamMessage,
requestReview,
createTeamTask,
@ -113,6 +130,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError,
lastSendMessageResult,
reviewActionError,
addMember,
removeMember,
launchTeam,
provisioningError,
isTeamProvisioning,
@ -126,7 +145,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
projects: s.projects,
selectTeam: s.selectTeam,
updateKanban: s.updateKanban,
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
updateTaskStatus: s.updateTaskStatus,
updateTaskOwner: s.updateTaskOwner,
sendTeamMessage: s.sendTeamMessage,
requestReview: s.requestReview,
createTeamTask: s.createTeamTask,
@ -137,6 +158,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError: s.sendMessageError,
lastSendMessageResult: s.lastSendMessageResult,
reviewActionError: s.reviewActionError,
addMember: s.addMember,
removeMember: s.removeMember,
launchTeam: s.launchTeam,
provisioningError: s.provisioningError,
isTeamProvisioning: Object.values(s.provisioningRuns).some(
@ -204,6 +227,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
};
}, [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
const teamSessions = useMemo(() => {
const sessionIds = new Set<string>();
@ -313,6 +358,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return filterKanbanTasks(filteredTasks, query);
}, [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 memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
@ -354,12 +404,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
};
const handleDeleteTeam = useCallback((): void => {
const confirmed = window.confirm(
`Delete team "${teamName}"? This action is irreversible. All team data and tasks will be deleted.`
);
if (!confirmed) {
return;
}
setDeleteConfirmOpen(true);
}, []);
const confirmDeleteTeam = useCallback((): void => {
setDeleteConfirmOpen(false);
void (async () => {
try {
await deleteTeam(teamName);
@ -475,46 +524,76 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
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>
{data.config.description && (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
{data.config.description}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{!data.isAlive ? (
<Button
variant="ghost"
size="sm"
className="h-7 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>
) : null}
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setEditDialogOpen(true)}
>
<Pencil size={12} />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={handleDeleteTeam}
>
<Trash2 size={12} />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setEditDialogOpen(true)}
>
<Pencil size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Edit team</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={handleDeleteTeam}
>
<Trash2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</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>
{!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} />
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
@ -528,7 +607,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div>
) : 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
members={data.members}
memberTaskCounts={memberTaskCounts}
@ -581,28 +678,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</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
tasks={kanbanDisplayTasks}
teamName={teamName}
@ -610,8 +685,37 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
filter={kanbanFilter}
sessions={teamSessions}
leadSessionId={data.config.leadSessionId}
members={data.members}
members={activeMembers}
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) => {
void requestReview(teamName, taskId);
}}
@ -652,6 +756,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onCompleteTask={(taskId) => {
void updateTaskStatus(teamName, taskId, 'completed');
}}
onColumnOrderChange={(columnId, orderedTaskIds) => {
void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
}}
onScrollToTask={(taskId) => {
const el = document.querySelector(`[data-task-id="${taskId}"]`);
if (el) {
@ -692,23 +799,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onOpenChange={setMessagesFilterOpen}
onApply={setMessagesFilter}
/>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setSendDialogRecipient(undefined);
setReplyQuote(undefined);
setSendDialogOpen(true);
}}
>
<MessageSquare size={12} />
Message
</Button>
</div>
}
>
<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
members={data.members}
pendingRepliesByMember={pendingRepliesByMember}
@ -722,6 +834,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
/>
<ActivityTimeline
messages={filteredMessages}
teamName={teamName}
members={data.members}
readState={{ readSet, getMessageKey: toMessageKey }}
onMemberClick={setSelectedMember}
@ -786,12 +899,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
setSelectedMember(null);
setSelectedTask(task);
}}
onRemoveMember={() => {
const name = selectedMember?.name;
if (!name) return;
setRemoveMemberConfirm(name);
}}
/>
<CreateTaskDialog
open={createTaskDialog.open}
teamName={teamName}
members={data.members}
members={activeMembers}
tasks={data.tasks}
isTeamAlive={data.isAlive && !isTeamProvisioning}
defaultSubject={createTaskDialog.defaultSubject}
@ -812,6 +930,81 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
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
open={launchDialogOpen}
teamName={teamName}
@ -826,7 +1019,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<SendMessageDialog
open={sendDialogOpen}
members={data.members}
members={activeMembers}
defaultRecipient={sendDialogRecipient}
quotedMessage={replyQuote}
sending={sendingMessage}
@ -860,7 +1053,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
teamName={teamName}
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
taskMap={taskMap}
members={data?.members ?? []}
members={activeMembers}
onClose={() => setSelectedTask(null)}
onScrollToTask={(taskId) => {
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);
}
}}
onOwnerChange={(taskId, owner) => {
void updateTaskOwner(teamName, taskId, owner);
}}
/>
</div>
);

View file

@ -14,7 +14,17 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, 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 { CreateTeamDialog } from './dialogs/CreateTeamDialog';
@ -97,6 +107,7 @@ export const TeamListView = (): React.JSX.Element => {
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
const {
teams,
teamsLoading,
@ -204,6 +215,49 @@ export const TeamListView = (): React.JSX.Element => {
return result;
}, [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(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
@ -509,9 +563,25 @@ export const TeamListView = (): React.JSX.Element => {
</Tooltip>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-2 flex min-h-10 items-start gap-2">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{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">
{team.members && team.members.length > 0 ? (
team.members.map((m) => {

View file

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

View file

@ -1,6 +1,9 @@
import { useMemo, useState } from 'react';
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 {
CARD_BG,
CARD_BORDER_STYLE,
@ -16,7 +19,8 @@ import {
} from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
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';
@ -27,6 +31,7 @@ type StructuredMessage = Record<string, unknown>;
interface ActivityItemProps {
message: InboxMessage;
teamName: string;
memberRole?: string;
memberColor?: string;
recipientColor?: string;
@ -126,6 +131,7 @@ function stripAgentBlocks(text: string): string {
export const ActivityItem = ({
message,
teamName,
memberRole,
memberColor,
recipientColor,
@ -135,7 +141,6 @@ export const ActivityItem = ({
onReply,
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null;
const formattedRole = formatAgentRole(memberRole);
const timestamp = Number.isNaN(Date.parse(message.timestamp))
@ -143,10 +148,13 @@ export const ActivityItem = ({
: new Date(message.timestamp).toLocaleString();
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
const systemLabel = !structured ? getSystemMessageLabel(message.text) : null;
// System/automated messages start collapsed (but not rate limits)
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
const [isExpanded, setIsExpanded] = useState(!systemLabel);
// Strip agent-only blocks from displayed text
@ -184,9 +192,11 @@ export const ActivityItem = ({
<article
className="group overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
backgroundColor: rateLimited ? 'var(--tool-result-error-bg)' : CARD_BG,
border: rateLimited ? '1px solid var(--tool-result-error-border)' : CARD_BORDER_STYLE,
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) */}
@ -224,41 +234,12 @@ export const ActivityItem = ({
/>
) : null}
{message.source === 'lead_session' || message.source === 'lead_process' ? (
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
) : (
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
)}
{/* 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>
)}
{/* Sender avatar + name badge */}
<MemberBadge
name={message.from}
color={memberColor ?? message.color}
onClick={onMemberNameClick}
/>
{/* Role */}
{formattedRole ? (
@ -289,60 +270,24 @@ export const ActivityItem = ({
</span>
) : null}
{/* Recipient — badge like sender, clickable to open member popup */}
{message.to && message.to !== message.from && recipientColors ? (
<span className="text-[10px]">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </span>
{onMemberNameClick ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{
backgroundColor: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: recipientColors.badge,
color: recipientColors.text,
border: `1px solid ${recipientColors.border}40`,
}}
>
{message.to}
</span>
)}
</span>
) : message.to && message.to !== message.from ? (
<span className="text-[10px]">
<span style={{ color: CARD_ICON_MUTED }}>&rarr; </span>
{onMemberNameClick ? (
<button
type="button"
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onMemberNameClick(message.to!);
}}
>
{message.to}
</button>
) : (
<span style={{ color: CARD_ICON_MUTED }}>{message.to}</span>
)}
{/* Rate limit warning badge */}
{rateLimited ? (
<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">
<AlertTriangle size={10} />
Rate Limited
</span>
) : 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 */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText}
@ -351,32 +296,40 @@ export const ActivityItem = ({
{/* Timestamp + reply + create task */}
<div className="flex shrink-0 items-center gap-1.5">
{onReply && (
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
title="Reply to message"
onClick={(e) => {
e.stopPropagation();
onReply(message);
}}
>
<Reply size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onReply(message);
}}
>
<Reply size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Reply to message</TooltipContent>
</Tooltip>
)}
{onCreateTask && (
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
title="Create task from message"
onClick={(e) => {
e.stopPropagation();
handleCreateTask();
}}
>
<ListPlus size={14} />
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
handleCreateTask();
}}
>
<ListPlus size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Create task from message</TooltipContent>
</Tooltip>
)}
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
@ -404,8 +357,20 @@ export const ActivityItem = ({
) : 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>
) : null}
</article>

View file

@ -8,6 +8,7 @@ import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps {
messages: InboxMessage[];
teamName: string;
members?: ResolvedTeamMember[];
/**
* When provided, unread is derived from this set and getMessageKey.
@ -25,6 +26,7 @@ const VIEWPORT_THRESHOLD = 0.15;
const MessageRowWithObserver = ({
message,
teamName,
memberRole,
memberColor,
recipientColor,
@ -35,6 +37,7 @@ const MessageRowWithObserver = ({
onVisible,
}: {
message: InboxMessage;
teamName: string;
memberRole?: string;
memberColor?: string;
recipientColor?: string;
@ -78,6 +81,7 @@ const MessageRowWithObserver = ({
<div ref={ref} className="min-h-px">
<ActivityItem
message={message}
teamName={teamName}
memberRole={memberRole}
memberColor={memberColor}
recipientColor={recipientColor}
@ -92,6 +96,7 @@ const MessageRowWithObserver = ({
export const ActivityTimeline = ({
messages,
teamName,
members,
readState,
onCreateTaskFromMessage,
@ -161,6 +166,7 @@ export const ActivityTimeline = ({
<MessageRowWithObserver
key={messageKey}
message={message}
teamName={teamName}
memberRole={info?.role}
memberColor={info?.color}
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 { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { AlertTriangle, Search } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
@ -73,6 +74,8 @@ export const CreateTaskDialog = ({
const [related, setRelated] = useState<string[]>([]);
const [startImmediately, setStartImmediately] = useState(true);
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
const [blockedBySearch, setBlockedBySearch] = useState('');
const [relatedSearch, setRelatedSearch] = useState('');
const [prevOpen, setPrevOpen] = useState(false);
if (open && !prevOpen) {
@ -85,6 +88,8 @@ export const CreateTaskDialog = ({
setRelated([]);
setStartImmediately(isTeamAlive);
promptDraft.clearDraft();
setBlockedBySearch('');
setRelatedSearch('');
}
if (open !== prevOpen) {
setPrevOpen(open);
@ -150,6 +155,16 @@ export const CreateTaskDialog = ({
</DialogDescription>
</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-2">
<Label htmlFor="task-subject">Subject</Label>
@ -265,39 +280,63 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<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">
{availableTasks.map((t) => {
const isSelected = blockedBy.includes(t.id);
return (
<button
key={t.id}
type="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 className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={blockedBySearch}
onChange={(e) => setBlockedBySearch(e.target.value)}
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"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!blockedBySearch ||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
t.id.includes(blockedBySearch)
)
.map((t) => {
const isSelected = blockedBy.includes(t.id);
return (
<button
key={t.id}
type="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>
{blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300">
@ -310,39 +349,63 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<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">
{availableTasks.map((t) => {
const isSelected = related.includes(t.id);
return (
<button
key={`related:${t.id}`}
type="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 className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={relatedSearch}
onChange={(e) => setRelatedSearch(e.target.value)}
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"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!relatedSearch ||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
t.id.includes(relatedSearch)
)
.map((t) => {
const isSelected = related.includes(t.id);
return (
<button
key={`related:${t.id}`}
type="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>
{related.length > 0 ? (
<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 { normalizePath } from '@renderer/utils/pathNormalize';
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 = [
'blue',
@ -173,6 +173,7 @@ function validateRequest(
options?: { requireCwd?: boolean }
): ValidationResult {
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) {
return {
valid: false,
@ -261,6 +262,7 @@ export const CreateTeamDialog = ({
const [isSubmitting, setIsSubmitting] = useState(false);
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const resetUIState = (): void => {
setLocalError(null);
@ -281,6 +283,7 @@ export const CreateTeamDialog = ({
setSelectedProjectPath('');
setCustomCwd('');
setLaunchTeam(true);
setSelectedModel('');
resetUIState();
};
@ -460,6 +463,9 @@ export const CreateTeamDialog = ({
[members]
);
const effectiveModel =
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
const request = useMemo<TeamCreateRequest>(
() => ({
teamName: teamName.trim(),
@ -468,8 +474,9 @@ export const CreateTeamDialog = ({
members: buildMembers(members),
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
model: effectiveModel,
}),
[teamName, description, teamColor, members, effectiveCwd, prompt]
[teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
);
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>
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
<DialogDescription className="text-xs">
@ -582,17 +589,31 @@ export const CreateTeamDialog = ({
</DialogHeader>
{canCreate && launchTeam && prepareState === 'failed' ? (
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
{prepareWarnings.length > 0 ? (
<div className="mt-1 space-y-1">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-red-300">
CLI environment is not available launch is blocked
</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>
) : null}
</div>
</div>
) : null}
@ -796,6 +817,23 @@ export const CreateTeamDialog = ({
</div>
) : 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 ? (
<div className="space-y-1.5 md:col-span-2">
<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 { Label } from '@renderer/components/ui/label';
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 { cn } from '@renderer/lib/utils';
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 {
@ -91,6 +98,7 @@ export const LaunchTeamDialog = ({
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedModel, setSelectedModel] = useState('');
const resetFormState = (): void => {
setLocalError(null);
@ -101,6 +109,7 @@ export const LaunchTeamDialog = ({
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
setSelectedModel('');
};
// Warm up CLI on open
@ -231,6 +240,7 @@ export const LaunchTeamDialog = ({
teamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined,
});
resetFormState();
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>
<DialogTitle className="text-sm">Launch Team</DialogTitle>
<DialogDescription className="text-xs">
@ -262,17 +272,31 @@ export const LaunchTeamDialog = ({
</DialogHeader>
{prepareState === 'failed' ? (
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
{prepareWarnings.length > 0 ? (
<div className="mt-1 space-y-1">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-red-300">
CLI environment is not available launch is blocked
</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>
) : null}
</div>
</div>
) : null}
@ -398,6 +422,21 @@ export const LaunchTeamDialog = ({
}
/>
</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>
{activeError ? (

View file

@ -118,19 +118,24 @@ export const TaskCommentsSection = ({
: formatDistanceToNow(date, { addSuffix: true });
})()}
</span>
<button
type="button"
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"
onClick={() =>
setReplyTo({
author: comment.author,
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
})
}
>
<Reply size={11} />
Reply
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
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"
onClick={() =>
setReplyTo({
author: comment.author,
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
})
}
>
<Reply size={11} />
Reply
</button>
</TooltipTrigger>
<TooltipContent side="left">Reply to comment</TooltipContent>
</Tooltip>
</div>
{(() => {
const reply = parseMessageReply(comment.text);

View file

@ -2,6 +2,7 @@ import { useEffect } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
@ -13,16 +14,23 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { markAsRead } from '@renderer/services/commentReadStorage';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import {
agentAvatarUrl,
KANBAN_COLUMN_DISPLAY,
TASK_STATUS_LABELS,
TASK_STATUS_STYLES,
} from '@renderer/utils/memberHelpers';
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';
@ -37,6 +45,7 @@ interface TaskDetailDialogProps {
members: ResolvedTeamMember[];
onClose: () => void;
onScrollToTask?: (taskId: string) => void;
onOwnerChange?: (taskId: string, owner: string | null) => void;
}
export const TaskDetailDialog = ({
@ -48,6 +57,7 @@ export const TaskDetailDialog = ({
members,
onClose,
onScrollToTask,
onOwnerChange,
}: TaskDetailDialogProps): React.JSX.Element => {
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
@ -101,10 +111,12 @@ export const TaskDetailDialog = ({
)
.map((t) => t.id);
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
const isTodo = status === 'pending' && !kanbanColumn;
const canReassign = isTodo && onOwnerChange;
return (
<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>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
@ -125,31 +137,46 @@ export const TaskDetailDialog = ({
{/* Metadata */}
<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">
{ownerMember ? (
<div
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1"
style={{
borderLeft: `3px solid ${getTeamColorSet(ownerMember.color ?? '').border}`,
backgroundColor: getTeamColorSet(ownerMember.color ?? '').badge,
{canReassign ? (
<Select
value={currentTask.owner ?? '__unassigned__'}
onValueChange={(v) => {
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
}}
>
<img
src={agentAvatarUrl(ownerMember.name, 32)}
alt={ownerMember.name}
className="size-6 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span className="min-w-0 truncate font-medium text-[var(--color-text)]">
{ownerMember.name}
</span>
</div>
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const memberColor = m.color ? getTeamColorSet(m.color) : null;
return (
<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)]">
<User size={12} />
<span className="text-[var(--color-text-secondary)]">
{currentTask.owner ?? '\u2014'}
</span>
</div>
<span className="text-xs text-[var(--color-text-muted)]">&mdash;</span>
)}
</div>
{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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { cn } from '@renderer/lib/utils';
@ -18,6 +22,7 @@ import { KanbanFilterPopover } from './KanbanFilterPopover';
import { KanbanTaskCard } from './KanbanTaskCard';
import type { KanbanFilterState } from './KanbanFilterPopover';
import type { DragEndEvent } from '@dnd-kit/core';
import type { Session } from '@renderer/types/data';
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
@ -69,6 +74,10 @@ interface KanbanBoardProps {
onCompleteTask: (taskId: string) => void;
onScrollToTask?: (taskId: string) => void;
onTaskClick?: (task: TeamTask) => void;
/** Вызывается после изменения порядка задач в колонке (drag-and-drop). */
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
toolbarLeft?: React.ReactNode;
}
type KanbanViewMode = 'grid' | 'columns';
@ -99,6 +108,97 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId
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 = ({
tasks,
teamName,
@ -116,6 +216,8 @@ export const KanbanBoard = ({
onCompleteTask,
onScrollToTask,
onTaskClick,
onColumnOrderChange,
toolbarLeft,
}: KanbanBoardProps): React.JSX.Element => {
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
@ -134,6 +236,45 @@ export const KanbanBoard = ({
return result;
}, [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 => {
if (columnTasks.length === 0) {
return (
@ -142,6 +283,32 @@ export const KanbanBoard = ({
</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 (
<>
{columnTasks.map((task) => (
@ -153,6 +320,7 @@ export const KanbanBoard = ({
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
taskMap={taskMap}
members={members}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
@ -167,62 +335,65 @@ export const KanbanBoard = ({
);
};
return (
<div>
<div className="mb-2 flex items-center justify-end gap-2">
<KanbanFilterPopover
filter={filter}
sessions={sessions}
leadSessionId={leadSessionId}
members={members}
onFilterChange={onFilterChange}
/>
<div className="inline-flex rounded-md border border-[var(--color-border)]">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-r-none px-2',
viewMode === 'grid'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
viewMode === 'columns'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
const boardContent = (
<>
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
{toolbarLeft != null && <div className="min-w-0 flex-1">{toolbarLeft}</div>}
<div className="flex shrink-0 items-center gap-2">
<KanbanFilterPopover
filter={filter}
sessions={sessions}
leadSessionId={leadSessionId}
members={members}
onFilterChange={onFilterChange}
/>
<div className="inline-flex rounded-md border border-[var(--color-border)]">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-r-none px-2',
viewMode === 'grid'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
>
<LayoutGrid size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid view</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
viewMode === 'columns'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('columns')}
aria-label="Columns view"
>
<Columns3 size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Columns view</TooltipContent>
</Tooltip>
</div>
</div>
</div>
{viewMode === 'grid' ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? [];
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return (
<KanbanColumn
@ -241,7 +412,7 @@ export const KanbanBoard = ({
) : (
<div className="flex gap-3 overflow-x-auto pb-2">
{COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? [];
const columnTasks = groupedOrdered.get(column.id) ?? [];
const accent = COLUMN_ACCENTS[column.id];
return (
<div key={column.id} className="w-64 shrink-0">
@ -259,6 +430,16 @@ export const KanbanBoard = ({
})}
</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 { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
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 {
task: TeamTask;
@ -13,6 +14,7 @@ interface KanbanTaskCardProps {
kanbanTaskState?: KanbanTaskState;
hasReviewers: boolean;
taskMap: Map<string, TeamTask>;
members: ResolvedTeamMember[];
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -63,6 +65,7 @@ export const KanbanTaskCard = ({
kanbanTaskState: _kanbanTaskState,
hasReviewers,
taskMap,
members,
onRequestReview,
onApprove,
onRequestChanges,
@ -96,23 +99,21 @@ export const KanbanTaskCard = ({
}
}}
>
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<div className="mb-1 flex items-center gap-1">
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
<UnreadCommentsBadge
unreadCount={unreadCount}
totalCount={task.comments?.length ?? 0}
/>
</div>
<h5 className="text-sm font-medium text-[var(--color-text)]">{task.subject}</h5>
</div>
<div className="mb-2 flex items-center gap-1">
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
{task.owner ? (
<MemberBadge
name={task.owner}
color={members.find((m) => m.name === task.owner)?.color}
/>
) : null}
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
{task.subject}
</h5>
</div>
<p className="mb-2 text-xs text-[var(--color-text-muted)]">Owner: {task.owner ?? '\u2014'}</p>
{hasBlockedBy ? (
<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">
@ -147,112 +148,118 @@ export const KanbanTaskCard = ({
</div>
) : null}
{columnId === 'todo' ? (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Start task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onStartTask(task.id);
}}
>
<Play size={12} />
Start
</Button>
<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>
</div>
) : null}
{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>
<div className="flex items-center gap-2">
<div className="flex flex-1 flex-wrap gap-2">
{columnId === 'todo' ? (
<>
<Button
variant="outline"
size="sm"
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
aria-label={`Start task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onStartTask(task.id);
}}
>
<Play size={12} />
Start
</Button>
<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}
<div className="flex gap-2">
{columnId === 'in_progress' ? (
<Button
variant="outline"
size="sm"
aria-label={`Approve task ${task.id}`}
className="gap-1"
aria-label={`Complete task ${task.id}`}
onClick={(e) => {
e.stopPropagation();
onApprove(task.id);
onCompleteTask(task.id);
}}
>
Approve
<CheckCircle2 size={12} />
Complete
</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}
) : 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}
{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}
<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>
);
};

View file

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

View file

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

View file

@ -1,28 +1,49 @@
import { formatDistanceToNow } from 'date-fns';
export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs';
interface MemberDetailStatsProps {
totalTasks: number;
inProgressTasks: number;
completedTasks: number;
messageCount: number;
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 = ({
label,
value,
sub,
onClick,
}: {
label: string;
value: string | number;
sub?: string;
}): React.JSX.Element => (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
<p className="text-lg font-semibold text-[var(--color-text)]">{value}</p>
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p>
{sub && <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">{sub}</p>}
</div>
);
onClick?: () => void;
}): React.JSX.Element => {
const classes = onClick ? `${baseClasses} ${clickableClasses}` : baseClasses;
const content = (
<>
<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 = ({
totalTasks,
@ -30,21 +51,35 @@ export const MemberDetailStats = ({
completedTasks,
messageCount,
lastActiveAt,
onTabChange,
}: MemberDetailStatsProps): React.JSX.Element => {
const lastActive = lastActiveAt
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
: '—';
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
label="Tasks"
value={totalTasks}
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>
);
};

View file

@ -4,10 +4,12 @@ import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
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 { 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 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 (
<div className="flex min-w-0 justify-end">
<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">
<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 className="py-1 text-[10px] text-[var(--color-text-muted)]">
{format(group.timestamp, 'h:mm:ss a')} (empty)
</div>
);
}
return (
<div className="flex min-w-0 justify-end">
<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-right text-[10px] text-[var(--color-text-muted)]">
{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 className="min-w-0 space-y-1 overflow-hidden py-1">
<div className="text-[10px] text-[var(--color-text-muted)]">
{format(group.timestamp, 'h:mm:ss a')}
</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>
);
};
@ -149,23 +186,28 @@ const AIExecutionGroup = ({
return (
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
{hasToggleContent ? (
<button
type="button"
className="flex w-full items-center gap-2 text-left"
onClick={onToggleExpanded}
aria-expanded={expanded}
>
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
Claude
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
{enhanced.itemsSummary}
</span>
<ChevronDown
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-2 text-left"
onClick={onToggleExpanded}
aria-expanded={expanded}
>
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
Claude
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
{enhanced.itemsSummary}
</span>
<ChevronDown
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}
{hasToggleContent && expanded ? (

View file

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

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { formatDuration } from '@renderer/utils/formatters';
import {
AlertCircle,
@ -207,35 +208,40 @@ const LogCard = ({
return (
<div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
<button
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)]"
onClick={onToggle}
>
{expanded ? (
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
) : (
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
)}
<div className="min-w-0 flex-1 overflow-hidden">
<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>
<Tooltip>
<TooltipTrigger asChild>
<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)]"
onClick={onToggle}
>
{expanded ? (
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
) : (
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
)}
</div>
</div>
</button>
<div className="min-w-0 flex-1 overflow-hidden">
<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>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{expanded ? 'Hide details' : 'Show details'}</TooltipContent>
</Tooltip>
{expanded && (
<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 {
messages: InboxMessage[];
teamName: string;
onCreateTask?: (subject: string, description: string) => void;
}
@ -11,6 +12,7 @@ const MAX_MESSAGES = 100;
export const MemberMessagesTab = ({
messages,
teamName,
onCreateTask,
}: MemberMessagesTabProps): React.JSX.Element => {
const displayMessages = messages.slice(0, MAX_MESSAGES);
@ -26,7 +28,12 @@ export const MemberMessagesTab = ({
return (
<div className="max-h-[320px] space-y-2 overflow-y-auto">
{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>
);

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

View file

@ -35,6 +35,7 @@ const DialogContent = React.forwardRef<
ref={ref}
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',
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
className
)}
{...props}

View file

@ -18,7 +18,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
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
)}
{...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
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
const navRequest = createErrorNavigationRequest(
{

View file

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

View file

@ -3,12 +3,16 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import { createLogger } from '@shared/utils/logger';
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
const logger = createLogger('teamSlice');
import type { AppState } from '../types';
import type {
AddMemberRequest,
CreateTaskRequest,
GlobalTask,
KanbanColumnId,
SendMessageRequest,
SendMessageResult,
TaskComment,
@ -71,12 +75,20 @@ export interface TeamSlice {
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
requestReview: (teamName: string, taskId: string) => 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>;
startTask: (teamName: string, taskId: string) => Promise<void>;
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
addingComment: boolean;
addCommentError: string | null;
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>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
@ -180,14 +192,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
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 existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (existing) {
state.setActiveTab(existing.id);
// Sync label in case display name changed
if (existing.label !== displayName) {
state.updateTabLabel(existing.id, displayName);
}
} else {
state.openTab({
type: 'team',
label: teamName,
label: displayName,
teamName,
});
}
@ -222,6 +242,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
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.
// Must search both flat projects and grouped repositoryGroups/worktrees
// 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 (state.selectedWorktreeId !== matchingWorktree.id) {
state.selectRepository(repo.id);
state.selectWorktree(matchingWorktree.id);
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
void get().fetchSessionsInitial(matchingWorktree.id);
}
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) => {
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
try {
@ -382,6 +421,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
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) => {
set({ addingComment: true, addCommentError: null });
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) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
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).
* 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';
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 {

View file

@ -14,8 +14,11 @@ import type {
TriggerTestResult,
} from './notifications';
import type {
AddMemberRequest,
AttachmentFileData,
CreateTaskRequest,
GlobalTask,
KanbanColumnId,
MemberFullStats,
MemberLogSummary,
SendMessageRequest,
@ -354,7 +357,13 @@ export interface TeamsAPI {
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
requestReview: (teamName: string, taskId: string) => 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>;
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
startTask: (teamName: string, taskId: string) => Promise<void>;
processSend: (teamName: string, message: string) => Promise<void>;
processAlive: (teamName: string) => Promise<boolean>;
@ -371,7 +380,11 @@ export interface TeamsAPI {
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
getAllTasks: () => Promise<GlobalTask[]>;
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>;
getProjectBranch: (projectPath: string) => Promise<string | null>;
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void

View file

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

View file

@ -5,6 +5,8 @@ export interface TeamMember {
role?: string;
color?: string;
joinedAt?: number;
cwd?: string;
removedAt?: number;
}
export interface TeamConfig {
@ -80,6 +82,25 @@ export interface TeamTaskWithKanban extends TeamTask {
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 {
from: string;
to?: string;
@ -89,7 +110,8 @@ export interface InboxMessage {
summary?: string;
color?: string;
messageId?: string;
source?: 'inbox' | 'lead_session' | 'lead_process';
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
attachments?: AttachmentMeta[];
}
export interface SendMessageRequest {
@ -97,10 +119,12 @@ export interface SendMessageRequest {
text: string;
summary?: string;
from?: string;
attachments?: AttachmentPayload[];
}
export interface SendMessageResult {
deliveredToInbox: boolean;
deliveredViaStdin?: boolean;
messageId: string;
}
@ -119,6 +143,8 @@ export interface KanbanState {
teamName: string;
reviewers: string[];
tasks: Record<string, KanbanTaskState>;
/** Порядок id задач по колонкам для отображения на канбан-доске (drag-and-drop). */
columnOrder?: Partial<Record<KanbanColumnId, string[]>>;
}
export type UpdateKanbanPatch =
@ -136,6 +162,10 @@ export interface ResolvedTeamMember {
color?: string;
agentType?: 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 {
@ -153,6 +183,7 @@ export interface TeamLaunchRequest {
teamName: string;
cwd: string;
prompt?: string;
model?: string;
}
export interface TeamLaunchResponse {
@ -199,6 +230,7 @@ export interface TeamCreateRequest {
members: TeamProvisioningMemberInput[];
cwd: string;
prompt?: string;
model?: string;
}
export interface TeamCreateConfigRequest {
@ -290,3 +322,12 @@ export interface MemberFullStats {
sessionCount: number;
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_REQUEST_REVIEW: 'team:requestReview',
TEAM_UPDATE_KANBAN: 'team:updateKanban',
TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder',
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner',
TEAM_PROCESS_SEND: 'team:processSend',
TEAM_PROCESS_ALIVE: 'team:processAlive',
TEAM_ALIVE_LIST: 'team:aliveList',
@ -28,6 +30,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_START_TASK: 'team:startTask',
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
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 {
@ -54,8 +60,13 @@ import {
TEAM_START_TASK,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
TEAM_UPDATE_TASK_STATUS,
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_GET_ATTACHMENTS,
TEAM_GET_PROJECT_BRANCH,
TEAM_REMOVE_MEMBER,
} from '../../../src/preload/constants/ipcChannels';
import {
initializeTeamHandlers,
@ -82,6 +93,7 @@ describe('ipc teams handlers', () => {
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
requestReview: vi.fn(async () => undefined),
updateKanban: vi.fn(async () => undefined),
updateKanbanColumnOrder: vi.fn(async () => undefined),
updateTaskStatus: vi.fn(async () => undefined),
startTask: vi.fn(async () => undefined),
addTaskComment: vi.fn(async () => ({
@ -90,6 +102,8 @@ describe('ipc teams handlers', () => {
text: 'test comment',
createdAt: new Date().toISOString(),
})),
addMember: vi.fn(async () => undefined),
removeMember: vi.fn(async () => undefined),
};
const provisioningService = {
prepareForProvisioning: vi.fn(async () => ({
@ -135,6 +149,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
expect(handlers.has(TEAM_REQUEST_REVIEW)).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_START_TASK)).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_GET_ALL_TASKS)).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 () => {
@ -212,6 +229,7 @@ describe('ipc teams handlers', () => {
it('dedups live lead replies when lead_session already has same text', async () => {
service.getTeamData.mockResolvedValueOnce({
teamName: 'my-team',
config: { name: 'My Team' },
messages: [
{
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', () => {
it('accepts valid prompt in team create request', async () => {
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_REQUEST_REVIEW)).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_START_TASK)).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_GET_ALL_TASKS)).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);
});
});