From 6a31d440a4edca179da556f25fe9d17c045ee76c Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 24 Feb 2026 14:05:50 +0200 Subject: [PATCH] feat: add/remove member, mesage box, improve ui... --- README.md | 87 +--- src/main/http/utility.ts | 7 +- src/main/ipc/teams.ts | 382 +++++++++++++++++- .../services/infrastructure/ConfigManager.ts | 2 + .../services/infrastructure/FileWatcher.ts | 2 +- .../services/parsing/AgentConfigReader.ts | 9 +- src/main/services/team/TeamAttachmentStore.ts | 86 ++++ src/main/services/team/TeamConfigReader.ts | 11 +- src/main/services/team/TeamDataService.ts | 164 +++++++- src/main/services/team/TeamKanbanManager.ts | 64 ++- src/main/services/team/TeamMemberResolver.ts | 11 +- .../services/team/TeamMembersMetaStore.ts | 1 + .../services/team/TeamProvisioningService.ts | 99 ++++- .../services/team/TeamSentMessagesStore.ts | 75 ++++ src/main/services/team/TeamTaskReader.ts | 1 + src/main/services/team/TeamTaskWriter.ts | 24 ++ src/main/services/team/index.ts | 2 + src/preload/constants/ipcChannels.ts | 18 + src/preload/index.ts | 36 ++ src/renderer/api/httpClient.ts | 31 ++ .../chat/viewers/MarkdownViewer.tsx | 17 +- .../components/dashboard/DashboardView.tsx | 54 ++- src/renderer/components/layout/Sidebar.tsx | 70 +++- .../components/settings/SettingsView.tsx | 1 + .../settings/hooks/useSettingsConfig.ts | 2 + .../settings/hooks/useSettingsHandlers.ts | 10 + .../settings/sections/GeneralSection.tsx | 55 +++ .../components/sidebar/GlobalTaskList.tsx | 42 +- .../team/CollapsibleTeamSection.tsx | 14 +- src/renderer/components/team/MemberBadge.tsx | 74 ++++ .../team/ProvisioningProgressBlock.tsx | 117 +++++- .../components/team/TeamDetailView.tsx | 358 ++++++++++++---- src/renderer/components/team/TeamListView.tsx | 78 +++- .../team/TeamProvisioningBanner.tsx | 3 + .../components/team/activity/ActivityItem.tsx | 203 ++++------ .../team/activity/ActivityTimeline.tsx | 6 + .../team/attachments/AttachmentDisplay.tsx | 98 +++++ .../attachments/AttachmentPreviewItem.tsx | 40 ++ .../attachments/AttachmentPreviewList.tsx | 37 ++ .../team/attachments/AttachmentThumbnail.tsx | 42 ++ .../team/attachments/DropZoneOverlay.tsx | 18 + .../team/attachments/ImageLightbox.tsx | 58 +++ .../team/dialogs/AddMemberDialog.tsx | 153 +++++++ .../team/dialogs/CreateTaskDialog.tsx | 195 ++++++--- .../team/dialogs/CreateTeamDialog.tsx | 64 ++- .../team/dialogs/LaunchTeamDialog.tsx | 63 ++- .../team/dialogs/TaskCommentsSection.tsx | 31 +- .../team/dialogs/TaskDetailDialog.tsx | 77 ++-- .../components/team/kanban/KanbanBoard.tsx | 287 ++++++++++--- .../components/team/kanban/KanbanTaskCard.tsx | 231 ++++++----- .../components/team/members/MemberCard.tsx | 122 +++--- .../team/members/MemberDetailDialog.tsx | 86 ++-- .../team/members/MemberDetailStats.tsx | 57 ++- .../team/members/MemberExecutionLog.tsx | 110 +++-- .../components/team/members/MemberList.tsx | 62 ++- .../components/team/members/MemberLogsTab.tsx | 62 +-- .../team/members/MemberMessagesTab.tsx | 9 +- .../team/messages/MessageComposer.tsx | 328 +++++++++++++++ .../team/messages/MessagesFilterPopover.tsx | 37 +- src/renderer/components/ui/dialog.tsx | 1 + src/renderer/components/ui/popover.tsx | 2 +- src/renderer/hooks/useAttachments.ts | 135 +++++++ .../store/slices/notificationSlice.ts | 7 + src/renderer/store/slices/tabSlice.ts | 6 +- src/renderer/store/slices/teamSlice.ts | 62 ++- src/renderer/store/utils/stateResetHelpers.ts | 16 + src/renderer/utils/attachmentUtils.ts | 52 +++ src/renderer/utils/pathNormalize.ts | 4 +- src/shared/types/api.ts | 13 + src/shared/types/notifications.ts | 2 + src/shared/types/team.ts | 43 +- src/shared/utils/agentLanguage.ts | 122 ++++++ src/shared/utils/rateLimitDetector.ts | 12 + test/main/ipc/teams.test.ts | 81 ++++ 74 files changed, 4174 insertions(+), 867 deletions(-) create mode 100644 src/main/services/team/TeamAttachmentStore.ts create mode 100644 src/main/services/team/TeamSentMessagesStore.ts create mode 100644 src/renderer/components/team/MemberBadge.tsx create mode 100644 src/renderer/components/team/attachments/AttachmentDisplay.tsx create mode 100644 src/renderer/components/team/attachments/AttachmentPreviewItem.tsx create mode 100644 src/renderer/components/team/attachments/AttachmentPreviewList.tsx create mode 100644 src/renderer/components/team/attachments/AttachmentThumbnail.tsx create mode 100644 src/renderer/components/team/attachments/DropZoneOverlay.tsx create mode 100644 src/renderer/components/team/attachments/ImageLightbox.tsx create mode 100644 src/renderer/components/team/dialogs/AddMemberDialog.tsx create mode 100644 src/renderer/components/team/messages/MessageComposer.tsx create mode 100644 src/renderer/hooks/useAttachments.ts create mode 100644 src/renderer/utils/attachmentUtils.ts create mode 100644 src/shared/utils/agentLanguage.ts create mode 100644 src/shared/utils/rateLimitDetector.ts diff --git a/README.md b/README.md index 0c7e06dd..1aecd604 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Latest Release  CI Status  Downloads  - Platform + Platform


@@ -26,9 +26,6 @@    Download for Windows -    - - Deploy with Docker

@@ -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
@@ -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). diff --git a/src/main/http/utility.ts b/src/main/http/utility.ts index ae86bb90..efd6228a 100644 --- a/src/main/http/utility.ts +++ b/src/main/http/utility.ts @@ -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'; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 7e693e5b..2da44c76 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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(); +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( } } +async function handleGetProjectBranch( + _event: IpcMainInvokeEvent, + projectPath: unknown +): Promise> { + 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> { 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> { + 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; + 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> { + 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> { + 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 getTeamDataService().getAllTasks()); } +async function handleAddMember( + _event: IpcMainInvokeEvent, + teamName: unknown, + payload: unknown +): Promise> { + 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> { + 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, diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index b8999536..f2502243 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -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, diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index ba7555b0..fb86201a 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -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, diff --git a/src/main/services/parsing/AgentConfigReader.ts b/src/main/services/parsing/AgentConfigReader.ts index a6e23ea5..a507763e 100644 --- a/src/main/services/parsing/AgentConfigReader.ts +++ b/src/main/services/parsing/AgentConfigReader.ts @@ -28,7 +28,10 @@ function parseFrontmatter(content: string): Record { 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 { * 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> { +export async function readAgentConfigs(projectRoot: string): Promise> { const agentsDir = path.join(projectRoot, '.claude', 'agents'); const result: Record = {}; diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts new file mode 100644 index 00000000..c402c895 --- /dev/null +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -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 { + 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 { + 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; + 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; + } +} diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 2c4ec347..0e80f48e 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -61,10 +61,15 @@ export class TeamConfigReader { } } + const removedNames = new Set(); 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({ diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 76a02e5d..b2fa167e 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 { @@ -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 { + // 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 { + 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 { + 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 { 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 { + await this.taskWriter.updateOwner(teamName, taskId, owner); + } + async addTaskComment(teamName: string, taskId: string, text: string): Promise { 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 { + 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 { + 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 { 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 { + await this.kanbanManager.updateColumnOrder(teamName, columnId, orderedTaskIds); + } } diff --git a/src/main/services/team/TeamKanbanManager.ts b/src/main/services/team/TeamKanbanManager.ts index 89c8e794..186915c3 100644 --- a/src/main/services/team/TeamKanbanManager.ts +++ b/src/main/services/team/TeamKanbanManager.ts @@ -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 = {}; + for (const colId of KANBAN_COLUMN_IDS) { + const arr = (raw as Record)[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 { 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 { + 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 { const state = await this.getState(teamName); @@ -106,12 +141,32 @@ export class TeamKanbanManager { } } + let columnOrderChanged = false; + if (state.columnOrder) { + const cleaned: NonNullable = {}; + 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)); } diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 46821f7f..a44ff571 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -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(); + 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, }); } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 89ff6de8..feee8bbd 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -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, }; } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6932a43e..ccc1e135 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 user→lead 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 { + async sendMessageToTeam( + teamName: string, + message: string, + attachments?: { data: string; mimeType: string }[] + ): Promise { 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[] = [{ 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); diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts new file mode 100644 index 00000000..893c6f93 --- /dev/null +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -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 { + 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; + 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 { + 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)); + } +} diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 0b4de97e..cec92301 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -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'] ) diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index c5b7f5d3..29ab89bd 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -118,6 +118,30 @@ export class TeamTaskWriter { }); } + async updateOwner(teamName: string, taskId: string, owner: string | null): Promise { + 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, diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index b3f642e0..604dcf6c 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -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'; diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 1a71dd20..ff33600b 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 99b3533f..40a88b30 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_UPDATE_KANBAN, teamName, taskId, patch); }, + updateKanbanColumnOrder: async ( + teamName: string, + columnId: KanbanColumnId, + orderedTaskIds: string[] + ) => { + return invokeIpcWithResult( + TEAM_UPDATE_KANBAN_COLUMN_ORDER, + teamName, + columnId, + orderedTaskIds + ); + }, updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => { return invokeIpcWithResult(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status); }, + updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => { + return invokeIpcWithResult(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner); + }, startTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_START_TASK, teamName, taskId); }, @@ -601,6 +625,18 @@ const electronAPI: ElectronAPI = { addTaskComment: async (teamName: string, taskId: string, text: string) => { return invokeIpcWithResult(TEAM_ADD_TASK_COMMENT, teamName, taskId, text); }, + addMember: async (teamName: string, request: AddMemberRequest) => { + return invokeIpcWithResult(TEAM_ADD_MEMBER, teamName, request); + }, + removeMember: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_REMOVE_MEMBER, teamName, memberName); + }, + getProjectBranch: async (projectPath: string) => { + return invokeIpcWithResult(TEAM_GET_PROJECT_BRANCH, projectPath); + }, + getAttachments: async (teamName: string, messageId: string) => { + return invokeIpcWithResult(TEAM_GET_ATTACHMENTS, teamName, messageId); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { ipcRenderer.on( TEAM_CHANGE, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 99048216..69f324a9 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => { throw new Error('Team kanban is not available in browser mode'); }, + updateKanbanColumnOrder: async ( + _teamName: string, + _columnId: KanbanColumnId, + _orderedTaskIds: string[] + ): Promise => { + 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 => { throw new Error('Team task status update is not available in browser mode'); }, + updateTaskOwner: async ( + _teamName: string, + _taskId: string, + _owner: string | null + ): Promise => { + throw new Error('Team task owner update is not available in browser mode'); + }, startTask: async (_teamName: string, _taskId: string): Promise => { 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 => { + throw new Error('Team member management is not available in browser mode'); + }, + removeMember: async (): Promise => { + throw new Error('Team member management is not available in browser mode'); + }, + getProjectBranch: async (_projectPath: string): Promise => { + return null; + }, + getAttachments: async ( + _teamName: string, + _messageId: string + ): Promise => { + return []; + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { return this.addEventListener('team-change', (data: unknown) => callback(null, data as TeamChangeEvent) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index f14d44a9..3f1abe23 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 = ({ 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 = ({ return (
{/* Copy button overlay (when no label header) */} {copyable && !label && } diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 3e57503b..56e8a1fc 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -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 => { 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); diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index 79a9afe3..1be73839 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -115,49 +115,79 @@ export const Sidebar = (): React.JSX.Element => { > - {/* Tab bar: Tasks | Sessions */} + {/* Tab bar: Tasks | Sessions — tab strip style, filters on the right */}
-
+
+
- {sidebarTab === 'tasks' && ( - ({ teamName: t.teamName, displayName: t.displayName }))} - filters={taskFilters} - onFiltersChange={setTaskFilters} - onApply={() => {}} - /> - )} +
+ {sidebarTab === 'tasks' && ( + ({ teamName: t.teamName, displayName: t.displayName }))} + filters={taskFilters} + onFiltersChange={setTaskFilters} + onApply={() => {}} + /> + )} +
{/* Content: Tasks list or Sessions list */} -
+
{sidebarTab === 'tasks' ? ( { saving={saving} onGeneralToggle={handlers.handleGeneralToggle} onThemeChange={handlers.handleThemeChange} + onLanguageChange={handlers.handleLanguageChange} /> )} diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 6d3f9c6a..6e0bd225 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -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, diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 5d6941b0..ebe7a960 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -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, diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx index cd28a176..ad872a1d 100644 --- a/src/renderer/components/settings/sections/GeneralSection.tsx +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -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({ 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 }, + isSelected: boolean + ) => ( + <> + + {option.label} + + ), + [] + ); + return (
+ + + + + {isElectron && ( <> diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 51512bfb..b5b86012 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -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 = ({ )}
- {/* Grouping mode */} -
+ {/* Grouping mode — compact segmented toggle */} +
Group by: - +
+ {(['project', 'time'] as const).map((mode) => ( + + ))} +
{/* Content */} diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 6ed2afef..e468328e 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -27,13 +27,15 @@ export const CollapsibleTeamSection = ({ const isOpen = forceOpen ? true : open; return ( -
-
+
+
- {action &&
{action}
} +
+ {action &&
{action}
}
{isOpen &&
{children}
}
diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx new file mode 100644 index 00000000..08dd2ada --- /dev/null +++ b/src/renderer/components/team/MemberBadge.tsx @@ -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 = ( + + ); + + const badge = ( + + {name} + + ); + + if (onClick) { + return ( + + ); + } + + return ( + + {avatar} + {badge} + + ); +}; diff --git a/src/renderer/components/team/ProvisioningProgressBlock.tsx b/src/renderer/components/team/ProvisioningProgressBlock.tsx index 91f656b5..632bfc9e 100644 --- a/src/renderer/components/team/ProvisioningProgressBlock.tsx +++ b/src/renderer/components/team/ProvisioningProgressBlock.tsx @@ -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(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 `${escapeHtml(line)}`; + } + return escapeHtml(line); + }) + .join('\n'); +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + 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(null); + const highlightedHtml = useMemo( + () => (cliLogsTail ? highlightLogsHtml(cliLogsTail) : ''), + [cliLogsTail] + ); + + useEffect(() => { + if (logsOpen && logsRef.current) { + logsRef.current.scrollTop = logsRef.current.scrollHeight; + } + }, [logsOpen, cliLogsTail]); + return (
) : null}

{title}

+ {elapsed !== null ? ( + + {elapsed} + + ) : null} + {pid !== undefined ? ( + PID {pid} + ) : null}
{onCancel ? (
+ {cliLogsTail ? ( +
+ + {logsOpen ? ( +
 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}
+        
+ ) : null}
); }; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 2bbae21a..b389b218 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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(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(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(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(); @@ -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' )} > -
+

{data.config.name}

- {data.config.description && ( -

- {data.config.description} -

- )}
- {!data.isAlive ? ( - - ) : null} - - + + + + + Edit team + + + + + + Delete team +
+ {(data.config.description || leadBranch) && ( +
+

+ {data.config.description || ''} +

+ {leadBranch ? ( + + + {leadBranch} + + ) : null} +
+ )}
+ {!data.isAlive ? ( +
+ Team is offline + +
+ ) : null} + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( @@ -528,7 +607,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
) : null} - + { + e.stopPropagation(); + setAddMemberDialogOpen(true); + }} + > + + Member + + } + > } > -
- - 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 && ( - - )} -
+ + 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 && ( + + + + + Clear search + + )} +
+ } 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} /> -
} > + { + 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; + }); + }); + }} + /> { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} /> void selectTeam(teamName)} /> + 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); + } + })(); + }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages will be + preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All team + data and tasks will be deleted. + + + + + + + + + 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); + }} /> ); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index aa48443b..066b0487 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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(null); const [searchQuery, setSearchQuery] = useState(''); const [aliveTeams, setAliveTeams] = useState([]); + const [branchByPath, setBranchByPath] = useState>(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(); + 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 => { + const next = new Map(); + 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 => { -

- {team.description || 'No description'} -

+
+

+ {team.description || 'No description'} +

+ {team.projectPath && + (() => { + const branch = branchByPath.get(normalizePath(team.projectPath)); + if (!branch) return null; + return ( + + + {branch} + + ); + })()} +
{team.members && team.members.length > 0 ? ( team.members.map((m) => { diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 8aff6cea..52f1a896 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -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 ? () => { diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 8a53450c..55d25e61 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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; 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 = ({
{/* Header — div with role=button (cannot use - ) : ( - - {message.from} - - )} + {/* Sender avatar + name badge */} + {/* Role */} {formattedRole ? ( @@ -289,60 +270,24 @@ export const ActivityItem = ({ ) : null} - {/* Recipient — badge like sender, clickable to open member popup */} - {message.to && message.to !== message.from && recipientColors ? ( - - - {onMemberNameClick ? ( - - ) : ( - - {message.to} - - )} - - ) : message.to && message.to !== message.from ? ( - - - {onMemberNameClick ? ( - - ) : ( - {message.to} - )} + {/* Rate limit warning badge */} + {rateLimited ? ( + + + Rate Limited ) : null} + {/* Recipient — arrow + avatar + badge */} + {message.to && message.to !== message.from ? ( + <> + + → + + + + ) : null} + {/* Summary */} {summaryText} @@ -351,32 +296,40 @@ export const ActivityItem = ({ {/* Timestamp + reply + create task */}
{onReply && ( - + + + + + Reply to message + )} {onCreateTask && ( - + + + + + Create task from message + )} {timestamp} @@ -404,8 +357,20 @@ export const ActivityItem = ({ ) : parsedReply ? ( ) : ( - + )} + {message.attachments?.length && message.messageId ? ( + + ) : null}
) : null}
diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index d90f5271..6571fa77 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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 = ({
{ + const [state, setState] = useState<{ + loaded: AttachmentFileData[]; + loading: boolean; + key: string; + }>({ loaded: [], loading: true, key: `${teamName}:${messageId}` }); + const [lightboxIndex, setLightboxIndex] = useState(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 ( +
+ + Loading attachments... +
+ ); + } + + // 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 ( + <> +
+ {items.map((item, i) => ( + setLightboxIndex(i)} + /> + ))} +
+ {lightboxIndex !== null && items[lightboxIndex] ? ( + setLightboxIndex(null)} + /> + ) : null} + + ); +}; diff --git a/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx new file mode 100644 index 00000000..0a97c7c2 --- /dev/null +++ b/src/renderer/components/team/attachments/AttachmentPreviewItem.tsx @@ -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 ( +
+ +
+ + {attachment.filename} + + + {formatFileSize(attachment.size)} + +
+ +
+ ); +}; diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx new file mode 100644 index 00000000..d2571ed1 --- /dev/null +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -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 ( +
+ {attachments.length > 0 ? ( +
+ {attachments.map((att) => ( + + ))} +
+ ) : null} + {error ? ( +
+ +

{error}

+
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/team/attachments/AttachmentThumbnail.tsx b/src/renderer/components/team/attachments/AttachmentThumbnail.tsx new file mode 100644 index 00000000..6a07b7c3 --- /dev/null +++ b/src/renderer/components/team/attachments/AttachmentThumbnail.tsx @@ -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 = { + sm: 'size-12', + md: 'size-20', + lg: 'size-32', +}; + +export const AttachmentThumbnail = ({ + src, + alt = 'attachment', + size = 'md', + onClick, +}: AttachmentThumbnailProps): React.JSX.Element => { + const img = ( + {alt} + ); + if (onClick) { + return ( + + ); + } + return img; +}; diff --git a/src/renderer/components/team/attachments/DropZoneOverlay.tsx b/src/renderer/components/team/attachments/DropZoneOverlay.tsx new file mode 100644 index 00000000..2096e5f8 --- /dev/null +++ b/src/renderer/components/team/attachments/DropZoneOverlay.tsx @@ -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 ( +
+
+ + Drop images here +
+
+ ); +}; diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx new file mode 100644 index 00000000..34169fe9 --- /dev/null +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx new file mode 100644 index 00000000..15a342aa --- /dev/null +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -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(NO_ROLE); + const [customRole, setCustomRole] = useState(''); + const [error, setError] = useState(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 ( + + + + Add Member + Add a new member to {teamName} + + +
+
+ + { + setName(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSubmit(); + }} + autoFocus + /> + {error &&

{error}

} +
+ +
+ + + {roleSelect === CUSTOM_ROLE && ( + setCustomRole(e.target.value)} + /> + )} +
+
+ + + + + +
+
+ ); +}; diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 11079162..26e84ff2 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -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([]); 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 = ({ + {!isTeamAlive ? ( +
+ +

+ Team is offline. The task will be added to TODO — launch the + team to start execution. +

+
+ ) : null} +
@@ -265,39 +280,63 @@ export const CreateTaskDialog = ({ {availableTasks.length > 0 ? (
-
- {availableTasks.map((t) => { - const isSelected = blockedBy.includes(t.id); - return ( - - ); - })} +
+ {availableTasks.length > 3 ? ( +
+ + 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" + /> +
+ ) : null} +
+ {availableTasks + .filter( + (t) => + !blockedBySearch || + t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) || + t.id.includes(blockedBySearch) + ) + .map((t) => { + const isSelected = blockedBy.includes(t.id); + return ( + + ); + })} +
{blockedBy.length > 0 ? (

@@ -310,39 +349,63 @@ export const CreateTaskDialog = ({ {availableTasks.length > 0 ? (

-
- {availableTasks.map((t) => { - const isSelected = related.includes(t.id); - return ( - - ); - })} +
+ {availableTasks.length > 3 ? ( +
+ + 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" + /> +
+ ) : null} +
+ {availableTasks + .filter( + (t) => + !relatedSearch || + t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) || + t.id.includes(relatedSearch) + ) + .map((t) => { + const isSelected = related.includes(t.id); + return ( + + ); + })} +
{related.length > 0 ? (

diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 37d02ab8..2cdaf47f 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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( () => ({ 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 = ({ } }} > - + {initialData ? 'Copy Team' : 'Create Team'} @@ -582,17 +589,31 @@ export const CreateTeamDialog = ({ {canCreate && launchTeam && prepareState === 'failed' ? ( -

-

{prepareMessage ?? 'Failed to prepare environment'}

- {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} +
+
+ +
+

+ CLI environment is not available — launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+ {prepareWarnings.length > 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +

+ Make sure claude CLI is installed and available + in PATH, then reopen this dialog. +

- ) : null} +
) : null} @@ -796,6 +817,23 @@ export const CreateTeamDialog = ({
) : null} + {launchTeam ? ( +
+ + +
+ ) : null} + {launchTeam ? (
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index b1f6936d..5f9d4198 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -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(null); const [prepareWarnings, setPrepareWarnings] = useState([]); 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 = ({ } }} > - + Launch Team @@ -262,17 +272,31 @@ export const LaunchTeamDialog = ({ {prepareState === 'failed' ? ( -
-

{prepareMessage ?? 'Failed to prepare environment'}

- {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} +
+
+ +
+

+ CLI environment is not available — launch is blocked +

+

+ {prepareMessage ?? 'Failed to prepare environment'} +

+ {prepareWarnings.length > 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +

+ Make sure claude CLI is installed and available + in PATH, then reopen this dialog. +

- ) : null} +
) : null} @@ -398,6 +422,21 @@ export const LaunchTeamDialog = ({ } />
+ +
+ + +
{activeError ? ( diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 2a9f3720..bf97cfa4 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -118,19 +118,24 @@ export const TaskCommentsSection = ({ : formatDistanceToNow(date, { addSuffix: true }); })()} - + + + + + Reply to comment +
{(() => { const reply = parseMessageReply(comment.text); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index a2a0d7b0..7bab3a80 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -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 ( !v && onClose()}> - +
@@ -125,31 +137,46 @@ export const TaskDetailDialog = ({ {/* Metadata */}
- {ownerMember ? ( -
{ + onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v); }} > - {ownerMember.name} - - {ownerMember.name} - -
+ + + + + Unassigned + {members.map((m) => { + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const memberColor = m.color ? getTeamColorSet(m.color) : null; + return ( + + + {memberColor ? ( + + ) : null} + + {m.name} + + {role ? ( + ({role}) + ) : null} + + + ); + })} + + + ) : currentTask.owner ? ( + ) : ( -
- - - {currentTask.owner ?? '\u2014'} - -
+ )}
{currentTask.createdBy ? ( diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 714e3e90..8da4c114 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -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(); + 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; + 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 +
+ 0} + taskMap={taskMap} + members={members} + onRequestReview={onRequestReview} + onApprove={onApprove} + onRequestChanges={onRequestChanges} + onMoveBackToDone={onMoveBackToDone} + onStartTask={onStartTask} + onCompleteTask={onCompleteTask} + onScrollToTask={onScrollToTask} + onTaskClick={onTaskClick} + /> +
+ ); +}; + 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('grid'); @@ -134,6 +236,45 @@ export const KanbanBoard = ({ return result; }, [tasks, kanbanState]); + const groupedOrdered = useMemo(() => { + const result = new Map(); + 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 = ({
); } + if (onColumnOrderChange) { + const itemIds = columnTasks.map((t) => t.id); + return ( + + {columnTasks.map((task) => ( + + ))} + + ); + } 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 ( -
-
- -
- - - - - Grid view - - - - - - Columns view - + const boardContent = ( + <> +
+ {toolbarLeft != null &&
{toolbarLeft}
} +
+ +
+ + + + + Grid view + + + + + + Columns view + +
{viewMode === 'grid' ? (
{COLUMNS.map((column) => { - const columnTasks = grouped.get(column.id) ?? []; + const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; return ( {COLUMNS.map((column) => { - const columnTasks = grouped.get(column.id) ?? []; + const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; return (
@@ -259,6 +430,16 @@ export const KanbanBoard = ({ })}
)} -
+ ); + + if (onColumnOrderChange) { + return ( + + {boardContent} + + ); + } + + return boardContent; }; diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 723f5366..6df814f8 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -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; + 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 = ({ } }} > -
-
-
- - #{task.id} - - -
-
{task.subject}
-
+
+ + #{task.id} + + {task.owner ? ( + m.name === task.owner)?.color} + /> + ) : null} +
+ {task.subject} +
-

Owner: {task.owner ?? '\u2014'}

- {hasBlockedBy ? (
@@ -147,112 +148,118 @@ export const KanbanTaskCard = ({
) : null} - {columnId === 'todo' ? ( -
- - -
- ) : null} - - {columnId === 'in_progress' ? ( - - ) : null} - - {columnId === 'done' ? ( - - ) : null} - - {columnId === 'review' ? ( -
- {!hasReviewers ? ( -

Manual review

+
+
+ {columnId === 'todo' ? ( + <> + + + ) : null} -
+ + {columnId === 'in_progress' ? ( - -
-
- ) : null} + ) : null} - {columnId === 'approved' ? ( - - ) : null} + {columnId === 'done' ? ( + + ) : null} + + {columnId === 'review' ? ( +
+ {!hasReviewers ? ( +

Manual review

+ ) : null} +
+ + +
+
+ ) : null} + + {columnId === 'approved' ? ( + + ) : null} +
+ + +
); }; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 48876d9f..88c92bc9 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 ( -
+
-
+
{member.name} + {member.gitBranch ? ( + + + {member.gitBranch} + + ) : null} {currentTask ? ( <> - {presenceLabel} + {isRemoved ? 'removed' : presenceLabel} - 0 ? `${completed}/${totalTasks} completed` : undefined} > - {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'} - -
- - + {member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'} + + {totalTasks > 0 && ( +
+
+
+ )}
+ {!isRemoved && ( +
+ + + + + Send message + + + + + + Assign task + +
+ )}
-
); }; diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 0a52a47a..67adb6e8 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -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('tasks'); + if (!member) return null; return ( !nextOpen && onClose()}> - - - +
+ + + + + - +
- - - + setActiveTab(v as MemberDetailTab)} + className="min-w-0 overflow-hidden" + > Tasks @@ -113,7 +124,7 @@ export const MemberDetailDialog = ({ - + @@ -124,14 +135,33 @@ export const MemberDetailDialog = ({ - - + {member.removedAt ? ( + + Removed {new Date(member.removedAt).toLocaleDateString()} + + ) : ( + <> + + + {onRemoveMember && member.agentType !== 'team-lead' && ( + + )} + + )}
diff --git a/src/renderer/components/team/members/MemberDetailStats.tsx b/src/renderer/components/team/members/MemberDetailStats.tsx index a8d0ee1d..5d2e1cf3 100644 --- a/src/renderer/components/team/members/MemberDetailStats.tsx +++ b/src/renderer/components/team/members/MemberDetailStats.tsx @@ -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 => ( -
-

{value}

-

{label}

- {sub &&

{sub}

} -
-); + onClick?: () => void; +}): React.JSX.Element => { + const classes = onClick ? `${baseClasses} ${clickableClasses}` : baseClasses; + const content = ( + <> +

{value}

+

{label}

+ {sub &&

{sub}

} + + ); + if (onClick) { + return ( + + ); + } + return
{content}
; +}; 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 ( -
+
0 ? `in progress: ${inProgressTasks}` : undefined} + onClick={onTabChange ? () => onTabChange('tasks') : undefined} + /> + onTabChange('tasks') : undefined} + /> + onTabChange('messages') : undefined} + /> + onTabChange('logs') : undefined} /> - - -
); }; diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index b6b14c47..1e639a76 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -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 ( -
-
-
- {format(group.timestamp, 'h:mm:ss a')} -
-
(empty)
-
+
+ {format(group.timestamp, 'h:mm:ss a')} — (empty)
); } return ( -
-
-
- {format(group.timestamp, 'h:mm:ss a')} -
-
- -
+
+
+ {format(group.timestamp, 'h:mm:ss a')}
+ {humanText && ( +
+ +
+ )} + {agentInfo.length > 0 && ( +
+ + {agentInfoOpen && ( +
+              {agentInfo.join('\n\n')}
+            
+ )} +
+ )}
); }; @@ -149,23 +186,28 @@ const AIExecutionGroup = ({ return (
{hasToggleContent ? ( - + + + + + {expanded ? 'Collapse' : 'Expand'} + ) : null} {hasToggleContent && expanded ? ( diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 98994ee0..22e5f610 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -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 (
@@ -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 ( + onOpenTask?.(currentTask) : undefined} + onClick={() => onMemberClick?.(member)} + onSendMessage={() => onSendMessage?.(member)} + onAssignTask={() => onAssignTask?.(member)} + /> + ); + }; + return (
- {members.map((member, index) => { - const currentTask = - member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null; - const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]); - return ( - onOpenTask?.(currentTask) : undefined} - onClick={() => onMemberClick?.(member)} - onSendMessage={() => onSendMessage?.(member)} - onAssignTask={() => onAssignTask?.(member)} - /> - ); - })} + {activeMembers.map((member, index) => renderCard(member, index, false))} + {removedMembers.length > 0 && ( + <> +
+ Removed ({removedMembers.length}) +
+ {removedMembers.map((member, index) => + renderCard(member, activeMembers.length + index, true) + )} + + )}
); }; diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index c6cbf0ee..b553227a 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -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 (
-
-
- +
+
+ {log.description} +
+
+ + + {timeAgo} + + {log.durationMs > 0 && {formatDuration(log.durationMs)}} + + + {log.messageCount} + + {log.isOngoing && ( + active + )} +
+
+ + + {expanded ? 'Hide details' : 'Show details'} + {expanded && (
diff --git a/src/renderer/components/team/members/MemberMessagesTab.tsx b/src/renderer/components/team/members/MemberMessagesTab.tsx index 21ee1ecf..8517c5c9 100644 --- a/src/renderer/components/team/members/MemberMessagesTab.tsx +++ b/src/renderer/components/team/members/MemberMessagesTab.tsx @@ -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 (
{displayMessages.map((msg, idx) => ( - + ))}
); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx new file mode 100644 index 00000000..ed43a856 --- /dev/null +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -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(() => { + 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(null); + + const draft = useDraftPersistence({ key: `compose:${teamName}` }); + const { + attachments, + error: attachmentError, + canAddMore, + addFiles, + removeAttachment, + clearAttachments, + handlePaste, + handleDrop, + } = useAttachments(); + + const mentionSuggestions = useMemo( + () => + 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) => { + 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 ( +
+ + +
+ + + + + +
+ {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 ( + + ); + })} +
+
+
+ + {isLeadRecipient ? ( + <> + + + + + + + {!isTeamAlive + ? 'Team must be online to attach images' + : !canAddMore + ? 'Maximum attachments reached' + : 'Attach images (paste or drag & drop)'} + + + + ) : null} + + {!isTeamAlive ? ( + Team offline + ) : null} +
+ + + + + + Send + + } + footerRight={ +
+ {sendError ? ( + + + {sendError} + + ) : null} + {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {draft.isSaved ? ( + Draft saved + ) : null} +
+ } + /> +
+ ); +}; diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index eb26d984..547193ca 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -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 ( - - - + + + + + + + Filter messages +

diff --git a/src/renderer/components/ui/dialog.tsx b/src/renderer/components/ui/dialog.tsx index df8db14a..47af98e8 100644 --- a/src/renderer/components/ui/dialog.tsx +++ b/src/renderer/components/ui/dialog.tsx @@ -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} diff --git a/src/renderer/components/ui/popover.tsx b/src/renderer/components/ui/popover.tsx index eec198e3..ab5c334d 100644 --- a/src/renderer/components/ui/popover.tsx +++ b/src/renderer/components/ui/popover.tsx @@ -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} diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts new file mode 100644 index 00000000..001f2e8f --- /dev/null +++ b/src/renderer/hooks/useAttachments.ts @@ -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; + removeAttachment: (id: string) => void; + clearAttachments: () => void; + handlePaste: (event: React.ClipboardEvent) => void; + handleDrop: (event: React.DragEvent) => void; +} + +export function useAttachments(): UseAttachmentsReturn { + const [attachments, setAttachments] = useState([]); + const [error, setError] = useState(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, + }; +} diff --git a/src/renderer/store/slices/notificationSlice.ts b/src/renderer/store/slices/notificationSlice.ts index 320c71a7..d9fc16df 100644 --- a/src/renderer/store/slices/notificationSlice.ts +++ b/src/renderer/store/slices/notificationSlice.ts @@ -206,6 +206,13 @@ export const createNotificationSlice: StateCreator = (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; } } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 547d5abb..0e32fcba 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -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; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; + updateKanbanColumnOrder: ( + teamName: string, + columnId: KanbanColumnId, + orderedTaskIds: string[] + ) => Promise; createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise; startTask: (teamName: string, taskId: string) => Promise; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; + updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise; addingComment: boolean; addCommentError: string | null; addTaskComment: (teamName: string, taskId: string, text: string) => Promise; + addMember: (teamName: string, request: AddMemberRequest) => Promise; + removeMember: (teamName: string, memberName: string) => Promise; deleteTeam: (teamName: string) => Promise; createTeam: (request: TeamCreateRequest) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; @@ -180,14 +192,22 @@ export const createTeamSlice: StateCreator = (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 = (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 = (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 = (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 = (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 = (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(); diff --git a/src/renderer/store/utils/stateResetHelpers.ts b/src/renderer/store/utils/stateResetHelpers.ts index 7fdc4a0b..8656ab3a 100644 --- a/src/renderer/store/utils/stateResetHelpers.ts +++ b/src/renderer/store/utils/stateResetHelpers.ts @@ -24,6 +24,22 @@ export function getSessionResetState(): Partial { }; } +/** + * 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 { + 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. diff --git a/src/renderer/utils/attachmentUtils.ts b/src/renderer/utils/attachmentUtils.ts new file mode 100644 index 00000000..c88e5f46 --- /dev/null +++ b/src/renderer/utils/attachmentUtils.ts @@ -0,0 +1,52 @@ +import type { AttachmentMediaType, AttachmentPayload } from '@shared/types'; + +export const ALLOWED_MIME_TYPES = new Set([ + '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 { + 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`; +} diff --git a/src/renderer/utils/pathNormalize.ts b/src/renderer/utils/pathNormalize.ts index 8c62a4c3..c8bb0e8b 100644 --- a/src/renderer/utils/pathNormalize.ts +++ b/src/renderer/utils/pathNormalize.ts @@ -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 { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 08c5b5d7..56873df9 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; requestReview: (teamName: string, taskId: string) => Promise; updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise; + updateKanbanColumnOrder: ( + teamName: string, + columnId: KanbanColumnId, + orderedTaskIds: string[] + ) => Promise; updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise; + updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise; startTask: (teamName: string, taskId: string) => Promise; processSend: (teamName: string, message: string) => Promise; processAlive: (teamName: string) => Promise; @@ -371,7 +380,11 @@ export interface TeamsAPI { launchTeam: (request: TeamLaunchRequest) => Promise; getAllTasks: () => Promise; updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise; + addMember: (teamName: string, request: AddMemberRequest) => Promise; + removeMember: (teamName: string, memberName: string) => Promise; addTaskComment: (teamName: string, taskId: string, text: string) => Promise; + getProjectBranch: (projectPath: string) => Promise; + getAttachments: (teamName: string, messageId: string) => Promise; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 6e51f757..b7d757a2 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -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: { diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 015a09bc..c2932329 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; + /** Порядок id задач по колонкам для отображения на канбан-доске (drag-and-drop). */ + columnOrder?: Partial>; } 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; +} diff --git a/src/shared/utils/agentLanguage.ts b/src/shared/utils/agentLanguage.ts new file mode 100644 index 00000000..c3ad35c0 --- /dev/null +++ b/src/shared/utils/agentLanguage.ts @@ -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; +} diff --git a/src/shared/utils/rateLimitDetector.ts b/src/shared/utils/rateLimitDetector.ts new file mode 100644 index 00000000..732b51aa --- /dev/null +++ b/src/shared/utils/rateLimitDetector.ts @@ -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); +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 2f3e0b17..e9ad20b3 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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); }); });