feat: add/remove member, mesage box, improve ui...
This commit is contained in:
parent
25b740c134
commit
aa49ce0ccc
74 changed files with 4174 additions and 867 deletions
87
README.md
87
README.md
|
|
@ -12,7 +12,7 @@
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases"><img src="https://img.shields.io/github/downloads/777genius/claude_agent_teams_ui/total?style=flat-square&color=green" alt="Downloads" /></a>
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases"><img src="https://img.shields.io/github/downloads/777genius/claude_agent_teams_ui/total?style=flat-square&color=green" alt="Downloads" /></a>
|
||||||
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows%20%7C%20Docker-lightgrey?style=flat-square" alt="Platform" />
|
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
@ -26,9 +26,6 @@
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
|
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
|
||||||
<img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" />
|
<img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" />
|
||||||
</a>
|
|
||||||
<a href="#docker--standalone-deployment">
|
|
||||||
<img src="https://img.shields.io/badge/Docker-Deploy-2496ED?logo=docker&logoColor=white&style=flat" alt="Deploy with Docker" height="30" />
|
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -58,87 +55,11 @@
|
||||||
| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open |
|
| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open |
|
||||||
| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). |
|
| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). |
|
||||||
| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
|
| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
|
||||||
| **Docker** | `docker compose up` | Open `http://localhost:3456`. See [Docker / Standalone Deployment](#docker--standalone-deployment) for details. |
|
|
||||||
|
|
||||||
The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login.
|
The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What the CLI Hides vs. What Claude Agent Teams UI Shows
|
|
||||||
|
|
||||||
| What you see in the terminal | What Claude Agent Teams UI shows you |
|
|
||||||
|------------------------------|-------------------------------|
|
|
||||||
| `Read 3 files` | Exact file paths, syntax-highlighted content with line numbers |
|
|
||||||
| `Searched for 1 pattern` | The regex pattern, every matching file, and the matched lines |
|
|
||||||
| `Edited 2 files` | Inline diffs with added/removed highlighting per file |
|
|
||||||
| A three-segment context bar | Per-turn token attribution across 7 categories — CLAUDE.md breakdown, skills, @-mentions, tool I/O, thinking, teams, user text — with compaction visualization showing how context fills, compresses, and refills |
|
|
||||||
| Subagent output interleaved with the main thread | Isolated execution trees per agent, expandable inline with their own metrics |
|
|
||||||
| Teammate messages buried in session logs | Color-coded teammate cards with name, message, and full team lifecycle visibility |
|
|
||||||
| Critical events mixed into normal output | Trigger-filtered notification inbox for `.env` access, payment-related file paths, execution errors, and high token usage |
|
|
||||||
| `--verbose` JSON dump | Structured, filterable, navigable interface — no noise |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker / Standalone Deployment
|
|
||||||
|
|
||||||
Run Claude Agent Teams UI without Electron — in Docker, on a remote server, or anywhere Node.js runs.
|
|
||||||
|
|
||||||
### Quick Start (Docker Compose)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
Open `http://localhost:3456` in your browser.
|
|
||||||
|
|
||||||
### Quick Start (Docker)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t claude-agent-teams-ui .
|
|
||||||
docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quick Start (Node.js)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm standalone:build
|
|
||||||
node dist-standalone/index.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `CLAUDE_ROOT` | `~/.claude` | Path to the `.claude` data directory |
|
|
||||||
| `HOST` | `0.0.0.0` | Bind address |
|
|
||||||
| `PORT` | `3456` | Listen port |
|
|
||||||
| `CORS_ORIGIN` | `*` (standalone) | CORS origin policy (`*`, specific origin, or comma-separated list) |
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- **Real-time updates may be slower than Electron.** The Electron app uses native file system watchers with IPC for instant updates. The Docker/standalone server uses SSE (Server-Sent Events) over HTTP, which may introduce slight delays when sessions are actively being written to.
|
|
||||||
- **Custom Claude root path.** If your `.claude` directory is not at `~/.claude`, update the volume mount to point to the correct location:
|
|
||||||
```bash
|
|
||||||
# Example: Claude root at /home/user/custom-claude-dir
|
|
||||||
docker run -p 3456:3456 -v /home/user/custom-claude-dir:/data/.claude:ro claude-agent-teams-ui
|
|
||||||
|
|
||||||
# Or with docker compose, set the CLAUDE_DIR env variable:
|
|
||||||
CLAUDE_DIR=/home/user/custom-claude-dir docker compose up
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security-Focused Deployment
|
|
||||||
|
|
||||||
The standalone server has **zero** outbound network calls. For maximum isolation:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
|
||||||
```
|
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md) for a full audit of network activity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
@ -184,6 +105,12 @@ pnpm dist # macOS + Windows + Linux
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
import {
|
||||||
|
type ClaudeMdFileInfo,
|
||||||
|
readAgentConfigs,
|
||||||
|
readAllClaudeMdFiles,
|
||||||
|
readDirectoryClaudeMd,
|
||||||
|
} from '../services';
|
||||||
import { validateFilePath } from '../utils/pathValidation';
|
import { validateFilePath } from '../utils/pathValidation';
|
||||||
import { countTokens } from '../utils/tokenizer';
|
import { countTokens } from '../utils/tokenizer';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
TEAM_ADD_MEMBER,
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
TEAM_CANCEL_PROVISIONING,
|
TEAM_CANCEL_PROVISIONING,
|
||||||
|
|
@ -7,10 +10,12 @@ import {
|
||||||
TEAM_CREATE_TASK,
|
TEAM_CREATE_TASK,
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
|
TEAM_GET_ATTACHMENTS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
|
|
@ -18,22 +23,35 @@ import {
|
||||||
TEAM_PROCESS_SEND,
|
TEAM_PROCESS_SEND,
|
||||||
TEAM_PROVISIONING_PROGRESS,
|
TEAM_PROVISIONING_PROGRESS,
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
|
TEAM_UPDATE_TASK_OWNER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||||
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||||
|
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||||
|
|
||||||
import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards';
|
import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards';
|
||||||
|
|
||||||
|
/** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */
|
||||||
|
const notifiedRateLimitKeys = new Set<string>();
|
||||||
|
const RATE_LIMIT_KEYS_MAX = 500;
|
||||||
|
|
||||||
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberStatsComputer,
|
MemberStatsComputer,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
|
|
@ -41,9 +59,13 @@ import type {
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import type {
|
import type {
|
||||||
|
AttachmentFileData,
|
||||||
|
AttachmentMeta,
|
||||||
|
AttachmentPayload,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
|
KanbanColumnId,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -67,11 +89,63 @@ import type {
|
||||||
|
|
||||||
const logger = createLogger('IPC:teams');
|
const logger = createLogger('IPC:teams');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check messages for rate limit indicators and fire native notifications for new ones.
|
||||||
|
*/
|
||||||
|
function checkRateLimitMessages(
|
||||||
|
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||||
|
teamName: string,
|
||||||
|
teamDisplayName: string,
|
||||||
|
projectPath?: string
|
||||||
|
): void {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.from === 'user') continue;
|
||||||
|
if (!isRateLimitMessage(msg.text)) continue;
|
||||||
|
|
||||||
|
// Prefix key with teamName to avoid collisions across teams
|
||||||
|
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||||
|
const key = `${teamName}:${rawKey}`;
|
||||||
|
if (notifiedRateLimitKeys.has(key)) continue;
|
||||||
|
notifiedRateLimitKeys.add(key);
|
||||||
|
|
||||||
|
// Prevent unbounded memory growth
|
||||||
|
if (notifiedRateLimitKeys.size > RATE_LIMIT_KEYS_MAX) {
|
||||||
|
const first = notifiedRateLimitKeys.values().next().value!;
|
||||||
|
notifiedRateLimitKeys.delete(first);
|
||||||
|
}
|
||||||
|
|
||||||
|
void NotificationManager.getInstance()
|
||||||
|
.addError({
|
||||||
|
id: randomUUID(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sessionId: `team:${teamName}`,
|
||||||
|
projectId: teamName,
|
||||||
|
filePath: '',
|
||||||
|
source: 'rate-limit',
|
||||||
|
message: `[${msg.from}] ${msg.text.slice(0, 200)}`,
|
||||||
|
triggerColor: 'red',
|
||||||
|
triggerName: 'Rate Limit',
|
||||||
|
context: {
|
||||||
|
projectName: teamDisplayName,
|
||||||
|
cwd: projectPath,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let teamDataService: TeamDataService | null = null;
|
let teamDataService: TeamDataService | null = null;
|
||||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||||
|
|
||||||
|
const attachmentStore = new TeamAttachmentStore();
|
||||||
|
|
||||||
|
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
|
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
||||||
|
const MAX_ATTACHMENTS = 5;
|
||||||
|
const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total
|
||||||
|
|
||||||
export function initializeTeamHandlers(
|
export function initializeTeamHandlers(
|
||||||
service: TeamDataService,
|
service: TeamDataService,
|
||||||
provisioningService: TeamProvisioningService,
|
provisioningService: TeamProvisioningService,
|
||||||
|
|
@ -96,7 +170,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||||
|
ipcMain.handle(TEAM_UPDATE_KANBAN_COLUMN_ORDER, handleUpdateKanbanColumnOrder);
|
||||||
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
|
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
|
||||||
|
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
|
||||||
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
|
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
|
||||||
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
|
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
|
||||||
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
|
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
|
||||||
|
|
@ -110,6 +186,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
||||||
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
||||||
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
|
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
|
||||||
|
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
||||||
|
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
||||||
|
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||||
|
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +205,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||||
|
ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
|
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
|
||||||
|
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
|
||||||
ipcMain.removeHandler(TEAM_DELETE_TEAM);
|
ipcMain.removeHandler(TEAM_DELETE_TEAM);
|
||||||
ipcMain.removeHandler(TEAM_PROCESS_SEND);
|
ipcMain.removeHandler(TEAM_PROCESS_SEND);
|
||||||
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
|
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
|
||||||
|
|
@ -139,6 +221,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_START_TASK);
|
ipcMain.removeHandler(TEAM_START_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
||||||
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
|
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
|
||||||
|
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
||||||
|
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamDataService(): TeamDataService {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -169,6 +255,23 @@ async function wrapTeamHandler<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetProjectBranch(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath: unknown
|
||||||
|
): Promise<IpcResult<string | null>> {
|
||||||
|
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
||||||
|
return { success: false, error: 'projectPath must be a non-empty string' };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const branch = await gitIdentityResolver.getBranch(projectPath.trim());
|
||||||
|
return { success: true, data: branch };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`[teams:getProjectBranch] ${message}`);
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
||||||
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
||||||
}
|
}
|
||||||
|
|
@ -203,8 +306,12 @@ async function handleGetData(
|
||||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName = data.config.name || tn;
|
||||||
|
const projectPath = data.config.projectPath;
|
||||||
|
|
||||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||||
if (live.length === 0) {
|
if (live.length === 0) {
|
||||||
|
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
|
||||||
return { success: true, data: { ...data, isAlive } };
|
return { success: true, data: { ...data, isAlive } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -244,6 +351,7 @@ async function handleGetData(
|
||||||
}
|
}
|
||||||
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||||
|
|
||||||
|
checkRateLimitMessages(merged, tn, displayName, projectPath);
|
||||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -387,6 +495,7 @@ async function validateProvisioningRequest(
|
||||||
members,
|
members,
|
||||||
cwd,
|
cwd,
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||||
|
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -447,12 +556,17 @@ async function handleLaunchTeam(
|
||||||
return { success: false, error: 'prompt must be a string' };
|
return { success: false, error: 'prompt must be a string' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||||
|
return { success: false, error: 'model must be a string' };
|
||||||
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('launch', () =>
|
return wrapTeamHandler('launch', () =>
|
||||||
getTeamProvisioningService().launchTeam(
|
getTeamProvisioningService().launchTeam(
|
||||||
{
|
{
|
||||||
teamName: validatedTeamName.value!,
|
teamName: validatedTeamName.value!,
|
||||||
cwd,
|
cwd,
|
||||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||||
|
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||||
},
|
},
|
||||||
(progress) => {
|
(progress) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -526,6 +640,76 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
|
||||||
return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved');
|
return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetAttachments(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
messageId: unknown
|
||||||
|
): Promise<IpcResult<AttachmentFileData[]>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
if (typeof messageId !== 'string' || messageId.trim().length === 0) {
|
||||||
|
return { success: false, error: 'messageId must be a non-empty string' };
|
||||||
|
}
|
||||||
|
const safeMessageId = messageId.trim();
|
||||||
|
if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) {
|
||||||
|
return { success: false, error: 'Invalid messageId' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('getAttachments', () =>
|
||||||
|
attachmentStore.getAttachments(vTeam.value!, safeMessageId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAttachments(
|
||||||
|
attachments: unknown
|
||||||
|
): { valid: true; value: AttachmentPayload[] } | { valid: false; error: string } {
|
||||||
|
if (!Array.isArray(attachments)) {
|
||||||
|
return { valid: false, error: 'attachments must be an array' };
|
||||||
|
}
|
||||||
|
if (attachments.length > MAX_ATTACHMENTS) {
|
||||||
|
return { valid: false, error: `Maximum ${MAX_ATTACHMENTS} attachments allowed` };
|
||||||
|
}
|
||||||
|
let totalSize = 0;
|
||||||
|
const result: AttachmentPayload[] = [];
|
||||||
|
for (const att of attachments) {
|
||||||
|
if (!att || typeof att !== 'object') {
|
||||||
|
return { valid: false, error: 'Invalid attachment entry' };
|
||||||
|
}
|
||||||
|
const a = att as Partial<AttachmentPayload>;
|
||||||
|
if (typeof a.id !== 'string' || typeof a.filename !== 'string') {
|
||||||
|
return { valid: false, error: 'Attachment must have id and filename' };
|
||||||
|
}
|
||||||
|
if (typeof a.data !== 'string' || typeof a.mimeType !== 'string') {
|
||||||
|
return { valid: false, error: 'Attachment must have data and mimeType' };
|
||||||
|
}
|
||||||
|
if (typeof a.size !== 'number' || a.size <= 0) {
|
||||||
|
return { valid: false, error: 'Attachment must have a positive size' };
|
||||||
|
}
|
||||||
|
if (!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)) {
|
||||||
|
return { valid: false, error: `Unsupported attachment type: ${a.mimeType}` };
|
||||||
|
}
|
||||||
|
if (a.size > MAX_ATTACHMENT_SIZE) {
|
||||||
|
return { valid: false, error: `Attachment "${a.filename}" exceeds 10MB limit` };
|
||||||
|
}
|
||||||
|
// Sanity check: base64 data should be roughly 4/3 of the reported binary size
|
||||||
|
const estimatedBinarySize = Math.ceil(a.data.length * 0.75);
|
||||||
|
if (estimatedBinarySize > MAX_ATTACHMENT_SIZE * 1.1) {
|
||||||
|
return { valid: false, error: `Attachment "${a.filename}" data exceeds size limit` };
|
||||||
|
}
|
||||||
|
totalSize += a.size;
|
||||||
|
result.push({
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
data: a.data,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
size: a.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (totalSize > MAX_TOTAL_ATTACHMENT_SIZE) {
|
||||||
|
return { valid: false, error: 'Total attachment size exceeds 20MB limit' };
|
||||||
|
}
|
||||||
|
return { valid: true, value: result };
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSendMessage(
|
async function handleSendMessage(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
@ -558,24 +742,98 @@ async function handleSendMessage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let validatedAttachments: AttachmentPayload[] | undefined;
|
||||||
|
if (
|
||||||
|
payload.attachments !== undefined &&
|
||||||
|
Array.isArray(payload.attachments) &&
|
||||||
|
payload.attachments.length > 0
|
||||||
|
) {
|
||||||
|
const attResult = validateAttachments(payload.attachments);
|
||||||
|
if (!attResult.valid) {
|
||||||
|
return { success: false, error: attResult.error };
|
||||||
|
}
|
||||||
|
validatedAttachments = attResult.value;
|
||||||
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('sendMessage', async () => {
|
return wrapTeamHandler('sendMessage', async () => {
|
||||||
const tn = validatedTeamName.value!;
|
const tn = validatedTeamName.value!;
|
||||||
|
const provisioning = getTeamProvisioningService();
|
||||||
|
const isAlive = provisioning.isTeamAlive(tn);
|
||||||
|
|
||||||
|
const leadName = await getTeamDataService().getLeadMemberName(tn);
|
||||||
|
const memberName = validatedMember.value!;
|
||||||
|
const isLeadRecipient = leadName !== null && memberName === leadName;
|
||||||
|
|
||||||
|
// Attachments only supported for live lead (stdin content blocks)
|
||||||
|
if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) {
|
||||||
|
throw new Error(
|
||||||
|
'Attachments are only supported when sending to the team lead while the team is online'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||||
|
if (isLeadRecipient && isAlive) {
|
||||||
|
try {
|
||||||
|
await provisioning.sendMessageToTeam(tn, payload.text!, validatedAttachments);
|
||||||
|
|
||||||
|
const result = await getTeamDataService().sendDirectToLead(
|
||||||
|
tn,
|
||||||
|
leadName,
|
||||||
|
payload.text!,
|
||||||
|
payload.summary
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
size: a.size,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Save attachment binary data to disk (best-effort)
|
||||||
|
if (validatedAttachments?.length && result.messageId) {
|
||||||
|
void attachmentStore
|
||||||
|
.saveAttachments(tn, result.messageId, validatedAttachments)
|
||||||
|
.catch((e) => logger.warn(`Failed to save attachments: ${e}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
provisioning.pushLiveLeadProcessMessage(tn, {
|
||||||
|
from: 'user',
|
||||||
|
to: leadName,
|
||||||
|
text: payload.text!,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
read: true,
|
||||||
|
summary: payload.summary,
|
||||||
|
messageId: result.messageId,
|
||||||
|
source: 'user_sent',
|
||||||
|
attachments: attachmentMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (stdinError) {
|
||||||
|
// Stdin failed (process died between check and write)
|
||||||
|
// If attachments were requested, fail rather than silently dropping them
|
||||||
|
if (validatedAttachments?.length) {
|
||||||
|
throw new Error(
|
||||||
|
'Failed to deliver message with attachments: team process became unavailable'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.warn('stdin fallback for ' + tn + ': ' + String(stdinError));
|
||||||
|
// Fallback to inbox path for text-only messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbox path: offline lead or regular members (no attachment support)
|
||||||
const result = await getTeamDataService().sendMessage(tn, {
|
const result = await getTeamDataService().sendMessage(tn, {
|
||||||
member: validatedMember.value!,
|
member: memberName,
|
||||||
text: payload.text!,
|
text: payload.text!,
|
||||||
summary: payload.summary,
|
summary: payload.summary,
|
||||||
from: payload.from,
|
from: payload.from,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort: if messaging the lead while process is alive, relay immediately (no UI dependency).
|
// Best-effort relay for lead via inbox
|
||||||
try {
|
if (isLeadRecipient && isAlive) {
|
||||||
const provisioning = getTeamProvisioningService();
|
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||||
if (provisioning.isTeamAlive(tn)) {
|
|
||||||
// Avoid reading unrelated inboxes; relayLeadInboxMessages will no-op when nothing new exists.
|
|
||||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -705,6 +963,44 @@ async function handleUpdateKanban(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved'];
|
||||||
|
|
||||||
|
function validateKanbanColumnId(
|
||||||
|
value: unknown
|
||||||
|
): { valid: true; value: KanbanColumnId } | { valid: false; error: string } {
|
||||||
|
if (typeof value !== 'string' || !KANBAN_COLUMN_IDS.includes(value as KanbanColumnId)) {
|
||||||
|
return { valid: false, error: `columnId must be one of: ${KANBAN_COLUMN_IDS.join(', ')}` };
|
||||||
|
}
|
||||||
|
return { valid: true, value: value as KanbanColumnId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateKanbanColumnOrder(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
columnId: unknown,
|
||||||
|
orderedTaskIds: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const validatedTeamName = validateTeamName(teamName);
|
||||||
|
if (!validatedTeamName.valid) {
|
||||||
|
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
const validatedColumnId = validateKanbanColumnId(columnId);
|
||||||
|
if (!validatedColumnId.valid) {
|
||||||
|
return { success: false, error: validatedColumnId.error ?? 'Invalid columnId' };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(orderedTaskIds)) {
|
||||||
|
return { success: false, error: 'orderedTaskIds must be an array' };
|
||||||
|
}
|
||||||
|
const ids = orderedTaskIds.filter((id): id is string => typeof id === 'string');
|
||||||
|
return wrapTeamHandler('updateKanbanColumnOrder', () =>
|
||||||
|
getTeamDataService().updateKanbanColumnOrder(
|
||||||
|
validatedTeamName.value!,
|
||||||
|
validatedColumnId.value,
|
||||||
|
ids
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed'];
|
const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed'];
|
||||||
|
|
||||||
async function handleUpdateTaskStatus(
|
async function handleUpdateTaskStatus(
|
||||||
|
|
@ -736,6 +1032,31 @@ async function handleUpdateTaskStatus(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleUpdateTaskOwner(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
taskId: unknown,
|
||||||
|
owner: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const validatedTeamName = validateTeamName(teamName);
|
||||||
|
if (!validatedTeamName.valid) {
|
||||||
|
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedTaskId = validateTaskId(taskId);
|
||||||
|
if (!validatedTaskId.valid) {
|
||||||
|
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owner !== null && (typeof owner !== 'string' || owner.length === 0)) {
|
||||||
|
return { success: false, error: 'owner must be a non-empty string or null' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('updateTaskOwner', () =>
|
||||||
|
getTeamDataService().updateTaskOwner(validatedTeamName.value!, validatedTaskId.value!, owner)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleProcessSend(
|
async function handleProcessSend(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
@ -946,6 +1267,47 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<
|
||||||
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddMember(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
payload: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
|
||||||
|
if (!payload || typeof payload !== 'object') {
|
||||||
|
return { success: false, error: 'Invalid payload' };
|
||||||
|
}
|
||||||
|
const { name, role } = payload as { name?: unknown; role?: unknown };
|
||||||
|
const vName = validateMemberName(name);
|
||||||
|
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||||
|
if (role !== undefined && typeof role !== 'string') {
|
||||||
|
return { success: false, error: 'role must be a string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('addMember', () =>
|
||||||
|
getTeamDataService().addMember(vTeam.value!, {
|
||||||
|
name: vName.value!,
|
||||||
|
role: role,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveMember(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
memberName: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
const vMember = validateMemberName(memberName);
|
||||||
|
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||||||
|
|
||||||
|
return wrapTeamHandler('removeMember', () =>
|
||||||
|
getTeamDataService().removeMember(vTeam.value!, vMember.value!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddTaskComment(
|
async function handleAddTaskComment(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,7 @@ export interface GeneralConfig {
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
claudeRootPath: string | null;
|
claudeRootPath: string | null;
|
||||||
|
agentLanguage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayConfig {
|
export interface DisplayConfig {
|
||||||
|
|
@ -248,6 +249,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
defaultTab: 'dashboard',
|
defaultTab: 'dashboard',
|
||||||
claudeRootPath: null,
|
claudeRootPath: null,
|
||||||
|
agentLanguage: 'system',
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
|
|
|
||||||
|
|
@ -921,7 +921,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classify only the paths we care about in iteration 02.
|
// Classify only the paths we care about in iteration 02.
|
||||||
if (normalized.includes('inboxes')) {
|
if (normalized.includes('inboxes') || relative === 'sentMessages.json') {
|
||||||
const event: TeamChangeEvent = {
|
const event: TeamChangeEvent = {
|
||||||
type: 'inbox',
|
type: 'inbox',
|
||||||
teamName,
|
teamName,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ function parseFrontmatter(content: string): Record<string, string> {
|
||||||
const key = line.slice(0, colonIdx).trim();
|
const key = line.slice(0, colonIdx).trim();
|
||||||
let value = line.slice(colonIdx + 1).trim();
|
let value = line.slice(colonIdx + 1).trim();
|
||||||
// Strip surrounding quotes
|
// Strip surrounding quotes
|
||||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
value = value.slice(1, -1);
|
value = value.slice(1, -1);
|
||||||
}
|
}
|
||||||
if (key) result[key] = value;
|
if (key) result[key] = value;
|
||||||
|
|
@ -40,9 +43,7 @@ function parseFrontmatter(content: string): Record<string, string> {
|
||||||
* Read agent config files from a project's `.claude/agents/` directory.
|
* Read agent config files from a project's `.claude/agents/` directory.
|
||||||
* Returns a map of agent name → config (with optional color).
|
* Returns a map of agent name → config (with optional color).
|
||||||
*/
|
*/
|
||||||
export async function readAgentConfigs(
|
export async function readAgentConfigs(projectRoot: string): Promise<Record<string, AgentConfig>> {
|
||||||
projectRoot: string
|
|
||||||
): Promise<Record<string, AgentConfig>> {
|
|
||||||
const agentsDir = path.join(projectRoot, '.claude', 'agents');
|
const agentsDir = path.join(projectRoot, '.claude', 'agents');
|
||||||
const result: Record<string, AgentConfig> = {};
|
const result: Record<string, AgentConfig> = {};
|
||||||
|
|
||||||
|
|
|
||||||
86
src/main/services/team/TeamAttachmentStore.ts
Normal file
86
src/main/services/team/TeamAttachmentStore.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
|
import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
const logger = createLogger('Service:TeamAttachmentStore');
|
||||||
|
|
||||||
|
const ATTACHMENTS_DIR = 'attachments';
|
||||||
|
|
||||||
|
export class TeamAttachmentStore {
|
||||||
|
private getDir(teamName: string): string {
|
||||||
|
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilePath(teamName: string, messageId: string): string {
|
||||||
|
return path.join(this.getDir(teamName), `${messageId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAttachments(
|
||||||
|
teamName: string,
|
||||||
|
messageId: string,
|
||||||
|
attachments: AttachmentPayload[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (attachments.length === 0) return;
|
||||||
|
|
||||||
|
const fileData: AttachmentFileData[] = attachments.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
data: a.data,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await atomicWriteAsync(this.getFilePath(teamName, messageId), JSON.stringify(fileData));
|
||||||
|
logger.debug(
|
||||||
|
`[${teamName}] Saved ${attachments.length} attachment(s) for message ${messageId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAttachments(teamName: string, messageId: string): Promise<AttachmentFileData[]> {
|
||||||
|
const filePath = this.getFilePath(teamName, messageId);
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.promises.readFile(filePath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AttachmentFileData[] = [];
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const row = item as Partial<AttachmentFileData>;
|
||||||
|
if (
|
||||||
|
typeof row.id !== 'string' ||
|
||||||
|
typeof row.data !== 'string' ||
|
||||||
|
typeof row.mimeType !== 'string'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
id: row.id,
|
||||||
|
data: row.data,
|
||||||
|
mimeType: row.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -61,10 +61,15 @@ export class TeamConfigReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removedNames = new Set<string>();
|
||||||
try {
|
try {
|
||||||
const metaMembers = await this.membersMetaStore.getMembers(entry.name);
|
const metaMembers = await this.membersMetaStore.getMembers(entry.name);
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
addMember(member);
|
if (member.removedAt) {
|
||||||
|
removedNames.add(member.name.trim());
|
||||||
|
} else {
|
||||||
|
addMember(member);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.debug(`Failed to read members.meta.json for team: ${entry.name}`);
|
logger.debug(`Failed to read members.meta.json for team: ${entry.name}`);
|
||||||
|
|
@ -86,6 +91,10 @@ export class TeamConfigReader {
|
||||||
// Inbox folder may not exist yet.
|
// Inbox folder may not exist yet.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const name of removedNames) {
|
||||||
|
memberMap.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
const memberCount = memberMap.size;
|
const memberCount = memberMap.size;
|
||||||
const members = Array.from(memberMap.values());
|
const members = Array.from(memberMap.values());
|
||||||
summaries.push({
|
summaries.push({
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@ import {
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
|
|
||||||
|
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
|
|
@ -20,21 +23,26 @@ import { TeamInboxWriter } from './TeamInboxWriter';
|
||||||
import { TeamKanbanManager } from './TeamKanbanManager';
|
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AddMemberRequest,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
KanbanColumnId,
|
||||||
KanbanState,
|
KanbanState,
|
||||||
KanbanTaskState,
|
KanbanTaskState,
|
||||||
|
ResolvedTeamMember,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamData,
|
TeamData,
|
||||||
|
TeamMember,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
|
@ -57,7 +65,8 @@ export class TeamDataService {
|
||||||
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
|
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
|
||||||
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
|
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
|
||||||
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller(),
|
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller(),
|
||||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||||
|
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listTeams(): Promise<TeamSummary[]> {
|
async listTeams(): Promise<TeamSummary[]> {
|
||||||
|
|
@ -162,12 +171,22 @@ export class TeamDataService {
|
||||||
const leadTexts = await this.extractLeadSessionTexts(config);
|
const leadTexts = await this.extractLeadSessionTexts(config);
|
||||||
if (leadTexts.length > 0) {
|
if (leadTexts.length > 0) {
|
||||||
messages = [...messages, ...leadTexts];
|
messages = [...messages, ...leadTexts];
|
||||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
warnings.push('Lead session texts failed to load');
|
warnings.push('Lead session texts failed to load');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sentMessages = await this.sentMessagesStore.readMessages(teamName);
|
||||||
|
if (sentMessages.length > 0) {
|
||||||
|
messages = [...messages, ...sentMessages];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warnings.push('Sent messages failed to load');
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||||
|
|
||||||
let metaMembers: TeamConfig['members'] = [];
|
let metaMembers: TeamConfig['members'] = [];
|
||||||
try {
|
try {
|
||||||
metaMembers = await this.membersMetaStore.getMembers(teamName);
|
metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
|
|
@ -211,6 +230,9 @@ export class TeamDataService {
|
||||||
messages
|
messages
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Enrich members with git branch when it differs from lead's branch
|
||||||
|
await this.enrichMemberBranches(members, config);
|
||||||
|
|
||||||
// Auto-sync: create comments from task-related inbox messages
|
// Auto-sync: create comments from task-related inbox messages
|
||||||
if (tasksLoaded && messages.length > 0) {
|
if (tasksLoaded && messages.length > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -241,6 +263,84 @@ export class TeamDataService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||||
|
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||||
|
*/
|
||||||
|
private async enrichMemberBranches(
|
||||||
|
members: ResolvedTeamMember[],
|
||||||
|
config: TeamConfig
|
||||||
|
): Promise<void> {
|
||||||
|
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
|
||||||
|
const leadEntry = config.members?.find((m) => m.name === 'team-lead');
|
||||||
|
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
||||||
|
if (!leadCwd) return;
|
||||||
|
|
||||||
|
let leadBranch: string | null = null;
|
||||||
|
try {
|
||||||
|
leadBranch = await gitIdentityResolver.getBranch(leadCwd);
|
||||||
|
} catch {
|
||||||
|
// Lead cwd may not be a git repo — skip enrichment entirely
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
members.map(async (member) => {
|
||||||
|
if (!member.cwd || member.cwd === leadCwd) return;
|
||||||
|
try {
|
||||||
|
const branch = await gitIdentityResolver.getBranch(member.cwd);
|
||||||
|
if (branch && branch !== leadBranch) {
|
||||||
|
// eslint-disable-next-line no-param-reassign -- intentional in-place enrichment
|
||||||
|
member.gitBranch = branch;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Member cwd may not be a git repo — skip silently
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
|
||||||
|
const members = await this.membersMetaStore.getMembers(teamName);
|
||||||
|
const existing = members.find((m) => m.name.toLowerCase() === request.name.toLowerCase());
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.removedAt) {
|
||||||
|
throw new Error(`Name "${request.name}" was previously used by a removed member`);
|
||||||
|
}
|
||||||
|
throw new Error(`Member "${request.name}" already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMember: TeamMember = {
|
||||||
|
name: request.name,
|
||||||
|
role: request.role?.trim() || undefined,
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
color: getMemberColor(members.filter((m) => !m.removedAt).length),
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
members.push(newMember);
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(teamName: string, memberName: string): Promise<void> {
|
||||||
|
const members = await this.membersMetaStore.getMembers(teamName);
|
||||||
|
const member = members.find((m) => m.name === memberName);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new Error(`Member "${memberName}" not found`);
|
||||||
|
}
|
||||||
|
if (member.removedAt) {
|
||||||
|
throw new Error(`Member "${memberName}" is already removed`);
|
||||||
|
}
|
||||||
|
if (member.agentType === 'team-lead') {
|
||||||
|
throw new Error('Cannot remove team lead');
|
||||||
|
}
|
||||||
|
|
||||||
|
member.removedAt = Date.now();
|
||||||
|
await this.membersMetaStore.writeMembers(teamName, members);
|
||||||
|
}
|
||||||
|
|
||||||
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> {
|
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> {
|
||||||
const nextId = await this.taskReader.getNextTaskId(teamName);
|
const nextId = await this.taskReader.getNextTaskId(teamName);
|
||||||
|
|
||||||
|
|
@ -364,6 +464,10 @@ export class TeamDataService {
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, status);
|
await this.taskWriter.updateStatus(teamName, taskId, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||||
|
await this.taskWriter.updateOwner(teamName, taskId, owner);
|
||||||
|
}
|
||||||
|
|
||||||
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||||
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
||||||
|
|
||||||
|
|
@ -398,6 +502,54 @@ export class TeamDataService {
|
||||||
return this.inboxWriter.sendMessage(teamName, request);
|
return this.inboxWriter.sendMessage(teamName, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sendDirectToLead(
|
||||||
|
teamName: string,
|
||||||
|
leadName: string,
|
||||||
|
text: string,
|
||||||
|
summary?: string
|
||||||
|
): Promise<SendMessageResult> {
|
||||||
|
const messageId = randomUUID();
|
||||||
|
const msg: InboxMessage = {
|
||||||
|
from: 'user',
|
||||||
|
to: leadName,
|
||||||
|
text,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
read: true,
|
||||||
|
summary,
|
||||||
|
messageId,
|
||||||
|
source: 'user_sent',
|
||||||
|
};
|
||||||
|
await this.sentMessagesStore.appendMessage(teamName, msg);
|
||||||
|
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeadMemberName(teamName: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const config = await this.configReader.getConfig(teamName);
|
||||||
|
|
||||||
|
// Check config.json members first (Claude Code-created teams)
|
||||||
|
if (config?.members?.length) {
|
||||||
|
const lead = config.members.find(
|
||||||
|
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
||||||
|
);
|
||||||
|
if (lead?.name) return lead.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check members.meta.json (UI-created teams)
|
||||||
|
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||||
|
if (metaMembers.length > 0) {
|
||||||
|
const lead = metaMembers.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead');
|
||||||
|
if (lead?.name) return lead.name;
|
||||||
|
return metaMembers[0]?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: check config.json first member
|
||||||
|
return config?.members?.[0]?.name ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||||
await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' });
|
await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' });
|
||||||
|
|
||||||
|
|
@ -630,4 +782,12 @@ export class TeamDataService {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateKanbanColumnOrder(
|
||||||
|
teamName: string,
|
||||||
|
columnId: KanbanColumnId,
|
||||||
|
orderedTaskIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
await this.kanbanManager.updateColumnOrder(teamName, columnId, orderedTaskIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,12 @@ import * as path from 'path';
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
import type { KanbanState, UpdateKanbanPatch } from '@shared/types';
|
import type { KanbanColumnId, KanbanState, UpdateKanbanPatch } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamKanbanManager');
|
const logger = createLogger('Service:TeamKanbanManager');
|
||||||
|
|
||||||
|
const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved'];
|
||||||
|
|
||||||
function createDefaultState(teamName: string): KanbanState {
|
function createDefaultState(teamName: string): KanbanState {
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -21,6 +23,23 @@ function isValidColumn(value: unknown): value is 'review' | 'approved' {
|
||||||
return value === 'review' || value === 'approved';
|
return value === 'review' || value === 'approved';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeColumnOrder(raw: unknown): KanbanState['columnOrder'] | undefined {
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const result: NonNullable<KanbanState['columnOrder']> = {};
|
||||||
|
for (const colId of KANBAN_COLUMN_IDS) {
|
||||||
|
const arr = (raw as Record<string, unknown>)[colId];
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
const ids = arr.filter((id): id is string => typeof id === 'string');
|
||||||
|
if (ids.length > 0) {
|
||||||
|
result[colId] = ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamKanbanManager {
|
export class TeamKanbanManager {
|
||||||
async getState(teamName: string): Promise<KanbanState> {
|
async getState(teamName: string): Promise<KanbanState> {
|
||||||
const statePath = this.getStatePath(teamName);
|
const statePath = this.getStatePath(teamName);
|
||||||
|
|
@ -72,9 +91,25 @@ export class TeamKanbanManager {
|
||||||
? parsed.reviewers.filter((r): r is string => typeof r === 'string' && r.trim().length > 0)
|
? parsed.reviewers.filter((r): r is string => typeof r === 'string' && r.trim().length > 0)
|
||||||
: [],
|
: [],
|
||||||
tasks: sanitizedTasks,
|
tasks: sanitizedTasks,
|
||||||
|
columnOrder: sanitizeColumnOrder(parsed.columnOrder),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateColumnOrder(
|
||||||
|
teamName: string,
|
||||||
|
columnId: KanbanColumnId,
|
||||||
|
orderedTaskIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
const state = await this.getState(teamName);
|
||||||
|
const columnOrder = { ...state.columnOrder };
|
||||||
|
if (orderedTaskIds.length > 0) {
|
||||||
|
columnOrder[columnId] = orderedTaskIds;
|
||||||
|
} else {
|
||||||
|
delete columnOrder[columnId];
|
||||||
|
}
|
||||||
|
await this.writeState(teamName, { ...state, columnOrder });
|
||||||
|
}
|
||||||
|
|
||||||
async updateTask(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
|
async updateTask(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
|
||||||
const state = await this.getState(teamName);
|
const state = await this.getState(teamName);
|
||||||
|
|
||||||
|
|
@ -106,12 +141,32 @@ export class TeamKanbanManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let columnOrderChanged = false;
|
||||||
|
if (state.columnOrder) {
|
||||||
|
const cleaned: NonNullable<KanbanState['columnOrder']> = {};
|
||||||
|
for (const [colId, ids] of Object.entries(state.columnOrder)) {
|
||||||
|
const valid = ids.filter((id) => validTaskIds.has(id));
|
||||||
|
if (valid.length > 0) {
|
||||||
|
cleaned[colId as KanbanColumnId] = valid;
|
||||||
|
}
|
||||||
|
if (valid.length !== ids.length) {
|
||||||
|
columnOrderChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (columnOrderChanged) {
|
||||||
|
state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const after = Object.keys(state.tasks).length;
|
const after = Object.keys(state.tasks).length;
|
||||||
if (before === after) {
|
const tasksChanged = before !== after;
|
||||||
|
if (!tasksChanged && !columnOrderChanged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`);
|
if (tasksChanged) {
|
||||||
|
logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`);
|
||||||
|
}
|
||||||
await this.writeState(teamName, state);
|
await this.writeState(teamName, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,6 +180,9 @@ export class TeamKanbanManager {
|
||||||
teamName,
|
teamName,
|
||||||
reviewers: state.reviewers,
|
reviewers: state.reviewers,
|
||||||
tasks: state.tasks,
|
tasks: state.tasks,
|
||||||
|
...(state.columnOrder && Object.keys(state.columnOrder).length > 0
|
||||||
|
? { columnOrder: state.columnOrder }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2));
|
await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export class TeamMemberResolver {
|
||||||
|
|
||||||
const configMemberMap = new Map<
|
const configMemberMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ agentType?: string; role?: string; color?: string }
|
{ agentType?: string; role?: string; color?: string; cwd?: string }
|
||||||
>();
|
>();
|
||||||
if (Array.isArray(config.members)) {
|
if (Array.isArray(config.members)) {
|
||||||
for (const m of config.members) {
|
for (const m of config.members) {
|
||||||
|
|
@ -49,12 +49,16 @@ export class TeamMemberResolver {
|
||||||
agentType: m.agentType,
|
agentType: m.agentType,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
color: m.color,
|
color: m.color,
|
||||||
|
cwd: m.cwd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaMemberMap = new Map<string, { agentType?: string; role?: string; color?: string }>();
|
const metaMemberMap = new Map<
|
||||||
|
string,
|
||||||
|
{ agentType?: string; role?: string; color?: string; removedAt?: number }
|
||||||
|
>();
|
||||||
if (Array.isArray(metaMembers)) {
|
if (Array.isArray(metaMembers)) {
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
||||||
|
|
@ -62,6 +66,7 @@ export class TeamMemberResolver {
|
||||||
agentType: member.agentType,
|
agentType: member.agentType,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
color: member.color,
|
color: member.color,
|
||||||
|
removedAt: member.removedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +94,8 @@ export class TeamMemberResolver {
|
||||||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
||||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||||
role: configMember?.role ?? metaMember?.role,
|
role: configMember?.role ?? metaMember?.role,
|
||||||
|
cwd: configMember?.cwd,
|
||||||
|
removedAt: metaMember?.removedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
||||||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||||
joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined,
|
joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined,
|
||||||
agentId: typeof member.agentId === 'string' ? member.agentId : undefined,
|
agentId: typeof member.agentId === 'string' ? member.agentId : undefined,
|
||||||
|
removedAt: typeof member.removedAt === 'number' ? member.removedAt : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
|
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
|
||||||
|
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||||
import {
|
import {
|
||||||
encodePath,
|
encodePath,
|
||||||
extractBaseDir,
|
extractBaseDir,
|
||||||
|
|
@ -8,9 +9,11 @@ import {
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
|
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { execFile, spawn } from 'child_process';
|
import { execFile, spawn } from 'child_process';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { app } from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -22,6 +25,7 @@ import { withInboxLock } from './inboxLock';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
|
@ -42,7 +46,7 @@ const VERIFY_POLL_MS = 500;
|
||||||
const STDERR_RING_LIMIT = 64 * 1024;
|
const STDERR_RING_LIMIT = 64 * 1024;
|
||||||
const STDOUT_RING_LIMIT = 64 * 1024;
|
const STDOUT_RING_LIMIT = 64 * 1024;
|
||||||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||||
const UI_LOGS_TAIL_LIMIT = 8000;
|
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||||
const CLI_PREPARE_TIMEOUT_MS = 10000;
|
const CLI_PREPARE_TIMEOUT_MS = 10000;
|
||||||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||||
|
|
@ -120,6 +124,11 @@ interface ProvisioningRun {
|
||||||
rejectOnce: (error: string) => void;
|
rejectOnce: (error: string) => void;
|
||||||
timeoutHandle: NodeJS.Timeout;
|
timeoutHandle: NodeJS.Timeout;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* Accumulates assistant text for direct user→lead messages (no relay capture active).
|
||||||
|
* Flushed to liveLeadProcessMessages on result.success.
|
||||||
|
*/
|
||||||
|
directReplyParts: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProvisioningAuthSource =
|
type ProvisioningAuthSource =
|
||||||
|
|
@ -279,11 +288,20 @@ function buildTaskStatusProtocol(teamName: string): string {
|
||||||
Failure to follow this protocol means the task board will show incorrect status.`;
|
Failure to follow this protocol means the task board will show incorrect status.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAgentLanguageInstruction(): string {
|
||||||
|
const config = ConfigManager.getInstance().getConfig();
|
||||||
|
const langCode = config.general.agentLanguage || 'system';
|
||||||
|
const systemLocale = app.getLocale();
|
||||||
|
const languageName = resolveLanguageName(langCode, systemLocale);
|
||||||
|
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
const displayName = request.displayName?.trim() || request.teamName;
|
const displayName = request.displayName?.trim() || request.teamName;
|
||||||
const description = request.description?.trim() || 'No description';
|
const description = request.description?.trim() || 'No description';
|
||||||
const members = buildMembersPrompt(request.members);
|
const members = buildMembersPrompt(request.members);
|
||||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||||
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
const userPromptBlock = request.prompt?.trim()
|
const userPromptBlock = request.prompt?.trim()
|
||||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||||
: '';
|
: '';
|
||||||
|
|
@ -296,6 +314,8 @@ You are "${leadName}", the team lead.
|
||||||
|
|
||||||
Goal: Provision a Claude Code agent team with live teammates.
|
Goal: Provision a Claude Code agent team with live teammates.
|
||||||
${userPromptBlock}
|
${userPromptBlock}
|
||||||
|
${languageInstruction}
|
||||||
|
|
||||||
Constraints:
|
Constraints:
|
||||||
- Do NOT call TeamDelete under any circumstances.
|
- Do NOT call TeamDelete under any circumstances.
|
||||||
- Do NOT use TodoWrite.
|
- Do NOT use TodoWrite.
|
||||||
|
|
@ -327,7 +347,8 @@ Steps (execute in this exact order):
|
||||||
- subagent_type: "general-purpose"
|
- subagent_type: "general-purpose"
|
||||||
- prompt:
|
- prompt:
|
||||||
You are {name}, a {role} on team "${displayName}" (${request.teamName}).
|
You are {name}, a {role} on team "${displayName}" (${request.teamName}).
|
||||||
Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
|
${languageInstruction}
|
||||||
|
Introduce yourself briefly (name and role) and confirm you are ready.
|
||||||
Then wait for task assignments.
|
Then wait for task assignments.
|
||||||
|
|
||||||
${taskProtocol}
|
${taskProtocol}
|
||||||
|
|
@ -352,6 +373,7 @@ function buildLaunchPrompt(
|
||||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||||
: '';
|
: '';
|
||||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||||
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
|
|
||||||
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||||
|
|
||||||
|
|
@ -360,6 +382,8 @@ You are "${leadName}", the team lead.
|
||||||
|
|
||||||
Goal: Reconnect with existing team "${request.teamName}".
|
Goal: Reconnect with existing team "${request.teamName}".
|
||||||
${userPromptBlock}
|
${userPromptBlock}
|
||||||
|
${languageInstruction}
|
||||||
|
|
||||||
Constraints:
|
Constraints:
|
||||||
- Do NOT call TeamDelete under any circumstances.
|
- Do NOT call TeamDelete under any circumstances.
|
||||||
- Do NOT use TodoWrite.
|
- Do NOT use TodoWrite.
|
||||||
|
|
@ -392,7 +416,8 @@ Steps (execute in this exact order):
|
||||||
- subagent_type: "general-purpose"
|
- subagent_type: "general-purpose"
|
||||||
- prompt:
|
- prompt:
|
||||||
You are {name}, a {role} on team "${request.teamName}".
|
You are {name}, a {role} on team "${request.teamName}".
|
||||||
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
|
${languageInstruction}
|
||||||
|
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready.
|
||||||
Then resume any pending work you own (if any) and wait for new assignments.
|
Then resume any pending work you own (if any) and wait for new assignments.
|
||||||
|
|
||||||
${taskProtocol}
|
${taskProtocol}
|
||||||
|
|
@ -501,7 +526,8 @@ export class TeamProvisioningService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||||
|
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
|
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
|
||||||
|
|
@ -668,6 +694,7 @@ export class TeamProvisioningService {
|
||||||
isLaunch: false,
|
isLaunch: false,
|
||||||
fsPhase: 'waiting_config',
|
fsPhase: 'waiting_config',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
|
directReplyParts: [],
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -706,6 +733,7 @@ export class TeamProvisioningService {
|
||||||
'--disallowedTools',
|
'--disallowedTools',
|
||||||
'TeamDelete,TodoWrite',
|
'TeamDelete,TodoWrite',
|
||||||
'--dangerously-skip-permissions',
|
'--dangerously-skip-permissions',
|
||||||
|
...(request.model ? ['--model', request.model] : []),
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
|
|
@ -936,6 +964,7 @@ export class TeamProvisioningService {
|
||||||
isLaunch: true,
|
isLaunch: true,
|
||||||
fsPhase: 'waiting_members',
|
fsPhase: 'waiting_members',
|
||||||
leadRelayCapture: null,
|
leadRelayCapture: null,
|
||||||
|
directReplyParts: [],
|
||||||
progress: {
|
progress: {
|
||||||
runId,
|
runId,
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
|
|
@ -984,6 +1013,9 @@ export class TeamProvisioningService {
|
||||||
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
|
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (request.model) {
|
||||||
|
launchArgs.push('--model', request.model);
|
||||||
|
}
|
||||||
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say
|
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say
|
||||||
// --resume is for existing sessions and may show an interactive picker if not found.
|
// --resume is for existing sessions and may show an interactive picker if not found.
|
||||||
|
|
||||||
|
|
@ -1140,7 +1172,11 @@ export class TeamProvisioningService {
|
||||||
* Send a message to the team's lead process via stream-json stdin.
|
* Send a message to the team's lead process via stream-json stdin.
|
||||||
* The lead will receive it as a new user turn and can delegate to teammates.
|
* The lead will receive it as a new user turn and can delegate to teammates.
|
||||||
*/
|
*/
|
||||||
async sendMessageToTeam(teamName: string, message: string): Promise<void> {
|
async sendMessageToTeam(
|
||||||
|
teamName: string,
|
||||||
|
message: string,
|
||||||
|
attachments?: { data: string; mimeType: string }[]
|
||||||
|
): Promise<void> {
|
||||||
const runId = this.activeByTeam.get(teamName);
|
const runId = this.activeByTeam.get(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
throw new Error(`No active process for team "${teamName}"`);
|
throw new Error(`No active process for team "${teamName}"`);
|
||||||
|
|
@ -1149,11 +1185,26 @@ export class TeamProvisioningService {
|
||||||
if (!run?.child?.stdin?.writable) {
|
if (!run?.child?.stdin?.writable) {
|
||||||
throw new Error(`Team "${teamName}" process stdin is not writable`);
|
throw new Error(`Team "${teamName}" process stdin is not writable`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
|
||||||
|
if (attachments?.length) {
|
||||||
|
for (const att of attachments) {
|
||||||
|
contentBlocks.push({
|
||||||
|
type: 'image',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: att.mimeType,
|
||||||
|
data: att.data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
type: 'user',
|
type: 'user',
|
||||||
message: {
|
message: {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [{ type: 'text', text: message }],
|
content: contentBlocks,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
run.child.stdin.write(payload + '\n');
|
run.child.stdin.write(payload + '\n');
|
||||||
|
|
@ -1273,6 +1324,8 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
run.leadRelayCapture = capture;
|
run.leadRelayCapture = capture;
|
||||||
|
// Clear any direct reply parts — relay capture takes priority
|
||||||
|
run.directReplyParts = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1442,7 +1495,7 @@ export class TeamProvisioningService {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||||
const MAX = 100;
|
const MAX = 100;
|
||||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
||||||
list.push(message);
|
list.push(message);
|
||||||
|
|
@ -1512,6 +1565,9 @@ export class TeamProvisioningService {
|
||||||
capture.resolveOnce(combined);
|
capture.resolveOnce(combined);
|
||||||
}, capture.idleMs);
|
}, capture.idleMs);
|
||||||
}
|
}
|
||||||
|
} else if (run.provisioningComplete) {
|
||||||
|
// Accumulate assistant text for direct user→lead messages (no relay capture).
|
||||||
|
run.directReplyParts.push(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1532,6 +1588,35 @@ export class TeamProvisioningService {
|
||||||
const capture = run.leadRelayCapture;
|
const capture = run.leadRelayCapture;
|
||||||
const combined = capture.textParts.join('').trim();
|
const combined = capture.textParts.join('').trim();
|
||||||
capture.resolveOnce(combined);
|
capture.resolveOnce(combined);
|
||||||
|
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
|
||||||
|
// Flush accumulated assistant reply from direct user→lead message
|
||||||
|
const replyText = run.directReplyParts.join('').trim();
|
||||||
|
run.directReplyParts = [];
|
||||||
|
const leadName =
|
||||||
|
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||||
|
'team-lead';
|
||||||
|
if (replyText.length > 0) {
|
||||||
|
const replyMsg: InboxMessage = {
|
||||||
|
from: leadName,
|
||||||
|
to: 'user',
|
||||||
|
text: replyText,
|
||||||
|
timestamp: nowIso(),
|
||||||
|
read: true,
|
||||||
|
summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText,
|
||||||
|
messageId: `lead-direct-${run.runId}-${Date.now()}`,
|
||||||
|
source: 'lead_process',
|
||||||
|
};
|
||||||
|
this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
|
||||||
|
// Persist to disk so replies survive app restart
|
||||||
|
void this.sentMessagesStore
|
||||||
|
.appendMessage(run.teamName, replyMsg)
|
||||||
|
.catch(() => undefined);
|
||||||
|
this.teamChangeEmitter?.({
|
||||||
|
type: 'inbox',
|
||||||
|
teamName: run.teamName,
|
||||||
|
detail: 'lead-direct-reply',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!run.provisioningComplete) {
|
if (!run.provisioningComplete) {
|
||||||
void this.handleProvisioningTurnComplete(run);
|
void this.handleProvisioningTurnComplete(run);
|
||||||
|
|
|
||||||
75
src/main/services/team/TeamSentMessagesStore.ts
Normal file
75
src/main/services/team/TeamSentMessagesStore.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
|
const MAX_MESSAGES = 200;
|
||||||
|
|
||||||
|
export class TeamSentMessagesStore {
|
||||||
|
private getFilePath(teamName: string): string {
|
||||||
|
return path.join(getTeamsBasePath(), teamName, 'sentMessages.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async readMessages(teamName: string): Promise<InboxMessage[]> {
|
||||||
|
const filePath = this.getFilePath(teamName);
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.promises.readFile(filePath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw) as unknown;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: InboxMessage[] = [];
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (!item || typeof item !== 'object') continue;
|
||||||
|
const row = item as Partial<InboxMessage>;
|
||||||
|
if (
|
||||||
|
typeof row.from !== 'string' ||
|
||||||
|
typeof row.text !== 'string' ||
|
||||||
|
typeof row.timestamp !== 'string'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
messages.push({
|
||||||
|
from: row.from,
|
||||||
|
to: typeof row.to === 'string' ? row.to : undefined,
|
||||||
|
text: row.text,
|
||||||
|
timestamp: row.timestamp,
|
||||||
|
read: typeof row.read === 'boolean' ? row.read : true,
|
||||||
|
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||||
|
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
|
||||||
|
source: 'user_sent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async appendMessage(teamName: string, message: InboxMessage): Promise<void> {
|
||||||
|
const existing = await this.readMessages(teamName);
|
||||||
|
existing.push(message);
|
||||||
|
|
||||||
|
// Trim to MAX_MESSAGES (keep newest)
|
||||||
|
const trimmed = existing.length > MAX_MESSAGES ? existing.slice(-MAX_MESSAGES) : existing;
|
||||||
|
|
||||||
|
await atomicWriteAsync(this.getFilePath(teamName), JSON.stringify(trimmed, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,7 @@ export class TeamTaskReader {
|
||||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
|
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||||
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||||
parsed.status as TeamTask['status']
|
parsed.status as TeamTask['status']
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,30 @@ export class TeamTaskWriter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||||
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
|
|
||||||
|
await withTaskLock(taskPath, async () => {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.promises.readFile(taskPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
|
if (owner) {
|
||||||
|
task.owner = owner;
|
||||||
|
} else {
|
||||||
|
delete task.owner;
|
||||||
|
}
|
||||||
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async addComment(
|
async addComment(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||||
export { MemberStatsComputer } from './MemberStatsComputer';
|
export { MemberStatsComputer } from './MemberStatsComputer';
|
||||||
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
||||||
|
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||||
export { TeamConfigReader } from './TeamConfigReader';
|
export { TeamConfigReader } from './TeamConfigReader';
|
||||||
export { TeamDataService } from './TeamDataService';
|
export { TeamDataService } from './TeamDataService';
|
||||||
export { TeamInboxReader } from './TeamInboxReader';
|
export { TeamInboxReader } from './TeamInboxReader';
|
||||||
|
|
@ -10,5 +11,6 @@ export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
export { TeamMemberResolver } from './TeamMemberResolver';
|
export { TeamMemberResolver } from './TeamMemberResolver';
|
||||||
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
export { TeamProvisioningService } from './TeamProvisioningService';
|
export { TeamProvisioningService } from './TeamProvisioningService';
|
||||||
|
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
export { TeamTaskReader } from './TeamTaskReader';
|
export { TeamTaskReader } from './TeamTaskReader';
|
||||||
export { TeamTaskWriter } from './TeamTaskWriter';
|
export { TeamTaskWriter } from './TeamTaskWriter';
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,9 @@ export const TEAM_GET_DATA = 'team:getData';
|
||||||
/** Update team kanban state */
|
/** Update team kanban state */
|
||||||
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
|
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
|
||||||
|
|
||||||
|
/** Update kanban column task order (drag-and-drop within column) */
|
||||||
|
export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
|
||||||
|
|
||||||
/** Send inbox message to team member */
|
/** Send inbox message to team member */
|
||||||
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
||||||
|
|
||||||
|
|
@ -230,6 +233,9 @@ export const TEAM_CREATE_TASK = 'team:createTask';
|
||||||
/** Update task status directly (pending/in_progress/completed) */
|
/** Update task status directly (pending/in_progress/completed) */
|
||||||
export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
|
export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
|
||||||
|
|
||||||
|
/** Update task owner (reassign) */
|
||||||
|
export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner';
|
||||||
|
|
||||||
/** Delete a team and its associated task directory */
|
/** Delete a team and its associated task directory */
|
||||||
export const TEAM_DELETE_TEAM = 'team:deleteTeam';
|
export const TEAM_DELETE_TEAM = 'team:deleteTeam';
|
||||||
|
|
||||||
|
|
@ -260,3 +266,15 @@ export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';
|
||||||
|
|
||||||
/** Add a comment to a task */
|
/** Add a comment to a task */
|
||||||
export const TEAM_ADD_TASK_COMMENT = 'team:addTaskComment';
|
export const TEAM_ADD_TASK_COMMENT = 'team:addTaskComment';
|
||||||
|
|
||||||
|
/** Get current git branch for a project path (live read from .git/HEAD) */
|
||||||
|
export const TEAM_GET_PROJECT_BRANCH = 'team:getProjectBranch';
|
||||||
|
|
||||||
|
/** Add a new member to an existing team */
|
||||||
|
export const TEAM_ADD_MEMBER = 'team:addMember';
|
||||||
|
|
||||||
|
/** Soft-delete a team member */
|
||||||
|
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
|
||||||
|
|
||||||
|
/** Get attachment data for a message */
|
||||||
|
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
SSH_SAVE_LAST_CONNECTION,
|
SSH_SAVE_LAST_CONNECTION,
|
||||||
SSH_STATUS,
|
SSH_STATUS,
|
||||||
SSH_TEST,
|
SSH_TEST,
|
||||||
|
TEAM_ADD_MEMBER,
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
TEAM_CANCEL_PROVISIONING,
|
TEAM_CANCEL_PROVISIONING,
|
||||||
|
|
@ -27,10 +28,12 @@ import {
|
||||||
TEAM_CREATE_TASK,
|
TEAM_CREATE_TASK,
|
||||||
TEAM_DELETE_TEAM,
|
TEAM_DELETE_TEAM,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
|
TEAM_GET_ATTACHMENTS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
TEAM_PREPARE_PROVISIONING,
|
TEAM_PREPARE_PROVISIONING,
|
||||||
|
|
@ -38,12 +41,15 @@ import {
|
||||||
TEAM_PROCESS_SEND,
|
TEAM_PROCESS_SEND,
|
||||||
TEAM_PROVISIONING_PROGRESS,
|
TEAM_PROVISIONING_PROGRESS,
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
|
TEAM_UPDATE_TASK_OWNER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
UPDATER_CHECK,
|
UPDATER_CHECK,
|
||||||
UPDATER_DOWNLOAD,
|
UPDATER_DOWNLOAD,
|
||||||
|
|
@ -84,7 +90,9 @@ import {
|
||||||
} from './constants/ipcChannels';
|
} from './constants/ipcChannels';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AddMemberRequest,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
AttachmentFileData,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
ContextInfo,
|
ContextInfo,
|
||||||
|
|
@ -93,6 +101,7 @@ import type {
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
HttpServerStatus,
|
HttpServerStatus,
|
||||||
IpcResult,
|
IpcResult,
|
||||||
|
KanbanColumnId,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
|
|
@ -553,9 +562,24 @@ const electronAPI: ElectronAPI = {
|
||||||
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
|
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_UPDATE_KANBAN, teamName, taskId, patch);
|
return invokeIpcWithResult<void>(TEAM_UPDATE_KANBAN, teamName, taskId, patch);
|
||||||
},
|
},
|
||||||
|
updateKanbanColumnOrder: async (
|
||||||
|
teamName: string,
|
||||||
|
columnId: KanbanColumnId,
|
||||||
|
orderedTaskIds: string[]
|
||||||
|
) => {
|
||||||
|
return invokeIpcWithResult<void>(
|
||||||
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
|
teamName,
|
||||||
|
columnId,
|
||||||
|
orderedTaskIds
|
||||||
|
);
|
||||||
|
},
|
||||||
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
|
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
|
||||||
},
|
},
|
||||||
|
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner);
|
||||||
|
},
|
||||||
startTask: async (teamName: string, taskId: string) => {
|
startTask: async (teamName: string, taskId: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
|
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
|
||||||
},
|
},
|
||||||
|
|
@ -601,6 +625,18 @@ const electronAPI: ElectronAPI = {
|
||||||
addTaskComment: async (teamName: string, taskId: string, text: string) => {
|
addTaskComment: async (teamName: string, taskId: string, text: string) => {
|
||||||
return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, text);
|
return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, text);
|
||||||
},
|
},
|
||||||
|
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
|
||||||
|
},
|
||||||
|
removeMember: async (teamName: string, memberName: string) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
|
||||||
|
},
|
||||||
|
getProjectBranch: async (projectPath: string) => {
|
||||||
|
return invokeIpcWithResult<string | null>(TEAM_GET_PROJECT_BRANCH, projectPath);
|
||||||
|
},
|
||||||
|
getAttachments: async (teamName: string, messageId: string) => {
|
||||||
|
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
|
AttachmentFileData,
|
||||||
ClaudeMdFileInfo,
|
ClaudeMdFileInfo,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
|
@ -20,6 +21,7 @@ import type {
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
HttpServerAPI,
|
HttpServerAPI,
|
||||||
HttpServerStatus,
|
HttpServerStatus,
|
||||||
|
KanbanColumnId,
|
||||||
NotificationsAPI,
|
NotificationsAPI,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
PaginatedSessionsResult,
|
PaginatedSessionsResult,
|
||||||
|
|
@ -666,6 +668,13 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
throw new Error('Team kanban is not available in browser mode');
|
throw new Error('Team kanban is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
updateKanbanColumnOrder: async (
|
||||||
|
_teamName: string,
|
||||||
|
_columnId: KanbanColumnId,
|
||||||
|
_orderedTaskIds: string[]
|
||||||
|
): Promise<void> => {
|
||||||
|
throw new Error('Team kanban column order is not available in browser mode');
|
||||||
|
},
|
||||||
updateTaskStatus: async (
|
updateTaskStatus: async (
|
||||||
_teamName: string,
|
_teamName: string,
|
||||||
_taskId: string,
|
_taskId: string,
|
||||||
|
|
@ -673,6 +682,13 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
throw new Error('Team task status update is not available in browser mode');
|
throw new Error('Team task status update is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
updateTaskOwner: async (
|
||||||
|
_teamName: string,
|
||||||
|
_taskId: string,
|
||||||
|
_owner: string | null
|
||||||
|
): Promise<void> => {
|
||||||
|
throw new Error('Team task owner update is not available in browser mode');
|
||||||
|
},
|
||||||
startTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
startTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||||
throw new Error('Team start task is not available in browser mode');
|
throw new Error('Team start task is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
|
@ -726,6 +742,21 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
addTaskComment: async () => {
|
addTaskComment: async () => {
|
||||||
throw new Error('Task comments are not available in browser mode');
|
throw new Error('Task comments are not available in browser mode');
|
||||||
},
|
},
|
||||||
|
addMember: async (): Promise<void> => {
|
||||||
|
throw new Error('Team member management is not available in browser mode');
|
||||||
|
},
|
||||||
|
removeMember: async (): Promise<void> => {
|
||||||
|
throw new Error('Team member management is not available in browser mode');
|
||||||
|
},
|
||||||
|
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getAttachments: async (
|
||||||
|
_teamName: string,
|
||||||
|
_messageId: string
|
||||||
|
): Promise<AttachmentFileData[]> => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
return this.addEventListener('team-change', (data: unknown) =>
|
return this.addEventListener('team-change', (data: unknown) =>
|
||||||
callback(null, data as TeamChangeEvent)
|
callback(null, data as TeamChangeEvent)
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ interface MarkdownViewerProps {
|
||||||
itemId?: string;
|
itemId?: string;
|
||||||
/** When true, shows a copy button (overlay when no label, inline in header when label exists) */
|
/** When true, shows a copy button (overlay when no label, inline in header when label exists) */
|
||||||
copyable?: boolean;
|
copyable?: boolean;
|
||||||
|
/** When true, renders without wrapper background/border (for embedding inside cards) */
|
||||||
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -277,6 +279,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
label,
|
label,
|
||||||
itemId,
|
itemId,
|
||||||
copyable = false,
|
copyable = false,
|
||||||
|
bare = false,
|
||||||
}) => {
|
}) => {
|
||||||
// Only subscribe to search store when itemId is provided
|
// Only subscribe to search store when itemId is provided
|
||||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||||
|
|
@ -300,11 +303,15 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-0 overflow-hidden rounded-lg shadow-sm ${copyable && !label ? 'group relative' : ''} ${className}`}
|
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
|
||||||
style={{
|
style={
|
||||||
backgroundColor: CODE_BG,
|
bare
|
||||||
border: `1px solid ${CODE_BORDER}`,
|
? undefined
|
||||||
}}
|
: {
|
||||||
|
backgroundColor: CODE_BG,
|
||||||
|
border: `1px solid ${CODE_BORDER}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* Copy button overlay (when no label header) */}
|
{/* Copy button overlay (when no label header) */}
|
||||||
{copyable && !label && <CopyButton text={content} />}
|
{copyable && !label && <CopyButton text={content} />}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
|
||||||
import {
|
import {
|
||||||
buildTaskCountsByProject,
|
buildTaskCountsByProject,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
|
|
@ -256,14 +257,39 @@ const RepositoryCard = ({
|
||||||
// Ghost Card (New Project)
|
// Ghost Card (New Project)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
interface WorktreeMatch {
|
||||||
|
repoId: string;
|
||||||
|
worktreeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findMatchingWorktree(
|
||||||
|
groups: RepositoryGroup[],
|
||||||
|
selectedPath: string
|
||||||
|
): WorktreeMatch | null {
|
||||||
|
const norm = normalizePath(selectedPath);
|
||||||
|
for (const repo of groups) {
|
||||||
|
for (const worktree of repo.worktrees) {
|
||||||
|
if (normalizePath(worktree.path) === norm) {
|
||||||
|
return { repoId: repo.id, worktreeId: worktree.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const NewProjectCard = (): React.JSX.Element => {
|
const NewProjectCard = (): React.JSX.Element => {
|
||||||
const { repositoryGroups, selectRepository } = useStore(
|
const { repositoryGroups, fetchRepositoryGroups } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
selectRepository: s.selectRepository,
|
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const navigateToMatch = (match: WorktreeMatch): void => {
|
||||||
|
useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId));
|
||||||
|
void useStore.getState().fetchSessionsInitial(match.worktreeId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = async (): Promise<void> => {
|
const handleClick = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const selectedPaths = await api.config.selectFolders();
|
const selectedPaths = await api.config.selectFolders();
|
||||||
|
|
@ -273,17 +299,23 @@ const NewProjectCard = (): React.JSX.Element => {
|
||||||
|
|
||||||
const selectedPath = selectedPaths[0];
|
const selectedPath = selectedPaths[0];
|
||||||
|
|
||||||
// Match selected path against known repository worktrees
|
// Match selected path against known repository worktrees (normalized comparison)
|
||||||
for (const repo of repositoryGroups) {
|
const match = findMatchingWorktree(repositoryGroups, selectedPath);
|
||||||
for (const worktree of repo.worktrees) {
|
if (match) {
|
||||||
if (worktree.path === selectedPath) {
|
navigateToMatch(match);
|
||||||
selectRepository(repo.id);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No match found - open the folder in file manager as fallback
|
// No match — refresh repository groups and retry
|
||||||
|
await fetchRepositoryGroups();
|
||||||
|
const refreshedGroups = useStore.getState().repositoryGroups;
|
||||||
|
const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath);
|
||||||
|
if (matchAfterRefresh) {
|
||||||
|
navigateToMatch(matchAfterRefresh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still no match — open the folder in file manager as fallback
|
||||||
const result = await api.openPath(selectedPath, undefined, true);
|
const result = await api.openPath(selectedPath, undefined, true);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error('Failed to open folder:', result.error);
|
logger.error('Failed to open folder:', result.error);
|
||||||
|
|
|
||||||
|
|
@ -115,49 +115,79 @@ export const Sidebar = (): React.JSX.Element => {
|
||||||
>
|
>
|
||||||
<SidebarHeader />
|
<SidebarHeader />
|
||||||
|
|
||||||
{/* Tab bar: Tasks | Sessions */}
|
{/* Tab bar: Tasks | Sessions — tab strip style, filters on the right */}
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5"
|
className="flex shrink-0 items-end gap-2 border-b px-3 pt-1"
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex flex-1" />
|
||||||
|
<div className="flex" role="tablist" aria-label="Sidebar view">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
role="tab"
|
||||||
sidebarTab === 'tasks'
|
aria-selected={sidebarTab === 'tasks'}
|
||||||
? 'bg-surface-raised text-text'
|
aria-controls="sidebar-tasks-panel"
|
||||||
: 'text-text-muted hover:text-text-secondary'
|
id="sidebar-tab-tasks"
|
||||||
|
className={`relative px-3 py-1.5 text-[11px] font-medium transition-colors ${
|
||||||
|
sidebarTab === 'tasks' ? 'text-text' : 'text-text-muted hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
|
style={
|
||||||
|
sidebarTab === 'tasks'
|
||||||
|
? {
|
||||||
|
borderBottom: '2px solid var(--color-text)',
|
||||||
|
marginBottom: '-1px',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => setSidebarTab('tasks')}
|
onClick={() => setSidebarTab('tasks')}
|
||||||
>
|
>
|
||||||
Tasks
|
Tasks
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
role="tab"
|
||||||
|
aria-selected={sidebarTab === 'sessions'}
|
||||||
|
aria-controls="sidebar-sessions-panel"
|
||||||
|
id="sidebar-tab-sessions"
|
||||||
|
className={`relative px-3 py-1.5 text-[11px] font-medium transition-colors ${
|
||||||
sidebarTab === 'sessions'
|
sidebarTab === 'sessions'
|
||||||
? 'bg-surface-raised text-text'
|
? 'text-text'
|
||||||
: 'text-text-muted hover:text-text-secondary'
|
: 'text-text-muted hover:text-text-secondary'
|
||||||
}`}
|
}`}
|
||||||
|
style={
|
||||||
|
sidebarTab === 'sessions'
|
||||||
|
? {
|
||||||
|
borderBottom: '2px solid var(--color-text)',
|
||||||
|
marginBottom: '-1px',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={() => setSidebarTab('sessions')}
|
onClick={() => setSidebarTab('sessions')}
|
||||||
>
|
>
|
||||||
Sessions
|
Sessions
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{sidebarTab === 'tasks' && (
|
<div className="flex flex-1 justify-end pb-0.5">
|
||||||
<TaskFiltersPopover
|
{sidebarTab === 'tasks' && (
|
||||||
open={taskFiltersPopoverOpen}
|
<TaskFiltersPopover
|
||||||
onOpenChange={setTaskFiltersPopoverOpen}
|
open={taskFiltersPopoverOpen}
|
||||||
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
|
onOpenChange={setTaskFiltersPopoverOpen}
|
||||||
filters={taskFilters}
|
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
|
||||||
onFiltersChange={setTaskFilters}
|
filters={taskFilters}
|
||||||
onApply={() => {}}
|
onFiltersChange={setTaskFilters}
|
||||||
/>
|
onApply={() => {}}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content: Tasks list or Sessions list */}
|
{/* Content: Tasks list or Sessions list */}
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<div
|
||||||
|
id={sidebarTab === 'tasks' ? 'sidebar-tasks-panel' : 'sidebar-sessions-panel'}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`sidebar-tab-${sidebarTab}`}
|
||||||
|
className="min-w-0 flex-1 overflow-hidden"
|
||||||
|
>
|
||||||
{sidebarTab === 'tasks' ? (
|
{sidebarTab === 'tasks' ? (
|
||||||
<GlobalTaskList
|
<GlobalTaskList
|
||||||
hideHeader
|
hideHeader
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ export const SettingsView = (): React.JSX.Element | null => {
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onGeneralToggle={handlers.handleGeneralToggle}
|
onGeneralToggle={handlers.handleGeneralToggle}
|
||||||
onThemeChange={handlers.handleThemeChange}
|
onThemeChange={handlers.handleThemeChange}
|
||||||
|
onLanguageChange={handlers.handleLanguageChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export interface SafeConfig {
|
||||||
theme: 'dark' | 'light' | 'system';
|
theme: 'dark' | 'light' | 'system';
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
claudeRootPath: string | null;
|
claudeRootPath: string | null;
|
||||||
|
agentLanguage: string;
|
||||||
};
|
};
|
||||||
notifications: {
|
notifications: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|
@ -154,6 +155,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
theme: displayConfig?.general?.theme ?? 'dark',
|
theme: displayConfig?.general?.theme ?? 'dark',
|
||||||
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
||||||
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
||||||
|
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
enabled: displayConfig?.notifications?.enabled ?? true,
|
enabled: displayConfig?.notifications?.enabled ?? true,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ interface SettingsHandlers {
|
||||||
// General handlers
|
// General handlers
|
||||||
handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
|
handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
|
||||||
handleThemeChange: (value: 'dark' | 'light' | 'system') => void;
|
handleThemeChange: (value: 'dark' | 'light' | 'system') => void;
|
||||||
|
handleLanguageChange: (value: string) => void;
|
||||||
handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void;
|
handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void;
|
||||||
|
|
||||||
// Notification handlers
|
// Notification handlers
|
||||||
|
|
@ -81,6 +82,13 @@ export function useSettingsHandlers({
|
||||||
[updateConfig]
|
[updateConfig]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleLanguageChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
void updateConfig('general', { agentLanguage: value });
|
||||||
|
},
|
||||||
|
[updateConfig]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDefaultTabChange = useCallback(
|
const handleDefaultTabChange = useCallback(
|
||||||
(value: 'dashboard' | 'last-session') => {
|
(value: 'dashboard' | 'last-session') => {
|
||||||
void updateConfig('general', { defaultTab: value });
|
void updateConfig('general', { defaultTab: value });
|
||||||
|
|
@ -287,6 +295,7 @@ export function useSettingsHandlers({
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
defaultTab: 'dashboard',
|
defaultTab: 'dashboard',
|
||||||
claudeRootPath: null,
|
claudeRootPath: null,
|
||||||
|
agentLanguage: 'system',
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
|
|
@ -373,6 +382,7 @@ export function useSettingsHandlers({
|
||||||
return {
|
return {
|
||||||
handleGeneralToggle,
|
handleGeneralToggle,
|
||||||
handleThemeChange,
|
handleThemeChange,
|
||||||
|
handleLanguageChange,
|
||||||
handleDefaultTabChange,
|
handleDefaultTabChange,
|
||||||
handleNotificationToggle,
|
handleNotificationToggle,
|
||||||
handleSnooze,
|
handleSnooze,
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api, isElectronMode } from '@renderer/api';
|
import { api, isElectronMode } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
|
import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||||
|
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||||
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
||||||
|
|
@ -28,6 +30,7 @@ interface GeneralSectionProps {
|
||||||
readonly saving: boolean;
|
readonly saving: boolean;
|
||||||
readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void;
|
readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void;
|
||||||
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
|
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
|
||||||
|
readonly onLanguageChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GeneralSection = ({
|
export const GeneralSection = ({
|
||||||
|
|
@ -35,6 +38,7 @@ export const GeneralSection = ({
|
||||||
saving,
|
saving,
|
||||||
onGeneralToggle,
|
onGeneralToggle,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onLanguageChange,
|
||||||
}: GeneralSectionProps): React.JSX.Element => {
|
}: GeneralSectionProps): React.JSX.Element => {
|
||||||
const [serverStatus, setServerStatus] = useState<HttpServerStatus>({
|
const [serverStatus, setServerStatus] = useState<HttpServerStatus>({
|
||||||
running: false,
|
running: false,
|
||||||
|
|
@ -247,8 +251,59 @@ export const GeneralSection = ({
|
||||||
|
|
||||||
const isElectron = useMemo(() => isElectronMode(), []);
|
const isElectron = useMemo(() => isElectronMode(), []);
|
||||||
|
|
||||||
|
const agentLanguageDescription = useMemo(() => {
|
||||||
|
const current = safeConfig.general.agentLanguage ?? 'system';
|
||||||
|
if (current === 'system') {
|
||||||
|
const browserLang = navigator.language;
|
||||||
|
const primaryCode = browserLang.includes('-') ? browserLang.split('-')[0] : browserLang;
|
||||||
|
const detected = resolveLanguageName('system', browserLang);
|
||||||
|
const detectedFlag = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === primaryCode)?.flag ?? '';
|
||||||
|
const flagPrefix = detectedFlag ? `${detectedFlag} ` : '';
|
||||||
|
return `Language for agent communication (detected: ${flagPrefix}${detected})`;
|
||||||
|
}
|
||||||
|
return 'Language for agent communication';
|
||||||
|
}, [safeConfig.general.agentLanguage]);
|
||||||
|
|
||||||
|
const languageComboboxOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
AGENT_LANGUAGE_OPTIONS.map((opt) => ({
|
||||||
|
value: opt.value,
|
||||||
|
label: `${opt.flag} ${opt.label}`,
|
||||||
|
meta: { flag: opt.flag },
|
||||||
|
})),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderLanguageOption = useCallback(
|
||||||
|
(
|
||||||
|
option: { value: string; label: string; meta?: Record<string, unknown> },
|
||||||
|
isSelected: boolean
|
||||||
|
) => (
|
||||||
|
<>
|
||||||
|
<Check className={`mr-2 size-3.5 shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
|
||||||
|
<span className="text-[var(--color-text)]">{option.label}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<SettingsSectionHeader title="Agent Language" />
|
||||||
|
<SettingRow label="Language" description={agentLanguageDescription}>
|
||||||
|
<Combobox
|
||||||
|
options={languageComboboxOptions}
|
||||||
|
value={safeConfig.general.agentLanguage ?? 'system'}
|
||||||
|
onValueChange={onLanguageChange}
|
||||||
|
placeholder="Select language..."
|
||||||
|
searchPlaceholder="Search language..."
|
||||||
|
emptyMessage="No language found."
|
||||||
|
disabled={saving}
|
||||||
|
className="min-w-[180px]"
|
||||||
|
renderOption={renderLanguageOption}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
{isElectron && (
|
{isElectron && (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionHeader title="Startup" />
|
<SettingsSectionHeader title="Startup" />
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import { cn } from '@renderer/lib/utils';
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@renderer/components/ui/select';
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import {
|
import {
|
||||||
|
|
@ -235,18 +229,30 @@ export const GlobalTaskList = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grouping mode */}
|
{/* Grouping mode — compact segmented toggle */}
|
||||||
<div className="flex shrink-0 items-center gap-2 px-2 py-1">
|
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
|
||||||
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
||||||
<Select value={groupingMode} onValueChange={(v) => setGroupingMode(v as TaskGroupingMode)}>
|
<div
|
||||||
<SelectTrigger className="h-7 min-w-0 flex-1 border-[var(--color-border)] px-2 text-[11px]">
|
className="bg-surface-raised/60 inline-flex rounded-md p-0.5 text-[11px]"
|
||||||
<SelectValue placeholder="Group by" />
|
role="group"
|
||||||
</SelectTrigger>
|
aria-label="Group by"
|
||||||
<SelectContent>
|
>
|
||||||
<SelectItem value="project">Project</SelectItem>
|
{(['project', 'time'] as const).map((mode) => (
|
||||||
<SelectItem value="time">Time</SelectItem>
|
<button
|
||||||
</SelectContent>
|
key={mode}
|
||||||
</Select>
|
type="button"
|
||||||
|
onClick={() => setGroupingMode(mode)}
|
||||||
|
className={cn(
|
||||||
|
'rounded px-2 py-0.5 transition-colors',
|
||||||
|
groupingMode === mode
|
||||||
|
? 'bg-surface-raised text-text shadow-sm'
|
||||||
|
: 'text-text-muted hover:text-text-secondary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{mode === 'project' ? 'Project' : 'Time'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,15 @@ export const CollapsibleTeamSection = ({
|
||||||
const isOpen = forceOpen ? true : open;
|
const isOpen = forceOpen ? true : open;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0">
|
<section className="border-b border-[var(--color-border)] pb-3 last:border-b-0">
|
||||||
<div className="flex items-center">
|
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex flex-1 items-center gap-2 text-left"
|
className="absolute inset-0 z-0 cursor-pointer rounded-md transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
>
|
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 basis-0 items-center gap-2 pl-4">
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
size={14}
|
size={14}
|
||||||
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
|
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
|
||||||
|
|
@ -60,8 +62,8 @@ export const CollapsibleTeamSection = ({
|
||||||
{secondaryBadge}
|
{secondaryBadge}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
{action && <div className="shrink-0">{action}</div>}
|
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && <div className="mt-2">{children}</div>}
|
{isOpen && <div className="mt-2">{children}</div>}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
74
src/renderer/components/team/MemberBadge.tsx
Normal file
74
src/renderer/components/team/MemberBadge.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
|
interface MemberBadgeProps {
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
/** Avatar + badge size variant */
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
onClick?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable member avatar + colored name badge.
|
||||||
|
* Avatar is rendered OUTSIDE the badge, to the left.
|
||||||
|
* When onClick is provided, both avatar and badge are clickable as one unit.
|
||||||
|
*/
|
||||||
|
export const MemberBadge = ({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
size = 'sm',
|
||||||
|
onClick,
|
||||||
|
}: MemberBadgeProps): React.JSX.Element => {
|
||||||
|
const colors = getTeamColorSet(color ?? '');
|
||||||
|
const avatarSize = size === 'md' ? 32 : 24;
|
||||||
|
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
|
||||||
|
const textClass = size === 'md' ? 'text-xs' : 'text-[10px]';
|
||||||
|
|
||||||
|
const badgeStyle = {
|
||||||
|
backgroundColor: colors.badge,
|
||||||
|
color: colors.text,
|
||||||
|
border: `1px solid ${colors.border}40`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatar = (
|
||||||
|
<img
|
||||||
|
src={agentAvatarUrl(name, avatarSize)}
|
||||||
|
alt=""
|
||||||
|
className={`${avatarClass} shrink-0 rounded-full bg-[var(--color-surface-raised)]`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = (
|
||||||
|
<span
|
||||||
|
className={`rounded px-1.5 py-0.5 ${textClass} font-medium tracking-wide`}
|
||||||
|
style={badgeStyle}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avatar}
|
||||||
|
{badge}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{avatar}
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { Loader2 } from 'lucide-react';
|
import hljs from 'highlight.js/lib/core';
|
||||||
|
import json from 'highlight.js/lib/languages/json';
|
||||||
|
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
hljs.registerLanguage('json', json);
|
||||||
|
|
||||||
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
|
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
|
||||||
|
|
||||||
|
|
@ -18,17 +24,93 @@ export interface ProvisioningProgressBlockProps {
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Cancel button label and handler */
|
/** Cancel button label and handler */
|
||||||
onCancel?: (() => void) | null;
|
onCancel?: (() => void) | null;
|
||||||
|
/** ISO timestamp when provisioning started */
|
||||||
|
startedAt?: string;
|
||||||
|
/** PID of the CLI process */
|
||||||
|
pid?: number;
|
||||||
|
/** Tail of CLI logs */
|
||||||
|
cliLogsTail?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatElapsed(seconds: number): string {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useElapsedTimer(startedAt?: string): string | null {
|
||||||
|
const [elapsed, setElapsed] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!startedAt) return () => setElapsed(null);
|
||||||
|
const startMs = Date.parse(startedAt);
|
||||||
|
if (isNaN(startMs)) return () => setElapsed(null);
|
||||||
|
|
||||||
|
const tick = (): void => {
|
||||||
|
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||||
|
setElapsed(formatElapsed(seconds));
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [startedAt]);
|
||||||
|
|
||||||
|
if (!startedAt) return null;
|
||||||
|
return elapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightLogsHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const trimmed = line.trimStart();
|
||||||
|
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
return hljs.highlight(line, { language: 'json' }).value;
|
||||||
|
} catch {
|
||||||
|
return escapeHtml(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line === '[stdout]' || line === '[stderr]') {
|
||||||
|
return `<span class="hljs-comment">${escapeHtml(line)}</span>`;
|
||||||
|
}
|
||||||
|
return escapeHtml(line);
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
export const ProvisioningProgressBlock = ({
|
export const ProvisioningProgressBlock = ({
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
currentStepIndex,
|
currentStepIndex,
|
||||||
loading = false,
|
loading = false,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
startedAt,
|
||||||
|
pid,
|
||||||
|
cliLogsTail,
|
||||||
className,
|
className,
|
||||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||||
|
const elapsed = useElapsedTimer(startedAt);
|
||||||
|
const [logsOpen, setLogsOpen] = useState(false);
|
||||||
|
const logsRef = useRef<HTMLPreElement>(null);
|
||||||
|
const highlightedHtml = useMemo(
|
||||||
|
() => (cliLogsTail ? highlightLogsHtml(cliLogsTail) : ''),
|
||||||
|
[cliLogsTail]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (logsOpen && logsRef.current) {
|
||||||
|
logsRef.current.scrollTop = logsRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [logsOpen, cliLogsTail]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -42,6 +124,14 @@ export const ProvisioningProgressBlock = ({
|
||||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
|
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
|
||||||
) : null}
|
) : null}
|
||||||
<p className="text-xs font-medium text-[var(--color-text)]">{title}</p>
|
<p className="text-xs font-medium text-[var(--color-text)]">{title}</p>
|
||||||
|
{elapsed !== null ? (
|
||||||
|
<span className="text-[10px] tabular-nums text-[var(--color-text-muted)]">
|
||||||
|
{elapsed}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{pid !== undefined ? (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">PID {pid}</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{onCancel ? (
|
{onCancel ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -62,6 +152,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={step} className="flex items-center gap-1">
|
<div key={step} className="flex items-center gap-1">
|
||||||
|
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */}
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -76,6 +167,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
</span>
|
</span>
|
||||||
{STEP_LABELS[step]}
|
{STEP_LABELS[step]}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */}
|
||||||
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
|
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
|
||||||
<span className="text-[var(--color-text-muted)]">→</span>
|
<span className="text-[var(--color-text-muted)]">→</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -83,6 +175,29 @@ export const ProvisioningProgressBlock = ({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{cliLogsTail ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
onClick={() => setLogsOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
CLI output
|
||||||
|
</button>
|
||||||
|
{logsOpen ? (
|
||||||
|
<pre
|
||||||
|
ref={logsRef}
|
||||||
|
className="hljs mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
|
||||||
|
// Safe: highlightedHtml is built from hljs.highlight() which only produces
|
||||||
|
// hljs-* <span> tags, combined with escapeHtml() for non-JSON lines.
|
||||||
|
// Input is CLI stdout/stderr from a local process, not user-supplied web content.
|
||||||
|
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,28 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@renderer/components/ui/dialog';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
|
import { GitBranch, Pencil, Play, Plus, Search, Trash2, UserPlus, X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
||||||
import { ActivityTimeline } from './activity/ActivityTimeline';
|
import { ActivityTimeline } from './activity/ActivityTimeline';
|
||||||
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
|
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
|
||||||
|
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
||||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||||
|
|
@ -24,6 +34,7 @@ import { KanbanBoard } from './kanban/KanbanBoard';
|
||||||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||||
import { MemberList } from './members/MemberList';
|
import { MemberList } from './members/MemberList';
|
||||||
|
import { MessageComposer } from './messages/MessageComposer';
|
||||||
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
|
|
@ -78,9 +89,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
defaultOwner: '',
|
defaultOwner: '',
|
||||||
});
|
});
|
||||||
const [creatingTask, setCreatingTask] = useState(false);
|
const [creatingTask, setCreatingTask] = useState(false);
|
||||||
|
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
||||||
|
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
||||||
|
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
||||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
||||||
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
|
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -102,7 +117,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
projects,
|
projects,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
updateKanban,
|
updateKanban,
|
||||||
|
updateKanbanColumnOrder,
|
||||||
updateTaskStatus,
|
updateTaskStatus,
|
||||||
|
updateTaskOwner,
|
||||||
sendTeamMessage,
|
sendTeamMessage,
|
||||||
requestReview,
|
requestReview,
|
||||||
createTeamTask,
|
createTeamTask,
|
||||||
|
|
@ -113,6 +130,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
sendMessageError,
|
sendMessageError,
|
||||||
lastSendMessageResult,
|
lastSendMessageResult,
|
||||||
reviewActionError,
|
reviewActionError,
|
||||||
|
addMember,
|
||||||
|
removeMember,
|
||||||
launchTeam,
|
launchTeam,
|
||||||
provisioningError,
|
provisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -126,7 +145,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
updateKanban: s.updateKanban,
|
updateKanban: s.updateKanban,
|
||||||
|
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
|
||||||
updateTaskStatus: s.updateTaskStatus,
|
updateTaskStatus: s.updateTaskStatus,
|
||||||
|
updateTaskOwner: s.updateTaskOwner,
|
||||||
sendTeamMessage: s.sendTeamMessage,
|
sendTeamMessage: s.sendTeamMessage,
|
||||||
requestReview: s.requestReview,
|
requestReview: s.requestReview,
|
||||||
createTeamTask: s.createTeamTask,
|
createTeamTask: s.createTeamTask,
|
||||||
|
|
@ -137,6 +158,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
sendMessageError: s.sendMessageError,
|
sendMessageError: s.sendMessageError,
|
||||||
lastSendMessageResult: s.lastSendMessageResult,
|
lastSendMessageResult: s.lastSendMessageResult,
|
||||||
reviewActionError: s.reviewActionError,
|
reviewActionError: s.reviewActionError,
|
||||||
|
addMember: s.addMember,
|
||||||
|
removeMember: s.removeMember,
|
||||||
launchTeam: s.launchTeam,
|
launchTeam: s.launchTeam,
|
||||||
provisioningError: s.provisioningError,
|
provisioningError: s.provisioningError,
|
||||||
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||||||
|
|
@ -204,6 +227,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
// Resolve lead's git branch from project path
|
||||||
|
const [leadBranch, setLeadBranch] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const projectPath = data?.config.projectPath?.trim();
|
||||||
|
if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') {
|
||||||
|
setLeadBranch(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
void api.teams.getProjectBranch(projectPath).then(
|
||||||
|
(branch) => {
|
||||||
|
if (!cancelled) setLeadBranch(branch);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!cancelled) setLeadBranch(null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [data?.config.projectPath]);
|
||||||
|
|
||||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||||
const teamSessions = useMemo(() => {
|
const teamSessions = useMemo(() => {
|
||||||
const sessionIds = new Set<string>();
|
const sessionIds = new Set<string>();
|
||||||
|
|
@ -313,6 +358,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return filterKanbanTasks(filteredTasks, query);
|
return filterKanbanTasks(filteredTasks, query);
|
||||||
}, [filteredTasks, kanbanSearch]);
|
}, [filteredTasks, kanbanSearch]);
|
||||||
|
|
||||||
|
const activeMembers = useMemo(
|
||||||
|
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
||||||
|
[data?.members]
|
||||||
|
);
|
||||||
|
|
||||||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||||
|
|
||||||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||||||
|
|
@ -354,12 +404,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTeam = useCallback((): void => {
|
const handleDeleteTeam = useCallback((): void => {
|
||||||
const confirmed = window.confirm(
|
setDeleteConfirmOpen(true);
|
||||||
`Delete team "${teamName}"? This action is irreversible. All team data and tasks will be deleted.`
|
}, []);
|
||||||
);
|
|
||||||
if (!confirmed) {
|
const confirmDeleteTeam = useCallback((): void => {
|
||||||
return;
|
setDeleteConfirmOpen(false);
|
||||||
}
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await deleteTeam(teamName);
|
await deleteTeam(teamName);
|
||||||
|
|
@ -475,46 +524,76 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
headerColorSet && 'relative z-10'
|
headerColorSet && 'relative z-10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
|
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
|
||||||
{data.config.description && (
|
|
||||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{data.config.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{!data.isAlive ? (
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-7 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
size="sm"
|
||||||
onClick={() => setLaunchDialogOpen(true)}
|
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
>
|
onClick={() => setEditDialogOpen(true)}
|
||||||
<Play size={12} />
|
>
|
||||||
Launch
|
<Pencil size={12} />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
</TooltipTrigger>
|
||||||
<Button
|
<TooltipContent side="bottom">Edit team</TooltipContent>
|
||||||
variant="ghost"
|
</Tooltip>
|
||||||
size="sm"
|
<Tooltip>
|
||||||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => setEditDialogOpen(true)}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<Pencil size={12} />
|
size="sm"
|
||||||
</Button>
|
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
<Button
|
onClick={handleDeleteTeam}
|
||||||
variant="ghost"
|
>
|
||||||
size="sm"
|
<Trash2 size={12} />
|
||||||
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
</Button>
|
||||||
onClick={handleDeleteTeam}
|
</TooltipTrigger>
|
||||||
>
|
<TooltipContent side="bottom">Delete team</TooltipContent>
|
||||||
<Trash2 size={12} />
|
</Tooltip>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{(data.config.description || leadBranch) && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-2',
|
||||||
|
headerColorSet && 'relative z-10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="min-w-0 truncate text-xs text-[var(--color-text-muted)]">
|
||||||
|
{data.config.description || ''}
|
||||||
|
</p>
|
||||||
|
{leadBranch ? (
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-text-muted)]"
|
||||||
|
title={leadBranch}
|
||||||
|
>
|
||||||
|
<GitBranch size={10} />
|
||||||
|
<span className="max-w-32 truncate">{leadBranch}</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!data.isAlive ? (
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
||||||
|
<span className="text-xs text-amber-200">Team is offline</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 shrink-0 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||||
|
onClick={() => setLaunchDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Play size={12} />
|
||||||
|
Launch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TeamProvisioningBanner teamName={teamName} />
|
<TeamProvisioningBanner teamName={teamName} />
|
||||||
|
|
||||||
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
|
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
|
||||||
|
|
@ -528,7 +607,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen>
|
<CollapsibleTeamSection
|
||||||
|
title="Team"
|
||||||
|
badge={activeMembers.length}
|
||||||
|
defaultOpen
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAddMemberDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserPlus size={12} />
|
||||||
|
Member
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<MemberList
|
<MemberList
|
||||||
members={data.members}
|
members={data.members}
|
||||||
memberTaskCounts={memberTaskCounts}
|
memberTaskCounts={memberTaskCounts}
|
||||||
|
|
@ -581,28 +678,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="relative mb-2">
|
|
||||||
<Search
|
|
||||||
size={14}
|
|
||||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search tasks… (#id or text)"
|
|
||||||
value={kanbanSearch}
|
|
||||||
onChange={(e) => setKanbanSearch(e.target.value)}
|
|
||||||
className="h-8 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
|
||||||
/>
|
|
||||||
{kanbanSearch && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
|
||||||
onClick={() => setKanbanSearch('')}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
tasks={kanbanDisplayTasks}
|
tasks={kanbanDisplayTasks}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -610,8 +685,37 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
filter={kanbanFilter}
|
filter={kanbanFilter}
|
||||||
sessions={teamSessions}
|
sessions={teamSessions}
|
||||||
leadSessionId={data.config.leadSessionId}
|
leadSessionId={data.config.leadSessionId}
|
||||||
members={data.members}
|
members={activeMembers}
|
||||||
onFilterChange={setKanbanFilter}
|
onFilterChange={setKanbanFilter}
|
||||||
|
toolbarLeft={
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search tasks… (#id or text)"
|
||||||
|
value={kanbanSearch}
|
||||||
|
onChange={(e) => setKanbanSearch(e.target.value)}
|
||||||
|
className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
{kanbanSearch && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={() => setKanbanSearch('')}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Clear search</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
onRequestReview={(taskId) => {
|
onRequestReview={(taskId) => {
|
||||||
void requestReview(teamName, taskId);
|
void requestReview(teamName, taskId);
|
||||||
}}
|
}}
|
||||||
|
|
@ -652,6 +756,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onCompleteTask={(taskId) => {
|
onCompleteTask={(taskId) => {
|
||||||
void updateTaskStatus(teamName, taskId, 'completed');
|
void updateTaskStatus(teamName, taskId, 'completed');
|
||||||
}}
|
}}
|
||||||
|
onColumnOrderChange={(columnId, orderedTaskIds) => {
|
||||||
|
void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
|
||||||
|
}}
|
||||||
onScrollToTask={(taskId) => {
|
onScrollToTask={(taskId) => {
|
||||||
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
||||||
if (el) {
|
if (el) {
|
||||||
|
|
@ -692,23 +799,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onOpenChange={setMessagesFilterOpen}
|
onOpenChange={setMessagesFilterOpen}
|
||||||
onApply={setMessagesFilter}
|
onApply={setMessagesFilter}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSendDialogRecipient(undefined);
|
|
||||||
setReplyQuote(undefined);
|
|
||||||
setSendDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageSquare size={12} />
|
|
||||||
Message
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<MessageComposer
|
||||||
|
teamName={teamName}
|
||||||
|
members={activeMembers}
|
||||||
|
isTeamAlive={data.isAlive}
|
||||||
|
sending={sendingMessage}
|
||||||
|
sendError={sendMessageError}
|
||||||
|
onSend={(member, text, summary, attachments) => {
|
||||||
|
const sentAtMs = Date.now();
|
||||||
|
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||||
|
void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => {
|
||||||
|
setPendingRepliesByMember((prev) => {
|
||||||
|
if (prev[member] !== sentAtMs) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[member];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<PendingRepliesBlock
|
<PendingRepliesBlock
|
||||||
members={data.members}
|
members={data.members}
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
|
|
@ -722,6 +834,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
/>
|
/>
|
||||||
<ActivityTimeline
|
<ActivityTimeline
|
||||||
messages={filteredMessages}
|
messages={filteredMessages}
|
||||||
|
teamName={teamName}
|
||||||
members={data.members}
|
members={data.members}
|
||||||
readState={{ readSet, getMessageKey: toMessageKey }}
|
readState={{ readSet, getMessageKey: toMessageKey }}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
|
|
@ -786,12 +899,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
setSelectedMember(null);
|
setSelectedMember(null);
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
}}
|
}}
|
||||||
|
onRemoveMember={() => {
|
||||||
|
const name = selectedMember?.name;
|
||||||
|
if (!name) return;
|
||||||
|
setRemoveMemberConfirm(name);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateTaskDialog
|
<CreateTaskDialog
|
||||||
open={createTaskDialog.open}
|
open={createTaskDialog.open}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={data.members}
|
members={activeMembers}
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||||
defaultSubject={createTaskDialog.defaultSubject}
|
defaultSubject={createTaskDialog.defaultSubject}
|
||||||
|
|
@ -812,6 +930,81 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onSaved={() => void selectTeam(teamName)}
|
onSaved={() => void selectTeam(teamName)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddMemberDialog
|
||||||
|
open={addMemberDialogOpen}
|
||||||
|
teamName={teamName}
|
||||||
|
existingNames={data.members.map((m) => m.name)}
|
||||||
|
adding={addingMemberLoading}
|
||||||
|
onClose={() => setAddMemberDialogOpen(false)}
|
||||||
|
onAdd={(name, role) => {
|
||||||
|
setAddingMemberLoading(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await addMember(teamName, { name, role });
|
||||||
|
setAddMemberDialogOpen(false);
|
||||||
|
} catch {
|
||||||
|
// error shown via store
|
||||||
|
} finally {
|
||||||
|
setAddingMemberLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={removeMemberConfirm !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setRemoveMemberConfirm(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Remove member</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Remove “{removeMemberConfirm}” from the team? Tasks and messages will be
|
||||||
|
preserved, but this name cannot be reused.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setRemoveMemberConfirm(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const name = removeMemberConfirm;
|
||||||
|
setRemoveMemberConfirm(null);
|
||||||
|
setSelectedMember(null);
|
||||||
|
if (name) void removeMember(teamName, name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete team</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Delete team “{data.config.name}”? This action is irreversible. All team
|
||||||
|
data and tasks will be deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirmOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={confirmDeleteTeam}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -826,7 +1019,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
|
|
||||||
<SendMessageDialog
|
<SendMessageDialog
|
||||||
open={sendDialogOpen}
|
open={sendDialogOpen}
|
||||||
members={data.members}
|
members={activeMembers}
|
||||||
defaultRecipient={sendDialogRecipient}
|
defaultRecipient={sendDialogRecipient}
|
||||||
quotedMessage={replyQuote}
|
quotedMessage={replyQuote}
|
||||||
sending={sendingMessage}
|
sending={sendingMessage}
|
||||||
|
|
@ -860,7 +1053,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
|
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
members={data?.members ?? []}
|
members={activeMembers}
|
||||||
onClose={() => setSelectedTask(null)}
|
onClose={() => setSelectedTask(null)}
|
||||||
onScrollToTask={(taskId) => {
|
onScrollToTask={(taskId) => {
|
||||||
setSelectedTask(null);
|
setSelectedTask(null);
|
||||||
|
|
@ -871,6 +1064,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
|
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onOwnerChange={(taskId, owner) => {
|
||||||
|
void updateTaskOwner(teamName, taskId, owner);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,17 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||||
import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Square, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Copy,
|
||||||
|
FolderOpen,
|
||||||
|
GitBranch,
|
||||||
|
Play,
|
||||||
|
Search,
|
||||||
|
Square,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||||
|
|
@ -97,6 +107,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
|
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||||
|
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
|
||||||
const {
|
const {
|
||||||
teams,
|
teams,
|
||||||
teamsLoading,
|
teamsLoading,
|
||||||
|
|
@ -204,6 +215,49 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
return result;
|
return result;
|
||||||
}, [teams, searchQuery, currentProjectPath]);
|
}, [teams, searchQuery, currentProjectPath]);
|
||||||
|
|
||||||
|
// Live branch/worktree for team project paths (poll so it updates during process)
|
||||||
|
const projectPathsToPoll = useMemo(() => {
|
||||||
|
const byKey = new Map<string, string>();
|
||||||
|
for (const team of filteredTeams) {
|
||||||
|
const p = team.projectPath?.trim();
|
||||||
|
if (p) {
|
||||||
|
const key = normalizePath(p);
|
||||||
|
if (!byKey.has(key)) byKey.set(key, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byKey.entries());
|
||||||
|
}, [filteredTeams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!electronMode || projectPathsToPoll.length === 0) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const poll = async (): Promise<void> => {
|
||||||
|
const next = new Map<string, string | null>();
|
||||||
|
for (const [pathKey, actualPath] of projectPathsToPoll) {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const branch = await api.teams.getProjectBranch(actualPath);
|
||||||
|
if (!cancelled) next.set(pathKey, branch);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) next.set(pathKey, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled && next.size > 0) {
|
||||||
|
setBranchByPath((prev) => {
|
||||||
|
const m = new Map(prev);
|
||||||
|
for (const [k, v] of next) m.set(k, v);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
const interval = setInterval(poll, 6000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [electronMode, projectPathsToPoll]);
|
||||||
|
|
||||||
const handleDeleteTeam = useCallback(
|
const handleDeleteTeam = useCallback(
|
||||||
(teamName: string, e: React.MouseEvent) => {
|
(teamName: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -509,9 +563,25 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
|
<div className="mt-2 flex min-h-10 items-start gap-2">
|
||||||
{team.description || 'No description'}
|
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
|
||||||
</p>
|
{team.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
{team.projectPath &&
|
||||||
|
(() => {
|
||||||
|
const branch = branchByPath.get(normalizePath(team.projectPath));
|
||||||
|
if (!branch) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
||||||
|
title={branch}
|
||||||
|
>
|
||||||
|
<GitBranch size={10} />
|
||||||
|
<span className="max-w-24 truncate">{branch}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
{team.members && team.members.length > 0 ? (
|
{team.members && team.members.length > 0 ? (
|
||||||
team.members.map((m) => {
|
team.members.map((m) => {
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,9 @@ export const TeamProvisioningBanner = ({
|
||||||
message={progress.message}
|
message={progress.message}
|
||||||
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
|
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
|
||||||
loading
|
loading
|
||||||
|
startedAt={progress.startedAt}
|
||||||
|
pid={progress.pid}
|
||||||
|
cliLogsTail={progress.cliLogsTail}
|
||||||
onCancel={
|
onCancel={
|
||||||
canCancel
|
canCancel
|
||||||
? () => {
|
? () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||||
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import {
|
import {
|
||||||
CARD_BG,
|
CARD_BG,
|
||||||
CARD_BORDER_STYLE,
|
CARD_BORDER_STYLE,
|
||||||
|
|
@ -16,7 +19,8 @@ import {
|
||||||
} from '@renderer/utils/agentMessageFormatting';
|
} from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
|
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
|
||||||
import { Bot, ChevronRight, ListPlus, MessageSquare, Reply } from 'lucide-react';
|
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||||
|
import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react';
|
||||||
|
|
||||||
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
||||||
|
|
||||||
|
|
@ -27,6 +31,7 @@ type StructuredMessage = Record<string, unknown>;
|
||||||
|
|
||||||
interface ActivityItemProps {
|
interface ActivityItemProps {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
|
teamName: string;
|
||||||
memberRole?: string;
|
memberRole?: string;
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
recipientColor?: string;
|
recipientColor?: string;
|
||||||
|
|
@ -126,6 +131,7 @@ function stripAgentBlocks(text: string): string {
|
||||||
|
|
||||||
export const ActivityItem = ({
|
export const ActivityItem = ({
|
||||||
message,
|
message,
|
||||||
|
teamName,
|
||||||
memberRole,
|
memberRole,
|
||||||
memberColor,
|
memberColor,
|
||||||
recipientColor,
|
recipientColor,
|
||||||
|
|
@ -135,7 +141,6 @@ export const ActivityItem = ({
|
||||||
onReply,
|
onReply,
|
||||||
}: ActivityItemProps): React.JSX.Element => {
|
}: ActivityItemProps): React.JSX.Element => {
|
||||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||||
const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null;
|
|
||||||
const formattedRole = formatAgentRole(memberRole);
|
const formattedRole = formatAgentRole(memberRole);
|
||||||
|
|
||||||
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
||||||
|
|
@ -143,10 +148,13 @@ export const ActivityItem = ({
|
||||||
: new Date(message.timestamp).toLocaleString();
|
: new Date(message.timestamp).toLocaleString();
|
||||||
|
|
||||||
const structured = parseStructuredAgentMessage(message.text);
|
const structured = parseStructuredAgentMessage(message.text);
|
||||||
const noiseLabel = structured ? getNoiseLabel(structured) : null;
|
// Only flag agent messages as rate-limited, not user's own quotes
|
||||||
|
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
||||||
|
// Never collapse rate limit messages as noise — they must be visible
|
||||||
|
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
||||||
|
|
||||||
// System/automated messages start collapsed
|
// System/automated messages start collapsed (but not rate limits)
|
||||||
const systemLabel = !structured ? getSystemMessageLabel(message.text) : null;
|
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||||
|
|
||||||
// Strip agent-only blocks from displayed text
|
// Strip agent-only blocks from displayed text
|
||||||
|
|
@ -184,9 +192,11 @@ export const ActivityItem = ({
|
||||||
<article
|
<article
|
||||||
className="group overflow-hidden rounded-md"
|
className="group overflow-hidden rounded-md"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: CARD_BG,
|
backgroundColor: rateLimited ? 'var(--tool-result-error-bg)' : CARD_BG,
|
||||||
border: CARD_BORDER_STYLE,
|
border: rateLimited ? '1px solid var(--tool-result-error-border)' : CARD_BORDER_STYLE,
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: rateLimited
|
||||||
|
? '3px solid var(--tool-result-error-text)'
|
||||||
|
: `3px solid ${colors.border}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
|
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
|
||||||
|
|
@ -224,41 +234,12 @@ export const ActivityItem = ({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{message.source === 'lead_session' || message.source === 'lead_process' ? (
|
{/* Sender avatar + name badge */}
|
||||||
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
<MemberBadge
|
||||||
) : (
|
name={message.from}
|
||||||
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
color={memberColor ?? message.color}
|
||||||
)}
|
onClick={onMemberNameClick}
|
||||||
|
/>
|
||||||
{/* Name badge — clickable to open member popup */}
|
|
||||||
{onMemberNameClick ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.badge,
|
|
||||||
color: colors.text,
|
|
||||||
border: `1px solid ${colors.border}40`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMemberNameClick(message.from);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.from}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.badge,
|
|
||||||
color: colors.text,
|
|
||||||
border: `1px solid ${colors.border}40`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.from}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Role */}
|
{/* Role */}
|
||||||
{formattedRole ? (
|
{formattedRole ? (
|
||||||
|
|
@ -289,60 +270,24 @@ export const ActivityItem = ({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Recipient — badge like sender, clickable to open member popup */}
|
{/* Rate limit warning badge */}
|
||||||
{message.to && message.to !== message.from && recipientColors ? (
|
{rateLimited ? (
|
||||||
<span className="text-[10px]">
|
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
|
||||||
<span style={{ color: CARD_ICON_MUTED }}>→ </span>
|
<AlertTriangle size={10} />
|
||||||
{onMemberNameClick ? (
|
Rate Limited
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: recipientColors.badge,
|
|
||||||
color: recipientColors.text,
|
|
||||||
border: `1px solid ${recipientColors.border}40`,
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMemberNameClick(message.to!);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.to}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
|
||||||
style={{
|
|
||||||
backgroundColor: recipientColors.badge,
|
|
||||||
color: recipientColors.text,
|
|
||||||
border: `1px solid ${recipientColors.border}40`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.to}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
) : message.to && message.to !== message.from ? (
|
|
||||||
<span className="text-[10px]">
|
|
||||||
<span style={{ color: CARD_ICON_MUTED }}>→ </span>
|
|
||||||
{onMemberNameClick ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onMemberNameClick(message.to!);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{message.to}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: CARD_ICON_MUTED }}>{message.to}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Recipient — arrow + avatar + badge */}
|
||||||
|
{message.to && message.to !== message.from ? (
|
||||||
|
<>
|
||||||
|
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
{summaryText}
|
{summaryText}
|
||||||
|
|
@ -351,32 +296,40 @@ export const ActivityItem = ({
|
||||||
{/* Timestamp + reply + create task */}
|
{/* Timestamp + reply + create task */}
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{onReply && (
|
{onReply && (
|
||||||
<button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
<button
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
type="button"
|
||||||
title="Reply to message"
|
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onReply(message);
|
e.stopPropagation();
|
||||||
}}
|
onReply(message);
|
||||||
>
|
}}
|
||||||
<Reply size={14} />
|
>
|
||||||
</button>
|
<Reply size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Reply to message</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onCreateTask && (
|
{onCreateTask && (
|
||||||
<button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
<button
|
||||||
style={{ color: CARD_ICON_MUTED }}
|
type="button"
|
||||||
title="Create task from message"
|
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
style={{ color: CARD_ICON_MUTED }}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleCreateTask();
|
e.stopPropagation();
|
||||||
}}
|
handleCreateTask();
|
||||||
>
|
}}
|
||||||
<ListPlus size={14} />
|
>
|
||||||
</button>
|
<ListPlus size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Create task from message</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||||
{timestamp}
|
{timestamp}
|
||||||
|
|
@ -404,8 +357,20 @@ export const ActivityItem = ({
|
||||||
) : parsedReply ? (
|
) : parsedReply ? (
|
||||||
<ReplyQuoteBlock reply={parsedReply} />
|
<ReplyQuoteBlock reply={parsedReply} />
|
||||||
) : (
|
) : (
|
||||||
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable />
|
<MarkdownViewer
|
||||||
|
content={displayText ?? message.text}
|
||||||
|
maxHeight="max-h-56"
|
||||||
|
copyable
|
||||||
|
bare
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
{message.attachments?.length && message.messageId ? (
|
||||||
|
<AttachmentDisplay
|
||||||
|
teamName={teamName}
|
||||||
|
messageId={message.messageId}
|
||||||
|
attachments={message.attachments}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
|
teamName: string;
|
||||||
members?: ResolvedTeamMember[];
|
members?: ResolvedTeamMember[];
|
||||||
/**
|
/**
|
||||||
* When provided, unread is derived from this set and getMessageKey.
|
* When provided, unread is derived from this set and getMessageKey.
|
||||||
|
|
@ -25,6 +26,7 @@ const VIEWPORT_THRESHOLD = 0.15;
|
||||||
|
|
||||||
const MessageRowWithObserver = ({
|
const MessageRowWithObserver = ({
|
||||||
message,
|
message,
|
||||||
|
teamName,
|
||||||
memberRole,
|
memberRole,
|
||||||
memberColor,
|
memberColor,
|
||||||
recipientColor,
|
recipientColor,
|
||||||
|
|
@ -35,6 +37,7 @@ const MessageRowWithObserver = ({
|
||||||
onVisible,
|
onVisible,
|
||||||
}: {
|
}: {
|
||||||
message: InboxMessage;
|
message: InboxMessage;
|
||||||
|
teamName: string;
|
||||||
memberRole?: string;
|
memberRole?: string;
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
recipientColor?: string;
|
recipientColor?: string;
|
||||||
|
|
@ -78,6 +81,7 @@ const MessageRowWithObserver = ({
|
||||||
<div ref={ref} className="min-h-px">
|
<div ref={ref} className="min-h-px">
|
||||||
<ActivityItem
|
<ActivityItem
|
||||||
message={message}
|
message={message}
|
||||||
|
teamName={teamName}
|
||||||
memberRole={memberRole}
|
memberRole={memberRole}
|
||||||
memberColor={memberColor}
|
memberColor={memberColor}
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
|
|
@ -92,6 +96,7 @@ const MessageRowWithObserver = ({
|
||||||
|
|
||||||
export const ActivityTimeline = ({
|
export const ActivityTimeline = ({
|
||||||
messages,
|
messages,
|
||||||
|
teamName,
|
||||||
members,
|
members,
|
||||||
readState,
|
readState,
|
||||||
onCreateTaskFromMessage,
|
onCreateTaskFromMessage,
|
||||||
|
|
@ -161,6 +166,7 @@ export const ActivityTimeline = ({
|
||||||
<MessageRowWithObserver
|
<MessageRowWithObserver
|
||||||
key={messageKey}
|
key={messageKey}
|
||||||
message={message}
|
message={message}
|
||||||
|
teamName={teamName}
|
||||||
memberRole={info?.role}
|
memberRole={info?.role}
|
||||||
memberColor={info?.color}
|
memberColor={info?.color}
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
||||||
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
|
||||||
|
import type { AttachmentFileData, AttachmentMeta } from '@shared/types';
|
||||||
|
|
||||||
|
interface AttachmentDisplayProps {
|
||||||
|
teamName: string;
|
||||||
|
messageId: string;
|
||||||
|
attachments: AttachmentMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AttachmentDisplay = ({
|
||||||
|
teamName,
|
||||||
|
messageId,
|
||||||
|
attachments,
|
||||||
|
}: AttachmentDisplayProps): React.JSX.Element | null => {
|
||||||
|
const [state, setState] = useState<{
|
||||||
|
loaded: AttachmentFileData[];
|
||||||
|
loading: boolean;
|
||||||
|
key: string;
|
||||||
|
}>({ loaded: [], loading: true, key: `${teamName}:${messageId}` });
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const currentKey = `${teamName}:${messageId}`;
|
||||||
|
// Reset loading state when deps change (React 18+ pattern: derive from props)
|
||||||
|
if (state.key !== currentKey) {
|
||||||
|
setState({ loaded: [], loading: true, key: currentKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void window.electronAPI.teams
|
||||||
|
.getAttachments(teamName, messageId)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setState({ loaded: data, loading: false, key: `${teamName}:${messageId}` });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setState((prev) => ({ ...prev, loading: false }));
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [teamName, messageId]);
|
||||||
|
|
||||||
|
const { loaded, loading } = state;
|
||||||
|
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 py-1 text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Loading attachments...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lookup for loaded data
|
||||||
|
const dataById = new Map(loaded.map((d) => [d.id, d]));
|
||||||
|
|
||||||
|
const items = attachments
|
||||||
|
.map((meta) => {
|
||||||
|
const data = dataById.get(meta.id);
|
||||||
|
if (!data) return null;
|
||||||
|
return { meta, dataUrl: `data:${data.mimeType};base64,${data.data}` };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as { meta: AttachmentMeta; dataUrl: string }[];
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<AttachmentThumbnail
|
||||||
|
key={item.meta.id}
|
||||||
|
src={item.dataUrl}
|
||||||
|
alt={item.meta.filename}
|
||||||
|
size="md"
|
||||||
|
onClick={() => setLightboxIndex(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lightboxIndex !== null && items[lightboxIndex] ? (
|
||||||
|
<ImageLightbox
|
||||||
|
src={items[lightboxIndex].dataUrl}
|
||||||
|
alt={items[lightboxIndex].meta.filename}
|
||||||
|
open
|
||||||
|
onClose={() => setLightboxIndex(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { formatFileSize } from '@renderer/utils/attachmentUtils';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AttachmentThumbnail } from './AttachmentThumbnail';
|
||||||
|
|
||||||
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
interface AttachmentPreviewItemProps {
|
||||||
|
attachment: AttachmentPayload;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AttachmentPreviewItem = ({
|
||||||
|
attachment,
|
||||||
|
onRemove,
|
||||||
|
}: AttachmentPreviewItemProps): React.JSX.Element => {
|
||||||
|
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group/att relative flex shrink-0 items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5">
|
||||||
|
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||||
|
{attachment.filename}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{formatFileSize(attachment.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full bg-red-500 text-white opacity-0 transition-opacity group-hover/att:opacity-100"
|
||||||
|
onClick={() => onRemove(attachment.id)}
|
||||||
|
aria-label={`Remove ${attachment.filename}`}
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
||||||
|
|
||||||
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
interface AttachmentPreviewListProps {
|
||||||
|
attachments: AttachmentPayload[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AttachmentPreviewList = ({
|
||||||
|
attachments,
|
||||||
|
onRemove,
|
||||||
|
error,
|
||||||
|
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||||
|
if (attachments.length === 0 && !error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 px-1">
|
||||||
|
{attachments.length > 0 ? (
|
||||||
|
<div className="flex gap-2 overflow-x-auto py-1">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<AttachmentPreviewItem key={att.id} attachment={att} onRemove={onRemove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5">
|
||||||
|
<AlertCircle size={13} className="shrink-0 text-red-400" />
|
||||||
|
<p className="text-[11px] text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
||||||
|
interface AttachmentThumbnailProps {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses: Record<string, string> = {
|
||||||
|
sm: 'size-12',
|
||||||
|
md: 'size-20',
|
||||||
|
lg: 'size-32',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttachmentThumbnail = ({
|
||||||
|
src,
|
||||||
|
alt = 'attachment',
|
||||||
|
size = 'md',
|
||||||
|
onClick,
|
||||||
|
}: AttachmentThumbnailProps): React.JSX.Element => {
|
||||||
|
const img = (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-[var(--color-border)] object-cover',
|
||||||
|
sizeClasses[size],
|
||||||
|
onClick && 'cursor-pointer transition-opacity hover:opacity-80'
|
||||||
|
)}
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" className="block border-0 bg-transparent p-0" onClick={onClick}>
|
||||||
|
{img}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
};
|
||||||
18
src/renderer/components/team/attachments/DropZoneOverlay.tsx
Normal file
18
src/renderer/components/team/attachments/DropZoneOverlay.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { ImagePlus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DropZoneOverlayProps {
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DropZoneOverlay = ({ active }: DropZoneOverlayProps): React.JSX.Element | null => {
|
||||||
|
if (!active) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-md border-2 border-dashed border-blue-400/60 bg-blue-500/10 backdrop-blur-[1px]">
|
||||||
|
<div className="flex flex-col items-center gap-1.5 text-blue-400">
|
||||||
|
<ImagePlus size={24} />
|
||||||
|
<span className="text-xs font-medium">Drop images here</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
58
src/renderer/components/team/attachments/ImageLightbox.tsx
Normal file
58
src/renderer/components/team/attachments/ImageLightbox.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ImageLightboxProps {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageLightbox = ({
|
||||||
|
src,
|
||||||
|
alt = 'Image',
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: ImageLightboxProps): React.JSX.Element | null => {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [open, handleKeyDown]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm duration-150 animate-in fade-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 border-0 bg-transparent p-0"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative z-10 max-h-[85vh] max-w-[90vw] border-0 bg-transparent p-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="rounded-lg object-contain shadow-2xl"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
src/renderer/components/team/dialogs/AddMemberDialog.tsx
Normal file
153
src/renderer/components/team/dialogs/AddMemberDialog.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@renderer/components/ui/dialog';
|
||||||
|
import { Input } from '@renderer/components/ui/input';
|
||||||
|
import { Label } from '@renderer/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@renderer/components/ui/select';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
|
||||||
|
const CUSTOM_ROLE = '__custom__';
|
||||||
|
const NO_ROLE = '__none__';
|
||||||
|
|
||||||
|
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
|
||||||
|
|
||||||
|
interface AddMemberDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
teamName: string;
|
||||||
|
existingNames: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (name: string, role?: string) => void;
|
||||||
|
adding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddMemberDialog = ({
|
||||||
|
open,
|
||||||
|
teamName,
|
||||||
|
existingNames,
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
adding,
|
||||||
|
}: AddMemberDialogProps): React.JSX.Element => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE);
|
||||||
|
const [customRole, setCustomRole] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const effectiveRole =
|
||||||
|
roleSelect === CUSTOM_ROLE
|
||||||
|
? customRole.trim()
|
||||||
|
: roleSelect === NO_ROLE
|
||||||
|
? undefined
|
||||||
|
: roleSelect;
|
||||||
|
|
||||||
|
const validate = (): string | null => {
|
||||||
|
const trimmed = name.trim().toLowerCase();
|
||||||
|
if (!trimmed) return 'Name is required';
|
||||||
|
if (trimmed.length < 2) return 'Name must be at least 2 characters';
|
||||||
|
if (trimmed.length > 30) return 'Name must be at most 30 characters';
|
||||||
|
if (!NAME_REGEX.test(trimmed))
|
||||||
|
return 'Name must be lowercase alphanumeric with hyphens (e.g. alice, dev-1)';
|
||||||
|
if (existingNames.some((n) => n.toLowerCase() === trimmed)) return 'Name is already taken';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (): void => {
|
||||||
|
const err = validate();
|
||||||
|
if (err) {
|
||||||
|
setError(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
onAdd(name.trim().toLowerCase(), effectiveRole);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean): void => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
setName('');
|
||||||
|
setRoleSelect(NO_ROLE);
|
||||||
|
setCustomRole('');
|
||||||
|
setError(null);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Member</DialogTitle>
|
||||||
|
<DialogDescription>Add a new member to {teamName}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. alice, dev-1"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setName(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSubmit();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Role (optional)</Label>
|
||||||
|
<Select value={roleSelect} onValueChange={setRoleSelect}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="No role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
||||||
|
{PRESET_ROLES.map((role) => (
|
||||||
|
<SelectItem key={role} value={role}>
|
||||||
|
{role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{roleSelect === CUSTOM_ROLE && (
|
||||||
|
<Input
|
||||||
|
placeholder="Custom role"
|
||||||
|
value={customRole}
|
||||||
|
onChange={(e) => setCustomRole(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={adding}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={adding || !name.trim()}>
|
||||||
|
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { AlertTriangle, Search } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
@ -73,6 +74,8 @@ export const CreateTaskDialog = ({
|
||||||
const [related, setRelated] = useState<string[]>([]);
|
const [related, setRelated] = useState<string[]>([]);
|
||||||
const [startImmediately, setStartImmediately] = useState(true);
|
const [startImmediately, setStartImmediately] = useState(true);
|
||||||
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
||||||
|
const [blockedBySearch, setBlockedBySearch] = useState('');
|
||||||
|
const [relatedSearch, setRelatedSearch] = useState('');
|
||||||
const [prevOpen, setPrevOpen] = useState(false);
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
|
||||||
if (open && !prevOpen) {
|
if (open && !prevOpen) {
|
||||||
|
|
@ -85,6 +88,8 @@ export const CreateTaskDialog = ({
|
||||||
setRelated([]);
|
setRelated([]);
|
||||||
setStartImmediately(isTeamAlive);
|
setStartImmediately(isTeamAlive);
|
||||||
promptDraft.clearDraft();
|
promptDraft.clearDraft();
|
||||||
|
setBlockedBySearch('');
|
||||||
|
setRelatedSearch('');
|
||||||
}
|
}
|
||||||
if (open !== prevOpen) {
|
if (open !== prevOpen) {
|
||||||
setPrevOpen(open);
|
setPrevOpen(open);
|
||||||
|
|
@ -150,6 +155,16 @@ export const CreateTaskDialog = ({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!isTeamAlive ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" />
|
||||||
|
<p className="text-xs leading-relaxed text-amber-300">
|
||||||
|
Team is offline. The task will be added to <strong>TODO</strong> — launch the
|
||||||
|
team to start execution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4 py-2">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="task-subject">Subject</Label>
|
<Label htmlFor="task-subject">Subject</Label>
|
||||||
|
|
@ -265,39 +280,63 @@ export const CreateTaskDialog = ({
|
||||||
{availableTasks.length > 0 ? (
|
{availableTasks.length > 0 ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Blocked by tasks (optional)</Label>
|
<Label>Blocked by tasks (optional)</Label>
|
||||||
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||||
{availableTasks.map((t) => {
|
{availableTasks.length > 3 ? (
|
||||||
const isSelected = blockedBy.includes(t.id);
|
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||||
return (
|
<Search
|
||||||
<button
|
size={12}
|
||||||
key={t.id}
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||||
type="button"
|
/>
|
||||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
<input
|
||||||
isSelected
|
type="text"
|
||||||
? 'bg-blue-500/15 text-blue-300'
|
placeholder="Search tasks..."
|
||||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
value={blockedBySearch}
|
||||||
}`}
|
onChange={(e) => setBlockedBySearch(e.target.value)}
|
||||||
onClick={() => toggleBlockedBy(t.id)}
|
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||||
>
|
/>
|
||||||
<span
|
</div>
|
||||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
) : null}
|
||||||
isSelected
|
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||||
? 'border-blue-400 bg-blue-500/30 text-blue-300'
|
{availableTasks
|
||||||
: 'border-[var(--color-border-emphasis)]'
|
.filter(
|
||||||
}`}
|
(t) =>
|
||||||
>
|
!blockedBySearch ||
|
||||||
{isSelected ? '\u2713' : ''}
|
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
|
||||||
</span>
|
t.id.includes(blockedBySearch)
|
||||||
<Badge
|
)
|
||||||
variant="secondary"
|
.map((t) => {
|
||||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
const isSelected = blockedBy.includes(t.id);
|
||||||
>
|
return (
|
||||||
#{t.id}
|
<button
|
||||||
</Badge>
|
key={t.id}
|
||||||
<span className="truncate">{t.subject}</span>
|
type="button"
|
||||||
</button>
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||||
);
|
isSelected
|
||||||
})}
|
? 'bg-blue-500/15 text-blue-300'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleBlockedBy(t.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||||
|
isSelected
|
||||||
|
? 'border-blue-400 bg-blue-500/30 text-blue-300'
|
||||||
|
: 'border-[var(--color-border-emphasis)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? '\u2713' : ''}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
#{t.id}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate">{t.subject}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{blockedBy.length > 0 ? (
|
{blockedBy.length > 0 ? (
|
||||||
<p className="text-[11px] text-yellow-300">
|
<p className="text-[11px] text-yellow-300">
|
||||||
|
|
@ -310,39 +349,63 @@ export const CreateTaskDialog = ({
|
||||||
{availableTasks.length > 0 ? (
|
{availableTasks.length > 0 ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>Related tasks (optional)</Label>
|
<Label>Related tasks (optional)</Label>
|
||||||
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||||
{availableTasks.map((t) => {
|
{availableTasks.length > 3 ? (
|
||||||
const isSelected = related.includes(t.id);
|
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||||
return (
|
<Search
|
||||||
<button
|
size={12}
|
||||||
key={`related:${t.id}`}
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||||
type="button"
|
/>
|
||||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
<input
|
||||||
isSelected
|
type="text"
|
||||||
? 'bg-purple-500/15 text-purple-300'
|
placeholder="Search tasks..."
|
||||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
value={relatedSearch}
|
||||||
}`}
|
onChange={(e) => setRelatedSearch(e.target.value)}
|
||||||
onClick={() => toggleRelated(t.id)}
|
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||||
>
|
/>
|
||||||
<span
|
</div>
|
||||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
) : null}
|
||||||
isSelected
|
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||||
? 'border-purple-400 bg-purple-500/30 text-purple-300'
|
{availableTasks
|
||||||
: 'border-[var(--color-border-emphasis)]'
|
.filter(
|
||||||
}`}
|
(t) =>
|
||||||
>
|
!relatedSearch ||
|
||||||
{isSelected ? '\u2713' : ''}
|
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
|
||||||
</span>
|
t.id.includes(relatedSearch)
|
||||||
<Badge
|
)
|
||||||
variant="secondary"
|
.map((t) => {
|
||||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
const isSelected = related.includes(t.id);
|
||||||
>
|
return (
|
||||||
#{t.id}
|
<button
|
||||||
</Badge>
|
key={`related:${t.id}`}
|
||||||
<span className="truncate">{t.subject}</span>
|
type="button"
|
||||||
</button>
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||||
);
|
isSelected
|
||||||
})}
|
? 'bg-purple-500/15 text-purple-300'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleRelated(t.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||||
|
isSelected
|
||||||
|
? 'border-purple-400 bg-purple-500/30 text-purple-300'
|
||||||
|
: 'border-[var(--color-border-emphasis)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? '\u2713' : ''}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
#{t.id}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate">{t.subject}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{related.length > 0 ? (
|
{related.length > 0 ? (
|
||||||
<p className="text-[11px] text-purple-300">
|
<p className="text-[11px] text-purple-300">
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
'blue',
|
'blue',
|
||||||
|
|
@ -173,6 +173,7 @@ function validateRequest(
|
||||||
options?: { requireCwd?: boolean }
|
options?: { requireCwd?: boolean }
|
||||||
): ValidationResult {
|
): ValidationResult {
|
||||||
const requireCwd = options?.requireCwd ?? true;
|
const requireCwd = options?.requireCwd ?? true;
|
||||||
|
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
|
||||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
|
|
@ -261,6 +262,7 @@ export const CreateTeamDialog = ({
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [launchTeam, setLaunchTeam] = useState(true);
|
const [launchTeam, setLaunchTeam] = useState(true);
|
||||||
const [teamColor, setTeamColor] = useState('');
|
const [teamColor, setTeamColor] = useState('');
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
|
||||||
const resetUIState = (): void => {
|
const resetUIState = (): void => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
|
|
@ -281,6 +283,7 @@ export const CreateTeamDialog = ({
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
setLaunchTeam(true);
|
setLaunchTeam(true);
|
||||||
|
setSelectedModel('');
|
||||||
resetUIState();
|
resetUIState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -460,6 +463,9 @@ export const CreateTeamDialog = ({
|
||||||
[members]
|
[members]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const effectiveModel =
|
||||||
|
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
||||||
|
|
||||||
const request = useMemo<TeamCreateRequest>(
|
const request = useMemo<TeamCreateRequest>(
|
||||||
() => ({
|
() => ({
|
||||||
teamName: teamName.trim(),
|
teamName: teamName.trim(),
|
||||||
|
|
@ -468,8 +474,9 @@ export const CreateTeamDialog = ({
|
||||||
members: buildMembers(members),
|
members: buildMembers(members),
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: prompt.trim() || undefined,
|
prompt: prompt.trim() || undefined,
|
||||||
|
model: effectiveModel,
|
||||||
}),
|
}),
|
||||||
[teamName, description, teamColor, members, effectiveCwd, prompt]
|
[teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeError = localError ?? provisioningError;
|
const activeError = localError ?? provisioningError;
|
||||||
|
|
@ -571,7 +578,7 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
|
|
@ -582,17 +589,31 @@ export const CreateTeamDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{canCreate && launchTeam && prepareState === 'failed' ? (
|
{canCreate && launchTeam && prepareState === 'failed' ? (
|
||||||
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||||
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
|
<div className="flex items-start gap-2">
|
||||||
{prepareWarnings.length > 0 ? (
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||||
<div className="mt-1 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
{prepareWarnings.map((warning) => (
|
<p className="font-medium text-red-300">
|
||||||
<p key={warning} className="text-[11px] text-amber-300">
|
CLI environment is not available — launch is blocked
|
||||||
{warning}
|
</p>
|
||||||
</p>
|
<p className="text-red-300/80">
|
||||||
))}
|
{prepareMessage ?? 'Failed to prepare environment'}
|
||||||
|
</p>
|
||||||
|
{prepareWarnings.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{prepareWarnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-[11px] text-amber-300">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
||||||
|
in PATH, then reopen this dialog.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -796,6 +817,23 @@ export const CreateTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{launchTeam ? (
|
||||||
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
|
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
|
||||||
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Default (account setting)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||||
|
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||||
|
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||||
|
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{launchTeam ? (
|
{launchTeam ? (
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
|
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,17 @@ import {
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@renderer/components/ui/select';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -91,6 +98,7 @@ export const LaunchTeamDialog = ({
|
||||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
|
||||||
const resetFormState = (): void => {
|
const resetFormState = (): void => {
|
||||||
setLocalError(null);
|
setLocalError(null);
|
||||||
|
|
@ -101,6 +109,7 @@ export const LaunchTeamDialog = ({
|
||||||
setCwdMode('project');
|
setCwdMode('project');
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
|
setSelectedModel('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Warm up CLI on open
|
// Warm up CLI on open
|
||||||
|
|
@ -231,6 +240,7 @@ export const LaunchTeamDialog = ({
|
||||||
teamName,
|
teamName,
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: promptDraft.value.trim() || undefined,
|
prompt: promptDraft.value.trim() || undefined,
|
||||||
|
model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined,
|
||||||
});
|
});
|
||||||
resetFormState();
|
resetFormState();
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -252,7 +262,7 @@ export const LaunchTeamDialog = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm">Launch Team</DialogTitle>
|
<DialogTitle className="text-sm">Launch Team</DialogTitle>
|
||||||
<DialogDescription className="text-xs">
|
<DialogDescription className="text-xs">
|
||||||
|
|
@ -262,17 +272,31 @@ export const LaunchTeamDialog = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{prepareState === 'failed' ? (
|
{prepareState === 'failed' ? (
|
||||||
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||||
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
|
<div className="flex items-start gap-2">
|
||||||
{prepareWarnings.length > 0 ? (
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||||
<div className="mt-1 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
{prepareWarnings.map((warning) => (
|
<p className="font-medium text-red-300">
|
||||||
<p key={warning} className="text-[11px] text-amber-300">
|
CLI environment is not available — launch is blocked
|
||||||
{warning}
|
</p>
|
||||||
</p>
|
<p className="text-red-300/80">
|
||||||
))}
|
{prepareMessage ?? 'Failed to prepare environment'}
|
||||||
|
</p>
|
||||||
|
{prepareWarnings.length > 0 ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{prepareWarnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-[11px] text-amber-300">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
|
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
||||||
|
in PATH, then reopen this dialog.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -398,6 +422,21 @@ export const LaunchTeamDialog = ({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
|
||||||
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Default (account setting)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||||
|
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||||
|
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||||
|
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeError ? (
|
{activeError ? (
|
||||||
|
|
|
||||||
|
|
@ -118,19 +118,24 @@ export const TaskCommentsSection = ({
|
||||||
: formatDistanceToNow(date, { addSuffix: true });
|
: formatDistanceToNow(date, { addSuffix: true });
|
||||||
})()}
|
})()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
<button
|
||||||
onClick={() =>
|
type="button"
|
||||||
setReplyTo({
|
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||||
author: comment.author,
|
onClick={() =>
|
||||||
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
|
setReplyTo({
|
||||||
})
|
author: comment.author,
|
||||||
}
|
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
|
||||||
>
|
})
|
||||||
<Reply size={11} />
|
}
|
||||||
Reply
|
>
|
||||||
</button>
|
<Reply size={11} />
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const reply = parseMessageReply(comment.text);
|
const reply = parseMessageReply(comment.text);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||||
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -13,16 +14,23 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@renderer/components/ui/select';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import {
|
import {
|
||||||
agentAvatarUrl,
|
|
||||||
KANBAN_COLUMN_DISPLAY,
|
KANBAN_COLUMN_DISPLAY,
|
||||||
TASK_STATUS_LABELS,
|
TASK_STATUS_LABELS,
|
||||||
TASK_STATUS_STYLES,
|
TASK_STATUS_STYLES,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine, User } from 'lucide-react';
|
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react';
|
||||||
|
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
||||||
|
|
@ -37,6 +45,7 @@ interface TaskDetailDialogProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
|
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskDetailDialog = ({
|
export const TaskDetailDialog = ({
|
||||||
|
|
@ -48,6 +57,7 @@ export const TaskDetailDialog = ({
|
||||||
members,
|
members,
|
||||||
onClose,
|
onClose,
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
|
onOwnerChange,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||||
|
|
||||||
|
|
@ -101,10 +111,12 @@ export const TaskDetailDialog = ({
|
||||||
)
|
)
|
||||||
.map((t) => t.id);
|
.map((t) => t.id);
|
||||||
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
|
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
|
||||||
|
const isTodo = status === 'pending' && !kanbanColumn;
|
||||||
|
const canReassign = isTodo && onOwnerChange;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="max-h-[85vh] min-w-0 overflow-y-auto overflow-x-hidden sm:max-w-4xl">
|
<DialogContent className="min-w-0 sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||||
|
|
@ -125,31 +137,46 @@ export const TaskDetailDialog = ({
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{ownerMember ? (
|
{canReassign ? (
|
||||||
<div
|
<Select
|
||||||
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1"
|
value={currentTask.owner ?? '__unassigned__'}
|
||||||
style={{
|
onValueChange={(v) => {
|
||||||
borderLeft: `3px solid ${getTeamColorSet(ownerMember.color ?? '').border}`,
|
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
|
||||||
backgroundColor: getTeamColorSet(ownerMember.color ?? '').badge,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
|
||||||
src={agentAvatarUrl(ownerMember.name, 32)}
|
<SelectValue placeholder="Unassigned" />
|
||||||
alt={ownerMember.name}
|
</SelectTrigger>
|
||||||
className="size-6 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
<SelectContent>
|
||||||
loading="lazy"
|
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||||
/>
|
{members.map((m) => {
|
||||||
<span className="min-w-0 truncate font-medium text-[var(--color-text)]">
|
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||||
{ownerMember.name}
|
const memberColor = m.color ? getTeamColorSet(m.color) : null;
|
||||||
</span>
|
return (
|
||||||
</div>
|
<SelectItem key={m.name} value={m.name}>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
{memberColor ? (
|
||||||
|
<span
|
||||||
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: memberColor.border }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
{role ? (
|
||||||
|
<span className="text-[var(--color-text-muted)]">({role})</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : currentTask.owner ? (
|
||||||
|
<MemberBadge name={currentTask.owner} color={ownerMember?.color} size="md" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||||
<User size={12} />
|
|
||||||
<span className="text-[var(--color-text-secondary)]">
|
|
||||||
{currentTask.owner ?? '\u2014'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTask.createdBy ? (
|
{currentTask.createdBy ? (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
|
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
@ -18,6 +22,7 @@ import { KanbanFilterPopover } from './KanbanFilterPopover';
|
||||||
import { KanbanTaskCard } from './KanbanTaskCard';
|
import { KanbanTaskCard } from './KanbanTaskCard';
|
||||||
|
|
||||||
import type { KanbanFilterState } from './KanbanFilterPopover';
|
import type { KanbanFilterState } from './KanbanFilterPopover';
|
||||||
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
import type { Session } from '@renderer/types/data';
|
import type { Session } from '@renderer/types/data';
|
||||||
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -69,6 +74,10 @@ interface KanbanBoardProps {
|
||||||
onCompleteTask: (taskId: string) => void;
|
onCompleteTask: (taskId: string) => void;
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
onTaskClick?: (task: TeamTask) => void;
|
onTaskClick?: (task: TeamTask) => void;
|
||||||
|
/** Вызывается после изменения порядка задач в колонке (drag-and-drop). */
|
||||||
|
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
|
||||||
|
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
|
||||||
|
toolbarLeft?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanViewMode = 'grid' | 'columns';
|
type KanbanViewMode = 'grid' | 'columns';
|
||||||
|
|
@ -99,6 +108,97 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */
|
||||||
|
function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): TeamTask[] {
|
||||||
|
if (!order?.length) {
|
||||||
|
return columnTasks;
|
||||||
|
}
|
||||||
|
const byId = new Map(columnTasks.map((t) => [t.id, t]));
|
||||||
|
const ordered: TeamTask[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const id of order) {
|
||||||
|
const task = byId.get(id);
|
||||||
|
if (task) {
|
||||||
|
ordered.push(task);
|
||||||
|
seen.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const task of columnTasks) {
|
||||||
|
if (!seen.has(task.id)) {
|
||||||
|
ordered.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortableKanbanTaskCardProps {
|
||||||
|
task: TeamTask;
|
||||||
|
columnId: KanbanColumnId;
|
||||||
|
teamName: string;
|
||||||
|
kanbanState: KanbanState;
|
||||||
|
taskMap: Map<string, TeamTask>;
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
|
onRequestReview: (taskId: string) => void;
|
||||||
|
onApprove: (taskId: string) => void;
|
||||||
|
onRequestChanges: (taskId: string) => void;
|
||||||
|
onMoveBackToDone: (taskId: string) => void;
|
||||||
|
onStartTask: (taskId: string) => void;
|
||||||
|
onCompleteTask: (taskId: string) => void;
|
||||||
|
onScrollToTask?: (taskId: string) => void;
|
||||||
|
onTaskClick?: (task: TeamTask) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableKanbanTaskCard = ({
|
||||||
|
task,
|
||||||
|
columnId,
|
||||||
|
teamName,
|
||||||
|
kanbanState,
|
||||||
|
taskMap,
|
||||||
|
members,
|
||||||
|
onRequestReview,
|
||||||
|
onApprove,
|
||||||
|
onRequestChanges,
|
||||||
|
onMoveBackToDone,
|
||||||
|
onStartTask,
|
||||||
|
onCompleteTask,
|
||||||
|
onScrollToTask,
|
||||||
|
onTaskClick,
|
||||||
|
}: SortableKanbanTaskCardProps): React.JSX.Element => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
|
id: task.id,
|
||||||
|
data: { type: 'kanban-task', columnId, taskId: task.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading -- dnd-kit useSortable requires spreading attributes/listeners
|
||||||
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||||
|
<KanbanTaskCard
|
||||||
|
task={task}
|
||||||
|
teamName={teamName}
|
||||||
|
columnId={columnId}
|
||||||
|
kanbanTaskState={kanbanState.tasks[task.id]}
|
||||||
|
hasReviewers={kanbanState.reviewers.length > 0}
|
||||||
|
taskMap={taskMap}
|
||||||
|
members={members}
|
||||||
|
onRequestReview={onRequestReview}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onRequestChanges={onRequestChanges}
|
||||||
|
onMoveBackToDone={onMoveBackToDone}
|
||||||
|
onStartTask={onStartTask}
|
||||||
|
onCompleteTask={onCompleteTask}
|
||||||
|
onScrollToTask={onScrollToTask}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const KanbanBoard = ({
|
export const KanbanBoard = ({
|
||||||
tasks,
|
tasks,
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -116,6 +216,8 @@ export const KanbanBoard = ({
|
||||||
onCompleteTask,
|
onCompleteTask,
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
|
onColumnOrderChange,
|
||||||
|
toolbarLeft,
|
||||||
}: KanbanBoardProps): React.JSX.Element => {
|
}: KanbanBoardProps): React.JSX.Element => {
|
||||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||||
|
|
||||||
|
|
@ -134,6 +236,45 @@ export const KanbanBoard = ({
|
||||||
return result;
|
return result;
|
||||||
}, [tasks, kanbanState]);
|
}, [tasks, kanbanState]);
|
||||||
|
|
||||||
|
const groupedOrdered = useMemo(() => {
|
||||||
|
const result = new Map<KanbanColumnId, TeamTask[]>();
|
||||||
|
for (const column of COLUMNS) {
|
||||||
|
const columnTasks = grouped.get(column.id) ?? [];
|
||||||
|
const order = kanbanState.columnOrder?.[column.id];
|
||||||
|
result.set(column.id, sortColumnTasksByOrder(columnTasks, order));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [grouped, kanbanState.columnOrder]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 8 },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!onColumnOrderChange || !over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeData = active.data.current;
|
||||||
|
if (activeData?.type !== 'kanban-task') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const columnId = activeData.columnId as KanbanColumnId;
|
||||||
|
const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
|
||||||
|
const oldIndex = orderedIds.indexOf(active.id as string);
|
||||||
|
const newIndex = orderedIds.indexOf(over.id as string);
|
||||||
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
|
||||||
|
onColumnOrderChange(columnId, newOrder);
|
||||||
|
},
|
||||||
|
[onColumnOrderChange, groupedOrdered]
|
||||||
|
);
|
||||||
|
|
||||||
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
|
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
|
||||||
if (columnTasks.length === 0) {
|
if (columnTasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -142,6 +283,32 @@ export const KanbanBoard = ({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (onColumnOrderChange) {
|
||||||
|
const itemIds = columnTasks.map((t) => t.id);
|
||||||
|
return (
|
||||||
|
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{columnTasks.map((task) => (
|
||||||
|
<SortableKanbanTaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
columnId={columnId}
|
||||||
|
teamName={teamName}
|
||||||
|
kanbanState={kanbanState}
|
||||||
|
taskMap={taskMap}
|
||||||
|
members={members}
|
||||||
|
onRequestReview={onRequestReview}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onRequestChanges={onRequestChanges}
|
||||||
|
onMoveBackToDone={onMoveBackToDone}
|
||||||
|
onStartTask={onStartTask}
|
||||||
|
onCompleteTask={onCompleteTask}
|
||||||
|
onScrollToTask={onScrollToTask}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{columnTasks.map((task) => (
|
{columnTasks.map((task) => (
|
||||||
|
|
@ -153,6 +320,7 @@ export const KanbanBoard = ({
|
||||||
kanbanTaskState={kanbanState.tasks[task.id]}
|
kanbanTaskState={kanbanState.tasks[task.id]}
|
||||||
hasReviewers={kanbanState.reviewers.length > 0}
|
hasReviewers={kanbanState.reviewers.length > 0}
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
|
members={members}
|
||||||
onRequestReview={onRequestReview}
|
onRequestReview={onRequestReview}
|
||||||
onApprove={onApprove}
|
onApprove={onApprove}
|
||||||
onRequestChanges={onRequestChanges}
|
onRequestChanges={onRequestChanges}
|
||||||
|
|
@ -167,62 +335,65 @@ export const KanbanBoard = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const boardContent = (
|
||||||
<div>
|
<>
|
||||||
<div className="mb-2 flex items-center justify-end gap-2">
|
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
|
||||||
<KanbanFilterPopover
|
{toolbarLeft != null && <div className="min-w-0 flex-1">{toolbarLeft}</div>}
|
||||||
filter={filter}
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
sessions={sessions}
|
<KanbanFilterPopover
|
||||||
leadSessionId={leadSessionId}
|
filter={filter}
|
||||||
members={members}
|
sessions={sessions}
|
||||||
onFilterChange={onFilterChange}
|
leadSessionId={leadSessionId}
|
||||||
/>
|
members={members}
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
onFilterChange={onFilterChange}
|
||||||
<Tooltip>
|
/>
|
||||||
<TooltipTrigger asChild>
|
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
'h-7 rounded-r-none px-2',
|
size="sm"
|
||||||
viewMode === 'grid'
|
className={cn(
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
'h-7 rounded-r-none px-2',
|
||||||
: 'text-[var(--color-text-muted)]'
|
viewMode === 'grid'
|
||||||
)}
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||||
onClick={() => setViewMode('grid')}
|
: 'text-[var(--color-text-muted)]'
|
||||||
aria-label="Grid view"
|
)}
|
||||||
>
|
onClick={() => setViewMode('grid')}
|
||||||
<LayoutGrid size={14} />
|
aria-label="Grid view"
|
||||||
</Button>
|
>
|
||||||
</TooltipTrigger>
|
<LayoutGrid size={14} />
|
||||||
<TooltipContent side="bottom">Grid view</TooltipContent>
|
</Button>
|
||||||
</Tooltip>
|
</TooltipTrigger>
|
||||||
<Tooltip>
|
<TooltipContent side="bottom">Grid view</TooltipContent>
|
||||||
<TooltipTrigger asChild>
|
</Tooltip>
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
size="sm"
|
||||||
viewMode === 'columns'
|
className={cn(
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
||||||
: 'text-[var(--color-text-muted)]'
|
viewMode === 'columns'
|
||||||
)}
|
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||||
onClick={() => setViewMode('columns')}
|
: 'text-[var(--color-text-muted)]'
|
||||||
aria-label="Columns view"
|
)}
|
||||||
>
|
onClick={() => setViewMode('columns')}
|
||||||
<Columns3 size={14} />
|
aria-label="Columns view"
|
||||||
</Button>
|
>
|
||||||
</TooltipTrigger>
|
<Columns3 size={14} />
|
||||||
<TooltipContent side="bottom">Columns view</TooltipContent>
|
</Button>
|
||||||
</Tooltip>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Columns view</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'grid' ? (
|
{viewMode === 'grid' ? (
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnTasks = grouped.get(column.id) ?? [];
|
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||||
const accent = COLUMN_ACCENTS[column.id];
|
const accent = COLUMN_ACCENTS[column.id];
|
||||||
return (
|
return (
|
||||||
<KanbanColumn
|
<KanbanColumn
|
||||||
|
|
@ -241,7 +412,7 @@ export const KanbanBoard = ({
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnTasks = grouped.get(column.id) ?? [];
|
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||||
const accent = COLUMN_ACCENTS[column.id];
|
const accent = COLUMN_ACCENTS[column.id];
|
||||||
return (
|
return (
|
||||||
<div key={column.id} className="w-64 shrink-0">
|
<div key={column.id} className="w-64 shrink-0">
|
||||||
|
|
@ -259,6 +430,16 @@ export const KanbanBoard = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onColumnOrderChange) {
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
|
{boardContent}
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return boardContent;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
|
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
|
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
|
||||||
|
|
||||||
import type { KanbanColumnId, KanbanTaskState, TeamTask } from '@shared/types';
|
import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
interface KanbanTaskCardProps {
|
interface KanbanTaskCardProps {
|
||||||
task: TeamTask;
|
task: TeamTask;
|
||||||
|
|
@ -13,6 +14,7 @@ interface KanbanTaskCardProps {
|
||||||
kanbanTaskState?: KanbanTaskState;
|
kanbanTaskState?: KanbanTaskState;
|
||||||
hasReviewers: boolean;
|
hasReviewers: boolean;
|
||||||
taskMap: Map<string, TeamTask>;
|
taskMap: Map<string, TeamTask>;
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
onRequestReview: (taskId: string) => void;
|
onRequestReview: (taskId: string) => void;
|
||||||
onApprove: (taskId: string) => void;
|
onApprove: (taskId: string) => void;
|
||||||
onRequestChanges: (taskId: string) => void;
|
onRequestChanges: (taskId: string) => void;
|
||||||
|
|
@ -63,6 +65,7 @@ export const KanbanTaskCard = ({
|
||||||
kanbanTaskState: _kanbanTaskState,
|
kanbanTaskState: _kanbanTaskState,
|
||||||
hasReviewers,
|
hasReviewers,
|
||||||
taskMap,
|
taskMap,
|
||||||
|
members,
|
||||||
onRequestReview,
|
onRequestReview,
|
||||||
onApprove,
|
onApprove,
|
||||||
onRequestChanges,
|
onRequestChanges,
|
||||||
|
|
@ -96,23 +99,21 @@ export const KanbanTaskCard = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-start justify-between gap-2">
|
<div className="mb-2 flex items-center gap-1">
|
||||||
<div>
|
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
|
||||||
<div className="mb-1 flex items-center gap-1">
|
#{task.id}
|
||||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
</Badge>
|
||||||
#{task.id}
|
{task.owner ? (
|
||||||
</Badge>
|
<MemberBadge
|
||||||
<UnreadCommentsBadge
|
name={task.owner}
|
||||||
unreadCount={unreadCount}
|
color={members.find((m) => m.name === task.owner)?.color}
|
||||||
totalCount={task.comments?.length ?? 0}
|
/>
|
||||||
/>
|
) : null}
|
||||||
</div>
|
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
||||||
<h5 className="text-sm font-medium text-[var(--color-text)]">{task.subject}</h5>
|
{task.subject}
|
||||||
</div>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mb-2 text-xs text-[var(--color-text-muted)]">Owner: {task.owner ?? '\u2014'}</p>
|
|
||||||
|
|
||||||
{hasBlockedBy ? (
|
{hasBlockedBy ? (
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-300">
|
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-300">
|
||||||
|
|
@ -147,112 +148,118 @@ export const KanbanTaskCard = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{columnId === 'todo' ? (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-1 flex-wrap gap-2">
|
||||||
<Button
|
{columnId === 'todo' ? (
|
||||||
variant="outline"
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
variant="outline"
|
||||||
aria-label={`Start task ${task.id}`}
|
size="sm"
|
||||||
onClick={(e) => {
|
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||||
e.stopPropagation();
|
aria-label={`Start task ${task.id}`}
|
||||||
onStartTask(task.id);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onStartTask(task.id);
|
||||||
<Play size={12} />
|
}}
|
||||||
Start
|
>
|
||||||
</Button>
|
<Play size={12} />
|
||||||
<Button
|
Start
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
className="gap-1"
|
variant="outline"
|
||||||
aria-label={`Complete task ${task.id}`}
|
size="sm"
|
||||||
onClick={(e) => {
|
className="gap-1"
|
||||||
e.stopPropagation();
|
aria-label={`Complete task ${task.id}`}
|
||||||
onCompleteTask(task.id);
|
onClick={(e) => {
|
||||||
}}
|
e.stopPropagation();
|
||||||
>
|
onCompleteTask(task.id);
|
||||||
<CheckCircle2 size={12} />
|
}}
|
||||||
Complete
|
>
|
||||||
</Button>
|
<CheckCircle2 size={12} />
|
||||||
</div>
|
Complete
|
||||||
) : null}
|
</Button>
|
||||||
|
</>
|
||||||
{columnId === 'in_progress' ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-1"
|
|
||||||
aria-label={`Complete task ${task.id}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCompleteTask(task.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={12} />
|
|
||||||
Complete
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{columnId === 'done' ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
aria-label={`Request review for task ${task.id}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRequestReview(task.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Request Review
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{columnId === 'review' ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{!hasReviewers ? (
|
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex gap-2">
|
|
||||||
|
{columnId === 'in_progress' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={`Approve task ${task.id}`}
|
className="gap-1"
|
||||||
|
aria-label={`Complete task ${task.id}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onApprove(task.id);
|
onCompleteTask(task.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Approve
|
<CheckCircle2 size={12} />
|
||||||
|
Complete
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
) : null}
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
aria-label={`Request changes for task ${task.id}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRequestChanges(task.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Request Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{columnId === 'approved' ? (
|
{columnId === 'done' ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={`Move task ${task.id} back to done`}
|
aria-label={`Request review for task ${task.id}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onMoveBackToDone(task.id);
|
onRequestReview(task.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Move back to DONE
|
Request Review
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{columnId === 'review' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!hasReviewers ? (
|
||||||
|
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Approve task ${task.id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onApprove(task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Request changes for task ${task.id}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRequestChanges(task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Request Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{columnId === 'approved' ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`Move task ${task.id} back to done`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMoveBackToDone(task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Move back to DONE
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
import { ListPlus, Loader2, MessageSquare } from 'lucide-react';
|
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -15,6 +16,7 @@ interface MemberCardProps {
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
currentTask?: TeamTaskWithKanban | null;
|
currentTask?: TeamTaskWithKanban | null;
|
||||||
isAwaitingReply?: boolean;
|
isAwaitingReply?: boolean;
|
||||||
|
isRemoved?: boolean;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSendMessage?: () => void;
|
onSendMessage?: () => void;
|
||||||
|
|
@ -29,6 +31,7 @@ export const MemberCard = ({
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
currentTask,
|
currentTask,
|
||||||
isAwaitingReply,
|
isAwaitingReply,
|
||||||
|
isRemoved,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onClick,
|
onClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
|
@ -41,14 +44,12 @@ export const MemberCard = ({
|
||||||
const inProgress = taskCounts?.inProgress ?? 0;
|
const inProgress = taskCounts?.inProgress ?? 0;
|
||||||
const completed = taskCounts?.completed ?? 0;
|
const completed = taskCounts?.completed ?? 0;
|
||||||
const totalTasks = pending + inProgress + completed;
|
const totalTasks = pending + inProgress + completed;
|
||||||
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
|
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||||
|
|
||||||
const progressPercent = Math.round(completedRatio * 100);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded">
|
<div className={isRemoved ? 'rounded opacity-50' : 'rounded'}>
|
||||||
<div
|
<div
|
||||||
className="group relative cursor-pointer rounded-t px-2 py-1.5"
|
className="group relative cursor-pointer rounded px-2 py-1.5"
|
||||||
style={{
|
style={{
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
backgroundColor: colors.badge,
|
backgroundColor: colors.badge,
|
||||||
|
|
@ -64,7 +65,7 @@ export const MemberCard = ({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" />
|
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<img
|
<img
|
||||||
|
|
@ -80,6 +81,12 @@ export const MemberCard = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
||||||
<span className="shrink-0 font-medium text-[var(--color-text)]">{member.name}</span>
|
<span className="shrink-0 font-medium text-[var(--color-text)]">{member.name}</span>
|
||||||
|
{member.gitBranch ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
<GitBranch size={10} />
|
||||||
|
{member.gitBranch}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{currentTask ? (
|
{currentTask ? (
|
||||||
<>
|
<>
|
||||||
<Loader2
|
<Loader2
|
||||||
|
|
@ -133,55 +140,72 @@ export const MemberCard = ({
|
||||||
})()}
|
})()}
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
|
||||||
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
title={
|
||||||
|
isRemoved
|
||||||
|
? 'This member has been removed'
|
||||||
|
: member.currentTaskId
|
||||||
|
? `Current task: ${member.currentTaskId}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{presenceLabel}
|
{isRemoved ? 'removed' : presenceLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<div
|
||||||
variant="secondary"
|
className="shrink-0"
|
||||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
||||||
>
|
>
|
||||||
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
<Badge
|
||||||
</Badge>
|
variant="secondary"
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
|
||||||
title="Send message"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSendMessage?.();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MessageSquare size={13} />
|
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
||||||
</button>
|
</Badge>
|
||||||
<button
|
{totalTasks > 0 && (
|
||||||
type="button"
|
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
<div
|
||||||
title="Assign task"
|
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||||
onClick={(e) => {
|
style={{ width: `${progressPercent}%` }}
|
||||||
e.stopPropagation();
|
/>
|
||||||
onAssignTask?.();
|
</div>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
<ListPlus size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!isRemoved && (
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSendMessage?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MessageSquare size={13} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Send message</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAssignTask?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Assign task</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className="h-px rounded-b bg-[var(--color-border)]"
|
|
||||||
role="progressbar"
|
|
||||||
aria-valuenow={completed}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={totalTasks}
|
|
||||||
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
|
||||||
title={`${completed}/${totalTasks} tasks`}
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, #10b981 ${progressPercent}%, var(--color-border) ${progressPercent}%)`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||||
import { BarChart3, FileText, ListPlus, MessageSquare } from 'lucide-react';
|
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||||
import { MemberDetailStats } from './MemberDetailStats';
|
import { MemberDetailStats, type MemberDetailTab } from './MemberDetailStats';
|
||||||
import { MemberLogsTab } from './MemberLogsTab';
|
import { MemberLogsTab } from './MemberLogsTab';
|
||||||
import { MemberMessagesTab } from './MemberMessagesTab';
|
import { MemberMessagesTab } from './MemberMessagesTab';
|
||||||
import { MemberStatsTab } from './MemberStatsTab';
|
import { MemberStatsTab } from './MemberStatsTab';
|
||||||
|
|
@ -26,6 +26,7 @@ interface MemberDetailDialogProps {
|
||||||
onSendMessage: () => void;
|
onSendMessage: () => void;
|
||||||
onAssignTask: () => void;
|
onAssignTask: () => void;
|
||||||
onTaskClick: (task: TeamTaskWithKanban) => void;
|
onTaskClick: (task: TeamTaskWithKanban) => void;
|
||||||
|
onRemoveMember?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDetailDialog = ({
|
export const MemberDetailDialog = ({
|
||||||
|
|
@ -40,6 +41,7 @@ export const MemberDetailDialog = ({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
|
onRemoveMember,
|
||||||
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
||||||
const memberTasks = useMemo(
|
const memberTasks = useMemo(
|
||||||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||||
|
|
@ -61,28 +63,37 @@ export const MemberDetailDialog = ({
|
||||||
[memberTasks]
|
[memberTasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<MemberDetailTab>('tasks');
|
||||||
|
|
||||||
if (!member) return null;
|
if (!member) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||||
<DialogContent className="min-w-0 overflow-hidden sm:max-w-4xl">
|
<DialogContent className="min-w-0 sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<div className="flex items-start gap-4">
|
||||||
<MemberDetailHeader
|
<DialogHeader className="shrink-0">
|
||||||
member={member}
|
<MemberDetailHeader
|
||||||
isTeamAlive={isTeamAlive}
|
member={member}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamAlive={isTeamAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<MemberDetailStats
|
||||||
|
totalTasks={memberTasks.length}
|
||||||
|
inProgressTasks={inProgressTasks}
|
||||||
|
completedTasks={completedTasks}
|
||||||
|
messageCount={memberMessages.length}
|
||||||
|
lastActiveAt={member.lastActiveAt}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
/>
|
/>
|
||||||
</DialogHeader>
|
</div>
|
||||||
|
|
||||||
<MemberDetailStats
|
<Tabs
|
||||||
totalTasks={memberTasks.length}
|
value={activeTab}
|
||||||
inProgressTasks={inProgressTasks}
|
onValueChange={(v) => setActiveTab(v as MemberDetailTab)}
|
||||||
completedTasks={completedTasks}
|
className="min-w-0 overflow-hidden"
|
||||||
messageCount={memberMessages.length}
|
>
|
||||||
lastActiveAt={member.lastActiveAt}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Tabs defaultValue="tasks">
|
|
||||||
<TabsList className="w-full">
|
<TabsList className="w-full">
|
||||||
<TabsTrigger value="tasks" className="flex-1 gap-1.5">
|
<TabsTrigger value="tasks" className="flex-1 gap-1.5">
|
||||||
Tasks
|
Tasks
|
||||||
|
|
@ -113,7 +124,7 @@ export const MemberDetailDialog = ({
|
||||||
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="messages">
|
<TabsContent value="messages">
|
||||||
<MemberMessagesTab messages={memberMessages} />
|
<MemberMessagesTab messages={memberMessages} teamName={teamName} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="stats">
|
<TabsContent value="stats">
|
||||||
<MemberStatsTab teamName={teamName} memberName={member.name} />
|
<MemberStatsTab teamName={teamName} memberName={member.name} />
|
||||||
|
|
@ -124,14 +135,33 @@ export const MemberDetailDialog = ({
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
{member.removedAt ? (
|
||||||
<MessageSquare size={14} />
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
Send Message
|
Removed {new Date(member.removedAt).toLocaleDateString()}
|
||||||
</Button>
|
</span>
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}>
|
) : (
|
||||||
<ListPlus size={14} />
|
<>
|
||||||
Assign Task
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
||||||
</Button>
|
<MessageSquare size={14} />
|
||||||
|
Send Message
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}>
|
||||||
|
<ListPlus size={14} />
|
||||||
|
Assign Task
|
||||||
|
</Button>
|
||||||
|
{onRemoveMember && member.agentType !== 'team-lead' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||||
|
onClick={onRemoveMember}
|
||||||
|
>
|
||||||
|
<UserMinus size={14} />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,49 @@
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
|
export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs';
|
||||||
|
|
||||||
interface MemberDetailStatsProps {
|
interface MemberDetailStatsProps {
|
||||||
totalTasks: number;
|
totalTasks: number;
|
||||||
inProgressTasks: number;
|
inProgressTasks: number;
|
||||||
completedTasks: number;
|
completedTasks: number;
|
||||||
messageCount: number;
|
messageCount: number;
|
||||||
lastActiveAt: string | null;
|
lastActiveAt: string | null;
|
||||||
|
onTabChange?: (tab: MemberDetailTab) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseClasses =
|
||||||
|
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1.5';
|
||||||
|
const clickableClasses =
|
||||||
|
'cursor-pointer transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-overlay)]';
|
||||||
|
|
||||||
const StatBlock = ({
|
const StatBlock = ({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
sub,
|
sub,
|
||||||
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
}): React.JSX.Element => (
|
onClick?: () => void;
|
||||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
|
}): React.JSX.Element => {
|
||||||
<p className="text-lg font-semibold text-[var(--color-text)]">{value}</p>
|
const classes = onClick ? `${baseClasses} ${clickableClasses}` : baseClasses;
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p>
|
const content = (
|
||||||
{sub && <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">{sub}</p>}
|
<>
|
||||||
</div>
|
<p className="text-base font-semibold leading-tight text-[var(--color-text)]">{value}</p>
|
||||||
);
|
<p className="text-[10px] text-[var(--color-text-muted)]">{label}</p>
|
||||||
|
{sub && <p className="mt-0.5 text-[9px] text-[var(--color-text-muted)]">{sub}</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={classes} onClick={onClick}>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className={classes}>{content}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
export const MemberDetailStats = ({
|
export const MemberDetailStats = ({
|
||||||
totalTasks,
|
totalTasks,
|
||||||
|
|
@ -30,21 +51,35 @@ export const MemberDetailStats = ({
|
||||||
completedTasks,
|
completedTasks,
|
||||||
messageCount,
|
messageCount,
|
||||||
lastActiveAt,
|
lastActiveAt,
|
||||||
|
onTabChange,
|
||||||
}: MemberDetailStatsProps): React.JSX.Element => {
|
}: MemberDetailStatsProps): React.JSX.Element => {
|
||||||
const lastActive = lastActiveAt
|
const lastActive = lastActiveAt
|
||||||
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
|
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
|
||||||
: '—';
|
: '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
<div className="grid min-w-0 flex-1 grid-cols-4 gap-1.5">
|
||||||
<StatBlock
|
<StatBlock
|
||||||
label="Tasks"
|
label="Tasks"
|
||||||
value={totalTasks}
|
value={totalTasks}
|
||||||
sub={inProgressTasks > 0 ? `in progress: ${inProgressTasks}` : undefined}
|
sub={inProgressTasks > 0 ? `in progress: ${inProgressTasks}` : undefined}
|
||||||
|
onClick={onTabChange ? () => onTabChange('tasks') : undefined}
|
||||||
|
/>
|
||||||
|
<StatBlock
|
||||||
|
label="Completed"
|
||||||
|
value={completedTasks}
|
||||||
|
onClick={onTabChange ? () => onTabChange('tasks') : undefined}
|
||||||
|
/>
|
||||||
|
<StatBlock
|
||||||
|
label="Messages"
|
||||||
|
value={messageCount}
|
||||||
|
onClick={onTabChange ? () => onTabChange('messages') : undefined}
|
||||||
|
/>
|
||||||
|
<StatBlock
|
||||||
|
label="Activity"
|
||||||
|
value={lastActive}
|
||||||
|
onClick={onTabChange ? () => onTabChange('logs') : undefined}
|
||||||
/>
|
/>
|
||||||
<StatBlock label="Completed" value={completedTasks} />
|
|
||||||
<StatBlock label="Messages" value={messageCount} />
|
|
||||||
<StatBlock label="Activity" value={lastActive} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
|
||||||
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
|
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
|
||||||
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
|
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
||||||
|
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Bot, ChevronDown } from 'lucide-react';
|
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
import type { EnhancedChunk } from '@renderer/types/data';
|
import type { EnhancedChunk } from '@renderer/types/data';
|
||||||
import type { AIGroup, UserGroup } from '@renderer/types/groups';
|
import type { AIGroup, UserGroup } from '@renderer/types/groups';
|
||||||
|
|
@ -88,31 +90,66 @@ export const MemberExecutionLog = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Extract agent-only instruction blocks and human-visible text from a message. */
|
||||||
|
function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[] } {
|
||||||
|
const agentInfo: string[] = [];
|
||||||
|
const regex = createAgentBlockRegex();
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = regex.exec(raw)) !== null) {
|
||||||
|
const content = m[0]
|
||||||
|
.replace(/^```info_for_agent\n?/, '')
|
||||||
|
.replace(/\n?```$/, '')
|
||||||
|
.trim();
|
||||||
|
if (content) agentInfo.push(content);
|
||||||
|
}
|
||||||
|
const humanText = raw.replace(createAgentBlockRegex(), '').trim();
|
||||||
|
return { humanText, agentInfo };
|
||||||
|
}
|
||||||
|
|
||||||
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
|
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
|
||||||
const text = group.content.rawText ?? group.content.text ?? '';
|
const text = group.content.rawText ?? group.content.text ?? '';
|
||||||
if (!text.trim()) {
|
const { humanText, agentInfo } = useMemo(() => splitAgentBlocks(text), [text]);
|
||||||
|
const [agentInfoOpen, setAgentInfoOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!humanText && agentInfo.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 justify-end">
|
<div className="py-1 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<div className="min-w-0 max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--color-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
{format(group.timestamp, 'h:mm:ss a')} — (empty)
|
||||||
<div className="text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
{format(group.timestamp, 'h:mm:ss a')}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">(empty)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 justify-end">
|
<div className="min-w-0 space-y-1 overflow-hidden py-1">
|
||||||
<div className="min-w-0 max-w-full rounded-2xl rounded-br-sm border border-[var(--chat-user-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
{format(group.timestamp, 'h:mm:ss a')}
|
||||||
{format(group.timestamp, 'h:mm:ss a')}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 min-w-0 break-words text-sm text-[var(--chat-user-text)]">
|
|
||||||
<MarkdownViewer content={text} copyable />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{humanText && (
|
||||||
|
<div className="min-w-0 overflow-x-auto break-words text-xs text-[var(--chat-user-text)]">
|
||||||
|
<MarkdownViewer content={humanText} copyable />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{agentInfo.length > 0 && (
|
||||||
|
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||||
|
onClick={() => setAgentInfoOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={10}
|
||||||
|
className={`shrink-0 transition-transform ${agentInfoOpen ? 'rotate-90' : ''}`}
|
||||||
|
/>
|
||||||
|
<Bot size={10} className="shrink-0" />
|
||||||
|
Agent instructions
|
||||||
|
</button>
|
||||||
|
{agentInfoOpen && (
|
||||||
|
<pre className="overflow-x-auto border-t border-[var(--color-border)] px-2 py-1.5 text-[10px] leading-relaxed text-[var(--color-text-muted)]">
|
||||||
|
{agentInfo.join('\n\n')}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -149,23 +186,28 @@ const AIExecutionGroup = ({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
|
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
|
||||||
{hasToggleContent ? (
|
{hasToggleContent ? (
|
||||||
<button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
className="flex w-full items-center gap-2 text-left"
|
<button
|
||||||
onClick={onToggleExpanded}
|
type="button"
|
||||||
aria-expanded={expanded}
|
className="flex w-full items-center gap-2 text-left"
|
||||||
>
|
onClick={onToggleExpanded}
|
||||||
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
aria-expanded={expanded}
|
||||||
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
>
|
||||||
Claude
|
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
||||||
</span>
|
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
|
Claude
|
||||||
{enhanced.itemsSummary}
|
</span>
|
||||||
</span>
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
|
||||||
<ChevronDown
|
{enhanced.itemsSummary}
|
||||||
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
|
</span>
|
||||||
/>
|
<ChevronDown
|
||||||
</button>
|
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{expanded ? 'Collapse' : 'Expand'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasToggleContent && expanded ? (
|
{hasToggleContent && expanded ? (
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ export const MemberList = ({
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
}: MemberListProps): React.JSX.Element => {
|
}: MemberListProps): React.JSX.Element => {
|
||||||
|
const activeMembers = members.filter((m) => !m.removedAt);
|
||||||
|
const removedMembers = members.filter((m) => m.removedAt);
|
||||||
|
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
|
@ -38,29 +41,46 @@ export const MemberList = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderCard = (
|
||||||
|
member: ResolvedTeamMember,
|
||||||
|
index: number,
|
||||||
|
isRemoved: boolean
|
||||||
|
): React.JSX.Element => {
|
||||||
|
const currentTask =
|
||||||
|
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||||
|
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||||
|
return (
|
||||||
|
<MemberCard
|
||||||
|
key={member.name}
|
||||||
|
member={member}
|
||||||
|
memberColor={member.color ?? getMemberColor(index)}
|
||||||
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
currentTask={isRemoved ? null : currentTask}
|
||||||
|
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||||
|
isRemoved={isRemoved}
|
||||||
|
onOpenTask={currentTask && !isRemoved ? () => onOpenTask?.(currentTask) : undefined}
|
||||||
|
onClick={() => onMemberClick?.(member)}
|
||||||
|
onSendMessage={() => onSendMessage?.(member)}
|
||||||
|
onAssignTask={() => onAssignTask?.(member)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{members.map((member, index) => {
|
{activeMembers.map((member, index) => renderCard(member, index, false))}
|
||||||
const currentTask =
|
{removedMembers.length > 0 && (
|
||||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
<>
|
||||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
return (
|
Removed ({removedMembers.length})
|
||||||
<MemberCard
|
</div>
|
||||||
key={member.name}
|
{removedMembers.map((member, index) =>
|
||||||
member={member}
|
renderCard(member, activeMembers.length + index, true)
|
||||||
memberColor={member.color ?? getMemberColor(index)}
|
)}
|
||||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
</>
|
||||||
isTeamAlive={isTeamAlive}
|
)}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
|
||||||
currentTask={currentTask}
|
|
||||||
isAwaitingReply={awaitingReply}
|
|
||||||
onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
|
||||||
onClick={() => onMemberClick?.(member)}
|
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
|
||||||
onAssignTask={() => onAssignTask?.(member)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { formatDuration } from '@renderer/utils/formatters';
|
import { formatDuration } from '@renderer/utils/formatters';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -207,35 +208,40 @@ const LogCard = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
<div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||||
<button
|
<Tooltip>
|
||||||
className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
<TooltipTrigger asChild>
|
||||||
onClick={onToggle}
|
<button
|
||||||
>
|
className="flex w-full min-w-0 items-center gap-2 overflow-hidden px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
||||||
{expanded ? (
|
onClick={onToggle}
|
||||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
>
|
||||||
) : (
|
{expanded ? (
|
||||||
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
)}
|
) : (
|
||||||
<div className="min-w-0 flex-1 overflow-hidden">
|
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
<div className="truncate text-[var(--color-text)]" title={log.description}>
|
|
||||||
{log.description}
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock size={10} />
|
|
||||||
{timeAgo}
|
|
||||||
</span>
|
|
||||||
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<MessageSquare size={10} />
|
|
||||||
{log.messageCount}
|
|
||||||
</span>
|
|
||||||
{log.isOngoing && (
|
|
||||||
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
</div>
|
<div className="truncate text-[var(--color-text)]" title={log.description}>
|
||||||
</button>
|
{log.description}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={10} />
|
||||||
|
{timeAgo}
|
||||||
|
</span>
|
||||||
|
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MessageSquare size={10} />
|
||||||
|
{log.messageCount}
|
||||||
|
</span>
|
||||||
|
{log.isOngoing && (
|
||||||
|
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{expanded ? 'Hide details' : 'Show details'}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="border-t border-[var(--color-border)] px-3 py-2">
|
<div className="border-t border-[var(--color-border)] px-3 py-2">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import type { InboxMessage } from '@shared/types';
|
||||||
|
|
||||||
interface MemberMessagesTabProps {
|
interface MemberMessagesTabProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
|
teamName: string;
|
||||||
onCreateTask?: (subject: string, description: string) => void;
|
onCreateTask?: (subject: string, description: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ const MAX_MESSAGES = 100;
|
||||||
|
|
||||||
export const MemberMessagesTab = ({
|
export const MemberMessagesTab = ({
|
||||||
messages,
|
messages,
|
||||||
|
teamName,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
}: MemberMessagesTabProps): React.JSX.Element => {
|
}: MemberMessagesTabProps): React.JSX.Element => {
|
||||||
const displayMessages = messages.slice(0, MAX_MESSAGES);
|
const displayMessages = messages.slice(0, MAX_MESSAGES);
|
||||||
|
|
@ -26,7 +28,12 @@ export const MemberMessagesTab = ({
|
||||||
return (
|
return (
|
||||||
<div className="max-h-[320px] space-y-2 overflow-y-auto">
|
<div className="max-h-[320px] space-y-2 overflow-y-auto">
|
||||||
{displayMessages.map((msg, idx) => (
|
{displayMessages.map((msg, idx) => (
|
||||||
<ActivityItem key={msg.messageId ?? idx} message={msg} onCreateTask={onCreateTask} />
|
<ActivityItem
|
||||||
|
key={msg.messageId ?? idx}
|
||||||
|
message={msg}
|
||||||
|
teamName={teamName}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
328
src/renderer/components/team/messages/MessageComposer.tsx
Normal file
328
src/renderer/components/team/messages/MessageComposer.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
||||||
|
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||||
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||||
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
|
import { AlertCircle, Check, ChevronDown, Paperclip, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
import type { AttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
interface MessageComposerProps {
|
||||||
|
teamName: string;
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
|
isTeamAlive?: boolean;
|
||||||
|
sending: boolean;
|
||||||
|
sendError: string | null;
|
||||||
|
onSend: (
|
||||||
|
recipient: string,
|
||||||
|
text: string,
|
||||||
|
summary?: string,
|
||||||
|
attachments?: AttachmentPayload[]
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MESSAGE_LENGTH = 4000;
|
||||||
|
|
||||||
|
export const MessageComposer = ({
|
||||||
|
teamName,
|
||||||
|
members,
|
||||||
|
isTeamAlive,
|
||||||
|
sending,
|
||||||
|
sendError,
|
||||||
|
onSend,
|
||||||
|
}: MessageComposerProps): React.JSX.Element => {
|
||||||
|
const [recipient, setRecipient] = useState<string>(() => {
|
||||||
|
const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead');
|
||||||
|
return lead?.name ?? members[0]?.name ?? '';
|
||||||
|
});
|
||||||
|
const [recipientOpen, setRecipientOpen] = useState(false);
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const dragCounterRef = useRef(0);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const draft = useDraftPersistence({ key: `compose:${teamName}` });
|
||||||
|
const {
|
||||||
|
attachments,
|
||||||
|
error: attachmentError,
|
||||||
|
canAddMore,
|
||||||
|
addFiles,
|
||||||
|
removeAttachment,
|
||||||
|
clearAttachments,
|
||||||
|
handlePaste,
|
||||||
|
handleDrop,
|
||||||
|
} = useAttachments();
|
||||||
|
|
||||||
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
|
() =>
|
||||||
|
members.map((m) => ({
|
||||||
|
id: m.name,
|
||||||
|
name: m.name,
|
||||||
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
|
color: m.color,
|
||||||
|
})),
|
||||||
|
[members]
|
||||||
|
);
|
||||||
|
|
||||||
|
const trimmed = draft.value.trim();
|
||||||
|
const canSend =
|
||||||
|
recipient.length > 0 && trimmed.length > 0 && trimmed.length <= MAX_MESSAGE_LENGTH && !sending;
|
||||||
|
|
||||||
|
const selectedMember = members.find((m) => m.name === recipient);
|
||||||
|
const selectedColorSet = selectedMember?.color ? getTeamColorSet(selectedMember.color) : null;
|
||||||
|
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||||
|
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
||||||
|
|
||||||
|
const handleSend = useCallback(() => {
|
||||||
|
if (!canSend) return;
|
||||||
|
const autoSummary = trimmed.length > 60 ? trimmed.slice(0, 57) + '...' : trimmed;
|
||||||
|
onSend(recipient, trimmed, autoSummary, attachments.length > 0 ? attachments : undefined);
|
||||||
|
draft.clearDraft();
|
||||||
|
clearAttachments();
|
||||||
|
}, [canSend, recipient, trimmed, onSend, draft, attachments, clearAttachments]);
|
||||||
|
|
||||||
|
const handleKeyDownCapture = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSend]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = e.target;
|
||||||
|
if (input.files?.length) {
|
||||||
|
void addFiles(input.files);
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounterRef.current += 1;
|
||||||
|
if (dragCounterRef.current === 1) setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounterRef.current -= 1;
|
||||||
|
if (dragCounterRef.current <= 0) {
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setIsDragOver(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDropWrapper = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setIsDragOver(false);
|
||||||
|
if (canAttach) handleDrop(e);
|
||||||
|
},
|
||||||
|
[canAttach, handleDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePasteWrapper = useCallback(
|
||||||
|
(e: React.ClipboardEvent) => {
|
||||||
|
if (canAttach) handlePaste(e);
|
||||||
|
},
|
||||||
|
[canAttach, handlePaste]
|
||||||
|
);
|
||||||
|
|
||||||
|
const remaining = MAX_MESSAGE_LENGTH - trimmed.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative mb-3 p-3"
|
||||||
|
role="group"
|
||||||
|
onKeyDownCapture={handleKeyDownCapture}
|
||||||
|
onDragEnter={canAttach ? handleDragEnter : undefined}
|
||||||
|
onDragLeave={canAttach ? handleDragLeave : undefined}
|
||||||
|
onDragOver={canAttach ? handleDragOver : undefined}
|
||||||
|
onDrop={canAttach ? handleDropWrapper : undefined}
|
||||||
|
onPaste={canAttach ? handlePasteWrapper : undefined}
|
||||||
|
>
|
||||||
|
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
|
||||||
|
>
|
||||||
|
{selectedColorSet ? (
|
||||||
|
<span
|
||||||
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: selectedColorSet.border }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="max-w-[120px] truncate font-medium"
|
||||||
|
style={selectedColorSet ? { color: selectedColorSet.text } : undefined}
|
||||||
|
>
|
||||||
|
{recipient || 'Select...'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-56 p-1.5">
|
||||||
|
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||||
|
{members.map((m) => {
|
||||||
|
const colorSet = m.color ? getTeamColorSet(m.color) : null;
|
||||||
|
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||||
|
const isSelected = m.name === recipient;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.name}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
|
||||||
|
isSelected && 'bg-[var(--color-surface-raised)]'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setRecipient(m.name);
|
||||||
|
setRecipientOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{colorSet ? (
|
||||||
|
<span
|
||||||
|
className="inline-block size-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: colorSet.border }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block size-2 shrink-0 rounded-full bg-[var(--color-text-muted)]" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate font-medium"
|
||||||
|
style={colorSet ? { color: colorSet.text } : undefined}
|
||||||
|
>
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
{role ? (
|
||||||
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{isSelected ? (
|
||||||
|
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{isLeadRecipient ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded p-1 transition-colors',
|
||||||
|
canAttach
|
||||||
|
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
||||||
|
: 'text-[var(--color-text-muted)] opacity-40'
|
||||||
|
)}
|
||||||
|
disabled={!canAttach}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Paperclip size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{!isTeamAlive
|
||||||
|
? 'Team must be online to attach images'
|
||||||
|
: !canAddMore
|
||||||
|
? 'Maximum attachments reached'
|
||||||
|
: 'Attach images (paste or drag & drop)'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isTeamAlive ? (
|
||||||
|
<span className="ml-auto text-[10px] text-amber-400">Team offline</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AttachmentPreviewList
|
||||||
|
attachments={attachments}
|
||||||
|
onRemove={removeAttachment}
|
||||||
|
error={attachmentError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MentionableTextarea
|
||||||
|
id={`compose-${teamName}`}
|
||||||
|
placeholder={`Write a message... (${getModifierKeyName()}+Enter to send)`}
|
||||||
|
value={draft.value}
|
||||||
|
onValueChange={draft.setValue}
|
||||||
|
suggestions={mentionSuggestions}
|
||||||
|
minRows={2}
|
||||||
|
maxRows={6}
|
||||||
|
maxLength={MAX_MESSAGE_LENGTH}
|
||||||
|
disabled={sending}
|
||||||
|
cornerAction={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!canSend}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
<Send size={12} />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
footerRight={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{sendError ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
|
||||||
|
<AlertCircle size={10} className="shrink-0" />
|
||||||
|
{sendError}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{remaining < 200 ? (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
|
||||||
|
>
|
||||||
|
{remaining} chars left
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{draft.isSaved ? (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
|
|
||||||
import type { InboxMessage } from '@shared/types';
|
import type { InboxMessage } from '@shared/types';
|
||||||
|
|
@ -93,22 +94,26 @@ export const MessagesFilterPopover = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<PopoverTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
variant="ghost"
|
||||||
aria-label="Filter messages"
|
size="sm"
|
||||||
title="Filter"
|
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||||
>
|
aria-label="Filter messages"
|
||||||
<Filter size={14} />
|
>
|
||||||
{activeCount > 0 && (
|
<Filter size={14} />
|
||||||
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
|
{activeCount > 0 && (
|
||||||
{activeCount}
|
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
|
||||||
</span>
|
{activeCount}
|
||||||
)}
|
</span>
|
||||||
</Button>
|
)}
|
||||||
</PopoverTrigger>
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Filter messages</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<PopoverContent align="end" className="w-72 p-0">
|
<PopoverContent align="end" className="w-72 p-0">
|
||||||
<div className="border-b border-[var(--color-border)] p-3">
|
<div className="border-b border-[var(--color-border)] p-3">
|
||||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ const DialogContent = React.forwardRef<
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const PopoverContent = React.forwardRef<
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 w-72 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'z-50 max-h-[min(80vh,24rem)] min-h-0 w-72 overflow-y-auto overflow-x-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
135
src/renderer/hooks/useAttachments.ts
Normal file
135
src/renderer/hooks/useAttachments.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
fileToAttachmentPayload,
|
||||||
|
MAX_FILES,
|
||||||
|
MAX_TOTAL_SIZE,
|
||||||
|
validateAttachment,
|
||||||
|
} from '@renderer/utils/attachmentUtils';
|
||||||
|
|
||||||
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
interface UseAttachmentsReturn {
|
||||||
|
attachments: AttachmentPayload[];
|
||||||
|
error: string | null;
|
||||||
|
totalSize: number;
|
||||||
|
canAddMore: boolean;
|
||||||
|
addFiles: (files: FileList | File[]) => Promise<void>;
|
||||||
|
removeAttachment: (id: string) => void;
|
||||||
|
clearAttachments: () => void;
|
||||||
|
handlePaste: (event: React.ClipboardEvent) => void;
|
||||||
|
handleDrop: (event: React.DragEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAttachments(): UseAttachmentsReturn {
|
||||||
|
const [attachments, setAttachments] = useState<AttachmentPayload[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
|
||||||
|
const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE;
|
||||||
|
|
||||||
|
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||||
|
setError(null);
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
if (fileArray.length === 0) return;
|
||||||
|
|
||||||
|
let batchSize = 0;
|
||||||
|
let valid = true;
|
||||||
|
for (const file of fileArray) {
|
||||||
|
const validation = validateAttachment(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
setError(validation.error);
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
batchSize += file.size;
|
||||||
|
}
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
const newPayloads: AttachmentPayload[] = [];
|
||||||
|
for (const file of fileArray) {
|
||||||
|
try {
|
||||||
|
const payload = await fileToAttachmentPayload(file);
|
||||||
|
newPayloads.push(payload);
|
||||||
|
} catch {
|
||||||
|
setError(`Failed to read file: ${file.name}`);
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
setAttachments((prev) => {
|
||||||
|
if (prev.length + newPayloads.length > MAX_FILES) {
|
||||||
|
setError(`Maximum ${MAX_FILES} attachments allowed`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
|
||||||
|
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
|
||||||
|
setError('Total attachment size exceeds 20MB limit');
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [...prev, ...newPayloads];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeAttachment = useCallback((id: string) => {
|
||||||
|
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAttachments = useCallback(() => {
|
||||||
|
setAttachments([]);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePaste = useCallback(
|
||||||
|
(event: React.ClipboardEvent) => {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const imageFiles: File[] = [];
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) imageFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
void addFiles(imageFiles);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = event.dataTransfer?.files;
|
||||||
|
if (!files?.length) return;
|
||||||
|
|
||||||
|
const allFiles = Array.from(files);
|
||||||
|
const imageFiles = allFiles.filter((f) => f.type.startsWith('image/'));
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
|
void addFiles(imageFiles);
|
||||||
|
} else if (allFiles.length > 0) {
|
||||||
|
setError('Only image files are supported');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments,
|
||||||
|
error,
|
||||||
|
totalSize,
|
||||||
|
canAddMore,
|
||||||
|
addFiles,
|
||||||
|
removeAttachment,
|
||||||
|
clearAttachments,
|
||||||
|
handlePaste,
|
||||||
|
handleDrop,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -206,6 +206,13 @@ export const createNotificationSlice: StateCreator<AppState, [], [], Notificatio
|
||||||
// Mark the notification as read
|
// Mark the notification as read
|
||||||
void state.markNotificationRead(error.id);
|
void state.markNotificationRead(error.id);
|
||||||
|
|
||||||
|
// Team rate-limit notifications: open the team tab instead of a session tab
|
||||||
|
if (error.source === 'rate-limit' && error.sessionId.startsWith('team:')) {
|
||||||
|
const teamName = error.sessionId.slice('team:'.length);
|
||||||
|
state.openTeamTab(teamName, error.context.cwd);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Create the navigation request with a fresh nonce
|
// Create the navigation request with a fresh nonce
|
||||||
const navRequest = createErrorNavigationRequest(
|
const navRequest = createErrorNavigationRequest(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
syncFocusedPaneState,
|
syncFocusedPaneState,
|
||||||
updatePane,
|
updatePane,
|
||||||
} from '../utils/paneHelpers';
|
} from '../utils/paneHelpers';
|
||||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
import { getFullResetState, getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
import type { AppState, SearchNavigationContext } from '../types';
|
import type { AppState, SearchNavigationContext } from '../types';
|
||||||
import type { PaneLayout } from '@renderer/types/panes';
|
import type { PaneLayout } from '@renderer/types/panes';
|
||||||
|
|
@ -384,8 +384,8 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
(wt) => normalizePath(wt.path) === normalizedTeamPath
|
(wt) => normalizePath(wt.path) === normalizedTeamPath
|
||||||
);
|
);
|
||||||
if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) {
|
if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) {
|
||||||
state.selectRepository(repo.id);
|
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
|
||||||
state.selectWorktree(matchingWorktree.id);
|
void get().fetchSessionsInitial(matchingWorktree.id);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||||
|
|
||||||
const logger = createLogger('teamSlice');
|
const logger = createLogger('teamSlice');
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
AddMemberRequest,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
|
KanbanColumnId,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
@ -71,12 +75,20 @@ export interface TeamSlice {
|
||||||
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
|
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
|
||||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||||
|
updateKanbanColumnOrder: (
|
||||||
|
teamName: string,
|
||||||
|
columnId: KanbanColumnId,
|
||||||
|
orderedTaskIds: string[]
|
||||||
|
) => Promise<void>;
|
||||||
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||||
startTask: (teamName: string, taskId: string) => Promise<void>;
|
startTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
||||||
|
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
|
||||||
addingComment: boolean;
|
addingComment: boolean;
|
||||||
addCommentError: string | null;
|
addCommentError: string | null;
|
||||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||||
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
||||||
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
||||||
|
|
@ -180,14 +192,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = get();
|
const state = get();
|
||||||
|
// Use display name from teams list or selected team data if available
|
||||||
|
const teamSummary = state.teams.find((t) => t.teamName === teamName);
|
||||||
|
const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
|
||||||
|
|
||||||
const allTabs = state.getAllPaneTabs();
|
const allTabs = state.getAllPaneTabs();
|
||||||
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
state.setActiveTab(existing.id);
|
state.setActiveTab(existing.id);
|
||||||
|
// Sync label in case display name changed
|
||||||
|
if (existing.label !== displayName) {
|
||||||
|
state.updateTabLabel(existing.id, displayName);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.openTab({
|
state.openTab({
|
||||||
type: 'team',
|
type: 'team',
|
||||||
label: teamName,
|
label: displayName,
|
||||||
teamName,
|
teamName,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -222,6 +242,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync tab label with the team's display name from config
|
||||||
|
const displayName = data.config.name || teamName;
|
||||||
|
const allTabs = get().getAllPaneTabs();
|
||||||
|
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
||||||
|
if (teamTab && teamTab.label !== displayName) {
|
||||||
|
get().updateTabLabel(teamTab.id, displayName);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select the project associated with this team's cwd/projectPath.
|
// Auto-select the project associated with this team's cwd/projectPath.
|
||||||
// Must search both flat projects and grouped repositoryGroups/worktrees
|
// Must search both flat projects and grouped repositoryGroups/worktrees
|
||||||
// because the default viewMode is 'grouped' and flat projects may be empty.
|
// because the default viewMode is 'grouped' and flat projects may be empty.
|
||||||
|
|
@ -244,8 +272,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
);
|
);
|
||||||
if (matchingWorktree) {
|
if (matchingWorktree) {
|
||||||
if (state.selectedWorktreeId !== matchingWorktree.id) {
|
if (state.selectedWorktreeId !== matchingWorktree.id) {
|
||||||
state.selectRepository(repo.id);
|
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
|
||||||
state.selectWorktree(matchingWorktree.id);
|
void get().fetchSessionsInitial(matchingWorktree.id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -330,6 +358,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateKanbanColumnOrder: async (
|
||||||
|
teamName: string,
|
||||||
|
columnId: KanbanColumnId,
|
||||||
|
orderedTaskIds: string[]
|
||||||
|
) => {
|
||||||
|
await unwrapIpc('team:updateKanbanColumnOrder', () =>
|
||||||
|
api.teams.updateKanbanColumnOrder(teamName, columnId, orderedTaskIds)
|
||||||
|
);
|
||||||
|
await get().refreshTeamData(teamName);
|
||||||
|
},
|
||||||
|
|
||||||
sendTeamMessage: async (teamName: string, request: SendMessageRequest) => {
|
sendTeamMessage: async (teamName: string, request: SendMessageRequest) => {
|
||||||
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
|
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
|
||||||
try {
|
try {
|
||||||
|
|
@ -382,6 +421,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
await get().refreshTeamData(teamName);
|
await get().refreshTeamData(teamName);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
|
||||||
|
await unwrapIpc('team:updateTaskOwner', () =>
|
||||||
|
api.teams.updateTaskOwner(teamName, taskId, owner)
|
||||||
|
);
|
||||||
|
await get().refreshTeamData(teamName);
|
||||||
|
},
|
||||||
|
|
||||||
addTaskComment: async (teamName, taskId, text) => {
|
addTaskComment: async (teamName, taskId, text) => {
|
||||||
set({ addingComment: true, addCommentError: null });
|
set({ addingComment: true, addCommentError: null });
|
||||||
try {
|
try {
|
||||||
|
|
@ -398,6 +444,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||||
|
await unwrapIpc('team:addMember', () => api.teams.addMember(teamName, request));
|
||||||
|
await get().refreshTeamData(teamName);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMember: async (teamName: string, memberName: string) => {
|
||||||
|
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
||||||
|
await get().refreshTeamData(teamName);
|
||||||
|
},
|
||||||
|
|
||||||
deleteTeam: async (teamName: string) => {
|
deleteTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,22 @@ export function getSessionResetState(): Partial<AppState> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically navigate to a specific worktree.
|
||||||
|
* Use instead of selectRepository() + selectWorktree() to avoid race condition
|
||||||
|
* (two competing fetchSessionsInitial calls where the stale response can overwrite).
|
||||||
|
*/
|
||||||
|
export function getWorktreeNavigationState(repoId: string, worktreeId: string): Partial<AppState> {
|
||||||
|
return {
|
||||||
|
selectedRepositoryId: repoId,
|
||||||
|
selectedWorktreeId: worktreeId,
|
||||||
|
selectedProjectId: worktreeId,
|
||||||
|
activeProjectId: worktreeId,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
...getSessionResetState(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full state reset (session + project + repository + conversation).
|
* Full state reset (session + project + repository + conversation).
|
||||||
* Used when closing all tabs or resetting to initial state.
|
* Used when closing all tabs or resetting to initial state.
|
||||||
|
|
|
||||||
52
src/renderer/utils/attachmentUtils.ts
Normal file
52
src/renderer/utils/attachmentUtils.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { AttachmentMediaType, AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
export const ALLOWED_MIME_TYPES = new Set<AttachmentMediaType>([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
|
export const MAX_FILES = 5;
|
||||||
|
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB
|
||||||
|
|
||||||
|
export function isImageMimeType(type: string): type is AttachmentMediaType {
|
||||||
|
return ALLOWED_MIME_TYPES.has(type as AttachmentMediaType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
|
||||||
|
if (!isImageMimeType(file.type)) {
|
||||||
|
return { valid: false, error: `Unsupported file type: ${file.type}` };
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return { valid: false, error: `File "${file.name}" exceeds 10MB limit` };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileToAttachmentPayload(file: File): Promise<AttachmentPayload> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string;
|
||||||
|
// Strip "data:image/png;base64," prefix to get raw base64
|
||||||
|
const base64 = dataUrl.split(',')[1] ?? '';
|
||||||
|
resolve({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
filename: file.name,
|
||||||
|
mimeType: file.type as AttachmentMediaType,
|
||||||
|
size: file.size,
|
||||||
|
data: base64,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error(`Failed to read file: ${file.name}`));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import type { GlobalTask } from '@shared/types';
|
import type { GlobalTask } from '@shared/types';
|
||||||
|
|
||||||
export function normalizePath(p: string): string {
|
export function normalizePath(p: string): string {
|
||||||
return p.endsWith('/') ? p.slice(0, -1) : p;
|
let s = p.replace(/\\/g, '/');
|
||||||
|
while (s.endsWith('/')) s = s.slice(0, -1);
|
||||||
|
return s.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskStatusCounts {
|
export interface TaskStatusCounts {
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,11 @@ import type {
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
} from './notifications';
|
} from './notifications';
|
||||||
import type {
|
import type {
|
||||||
|
AddMemberRequest,
|
||||||
|
AttachmentFileData,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
|
KanbanColumnId,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
|
|
@ -354,7 +357,13 @@ export interface TeamsAPI {
|
||||||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||||
|
updateKanbanColumnOrder: (
|
||||||
|
teamName: string,
|
||||||
|
columnId: KanbanColumnId,
|
||||||
|
orderedTaskIds: string[]
|
||||||
|
) => Promise<void>;
|
||||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
||||||
|
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
|
||||||
startTask: (teamName: string, taskId: string) => Promise<void>;
|
startTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
processSend: (teamName: string, message: string) => Promise<void>;
|
processSend: (teamName: string, message: string) => Promise<void>;
|
||||||
processAlive: (teamName: string) => Promise<boolean>;
|
processAlive: (teamName: string) => Promise<boolean>;
|
||||||
|
|
@ -371,7 +380,11 @@ export interface TeamsAPI {
|
||||||
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
||||||
getAllTasks: () => Promise<GlobalTask[]>;
|
getAllTasks: () => Promise<GlobalTask[]>;
|
||||||
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
||||||
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||||
|
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||||
|
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||||
onProvisioningProgress: (
|
onProvisioningProgress: (
|
||||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,8 @@ export interface AppConfig {
|
||||||
defaultTab: 'dashboard' | 'last-session';
|
defaultTab: 'dashboard' | 'last-session';
|
||||||
/** Optional custom Claude root folder (auto-detected when null) */
|
/** Optional custom Claude root folder (auto-detected when null) */
|
||||||
claudeRootPath: string | null;
|
claudeRootPath: string | null;
|
||||||
|
/** Agent communication language ('system' = use OS locale) */
|
||||||
|
agentLanguage: string;
|
||||||
};
|
};
|
||||||
/** Display and UI settings */
|
/** Display and UI settings */
|
||||||
display: {
|
display: {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ export interface TeamMember {
|
||||||
role?: string;
|
role?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
joinedAt?: number;
|
joinedAt?: number;
|
||||||
|
cwd?: string;
|
||||||
|
removedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamConfig {
|
export interface TeamConfig {
|
||||||
|
|
@ -80,6 +82,25 @@ export interface TeamTaskWithKanban extends TeamTask {
|
||||||
kanbanColumn?: 'review' | 'approved';
|
kanbanColumn?: 'review' | 'approved';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||||
|
|
||||||
|
export interface AttachmentMeta {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: AttachmentMediaType;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentPayload extends AttachmentMeta {
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentFileData {
|
||||||
|
id: string;
|
||||||
|
data: string;
|
||||||
|
mimeType: AttachmentMediaType;
|
||||||
|
}
|
||||||
|
|
||||||
export interface InboxMessage {
|
export interface InboxMessage {
|
||||||
from: string;
|
from: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
@ -89,7 +110,8 @@ export interface InboxMessage {
|
||||||
summary?: string;
|
summary?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
source?: 'inbox' | 'lead_session' | 'lead_process';
|
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
|
||||||
|
attachments?: AttachmentMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageRequest {
|
export interface SendMessageRequest {
|
||||||
|
|
@ -97,10 +119,12 @@ export interface SendMessageRequest {
|
||||||
text: string;
|
text: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
|
attachments?: AttachmentPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageResult {
|
export interface SendMessageResult {
|
||||||
deliveredToInbox: boolean;
|
deliveredToInbox: boolean;
|
||||||
|
deliveredViaStdin?: boolean;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,6 +143,8 @@ export interface KanbanState {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
reviewers: string[];
|
reviewers: string[];
|
||||||
tasks: Record<string, KanbanTaskState>;
|
tasks: Record<string, KanbanTaskState>;
|
||||||
|
/** Порядок id задач по колонкам для отображения на канбан-доске (drag-and-drop). */
|
||||||
|
columnOrder?: Partial<Record<KanbanColumnId, string[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateKanbanPatch =
|
export type UpdateKanbanPatch =
|
||||||
|
|
@ -136,6 +162,10 @@ export interface ResolvedTeamMember {
|
||||||
color?: string;
|
color?: string;
|
||||||
agentType?: string;
|
agentType?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
cwd?: string;
|
||||||
|
/** Set only when member's git branch differs from the lead's branch. */
|
||||||
|
gitBranch?: string;
|
||||||
|
removedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamData {
|
export interface TeamData {
|
||||||
|
|
@ -153,6 +183,7 @@ export interface TeamLaunchRequest {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamLaunchResponse {
|
export interface TeamLaunchResponse {
|
||||||
|
|
@ -199,6 +230,7 @@ export interface TeamCreateRequest {
|
||||||
members: TeamProvisioningMemberInput[];
|
members: TeamProvisioningMemberInput[];
|
||||||
cwd: string;
|
cwd: string;
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamCreateConfigRequest {
|
export interface TeamCreateConfigRequest {
|
||||||
|
|
@ -290,3 +322,12 @@ export interface MemberFullStats {
|
||||||
sessionCount: number;
|
sessionCount: number;
|
||||||
computedAt: string;
|
computedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddMemberRequest {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveMemberRequest {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
122
src/shared/utils/agentLanguage.ts
Normal file
122
src/shared/utils/agentLanguage.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
/**
|
||||||
|
* Agent language configuration utilities.
|
||||||
|
* Pure functions — no Electron or DOM dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AgentLanguageOption {
|
||||||
|
readonly value: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly flag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Curated list of language options for agent communication (sorted alphabetically after System). */
|
||||||
|
export const AGENT_LANGUAGE_OPTIONS: readonly AgentLanguageOption[] = [
|
||||||
|
{ value: 'system', label: 'System', flag: '\u{1F310}' },
|
||||||
|
{ value: 'af', label: 'Afrikaans', flag: '\u{1F1FF}\u{1F1E6}' },
|
||||||
|
{ value: 'am', label: 'Amharic', flag: '\u{1F1EA}\u{1F1F9}' },
|
||||||
|
{ value: 'ar', label: 'Arabic', flag: '\u{1F1F8}\u{1F1E6}' },
|
||||||
|
{ value: 'az', label: 'Azerbaijani', flag: '\u{1F1E6}\u{1F1FF}' },
|
||||||
|
{ value: 'be', label: 'Belarusian', flag: '\u{1F1E7}\u{1F1FE}' },
|
||||||
|
{ value: 'bg', label: 'Bulgarian', flag: '\u{1F1E7}\u{1F1EC}' },
|
||||||
|
{ value: 'bn', label: 'Bengali', flag: '\u{1F1E7}\u{1F1E9}' },
|
||||||
|
{ value: 'bs', label: 'Bosnian', flag: '\u{1F1E7}\u{1F1E6}' },
|
||||||
|
{ value: 'ca', label: 'Catalan', flag: '\u{1F1EA}\u{1F1F8}' },
|
||||||
|
{ value: 'cs', label: 'Czech', flag: '\u{1F1E8}\u{1F1FF}' },
|
||||||
|
{
|
||||||
|
value: 'cy',
|
||||||
|
label: 'Welsh',
|
||||||
|
flag: '\u{1F3F4}\u{E0067}\u{E0062}\u{E0077}\u{E006C}\u{E0073}\u{E007F}',
|
||||||
|
},
|
||||||
|
{ value: 'da', label: 'Danish', flag: '\u{1F1E9}\u{1F1F0}' },
|
||||||
|
{ value: 'de', label: 'German', flag: '\u{1F1E9}\u{1F1EA}' },
|
||||||
|
{ value: 'el', label: 'Greek', flag: '\u{1F1EC}\u{1F1F7}' },
|
||||||
|
{ value: 'en', label: 'English', flag: '\u{1F1EC}\u{1F1E7}' },
|
||||||
|
{ value: 'es', label: 'Spanish', flag: '\u{1F1EA}\u{1F1F8}' },
|
||||||
|
{ value: 'et', label: 'Estonian', flag: '\u{1F1EA}\u{1F1EA}' },
|
||||||
|
{ value: 'eu', label: 'Basque', flag: '\u{1F1EA}\u{1F1F8}' },
|
||||||
|
{ value: 'fa', label: 'Persian', flag: '\u{1F1EE}\u{1F1F7}' },
|
||||||
|
{ value: 'fi', label: 'Finnish', flag: '\u{1F1EB}\u{1F1EE}' },
|
||||||
|
{ value: 'fil', label: 'Filipino', flag: '\u{1F1F5}\u{1F1ED}' },
|
||||||
|
{ value: 'fr', label: 'French', flag: '\u{1F1EB}\u{1F1F7}' },
|
||||||
|
{ value: 'ga', label: 'Irish', flag: '\u{1F1EE}\u{1F1EA}' },
|
||||||
|
{ value: 'gl', label: 'Galician', flag: '\u{1F1EA}\u{1F1F8}' },
|
||||||
|
{ value: 'gu', label: 'Gujarati', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'he', label: 'Hebrew', flag: '\u{1F1EE}\u{1F1F1}' },
|
||||||
|
{ value: 'hi', label: 'Hindi', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'hr', label: 'Croatian', flag: '\u{1F1ED}\u{1F1F7}' },
|
||||||
|
{ value: 'hu', label: 'Hungarian', flag: '\u{1F1ED}\u{1F1FA}' },
|
||||||
|
{ value: 'hy', label: 'Armenian', flag: '\u{1F1E6}\u{1F1F2}' },
|
||||||
|
{ value: 'id', label: 'Indonesian', flag: '\u{1F1EE}\u{1F1E9}' },
|
||||||
|
{ value: 'is', label: 'Icelandic', flag: '\u{1F1EE}\u{1F1F8}' },
|
||||||
|
{ value: 'it', label: 'Italian', flag: '\u{1F1EE}\u{1F1F9}' },
|
||||||
|
{ value: 'ja', label: 'Japanese', flag: '\u{1F1EF}\u{1F1F5}' },
|
||||||
|
{ value: 'ka', label: 'Georgian', flag: '\u{1F1EC}\u{1F1EA}' },
|
||||||
|
{ value: 'kk', label: 'Kazakh', flag: '\u{1F1F0}\u{1F1FF}' },
|
||||||
|
{ value: 'km', label: 'Khmer', flag: '\u{1F1F0}\u{1F1ED}' },
|
||||||
|
{ value: 'kn', label: 'Kannada', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'ko', label: 'Korean', flag: '\u{1F1F0}\u{1F1F7}' },
|
||||||
|
{ value: 'lt', label: 'Lithuanian', flag: '\u{1F1F1}\u{1F1F9}' },
|
||||||
|
{ value: 'lv', label: 'Latvian', flag: '\u{1F1F1}\u{1F1FB}' },
|
||||||
|
{ value: 'mk', label: 'Macedonian', flag: '\u{1F1F2}\u{1F1F0}' },
|
||||||
|
{ value: 'ml', label: 'Malayalam', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'mn', label: 'Mongolian', flag: '\u{1F1F2}\u{1F1F3}' },
|
||||||
|
{ value: 'mr', label: 'Marathi', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'ms', label: 'Malay', flag: '\u{1F1F2}\u{1F1FE}' },
|
||||||
|
{ value: 'my', label: 'Burmese', flag: '\u{1F1F2}\u{1F1F2}' },
|
||||||
|
{ value: 'ne', label: 'Nepali', flag: '\u{1F1F3}\u{1F1F5}' },
|
||||||
|
{ value: 'nl', label: 'Dutch', flag: '\u{1F1F3}\u{1F1F1}' },
|
||||||
|
{ value: 'no', label: 'Norwegian', flag: '\u{1F1F3}\u{1F1F4}' },
|
||||||
|
{ value: 'pa', label: 'Punjabi', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'pl', label: 'Polish', flag: '\u{1F1F5}\u{1F1F1}' },
|
||||||
|
{ value: 'pt', label: 'Portuguese', flag: '\u{1F1E7}\u{1F1F7}' },
|
||||||
|
{ value: 'ro', label: 'Romanian', flag: '\u{1F1F7}\u{1F1F4}' },
|
||||||
|
{ value: 'ru', label: 'Russian', flag: '\u{1F1F7}\u{1F1FA}' },
|
||||||
|
{ value: 'si', label: 'Sinhala', flag: '\u{1F1F1}\u{1F1F0}' },
|
||||||
|
{ value: 'sk', label: 'Slovak', flag: '\u{1F1F8}\u{1F1F0}' },
|
||||||
|
{ value: 'sl', label: 'Slovenian', flag: '\u{1F1F8}\u{1F1EE}' },
|
||||||
|
{ value: 'sq', label: 'Albanian', flag: '\u{1F1E6}\u{1F1F1}' },
|
||||||
|
{ value: 'sr', label: 'Serbian', flag: '\u{1F1F7}\u{1F1F8}' },
|
||||||
|
{ value: 'sv', label: 'Swedish', flag: '\u{1F1F8}\u{1F1EA}' },
|
||||||
|
{ value: 'sw', label: 'Swahili', flag: '\u{1F1F0}\u{1F1EA}' },
|
||||||
|
{ value: 'ta', label: 'Tamil', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'te', label: 'Telugu', flag: '\u{1F1EE}\u{1F1F3}' },
|
||||||
|
{ value: 'th', label: 'Thai', flag: '\u{1F1F9}\u{1F1ED}' },
|
||||||
|
{ value: 'tr', label: 'Turkish', flag: '\u{1F1F9}\u{1F1F7}' },
|
||||||
|
{ value: 'uk', label: 'Ukrainian', flag: '\u{1F1FA}\u{1F1E6}' },
|
||||||
|
{ value: 'ur', label: 'Urdu', flag: '\u{1F1F5}\u{1F1F0}' },
|
||||||
|
{ value: 'uz', label: 'Uzbek', flag: '\u{1F1FA}\u{1F1FF}' },
|
||||||
|
{ value: 'vi', label: 'Vietnamese', flag: '\u{1F1FB}\u{1F1F3}' },
|
||||||
|
{ value: 'zh', label: 'Chinese', flag: '\u{1F1E8}\u{1F1F3}' },
|
||||||
|
{ value: 'zu', label: 'Zulu', flag: '\u{1F1FF}\u{1F1E6}' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a language code to a human-readable language name.
|
||||||
|
*
|
||||||
|
* - `'system'` → resolved from `systemLocale` via `Intl.DisplayNames` (e.g. "English", "Русский")
|
||||||
|
* - Known BCP-47 code → human name via `Intl.DisplayNames`
|
||||||
|
* - Fallback → returns the code itself
|
||||||
|
*/
|
||||||
|
export function resolveLanguageName(code: string, systemLocale?: string): string {
|
||||||
|
const effectiveCode = code === 'system' ? extractPrimaryLanguage(systemLocale ?? 'en') : code;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const displayNames = new Intl.DisplayNames([effectiveCode], { type: 'language' });
|
||||||
|
const name = displayNames.of(effectiveCode);
|
||||||
|
if (name) {
|
||||||
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Intl.DisplayNames not available or invalid code — fall through
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check our curated list
|
||||||
|
const option = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === effectiveCode);
|
||||||
|
return option?.label ?? effectiveCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts primary language subtag from a locale string (e.g. "en-US" → "en"). */
|
||||||
|
function extractPrimaryLanguage(locale: string): string {
|
||||||
|
const dash = locale.indexOf('-');
|
||||||
|
return dash > 0 ? locale.slice(0, dash) : locale;
|
||||||
|
}
|
||||||
12
src/shared/utils/rateLimitDetector.ts
Normal file
12
src/shared/utils/rateLimitDetector.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* Detects rate limit messages from Claude.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RATE_LIMIT_SUBSTRING = "You've hit your limit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the message text contains the rate limit indicator.
|
||||||
|
*/
|
||||||
|
export function isRateLimitMessage(text: string): boolean {
|
||||||
|
return text.includes(RATE_LIMIT_SUBSTRING);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_SEND_MESSAGE: 'team:sendMessage',
|
TEAM_SEND_MESSAGE: 'team:sendMessage',
|
||||||
TEAM_REQUEST_REVIEW: 'team:requestReview',
|
TEAM_REQUEST_REVIEW: 'team:requestReview',
|
||||||
TEAM_UPDATE_KANBAN: 'team:updateKanban',
|
TEAM_UPDATE_KANBAN: 'team:updateKanban',
|
||||||
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder',
|
||||||
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
|
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
|
||||||
|
TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner',
|
||||||
TEAM_PROCESS_SEND: 'team:processSend',
|
TEAM_PROCESS_SEND: 'team:processSend',
|
||||||
TEAM_PROCESS_ALIVE: 'team:processAlive',
|
TEAM_PROCESS_ALIVE: 'team:processAlive',
|
||||||
TEAM_ALIVE_LIST: 'team:aliveList',
|
TEAM_ALIVE_LIST: 'team:aliveList',
|
||||||
|
|
@ -28,6 +30,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_START_TASK: 'team:startTask',
|
TEAM_START_TASK: 'team:startTask',
|
||||||
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
|
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
|
||||||
TEAM_ADD_TASK_COMMENT: 'team:addTaskComment',
|
TEAM_ADD_TASK_COMMENT: 'team:addTaskComment',
|
||||||
|
TEAM_ADD_MEMBER: 'team:addMember',
|
||||||
|
TEAM_REMOVE_MEMBER: 'team:removeMember',
|
||||||
|
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
|
||||||
|
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -54,8 +60,13 @@ import {
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
|
TEAM_ADD_MEMBER,
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
|
TEAM_GET_ATTACHMENTS,
|
||||||
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
|
TEAM_REMOVE_MEMBER,
|
||||||
} from '../../../src/preload/constants/ipcChannels';
|
} from '../../../src/preload/constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
initializeTeamHandlers,
|
initializeTeamHandlers,
|
||||||
|
|
@ -82,6 +93,7 @@ describe('ipc teams handlers', () => {
|
||||||
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
||||||
requestReview: vi.fn(async () => undefined),
|
requestReview: vi.fn(async () => undefined),
|
||||||
updateKanban: vi.fn(async () => undefined),
|
updateKanban: vi.fn(async () => undefined),
|
||||||
|
updateKanbanColumnOrder: vi.fn(async () => undefined),
|
||||||
updateTaskStatus: vi.fn(async () => undefined),
|
updateTaskStatus: vi.fn(async () => undefined),
|
||||||
startTask: vi.fn(async () => undefined),
|
startTask: vi.fn(async () => undefined),
|
||||||
addTaskComment: vi.fn(async () => ({
|
addTaskComment: vi.fn(async () => ({
|
||||||
|
|
@ -90,6 +102,8 @@ describe('ipc teams handlers', () => {
|
||||||
text: 'test comment',
|
text: 'test comment',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
})),
|
})),
|
||||||
|
addMember: vi.fn(async () => undefined),
|
||||||
|
removeMember: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const provisioningService = {
|
const provisioningService = {
|
||||||
prepareForProvisioning: vi.fn(async () => ({
|
prepareForProvisioning: vi.fn(async () => ({
|
||||||
|
|
@ -135,6 +149,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
|
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
|
||||||
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
||||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(true);
|
||||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
|
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
|
||||||
expect(handlers.has(TEAM_START_TASK)).toBe(true);
|
expect(handlers.has(TEAM_START_TASK)).toBe(true);
|
||||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
||||||
|
|
@ -148,6 +163,8 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
|
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
|
||||||
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
|
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns success false on invalid sendMessage args', async () => {
|
it('returns success false on invalid sendMessage args', async () => {
|
||||||
|
|
@ -212,6 +229,7 @@ describe('ipc teams handlers', () => {
|
||||||
it('dedups live lead replies when lead_session already has same text', async () => {
|
it('dedups live lead replies when lead_session already has same text', async () => {
|
||||||
service.getTeamData.mockResolvedValueOnce({
|
service.getTeamData.mockResolvedValueOnce({
|
||||||
teamName: 'my-team',
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
from: 'team-lead',
|
from: 'team-lead',
|
||||||
|
|
@ -301,6 +319,64 @@ describe('ipc teams handlers', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addMember', () => {
|
||||||
|
it('calls service on valid input', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
name: 'alice',
|
||||||
|
role: 'developer',
|
||||||
|
})) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.addMember).toHaveBeenCalledWith('my-team', {
|
||||||
|
name: 'alice',
|
||||||
|
role: 'developer',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid team name', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||||
|
const result = (await handler({} as never, '../bad', {
|
||||||
|
name: 'alice',
|
||||||
|
})) as { success: boolean };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid member name', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', {
|
||||||
|
name: '../bad',
|
||||||
|
})) as { success: boolean };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects missing payload', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', null)) as { success: boolean };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeMember', () => {
|
||||||
|
it('calls service on valid input', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid team name', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||||
|
const result = (await handler({} as never, '../bad', 'alice')) as { success: boolean };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid member name', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean };
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createTeam prompt validation', () => {
|
describe('createTeam prompt validation', () => {
|
||||||
it('accepts valid prompt in team create request', async () => {
|
it('accepts valid prompt in team create request', async () => {
|
||||||
const handler = handlers.get(TEAM_CREATE)!;
|
const handler = handlers.get(TEAM_CREATE)!;
|
||||||
|
|
@ -342,6 +418,7 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
|
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
|
||||||
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
|
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
|
||||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(false);
|
||||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
|
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
|
||||||
expect(handlers.has(TEAM_START_TASK)).toBe(false);
|
expect(handlers.has(TEAM_START_TASK)).toBe(false);
|
||||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
||||||
|
|
@ -355,5 +432,9 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
|
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
|
||||||
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
|
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue