feat: add/remove member, mesage box, improve ui...
This commit is contained in:
parent
25b740c134
commit
6a31d440a4
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/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>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows%20%7C%20Docker-lightgrey?style=flat-square" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform" />
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
|
@ -26,9 +26,6 @@
|
|||
</a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
|
||||
<img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" />
|
||||
</a>
|
||||
<a href="#docker--standalone-deployment">
|
||||
<img src="https://img.shields.io/badge/Docker-Deploy-2496ED?logo=docker&logoColor=white&style=flat" alt="Deploy with Docker" height="30" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
|
@ -58,87 +55,11 @@
|
|||
| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open |
|
||||
| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). |
|
||||
| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
|
||||
| **Docker** | `docker compose up` | Open `http://localhost:3456`. See [Docker / Standalone Deployment](#docker--standalone-deployment) for details. |
|
||||
|
||||
The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login.
|
||||
|
||||
---
|
||||
|
||||
## What the CLI Hides vs. What Claude Agent Teams UI Shows
|
||||
|
||||
| What you see in the terminal | What Claude Agent Teams UI shows you |
|
||||
|------------------------------|-------------------------------|
|
||||
| `Read 3 files` | Exact file paths, syntax-highlighted content with line numbers |
|
||||
| `Searched for 1 pattern` | The regex pattern, every matching file, and the matched lines |
|
||||
| `Edited 2 files` | Inline diffs with added/removed highlighting per file |
|
||||
| A three-segment context bar | Per-turn token attribution across 7 categories — CLAUDE.md breakdown, skills, @-mentions, tool I/O, thinking, teams, user text — with compaction visualization showing how context fills, compresses, and refills |
|
||||
| Subagent output interleaved with the main thread | Isolated execution trees per agent, expandable inline with their own metrics |
|
||||
| Teammate messages buried in session logs | Color-coded teammate cards with name, message, and full team lifecycle visibility |
|
||||
| Critical events mixed into normal output | Trigger-filtered notification inbox for `.env` access, payment-related file paths, execution errors, and high token usage |
|
||||
| `--verbose` JSON dump | Structured, filterable, navigable interface — no noise |
|
||||
|
||||
---
|
||||
|
||||
## Docker / Standalone Deployment
|
||||
|
||||
Run Claude Agent Teams UI without Electron — in Docker, on a remote server, or anywhere Node.js runs.
|
||||
|
||||
### Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Open `http://localhost:3456` in your browser.
|
||||
|
||||
### Quick Start (Docker)
|
||||
|
||||
```bash
|
||||
docker build -t claude-agent-teams-ui .
|
||||
docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
||||
```
|
||||
|
||||
### Quick Start (Node.js)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm standalone:build
|
||||
node dist-standalone/index.cjs
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_ROOT` | `~/.claude` | Path to the `.claude` data directory |
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `PORT` | `3456` | Listen port |
|
||||
| `CORS_ORIGIN` | `*` (standalone) | CORS origin policy (`*`, specific origin, or comma-separated list) |
|
||||
|
||||
### Notes
|
||||
|
||||
- **Real-time updates may be slower than Electron.** The Electron app uses native file system watchers with IPC for instant updates. The Docker/standalone server uses SSE (Server-Sent Events) over HTTP, which may introduce slight delays when sessions are actively being written to.
|
||||
- **Custom Claude root path.** If your `.claude` directory is not at `~/.claude`, update the volume mount to point to the correct location:
|
||||
```bash
|
||||
# Example: Claude root at /home/user/custom-claude-dir
|
||||
docker run -p 3456:3456 -v /home/user/custom-claude-dir:/data/.claude:ro claude-agent-teams-ui
|
||||
|
||||
# Or with docker compose, set the CLAUDE_DIR env variable:
|
||||
CLAUDE_DIR=/home/user/custom-claude-dir docker compose up
|
||||
```
|
||||
|
||||
### Security-Focused Deployment
|
||||
|
||||
The standalone server has **zero** outbound network calls. For maximum isolation:
|
||||
|
||||
```bash
|
||||
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
|
||||
```
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for a full audit of network activity.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
<details>
|
||||
|
|
@ -184,6 +105,12 @@ pnpm dist # macOS + Windows + Linux
|
|||
|
||||
---
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services';
|
||||
import {
|
||||
type ClaudeMdFileInfo,
|
||||
readAgentConfigs,
|
||||
readAllClaudeMdFiles,
|
||||
readDirectoryClaudeMd,
|
||||
} from '../services';
|
||||
import { validateFilePath } from '../utils/pathValidation';
|
||||
import { countTokens } from '../utils/tokenizer';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_CANCEL_PROVISIONING,
|
||||
|
|
@ -7,10 +10,12 @@ import {
|
|||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LIST,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -18,22 +23,35 @@ import {
|
|||
TEAM_PROCESS_SEND,
|
||||
TEAM_PROVISIONING_PROGRESS,
|
||||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_STOP,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||
TEAM_UPDATE_TASK_OWNER,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { type IpcMain, type IpcMainInvokeEvent } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||
|
||||
import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards';
|
||||
|
||||
/** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */
|
||||
const notifiedRateLimitKeys = new Set<string>();
|
||||
const RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
|
||||
import type {
|
||||
MemberStatsComputer,
|
||||
TeamDataService,
|
||||
|
|
@ -41,9 +59,13 @@ import type {
|
|||
TeamProvisioningService,
|
||||
} from '../services';
|
||||
import type {
|
||||
AttachmentFileData,
|
||||
AttachmentMeta,
|
||||
AttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
SendMessageRequest,
|
||||
|
|
@ -67,11 +89,63 @@ import type {
|
|||
|
||||
const logger = createLogger('IPC:teams');
|
||||
|
||||
/**
|
||||
* Check messages for rate limit indicators and fire native notifications for new ones.
|
||||
*/
|
||||
function checkRateLimitMessages(
|
||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||
teamName: string,
|
||||
teamDisplayName: string,
|
||||
projectPath?: string
|
||||
): void {
|
||||
for (const msg of messages) {
|
||||
if (msg.from === 'user') continue;
|
||||
if (!isRateLimitMessage(msg.text)) continue;
|
||||
|
||||
// Prefix key with teamName to avoid collisions across teams
|
||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||
const key = `${teamName}:${rawKey}`;
|
||||
if (notifiedRateLimitKeys.has(key)) continue;
|
||||
notifiedRateLimitKeys.add(key);
|
||||
|
||||
// Prevent unbounded memory growth
|
||||
if (notifiedRateLimitKeys.size > RATE_LIMIT_KEYS_MAX) {
|
||||
const first = notifiedRateLimitKeys.values().next().value!;
|
||||
notifiedRateLimitKeys.delete(first);
|
||||
}
|
||||
|
||||
void NotificationManager.getInstance()
|
||||
.addError({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
sessionId: `team:${teamName}`,
|
||||
projectId: teamName,
|
||||
filePath: '',
|
||||
source: 'rate-limit',
|
||||
message: `[${msg.from}] ${msg.text.slice(0, 200)}`,
|
||||
triggerColor: 'red',
|
||||
triggerName: 'Rate Limit',
|
||||
context: {
|
||||
projectName: teamDisplayName,
|
||||
cwd: projectPath,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
let teamDataService: TeamDataService | null = null;
|
||||
let teamProvisioningService: TeamProvisioningService | null = null;
|
||||
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||
|
||||
const attachmentStore = new TeamAttachmentStore();
|
||||
|
||||
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB total
|
||||
|
||||
export function initializeTeamHandlers(
|
||||
service: TeamDataService,
|
||||
provisioningService: TeamProvisioningService,
|
||||
|
|
@ -96,7 +170,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_CREATE_TASK, handleCreateTask);
|
||||
ipcMain.handle(TEAM_REQUEST_REVIEW, handleRequestReview);
|
||||
ipcMain.handle(TEAM_UPDATE_KANBAN, handleUpdateKanban);
|
||||
ipcMain.handle(TEAM_UPDATE_KANBAN_COLUMN_ORDER, handleUpdateKanbanColumnOrder);
|
||||
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
|
||||
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
|
||||
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
|
||||
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
|
||||
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
|
||||
|
|
@ -110,6 +186,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
||||
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
||||
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
|
||||
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
||||
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +205,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_CREATE_TASK);
|
||||
ipcMain.removeHandler(TEAM_REQUEST_REVIEW);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_KANBAN_COLUMN_ORDER);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
|
||||
ipcMain.removeHandler(TEAM_DELETE_TEAM);
|
||||
ipcMain.removeHandler(TEAM_PROCESS_SEND);
|
||||
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
|
||||
|
|
@ -139,6 +221,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_START_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
||||
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
|
||||
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||
}
|
||||
|
||||
function getTeamDataService(): TeamDataService {
|
||||
|
|
@ -169,6 +255,23 @@ async function wrapTeamHandler<T>(
|
|||
}
|
||||
}
|
||||
|
||||
async function handleGetProjectBranch(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectPath: unknown
|
||||
): Promise<IpcResult<string | null>> {
|
||||
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
||||
return { success: false, error: 'projectPath must be a non-empty string' };
|
||||
}
|
||||
try {
|
||||
const branch = await gitIdentityResolver.getBranch(projectPath.trim());
|
||||
return { success: true, data: branch };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.error(`[teams:getProjectBranch] ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListTeams(_event: IpcMainInvokeEvent): Promise<IpcResult<TeamSummary[]>> {
|
||||
return wrapTeamHandler('list', () => getTeamDataService().listTeams());
|
||||
}
|
||||
|
|
@ -203,8 +306,12 @@ async function handleGetData(
|
|||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||
}
|
||||
|
||||
const displayName = data.config.name || tn;
|
||||
const projectPath = data.config.projectPath;
|
||||
|
||||
const live = provisioning.getLiveLeadProcessMessages(tn);
|
||||
if (live.length === 0) {
|
||||
checkRateLimitMessages(data.messages, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive } };
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +351,7 @@ async function handleGetData(
|
|||
}
|
||||
merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
checkRateLimitMessages(merged, tn, displayName, projectPath);
|
||||
return { success: true, data: { ...data, isAlive, messages: merged } };
|
||||
}
|
||||
|
||||
|
|
@ -387,6 +495,7 @@ async function validateProvisioningRequest(
|
|||
members,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -447,12 +556,17 @@ async function handleLaunchTeam(
|
|||
return { success: false, error: 'prompt must be a string' };
|
||||
}
|
||||
|
||||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('launch', () =>
|
||||
getTeamProvisioningService().launchTeam(
|
||||
{
|
||||
teamName: validatedTeamName.value!,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
},
|
||||
(progress) => {
|
||||
try {
|
||||
|
|
@ -526,6 +640,76 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
|
|||
return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved');
|
||||
}
|
||||
|
||||
async function handleGetAttachments(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
messageId: unknown
|
||||
): Promise<IpcResult<AttachmentFileData[]>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
if (typeof messageId !== 'string' || messageId.trim().length === 0) {
|
||||
return { success: false, error: 'messageId must be a non-empty string' };
|
||||
}
|
||||
const safeMessageId = messageId.trim();
|
||||
if (safeMessageId.includes('/') || safeMessageId.includes('\\') || safeMessageId.includes('..')) {
|
||||
return { success: false, error: 'Invalid messageId' };
|
||||
}
|
||||
return wrapTeamHandler('getAttachments', () =>
|
||||
attachmentStore.getAttachments(vTeam.value!, safeMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function validateAttachments(
|
||||
attachments: unknown
|
||||
): { valid: true; value: AttachmentPayload[] } | { valid: false; error: string } {
|
||||
if (!Array.isArray(attachments)) {
|
||||
return { valid: false, error: 'attachments must be an array' };
|
||||
}
|
||||
if (attachments.length > MAX_ATTACHMENTS) {
|
||||
return { valid: false, error: `Maximum ${MAX_ATTACHMENTS} attachments allowed` };
|
||||
}
|
||||
let totalSize = 0;
|
||||
const result: AttachmentPayload[] = [];
|
||||
for (const att of attachments) {
|
||||
if (!att || typeof att !== 'object') {
|
||||
return { valid: false, error: 'Invalid attachment entry' };
|
||||
}
|
||||
const a = att as Partial<AttachmentPayload>;
|
||||
if (typeof a.id !== 'string' || typeof a.filename !== 'string') {
|
||||
return { valid: false, error: 'Attachment must have id and filename' };
|
||||
}
|
||||
if (typeof a.data !== 'string' || typeof a.mimeType !== 'string') {
|
||||
return { valid: false, error: 'Attachment must have data and mimeType' };
|
||||
}
|
||||
if (typeof a.size !== 'number' || a.size <= 0) {
|
||||
return { valid: false, error: 'Attachment must have a positive size' };
|
||||
}
|
||||
if (!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)) {
|
||||
return { valid: false, error: `Unsupported attachment type: ${a.mimeType}` };
|
||||
}
|
||||
if (a.size > MAX_ATTACHMENT_SIZE) {
|
||||
return { valid: false, error: `Attachment "${a.filename}" exceeds 10MB limit` };
|
||||
}
|
||||
// Sanity check: base64 data should be roughly 4/3 of the reported binary size
|
||||
const estimatedBinarySize = Math.ceil(a.data.length * 0.75);
|
||||
if (estimatedBinarySize > MAX_ATTACHMENT_SIZE * 1.1) {
|
||||
return { valid: false, error: `Attachment "${a.filename}" data exceeds size limit` };
|
||||
}
|
||||
totalSize += a.size;
|
||||
result.push({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
data: a.data,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
});
|
||||
}
|
||||
if (totalSize > MAX_TOTAL_ATTACHMENT_SIZE) {
|
||||
return { valid: false, error: 'Total attachment size exceeds 20MB limit' };
|
||||
}
|
||||
return { valid: true, value: result };
|
||||
}
|
||||
|
||||
async function handleSendMessage(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
@ -558,24 +742,98 @@ async function handleSendMessage(
|
|||
}
|
||||
}
|
||||
|
||||
let validatedAttachments: AttachmentPayload[] | undefined;
|
||||
if (
|
||||
payload.attachments !== undefined &&
|
||||
Array.isArray(payload.attachments) &&
|
||||
payload.attachments.length > 0
|
||||
) {
|
||||
const attResult = validateAttachments(payload.attachments);
|
||||
if (!attResult.valid) {
|
||||
return { success: false, error: attResult.error };
|
||||
}
|
||||
validatedAttachments = attResult.value;
|
||||
}
|
||||
|
||||
return wrapTeamHandler('sendMessage', async () => {
|
||||
const tn = validatedTeamName.value!;
|
||||
const provisioning = getTeamProvisioningService();
|
||||
const isAlive = provisioning.isTeamAlive(tn);
|
||||
|
||||
const leadName = await getTeamDataService().getLeadMemberName(tn);
|
||||
const memberName = validatedMember.value!;
|
||||
const isLeadRecipient = leadName !== null && memberName === leadName;
|
||||
|
||||
// Attachments only supported for live lead (stdin content blocks)
|
||||
if (validatedAttachments?.length && (!isLeadRecipient || !isAlive)) {
|
||||
throw new Error(
|
||||
'Attachments are only supported when sending to the team lead while the team is online'
|
||||
);
|
||||
}
|
||||
|
||||
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, payload.text!, validatedAttachments);
|
||||
|
||||
const result = await getTeamDataService().sendDirectToLead(
|
||||
tn,
|
||||
leadName,
|
||||
payload.text!,
|
||||
payload.summary
|
||||
);
|
||||
|
||||
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
// Save attachment binary data to disk (best-effort)
|
||||
if (validatedAttachments?.length && result.messageId) {
|
||||
void attachmentStore
|
||||
.saveAttachments(tn, result.messageId, validatedAttachments)
|
||||
.catch((e) => logger.warn(`Failed to save attachments: ${e}`));
|
||||
}
|
||||
|
||||
provisioning.pushLiveLeadProcessMessage(tn, {
|
||||
from: 'user',
|
||||
to: leadName,
|
||||
text: payload.text!,
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
summary: payload.summary,
|
||||
messageId: result.messageId,
|
||||
source: 'user_sent',
|
||||
attachments: attachmentMeta,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (stdinError) {
|
||||
// Stdin failed (process died between check and write)
|
||||
// If attachments were requested, fail rather than silently dropping them
|
||||
if (validatedAttachments?.length) {
|
||||
throw new Error(
|
||||
'Failed to deliver message with attachments: team process became unavailable'
|
||||
);
|
||||
}
|
||||
logger.warn('stdin fallback for ' + tn + ': ' + String(stdinError));
|
||||
// Fallback to inbox path for text-only messages
|
||||
}
|
||||
}
|
||||
|
||||
// Inbox path: offline lead or regular members (no attachment support)
|
||||
const result = await getTeamDataService().sendMessage(tn, {
|
||||
member: validatedMember.value!,
|
||||
member: memberName,
|
||||
text: payload.text!,
|
||||
summary: payload.summary,
|
||||
from: payload.from,
|
||||
});
|
||||
|
||||
// Best-effort: if messaging the lead while process is alive, relay immediately (no UI dependency).
|
||||
try {
|
||||
const provisioning = getTeamProvisioningService();
|
||||
if (provisioning.isTeamAlive(tn)) {
|
||||
// Avoid reading unrelated inboxes; relayLeadInboxMessages will no-op when nothing new exists.
|
||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// Best-effort relay for lead via inbox
|
||||
if (isLeadRecipient && isAlive) {
|
||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -705,6 +963,44 @@ async function handleUpdateKanban(
|
|||
});
|
||||
}
|
||||
|
||||
const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved'];
|
||||
|
||||
function validateKanbanColumnId(
|
||||
value: unknown
|
||||
): { valid: true; value: KanbanColumnId } | { valid: false; error: string } {
|
||||
if (typeof value !== 'string' || !KANBAN_COLUMN_IDS.includes(value as KanbanColumnId)) {
|
||||
return { valid: false, error: `columnId must be one of: ${KANBAN_COLUMN_IDS.join(', ')}` };
|
||||
}
|
||||
return { valid: true, value: value as KanbanColumnId };
|
||||
}
|
||||
|
||||
async function handleUpdateKanbanColumnOrder(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
columnId: unknown,
|
||||
orderedTaskIds: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const validatedColumnId = validateKanbanColumnId(columnId);
|
||||
if (!validatedColumnId.valid) {
|
||||
return { success: false, error: validatedColumnId.error ?? 'Invalid columnId' };
|
||||
}
|
||||
if (!Array.isArray(orderedTaskIds)) {
|
||||
return { success: false, error: 'orderedTaskIds must be an array' };
|
||||
}
|
||||
const ids = orderedTaskIds.filter((id): id is string => typeof id === 'string');
|
||||
return wrapTeamHandler('updateKanbanColumnOrder', () =>
|
||||
getTeamDataService().updateKanbanColumnOrder(
|
||||
validatedTeamName.value!,
|
||||
validatedColumnId.value,
|
||||
ids
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const VALID_TASK_STATUSES: TeamTaskStatus[] = ['pending', 'in_progress', 'completed'];
|
||||
|
||||
async function handleUpdateTaskStatus(
|
||||
|
|
@ -736,6 +1032,31 @@ async function handleUpdateTaskStatus(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleUpdateTaskOwner(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
owner: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
|
||||
const validatedTaskId = validateTaskId(taskId);
|
||||
if (!validatedTaskId.valid) {
|
||||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||||
}
|
||||
|
||||
if (owner !== null && (typeof owner !== 'string' || owner.length === 0)) {
|
||||
return { success: false, error: 'owner must be a non-empty string or null' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('updateTaskOwner', () =>
|
||||
getTeamDataService().updateTaskOwner(validatedTeamName.value!, validatedTaskId.value!, owner)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleProcessSend(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
@ -946,6 +1267,47 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<
|
|||
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
|
||||
}
|
||||
|
||||
async function handleAddMember(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
payload: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: 'Invalid payload' };
|
||||
}
|
||||
const { name, role } = payload as { name?: unknown; role?: unknown };
|
||||
const vName = validateMemberName(name);
|
||||
if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' };
|
||||
if (role !== undefined && typeof role !== 'string') {
|
||||
return { success: false, error: 'role must be a string' };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addMember', () =>
|
||||
getTeamDataService().addMember(vTeam.value!, {
|
||||
name: vName.value!,
|
||||
role: role,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRemoveMember(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
memberName: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
const vMember = validateMemberName(memberName);
|
||||
if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' };
|
||||
|
||||
return wrapTeamHandler('removeMember', () =>
|
||||
getTeamDataService().removeMember(vTeam.value!, vMember.value!)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleAddTaskComment(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ export interface GeneralConfig {
|
|||
theme: 'dark' | 'light' | 'system';
|
||||
defaultTab: 'dashboard' | 'last-session';
|
||||
claudeRootPath: string | null;
|
||||
agentLanguage: string;
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
|
|
@ -248,6 +249,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
theme: 'dark',
|
||||
defaultTab: 'dashboard',
|
||||
claudeRootPath: null,
|
||||
agentLanguage: 'system',
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
|
|||
|
|
@ -921,7 +921,7 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
|
||||
// Classify only the paths we care about in iteration 02.
|
||||
if (normalized.includes('inboxes')) {
|
||||
if (normalized.includes('inboxes') || relative === 'sentMessages.json') {
|
||||
const event: TeamChangeEvent = {
|
||||
type: 'inbox',
|
||||
teamName,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ function parseFrontmatter(content: string): Record<string, string> {
|
|||
const key = line.slice(0, colonIdx).trim();
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
// Strip surrounding quotes
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key) result[key] = value;
|
||||
|
|
@ -40,9 +43,7 @@ function parseFrontmatter(content: string): Record<string, string> {
|
|||
* Read agent config files from a project's `.claude/agents/` directory.
|
||||
* Returns a map of agent name → config (with optional color).
|
||||
*/
|
||||
export async function readAgentConfigs(
|
||||
projectRoot: string
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
export async function readAgentConfigs(projectRoot: string): Promise<Record<string, AgentConfig>> {
|
||||
const agentsDir = path.join(projectRoot, '.claude', 'agents');
|
||||
const result: Record<string, AgentConfig> = {};
|
||||
|
||||
|
|
|
|||
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 {
|
||||
const metaMembers = await this.membersMetaStore.getMembers(entry.name);
|
||||
for (const member of metaMembers) {
|
||||
addMember(member);
|
||||
if (member.removedAt) {
|
||||
removedNames.add(member.name.trim());
|
||||
} else {
|
||||
addMember(member);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.debug(`Failed to read members.meta.json for team: ${entry.name}`);
|
||||
|
|
@ -86,6 +91,10 @@ export class TeamConfigReader {
|
|||
// Inbox folder may not exist yet.
|
||||
}
|
||||
|
||||
for (const name of removedNames) {
|
||||
memberMap.delete(name);
|
||||
}
|
||||
|
||||
const memberCount = memberMap.size;
|
||||
const members = Array.from(memberMap.values());
|
||||
summaries.push({
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ import {
|
|||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
||||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
|
|
@ -20,21 +23,26 @@ import { TeamInboxWriter } from './TeamInboxWriter';
|
|||
import { TeamKanbanManager } from './TeamKanbanManager';
|
||||
import { TeamMemberResolver } from './TeamMemberResolver';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTaskWriter } from './TeamTaskWriter';
|
||||
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
InboxMessage,
|
||||
KanbanColumnId,
|
||||
KanbanState,
|
||||
KanbanTaskState,
|
||||
ResolvedTeamMember,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamData,
|
||||
TeamMember,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
|
|
@ -57,7 +65,8 @@ export class TeamDataService {
|
|||
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
|
||||
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
|
||||
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller(),
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
||||
) {}
|
||||
|
||||
async listTeams(): Promise<TeamSummary[]> {
|
||||
|
|
@ -162,12 +171,22 @@ export class TeamDataService {
|
|||
const leadTexts = await this.extractLeadSessionTexts(config);
|
||||
if (leadTexts.length > 0) {
|
||||
messages = [...messages, ...leadTexts];
|
||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
}
|
||||
} catch {
|
||||
warnings.push('Lead session texts failed to load');
|
||||
}
|
||||
|
||||
try {
|
||||
const sentMessages = await this.sentMessagesStore.readMessages(teamName);
|
||||
if (sentMessages.length > 0) {
|
||||
messages = [...messages, ...sentMessages];
|
||||
}
|
||||
} catch {
|
||||
warnings.push('Sent messages failed to load');
|
||||
}
|
||||
|
||||
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
||||
|
||||
let metaMembers: TeamConfig['members'] = [];
|
||||
try {
|
||||
metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
|
|
@ -211,6 +230,9 @@ export class TeamDataService {
|
|||
messages
|
||||
);
|
||||
|
||||
// Enrich members with git branch when it differs from lead's branch
|
||||
await this.enrichMemberBranches(members, config);
|
||||
|
||||
// Auto-sync: create comments from task-related inbox messages
|
||||
if (tasksLoaded && messages.length > 0) {
|
||||
try {
|
||||
|
|
@ -241,6 +263,84 @@ export class TeamDataService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||
*/
|
||||
private async enrichMemberBranches(
|
||||
members: ResolvedTeamMember[],
|
||||
config: TeamConfig
|
||||
): Promise<void> {
|
||||
// Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath
|
||||
const leadEntry = config.members?.find((m) => m.name === 'team-lead');
|
||||
const leadCwd = leadEntry?.cwd ?? config.projectPath;
|
||||
if (!leadCwd) return;
|
||||
|
||||
let leadBranch: string | null = null;
|
||||
try {
|
||||
leadBranch = await gitIdentityResolver.getBranch(leadCwd);
|
||||
} catch {
|
||||
// Lead cwd may not be a git repo — skip enrichment entirely
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
members.map(async (member) => {
|
||||
if (!member.cwd || member.cwd === leadCwd) return;
|
||||
try {
|
||||
const branch = await gitIdentityResolver.getBranch(member.cwd);
|
||||
if (branch && branch !== leadBranch) {
|
||||
// eslint-disable-next-line no-param-reassign -- intentional in-place enrichment
|
||||
member.gitBranch = branch;
|
||||
}
|
||||
} catch {
|
||||
// Member cwd may not be a git repo — skip silently
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async addMember(teamName: string, request: AddMemberRequest): Promise<void> {
|
||||
const members = await this.membersMetaStore.getMembers(teamName);
|
||||
const existing = members.find((m) => m.name.toLowerCase() === request.name.toLowerCase());
|
||||
|
||||
if (existing) {
|
||||
if (existing.removedAt) {
|
||||
throw new Error(`Name "${request.name}" was previously used by a removed member`);
|
||||
}
|
||||
throw new Error(`Member "${request.name}" already exists`);
|
||||
}
|
||||
|
||||
const newMember: TeamMember = {
|
||||
name: request.name,
|
||||
role: request.role?.trim() || undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColor(members.filter((m) => !m.removedAt).length),
|
||||
joinedAt: Date.now(),
|
||||
};
|
||||
|
||||
members.push(newMember);
|
||||
await this.membersMetaStore.writeMembers(teamName, members);
|
||||
}
|
||||
|
||||
async removeMember(teamName: string, memberName: string): Promise<void> {
|
||||
const members = await this.membersMetaStore.getMembers(teamName);
|
||||
const member = members.find((m) => m.name === memberName);
|
||||
|
||||
if (!member) {
|
||||
throw new Error(`Member "${memberName}" not found`);
|
||||
}
|
||||
if (member.removedAt) {
|
||||
throw new Error(`Member "${memberName}" is already removed`);
|
||||
}
|
||||
if (member.agentType === 'team-lead') {
|
||||
throw new Error('Cannot remove team lead');
|
||||
}
|
||||
|
||||
member.removedAt = Date.now();
|
||||
await this.membersMetaStore.writeMembers(teamName, members);
|
||||
}
|
||||
|
||||
async createTask(teamName: string, request: CreateTaskRequest): Promise<TeamTask> {
|
||||
const nextId = await this.taskReader.getNextTaskId(teamName);
|
||||
|
||||
|
|
@ -364,6 +464,10 @@ export class TeamDataService {
|
|||
await this.taskWriter.updateStatus(teamName, taskId, status);
|
||||
}
|
||||
|
||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||
await this.taskWriter.updateOwner(teamName, taskId, owner);
|
||||
}
|
||||
|
||||
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
||||
|
||||
|
|
@ -398,6 +502,54 @@ export class TeamDataService {
|
|||
return this.inboxWriter.sendMessage(teamName, request);
|
||||
}
|
||||
|
||||
async sendDirectToLead(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
text: string,
|
||||
summary?: string
|
||||
): Promise<SendMessageResult> {
|
||||
const messageId = randomUUID();
|
||||
const msg: InboxMessage = {
|
||||
from: 'user',
|
||||
to: leadName,
|
||||
text,
|
||||
timestamp: new Date().toISOString(),
|
||||
read: true,
|
||||
summary,
|
||||
messageId,
|
||||
source: 'user_sent',
|
||||
};
|
||||
await this.sentMessagesStore.appendMessage(teamName, msg);
|
||||
return { deliveredToInbox: false, deliveredViaStdin: true, messageId };
|
||||
}
|
||||
|
||||
async getLeadMemberName(teamName: string): Promise<string | null> {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
|
||||
// Check config.json members first (Claude Code-created teams)
|
||||
if (config?.members?.length) {
|
||||
const lead = config.members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
||||
);
|
||||
if (lead?.name) return lead.name;
|
||||
}
|
||||
|
||||
// Fallback: check members.meta.json (UI-created teams)
|
||||
const metaMembers = await this.membersMetaStore.getMembers(teamName);
|
||||
if (metaMembers.length > 0) {
|
||||
const lead = metaMembers.find((m) => m.agentType === 'team-lead' || m.name === 'team-lead');
|
||||
if (lead?.name) return lead.name;
|
||||
return metaMembers[0]?.name ?? null;
|
||||
}
|
||||
|
||||
// Last resort: check config.json first member
|
||||
return config?.members?.[0]?.name ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||
await this.kanbanManager.updateTask(teamName, taskId, { op: 'set_column', column: 'review' });
|
||||
|
||||
|
|
@ -630,4 +782,12 @@ export class TeamDataService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateKanbanColumnOrder(
|
||||
teamName: string,
|
||||
columnId: KanbanColumnId,
|
||||
orderedTaskIds: string[]
|
||||
): Promise<void> {
|
||||
await this.kanbanManager.updateColumnOrder(teamName, columnId, orderedTaskIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import * as path from 'path';
|
|||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
import type { KanbanState, UpdateKanbanPatch } from '@shared/types';
|
||||
import type { KanbanColumnId, KanbanState, UpdateKanbanPatch } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamKanbanManager');
|
||||
|
||||
const KANBAN_COLUMN_IDS: KanbanColumnId[] = ['todo', 'in_progress', 'done', 'review', 'approved'];
|
||||
|
||||
function createDefaultState(teamName: string): KanbanState {
|
||||
return {
|
||||
teamName,
|
||||
|
|
@ -21,6 +23,23 @@ function isValidColumn(value: unknown): value is 'review' | 'approved' {
|
|||
return value === 'review' || value === 'approved';
|
||||
}
|
||||
|
||||
function sanitizeColumnOrder(raw: unknown): KanbanState['columnOrder'] | undefined {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const result: NonNullable<KanbanState['columnOrder']> = {};
|
||||
for (const colId of KANBAN_COLUMN_IDS) {
|
||||
const arr = (raw as Record<string, unknown>)[colId];
|
||||
if (Array.isArray(arr)) {
|
||||
const ids = arr.filter((id): id is string => typeof id === 'string');
|
||||
if (ids.length > 0) {
|
||||
result[colId] = ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
export class TeamKanbanManager {
|
||||
async getState(teamName: string): Promise<KanbanState> {
|
||||
const statePath = this.getStatePath(teamName);
|
||||
|
|
@ -72,9 +91,25 @@ export class TeamKanbanManager {
|
|||
? parsed.reviewers.filter((r): r is string => typeof r === 'string' && r.trim().length > 0)
|
||||
: [],
|
||||
tasks: sanitizedTasks,
|
||||
columnOrder: sanitizeColumnOrder(parsed.columnOrder),
|
||||
};
|
||||
}
|
||||
|
||||
async updateColumnOrder(
|
||||
teamName: string,
|
||||
columnId: KanbanColumnId,
|
||||
orderedTaskIds: string[]
|
||||
): Promise<void> {
|
||||
const state = await this.getState(teamName);
|
||||
const columnOrder = { ...state.columnOrder };
|
||||
if (orderedTaskIds.length > 0) {
|
||||
columnOrder[columnId] = orderedTaskIds;
|
||||
} else {
|
||||
delete columnOrder[columnId];
|
||||
}
|
||||
await this.writeState(teamName, { ...state, columnOrder });
|
||||
}
|
||||
|
||||
async updateTask(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
|
||||
const state = await this.getState(teamName);
|
||||
|
||||
|
|
@ -106,12 +141,32 @@ export class TeamKanbanManager {
|
|||
}
|
||||
}
|
||||
|
||||
let columnOrderChanged = false;
|
||||
if (state.columnOrder) {
|
||||
const cleaned: NonNullable<KanbanState['columnOrder']> = {};
|
||||
for (const [colId, ids] of Object.entries(state.columnOrder)) {
|
||||
const valid = ids.filter((id) => validTaskIds.has(id));
|
||||
if (valid.length > 0) {
|
||||
cleaned[colId as KanbanColumnId] = valid;
|
||||
}
|
||||
if (valid.length !== ids.length) {
|
||||
columnOrderChanged = true;
|
||||
}
|
||||
}
|
||||
if (columnOrderChanged) {
|
||||
state.columnOrder = Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const after = Object.keys(state.tasks).length;
|
||||
if (before === after) {
|
||||
const tasksChanged = before !== after;
|
||||
if (!tasksChanged && !columnOrderChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`);
|
||||
if (tasksChanged) {
|
||||
logger.debug(`Removed ${before - after} stale kanban entries for team ${teamName}`);
|
||||
}
|
||||
await this.writeState(teamName, state);
|
||||
}
|
||||
|
||||
|
|
@ -125,6 +180,9 @@ export class TeamKanbanManager {
|
|||
teamName,
|
||||
reviewers: state.reviewers,
|
||||
tasks: state.tasks,
|
||||
...(state.columnOrder && Object.keys(state.columnOrder).length > 0
|
||||
? { columnOrder: state.columnOrder }
|
||||
: {}),
|
||||
};
|
||||
await atomicWriteAsync(statePath, JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export class TeamMemberResolver {
|
|||
|
||||
const configMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; color?: string }
|
||||
{ agentType?: string; role?: string; color?: string; cwd?: string }
|
||||
>();
|
||||
if (Array.isArray(config.members)) {
|
||||
for (const m of config.members) {
|
||||
|
|
@ -49,12 +49,16 @@ export class TeamMemberResolver {
|
|||
agentType: m.agentType,
|
||||
role: m.role,
|
||||
color: m.color,
|
||||
cwd: m.cwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metaMemberMap = new Map<string, { agentType?: string; role?: string; color?: string }>();
|
||||
const metaMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; color?: string; removedAt?: number }
|
||||
>();
|
||||
if (Array.isArray(metaMembers)) {
|
||||
for (const member of metaMembers) {
|
||||
if (typeof member?.name === 'string' && member.name.trim() !== '') {
|
||||
|
|
@ -62,6 +66,7 @@ export class TeamMemberResolver {
|
|||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
color: member.color,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +94,8 @@ export class TeamMemberResolver {
|
|||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
cwd: configMember?.cwd,
|
||||
removedAt: metaMember?.removedAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
|||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||
joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined,
|
||||
agentId: typeof member.agentId === 'string' ? member.agentId : undefined,
|
||||
removedAt: typeof member.removedAt === 'number' ? member.removedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable no-param-reassign -- ProvisioningRun object is intentionally mutated as a state tracker throughout the provisioning lifecycle */
|
||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||
import {
|
||||
encodePath,
|
||||
extractBaseDir,
|
||||
|
|
@ -8,9 +9,11 @@ import {
|
|||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile, spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { app } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
|
@ -22,6 +25,7 @@ import { withInboxLock } from './inboxLock';
|
|||
import { TeamConfigReader } from './TeamConfigReader';
|
||||
import { TeamInboxReader } from './TeamInboxReader';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
|
||||
import type {
|
||||
InboxMessage,
|
||||
|
|
@ -42,7 +46,7 @@ const VERIFY_POLL_MS = 500;
|
|||
const STDERR_RING_LIMIT = 64 * 1024;
|
||||
const STDOUT_RING_LIMIT = 64 * 1024;
|
||||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||
const UI_LOGS_TAIL_LIMIT = 8000;
|
||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||
const CLI_PREPARE_TIMEOUT_MS = 10000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||
|
|
@ -120,6 +124,11 @@ interface ProvisioningRun {
|
|||
rejectOnce: (error: string) => void;
|
||||
timeoutHandle: NodeJS.Timeout;
|
||||
} | null;
|
||||
/**
|
||||
* Accumulates assistant text for direct user→lead messages (no relay capture active).
|
||||
* Flushed to liveLeadProcessMessages on result.success.
|
||||
*/
|
||||
directReplyParts: string[];
|
||||
}
|
||||
|
||||
type ProvisioningAuthSource =
|
||||
|
|
@ -279,11 +288,20 @@ function buildTaskStatusProtocol(teamName: string): string {
|
|||
Failure to follow this protocol means the task board will show incorrect status.`;
|
||||
}
|
||||
|
||||
function getAgentLanguageInstruction(): string {
|
||||
const config = ConfigManager.getInstance().getConfig();
|
||||
const langCode = config.general.agentLanguage || 'system';
|
||||
const systemLocale = app.getLocale();
|
||||
const languageName = resolveLanguageName(langCode, systemLocale);
|
||||
return `IMPORTANT: Communicate in ${languageName}. All messages, summaries, and task descriptions MUST be in ${languageName}.`;
|
||||
}
|
||||
|
||||
function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||
const displayName = request.displayName?.trim() || request.teamName;
|
||||
const description = request.description?.trim() || 'No description';
|
||||
const members = buildMembersPrompt(request.members);
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
|
|
@ -296,6 +314,8 @@ You are "${leadName}", the team lead.
|
|||
|
||||
Goal: Provision a Claude Code agent team with live teammates.
|
||||
${userPromptBlock}
|
||||
${languageInstruction}
|
||||
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite.
|
||||
|
|
@ -327,7 +347,8 @@ Steps (execute in this exact order):
|
|||
- subagent_type: "general-purpose"
|
||||
- prompt:
|
||||
You are {name}, a {role} on team "${displayName}" (${request.teamName}).
|
||||
Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
|
||||
${languageInstruction}
|
||||
Introduce yourself briefly (name and role) and confirm you are ready.
|
||||
Then wait for task assignments.
|
||||
|
||||
${taskProtocol}
|
||||
|
|
@ -352,6 +373,7 @@ function buildLaunchPrompt(
|
|||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
|
||||
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
|
||||
|
|
@ -360,6 +382,8 @@ You are "${leadName}", the team lead.
|
|||
|
||||
Goal: Reconnect with existing team "${request.teamName}".
|
||||
${userPromptBlock}
|
||||
${languageInstruction}
|
||||
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite.
|
||||
|
|
@ -392,7 +416,8 @@ Steps (execute in this exact order):
|
|||
- subagent_type: "general-purpose"
|
||||
- prompt:
|
||||
You are {name}, a {role} on team "${request.teamName}".
|
||||
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready — use the language that matches the project's CLAUDE.md or the user's locale.
|
||||
${languageInstruction}
|
||||
The team has been reconnected. Introduce yourself briefly (name and role) and confirm you are ready.
|
||||
Then resume any pending work you own (if any) and wait for new assignments.
|
||||
|
||||
${taskProtocol}
|
||||
|
|
@ -501,7 +526,8 @@ export class TeamProvisioningService {
|
|||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly sentMessagesStore: TeamSentMessagesStore = new TeamSentMessagesStore()
|
||||
) {}
|
||||
|
||||
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void {
|
||||
|
|
@ -668,6 +694,7 @@ export class TeamProvisioningService {
|
|||
isLaunch: false,
|
||||
fsPhase: 'waiting_config',
|
||||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -706,6 +733,7 @@ export class TeamProvisioningService {
|
|||
'--disallowedTools',
|
||||
'TeamDelete,TodoWrite',
|
||||
'--dangerously-skip-permissions',
|
||||
...(request.model ? ['--model', request.model] : []),
|
||||
],
|
||||
{
|
||||
cwd: request.cwd,
|
||||
|
|
@ -936,6 +964,7 @@ export class TeamProvisioningService {
|
|||
isLaunch: true,
|
||||
fsPhase: 'waiting_members',
|
||||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -984,6 +1013,9 @@ export class TeamProvisioningService {
|
|||
`[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity`
|
||||
);
|
||||
}
|
||||
if (request.model) {
|
||||
launchArgs.push('--model', request.model);
|
||||
}
|
||||
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say
|
||||
// --resume is for existing sessions and may show an interactive picker if not found.
|
||||
|
||||
|
|
@ -1140,7 +1172,11 @@ export class TeamProvisioningService {
|
|||
* Send a message to the team's lead process via stream-json stdin.
|
||||
* The lead will receive it as a new user turn and can delegate to teammates.
|
||||
*/
|
||||
async sendMessageToTeam(teamName: string, message: string): Promise<void> {
|
||||
async sendMessageToTeam(
|
||||
teamName: string,
|
||||
message: string,
|
||||
attachments?: { data: string; mimeType: string }[]
|
||||
): Promise<void> {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) {
|
||||
throw new Error(`No active process for team "${teamName}"`);
|
||||
|
|
@ -1149,11 +1185,26 @@ export class TeamProvisioningService {
|
|||
if (!run?.child?.stdin?.writable) {
|
||||
throw new Error(`Team "${teamName}" process stdin is not writable`);
|
||||
}
|
||||
|
||||
const contentBlocks: Record<string, unknown>[] = [{ type: 'text', text: message }];
|
||||
if (attachments?.length) {
|
||||
for (const att of attachments) {
|
||||
contentBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: att.mimeType,
|
||||
data: att.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: message }],
|
||||
content: contentBlocks,
|
||||
},
|
||||
});
|
||||
run.child.stdin.write(payload + '\n');
|
||||
|
|
@ -1273,6 +1324,8 @@ export class TeamProvisioningService {
|
|||
},
|
||||
};
|
||||
run.leadRelayCapture = capture;
|
||||
// Clear any direct reply parts — relay capture takes priority
|
||||
run.directReplyParts = [];
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -1442,7 +1495,7 @@ export class TeamProvisioningService {
|
|||
return next;
|
||||
}
|
||||
|
||||
private pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||
const MAX = 100;
|
||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
||||
list.push(message);
|
||||
|
|
@ -1512,6 +1565,9 @@ export class TeamProvisioningService {
|
|||
capture.resolveOnce(combined);
|
||||
}, capture.idleMs);
|
||||
}
|
||||
} else if (run.provisioningComplete) {
|
||||
// Accumulate assistant text for direct user→lead messages (no relay capture).
|
||||
run.directReplyParts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1532,6 +1588,35 @@ export class TeamProvisioningService {
|
|||
const capture = run.leadRelayCapture;
|
||||
const combined = capture.textParts.join('').trim();
|
||||
capture.resolveOnce(combined);
|
||||
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
|
||||
// Flush accumulated assistant reply from direct user→lead message
|
||||
const replyText = run.directReplyParts.join('').trim();
|
||||
run.directReplyParts = [];
|
||||
const leadName =
|
||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||
'team-lead';
|
||||
if (replyText.length > 0) {
|
||||
const replyMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
to: 'user',
|
||||
text: replyText,
|
||||
timestamp: nowIso(),
|
||||
read: true,
|
||||
summary: replyText.length > 60 ? replyText.slice(0, 57) + '...' : replyText,
|
||||
messageId: `lead-direct-${run.runId}-${Date.now()}`,
|
||||
source: 'lead_process',
|
||||
};
|
||||
this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
|
||||
// Persist to disk so replies survive app restart
|
||||
void this.sentMessagesStore
|
||||
.appendMessage(run.teamName, replyMsg)
|
||||
.catch(() => undefined);
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName: run.teamName,
|
||||
detail: 'lead-direct-reply',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!run.provisioningComplete) {
|
||||
void this.handleProvisioningTurnComplete(run);
|
||||
|
|
|
|||
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,
|
||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||
parsed.status as TeamTask['status']
|
||||
)
|
||||
|
|
|
|||
|
|
@ -118,6 +118,30 @@ export class TeamTaskWriter {
|
|||
});
|
||||
}
|
||||
|
||||
async updateOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.promises.readFile(taskPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
if (owner) {
|
||||
task.owner = owner;
|
||||
} else {
|
||||
delete task.owner;
|
||||
}
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
async addComment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
||||
export { MemberStatsComputer } from './MemberStatsComputer';
|
||||
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
||||
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||
export { TeamConfigReader } from './TeamConfigReader';
|
||||
export { TeamDataService } from './TeamDataService';
|
||||
export { TeamInboxReader } from './TeamInboxReader';
|
||||
|
|
@ -10,5 +11,6 @@ export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
|||
export { TeamMemberResolver } from './TeamMemberResolver';
|
||||
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
export { TeamProvisioningService } from './TeamProvisioningService';
|
||||
export { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
export { TeamTaskReader } from './TeamTaskReader';
|
||||
export { TeamTaskWriter } from './TeamTaskWriter';
|
||||
|
|
|
|||
|
|
@ -191,6 +191,9 @@ export const TEAM_GET_DATA = 'team:getData';
|
|||
/** Update team kanban state */
|
||||
export const TEAM_UPDATE_KANBAN = 'team:updateKanban';
|
||||
|
||||
/** Update kanban column task order (drag-and-drop within column) */
|
||||
export const TEAM_UPDATE_KANBAN_COLUMN_ORDER = 'team:updateKanbanColumnOrder';
|
||||
|
||||
/** Send inbox message to team member */
|
||||
export const TEAM_SEND_MESSAGE = 'team:sendMessage';
|
||||
|
||||
|
|
@ -230,6 +233,9 @@ export const TEAM_CREATE_TASK = 'team:createTask';
|
|||
/** Update task status directly (pending/in_progress/completed) */
|
||||
export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
|
||||
|
||||
/** Update task owner (reassign) */
|
||||
export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner';
|
||||
|
||||
/** Delete a team and its associated task directory */
|
||||
export const TEAM_DELETE_TEAM = 'team:deleteTeam';
|
||||
|
||||
|
|
@ -260,3 +266,15 @@ export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';
|
|||
|
||||
/** Add a comment to a task */
|
||||
export const TEAM_ADD_TASK_COMMENT = 'team:addTaskComment';
|
||||
|
||||
/** Get current git branch for a project path (live read from .git/HEAD) */
|
||||
export const TEAM_GET_PROJECT_BRANCH = 'team:getProjectBranch';
|
||||
|
||||
/** Add a new member to an existing team */
|
||||
export const TEAM_ADD_MEMBER = 'team:addMember';
|
||||
|
||||
/** Soft-delete a team member */
|
||||
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
|
||||
|
||||
/** Get attachment data for a message */
|
||||
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
SSH_SAVE_LAST_CONNECTION,
|
||||
SSH_STATUS,
|
||||
SSH_TEST,
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_CANCEL_PROVISIONING,
|
||||
|
|
@ -27,10 +28,12 @@ import {
|
|||
TEAM_CREATE_TASK,
|
||||
TEAM_DELETE_TEAM,
|
||||
TEAM_GET_ALL_TASKS,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_DATA,
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LIST,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -38,12 +41,15 @@ import {
|
|||
TEAM_PROCESS_SEND,
|
||||
TEAM_PROVISIONING_PROGRESS,
|
||||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_SEND_MESSAGE,
|
||||
TEAM_START_TASK,
|
||||
TEAM_STOP,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||
TEAM_UPDATE_TASK_OWNER,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
UPDATER_CHECK,
|
||||
UPDATER_DOWNLOAD,
|
||||
|
|
@ -84,7 +90,9 @@ import {
|
|||
} from './constants/ipcChannels';
|
||||
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
AppConfig,
|
||||
AttachmentFileData,
|
||||
ClaudeRootFolderSelection,
|
||||
ClaudeRootInfo,
|
||||
ContextInfo,
|
||||
|
|
@ -93,6 +101,7 @@ import type {
|
|||
GlobalTask,
|
||||
HttpServerStatus,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
NotificationTrigger,
|
||||
|
|
@ -553,9 +562,24 @@ const electronAPI: ElectronAPI = {
|
|||
updateKanban: async (teamName: string, taskId: string, patch: UpdateKanbanPatch) => {
|
||||
return invokeIpcWithResult<void>(TEAM_UPDATE_KANBAN, teamName, taskId, patch);
|
||||
},
|
||||
updateKanbanColumnOrder: async (
|
||||
teamName: string,
|
||||
columnId: KanbanColumnId,
|
||||
orderedTaskIds: string[]
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||
teamName,
|
||||
columnId,
|
||||
orderedTaskIds
|
||||
);
|
||||
},
|
||||
updateTaskStatus: async (teamName: string, taskId: string, status: TeamTaskStatus) => {
|
||||
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_STATUS, teamName, taskId, status);
|
||||
},
|
||||
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
|
||||
return invokeIpcWithResult<void>(TEAM_UPDATE_TASK_OWNER, teamName, taskId, owner);
|
||||
},
|
||||
startTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_START_TASK, teamName, taskId);
|
||||
},
|
||||
|
|
@ -601,6 +625,18 @@ const electronAPI: ElectronAPI = {
|
|||
addTaskComment: async (teamName: string, taskId: string, text: string) => {
|
||||
return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, text);
|
||||
},
|
||||
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
|
||||
},
|
||||
removeMember: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
|
||||
},
|
||||
getProjectBranch: async (projectPath: string) => {
|
||||
return invokeIpcWithResult<string | null>(TEAM_GET_PROJECT_BRANCH, projectPath);
|
||||
},
|
||||
getAttachments: async (teamName: string, messageId: string) => {
|
||||
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
|
||||
},
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||
ipcRenderer.on(
|
||||
TEAM_CHANGE,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import type {
|
||||
AppConfig,
|
||||
AttachmentFileData,
|
||||
ClaudeMdFileInfo,
|
||||
ClaudeRootFolderSelection,
|
||||
ClaudeRootInfo,
|
||||
|
|
@ -20,6 +21,7 @@ import type {
|
|||
GlobalTask,
|
||||
HttpServerAPI,
|
||||
HttpServerStatus,
|
||||
KanbanColumnId,
|
||||
NotificationsAPI,
|
||||
NotificationTrigger,
|
||||
PaginatedSessionsResult,
|
||||
|
|
@ -666,6 +668,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<void> => {
|
||||
throw new Error('Team kanban is not available in browser mode');
|
||||
},
|
||||
updateKanbanColumnOrder: async (
|
||||
_teamName: string,
|
||||
_columnId: KanbanColumnId,
|
||||
_orderedTaskIds: string[]
|
||||
): Promise<void> => {
|
||||
throw new Error('Team kanban column order is not available in browser mode');
|
||||
},
|
||||
updateTaskStatus: async (
|
||||
_teamName: string,
|
||||
_taskId: string,
|
||||
|
|
@ -673,6 +682,13 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<void> => {
|
||||
throw new Error('Team task status update is not available in browser mode');
|
||||
},
|
||||
updateTaskOwner: async (
|
||||
_teamName: string,
|
||||
_taskId: string,
|
||||
_owner: string | null
|
||||
): Promise<void> => {
|
||||
throw new Error('Team task owner update is not available in browser mode');
|
||||
},
|
||||
startTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||
throw new Error('Team start task is not available in browser mode');
|
||||
},
|
||||
|
|
@ -726,6 +742,21 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
addTaskComment: async () => {
|
||||
throw new Error('Task comments are not available in browser mode');
|
||||
},
|
||||
addMember: async (): Promise<void> => {
|
||||
throw new Error('Team member management is not available in browser mode');
|
||||
},
|
||||
removeMember: async (): Promise<void> => {
|
||||
throw new Error('Team member management is not available in browser mode');
|
||||
},
|
||||
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
|
||||
return null;
|
||||
},
|
||||
getAttachments: async (
|
||||
_teamName: string,
|
||||
_messageId: string
|
||||
): Promise<AttachmentFileData[]> => {
|
||||
return [];
|
||||
},
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||
return this.addEventListener('team-change', (data: unknown) =>
|
||||
callback(null, data as TeamChangeEvent)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ interface MarkdownViewerProps {
|
|||
itemId?: string;
|
||||
/** When true, shows a copy button (overlay when no label, inline in header when label exists) */
|
||||
copyable?: boolean;
|
||||
/** When true, renders without wrapper background/border (for embedding inside cards) */
|
||||
bare?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -277,6 +279,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
label,
|
||||
itemId,
|
||||
copyable = false,
|
||||
bare = false,
|
||||
}) => {
|
||||
// Only subscribe to search store when itemId is provided
|
||||
const { searchQuery, searchMatches, currentSearchIndex } = useStore(
|
||||
|
|
@ -300,11 +303,15 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-0 overflow-hidden rounded-lg shadow-sm ${copyable && !label ? 'group relative' : ''} ${className}`}
|
||||
style={{
|
||||
backgroundColor: CODE_BG,
|
||||
border: `1px solid ${CODE_BORDER}`,
|
||||
}}
|
||||
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ${copyable && !label ? 'group relative' : ''} ${className}`}
|
||||
style={
|
||||
bare
|
||||
? undefined
|
||||
: {
|
||||
backgroundColor: CODE_BG,
|
||||
border: `1px solid ${CODE_BORDER}`,
|
||||
}
|
||||
}
|
||||
>
|
||||
{/* Copy button overlay (when no label header) */}
|
||||
{copyable && !label && <CopyButton text={content} />}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import {
|
||||
buildTaskCountsByProject,
|
||||
normalizePath,
|
||||
|
|
@ -256,14 +257,39 @@ const RepositoryCard = ({
|
|||
// Ghost Card (New Project)
|
||||
// =============================================================================
|
||||
|
||||
interface WorktreeMatch {
|
||||
repoId: string;
|
||||
worktreeId: string;
|
||||
}
|
||||
|
||||
function findMatchingWorktree(
|
||||
groups: RepositoryGroup[],
|
||||
selectedPath: string
|
||||
): WorktreeMatch | null {
|
||||
const norm = normalizePath(selectedPath);
|
||||
for (const repo of groups) {
|
||||
for (const worktree of repo.worktrees) {
|
||||
if (normalizePath(worktree.path) === norm) {
|
||||
return { repoId: repo.id, worktreeId: worktree.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const NewProjectCard = (): React.JSX.Element => {
|
||||
const { repositoryGroups, selectRepository } = useStore(
|
||||
const { repositoryGroups, fetchRepositoryGroups } = useStore(
|
||||
useShallow((s) => ({
|
||||
repositoryGroups: s.repositoryGroups,
|
||||
selectRepository: s.selectRepository,
|
||||
fetchRepositoryGroups: s.fetchRepositoryGroups,
|
||||
}))
|
||||
);
|
||||
|
||||
const navigateToMatch = (match: WorktreeMatch): void => {
|
||||
useStore.setState(getWorktreeNavigationState(match.repoId, match.worktreeId));
|
||||
void useStore.getState().fetchSessionsInitial(match.worktreeId);
|
||||
};
|
||||
|
||||
const handleClick = async (): Promise<void> => {
|
||||
try {
|
||||
const selectedPaths = await api.config.selectFolders();
|
||||
|
|
@ -273,17 +299,23 @@ const NewProjectCard = (): React.JSX.Element => {
|
|||
|
||||
const selectedPath = selectedPaths[0];
|
||||
|
||||
// Match selected path against known repository worktrees
|
||||
for (const repo of repositoryGroups) {
|
||||
for (const worktree of repo.worktrees) {
|
||||
if (worktree.path === selectedPath) {
|
||||
selectRepository(repo.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Match selected path against known repository worktrees (normalized comparison)
|
||||
const match = findMatchingWorktree(repositoryGroups, selectedPath);
|
||||
if (match) {
|
||||
navigateToMatch(match);
|
||||
return;
|
||||
}
|
||||
|
||||
// No match found - open the folder in file manager as fallback
|
||||
// No match — refresh repository groups and retry
|
||||
await fetchRepositoryGroups();
|
||||
const refreshedGroups = useStore.getState().repositoryGroups;
|
||||
const matchAfterRefresh = findMatchingWorktree(refreshedGroups, selectedPath);
|
||||
if (matchAfterRefresh) {
|
||||
navigateToMatch(matchAfterRefresh);
|
||||
return;
|
||||
}
|
||||
|
||||
// Still no match — open the folder in file manager as fallback
|
||||
const result = await api.openPath(selectedPath, undefined, true);
|
||||
if (!result.success) {
|
||||
logger.error('Failed to open folder:', result.error);
|
||||
|
|
|
|||
|
|
@ -115,49 +115,79 @@ export const Sidebar = (): React.JSX.Element => {
|
|||
>
|
||||
<SidebarHeader />
|
||||
|
||||
{/* Tab bar: Tasks | Sessions */}
|
||||
{/* Tab bar: Tasks | Sessions — tab strip style, filters on the right */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5"
|
||||
className="flex shrink-0 items-end gap-2 border-b px-3 pt-1"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div className="flex gap-0.5">
|
||||
<div className="flex flex-1" />
|
||||
<div className="flex" role="tablist" aria-label="Sidebar view">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
sidebarTab === 'tasks'
|
||||
? 'bg-surface-raised text-text'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
role="tab"
|
||||
aria-selected={sidebarTab === 'tasks'}
|
||||
aria-controls="sidebar-tasks-panel"
|
||||
id="sidebar-tab-tasks"
|
||||
className={`relative px-3 py-1.5 text-[11px] font-medium transition-colors ${
|
||||
sidebarTab === 'tasks' ? 'text-text' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
style={
|
||||
sidebarTab === 'tasks'
|
||||
? {
|
||||
borderBottom: '2px solid var(--color-text)',
|
||||
marginBottom: '-1px',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setSidebarTab('tasks')}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
role="tab"
|
||||
aria-selected={sidebarTab === 'sessions'}
|
||||
aria-controls="sidebar-sessions-panel"
|
||||
id="sidebar-tab-sessions"
|
||||
className={`relative px-3 py-1.5 text-[11px] font-medium transition-colors ${
|
||||
sidebarTab === 'sessions'
|
||||
? 'bg-surface-raised text-text'
|
||||
? 'text-text'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
style={
|
||||
sidebarTab === 'sessions'
|
||||
? {
|
||||
borderBottom: '2px solid var(--color-text)',
|
||||
marginBottom: '-1px',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setSidebarTab('sessions')}
|
||||
>
|
||||
Sessions
|
||||
</button>
|
||||
</div>
|
||||
{sidebarTab === 'tasks' && (
|
||||
<TaskFiltersPopover
|
||||
open={taskFiltersPopoverOpen}
|
||||
onOpenChange={setTaskFiltersPopoverOpen}
|
||||
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
|
||||
filters={taskFilters}
|
||||
onFiltersChange={setTaskFilters}
|
||||
onApply={() => {}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-1 justify-end pb-0.5">
|
||||
{sidebarTab === 'tasks' && (
|
||||
<TaskFiltersPopover
|
||||
open={taskFiltersPopoverOpen}
|
||||
onOpenChange={setTaskFiltersPopoverOpen}
|
||||
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
|
||||
filters={taskFilters}
|
||||
onFiltersChange={setTaskFilters}
|
||||
onApply={() => {}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content: Tasks list or Sessions list */}
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div
|
||||
id={sidebarTab === 'tasks' ? 'sidebar-tasks-panel' : 'sidebar-sessions-panel'}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`sidebar-tab-${sidebarTab}`}
|
||||
className="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
{sidebarTab === 'tasks' ? (
|
||||
<GlobalTaskList
|
||||
hideHeader
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export const SettingsView = (): React.JSX.Element | null => {
|
|||
saving={saving}
|
||||
onGeneralToggle={handlers.handleGeneralToggle}
|
||||
onThemeChange={handlers.handleThemeChange}
|
||||
onLanguageChange={handlers.handleLanguageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface SafeConfig {
|
|||
theme: 'dark' | 'light' | 'system';
|
||||
defaultTab: 'dashboard' | 'last-session';
|
||||
claudeRootPath: string | null;
|
||||
agentLanguage: string;
|
||||
};
|
||||
notifications: {
|
||||
enabled: boolean;
|
||||
|
|
@ -154,6 +155,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
theme: displayConfig?.general?.theme ?? 'dark',
|
||||
defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard',
|
||||
claudeRootPath: displayConfig?.general?.claudeRootPath ?? null,
|
||||
agentLanguage: displayConfig?.general?.agentLanguage ?? 'system',
|
||||
},
|
||||
notifications: {
|
||||
enabled: displayConfig?.notifications?.enabled ?? true,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ interface SettingsHandlers {
|
|||
// General handlers
|
||||
handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void;
|
||||
handleThemeChange: (value: 'dark' | 'light' | 'system') => void;
|
||||
handleLanguageChange: (value: string) => void;
|
||||
handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void;
|
||||
|
||||
// Notification handlers
|
||||
|
|
@ -81,6 +82,13 @@ export function useSettingsHandlers({
|
|||
[updateConfig]
|
||||
);
|
||||
|
||||
const handleLanguageChange = useCallback(
|
||||
(value: string) => {
|
||||
void updateConfig('general', { agentLanguage: value });
|
||||
},
|
||||
[updateConfig]
|
||||
);
|
||||
|
||||
const handleDefaultTabChange = useCallback(
|
||||
(value: 'dashboard' | 'last-session') => {
|
||||
void updateConfig('general', { defaultTab: value });
|
||||
|
|
@ -287,6 +295,7 @@ export function useSettingsHandlers({
|
|||
theme: 'dark',
|
||||
defaultTab: 'dashboard',
|
||||
claudeRootPath: null,
|
||||
agentLanguage: 'system',
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
@ -373,6 +382,7 @@ export function useSettingsHandlers({
|
|||
return {
|
||||
handleGeneralToggle,
|
||||
handleThemeChange,
|
||||
handleLanguageChange,
|
||||
handleDefaultTabChange,
|
||||
handleNotificationToggle,
|
||||
handleSnooze,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
||||
|
|
@ -28,6 +30,7 @@ interface GeneralSectionProps {
|
|||
readonly saving: boolean;
|
||||
readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void;
|
||||
readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void;
|
||||
readonly onLanguageChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const GeneralSection = ({
|
||||
|
|
@ -35,6 +38,7 @@ export const GeneralSection = ({
|
|||
saving,
|
||||
onGeneralToggle,
|
||||
onThemeChange,
|
||||
onLanguageChange,
|
||||
}: GeneralSectionProps): React.JSX.Element => {
|
||||
const [serverStatus, setServerStatus] = useState<HttpServerStatus>({
|
||||
running: false,
|
||||
|
|
@ -247,8 +251,59 @@ export const GeneralSection = ({
|
|||
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
|
||||
const agentLanguageDescription = useMemo(() => {
|
||||
const current = safeConfig.general.agentLanguage ?? 'system';
|
||||
if (current === 'system') {
|
||||
const browserLang = navigator.language;
|
||||
const primaryCode = browserLang.includes('-') ? browserLang.split('-')[0] : browserLang;
|
||||
const detected = resolveLanguageName('system', browserLang);
|
||||
const detectedFlag = AGENT_LANGUAGE_OPTIONS.find((o) => o.value === primaryCode)?.flag ?? '';
|
||||
const flagPrefix = detectedFlag ? `${detectedFlag} ` : '';
|
||||
return `Language for agent communication (detected: ${flagPrefix}${detected})`;
|
||||
}
|
||||
return 'Language for agent communication';
|
||||
}, [safeConfig.general.agentLanguage]);
|
||||
|
||||
const languageComboboxOptions = useMemo(
|
||||
() =>
|
||||
AGENT_LANGUAGE_OPTIONS.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: `${opt.flag} ${opt.label}`,
|
||||
meta: { flag: opt.flag },
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
const renderLanguageOption = useCallback(
|
||||
(
|
||||
option: { value: string; label: string; meta?: Record<string, unknown> },
|
||||
isSelected: boolean
|
||||
) => (
|
||||
<>
|
||||
<Check className={`mr-2 size-3.5 shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="text-[var(--color-text)]">{option.label}</span>
|
||||
</>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsSectionHeader title="Agent Language" />
|
||||
<SettingRow label="Language" description={agentLanguageDescription}>
|
||||
<Combobox
|
||||
options={languageComboboxOptions}
|
||||
value={safeConfig.general.agentLanguage ?? 'system'}
|
||||
onValueChange={onLanguageChange}
|
||||
placeholder="Select language..."
|
||||
searchPlaceholder="Search language..."
|
||||
emptyMessage="No language found."
|
||||
disabled={saving}
|
||||
className="min-w-[180px]"
|
||||
renderOption={renderLanguageOption}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{isElectron && (
|
||||
<>
|
||||
<SettingsSectionHeader title="Startup" />
|
||||
|
|
|
|||
|
|
@ -1,12 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import {
|
||||
|
|
@ -235,18 +229,30 @@ export const GlobalTaskList = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Grouping mode */}
|
||||
<div className="flex shrink-0 items-center gap-2 px-2 py-1">
|
||||
{/* Grouping mode — compact segmented toggle */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
|
||||
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
||||
<Select value={groupingMode} onValueChange={(v) => setGroupingMode(v as TaskGroupingMode)}>
|
||||
<SelectTrigger className="h-7 min-w-0 flex-1 border-[var(--color-border)] px-2 text-[11px]">
|
||||
<SelectValue placeholder="Group by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="project">Project</SelectItem>
|
||||
<SelectItem value="time">Time</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div
|
||||
className="bg-surface-raised/60 inline-flex rounded-md p-0.5 text-[11px]"
|
||||
role="group"
|
||||
aria-label="Group by"
|
||||
>
|
||||
{(['project', 'time'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setGroupingMode(mode)}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 transition-colors',
|
||||
groupingMode === mode
|
||||
? 'bg-surface-raised text-text shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
)}
|
||||
>
|
||||
{mode === 'project' ? 'Project' : 'Time'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
|
|
|||
|
|
@ -27,13 +27,15 @@ export const CollapsibleTeamSection = ({
|
|||
const isOpen = forceOpen ? true : open;
|
||||
|
||||
return (
|
||||
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0">
|
||||
<div className="flex items-center">
|
||||
<section className="border-b border-[var(--color-border)] pb-3 last:border-b-0">
|
||||
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
className="absolute inset-0 z-0 cursor-pointer rounded-md transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
||||
/>
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 basis-0 items-center gap-2 pl-4">
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
|
||||
|
|
@ -60,8 +62,8 @@ export const CollapsibleTeamSection = ({
|
|||
{secondaryBadge}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||
</div>
|
||||
{isOpen && <div className="mt-2">{children}</div>}
|
||||
</section>
|
||||
|
|
|
|||
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 { Button } from '@renderer/components/ui/button';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
hljs.registerLanguage('json', json);
|
||||
|
||||
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
|
||||
|
||||
|
|
@ -18,17 +24,93 @@ export interface ProvisioningProgressBlockProps {
|
|||
loading?: boolean;
|
||||
/** Cancel button label and handler */
|
||||
onCancel?: (() => void) | null;
|
||||
/** ISO timestamp when provisioning started */
|
||||
startedAt?: string;
|
||||
/** PID of the CLI process */
|
||||
pid?: number;
|
||||
/** Tail of CLI logs */
|
||||
cliLogsTail?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatElapsed(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function useElapsedTimer(startedAt?: string): string | null {
|
||||
const [elapsed, setElapsed] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!startedAt) return () => setElapsed(null);
|
||||
const startMs = Date.parse(startedAt);
|
||||
if (isNaN(startMs)) return () => setElapsed(null);
|
||||
|
||||
const tick = (): void => {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
|
||||
setElapsed(formatElapsed(seconds));
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => {
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [startedAt]);
|
||||
|
||||
if (!startedAt) return null;
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
function highlightLogsHtml(text: string): string {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
return hljs.highlight(line, { language: 'json' }).value;
|
||||
} catch {
|
||||
return escapeHtml(line);
|
||||
}
|
||||
}
|
||||
if (line === '[stdout]' || line === '[stderr]') {
|
||||
return `<span class="hljs-comment">${escapeHtml(line)}</span>`;
|
||||
}
|
||||
return escapeHtml(line);
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export const ProvisioningProgressBlock = ({
|
||||
title,
|
||||
message,
|
||||
currentStepIndex,
|
||||
loading = false,
|
||||
onCancel,
|
||||
startedAt,
|
||||
pid,
|
||||
cliLogsTail,
|
||||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt);
|
||||
const [logsOpen, setLogsOpen] = useState(false);
|
||||
const logsRef = useRef<HTMLPreElement>(null);
|
||||
const highlightedHtml = useMemo(
|
||||
() => (cliLogsTail ? highlightLogsHtml(cliLogsTail) : ''),
|
||||
[cliLogsTail]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (logsOpen && logsRef.current) {
|
||||
logsRef.current.scrollTop = logsRef.current.scrollHeight;
|
||||
}
|
||||
}, [logsOpen, cliLogsTail]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -42,6 +124,14 @@ export const ProvisioningProgressBlock = ({
|
|||
<Loader2 className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" />
|
||||
) : null}
|
||||
<p className="text-xs font-medium text-[var(--color-text)]">{title}</p>
|
||||
{elapsed !== null ? (
|
||||
<span className="text-[10px] tabular-nums text-[var(--color-text-muted)]">
|
||||
{elapsed}
|
||||
</span>
|
||||
) : null}
|
||||
{pid !== undefined ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">PID {pid}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{onCancel ? (
|
||||
<Button
|
||||
|
|
@ -62,6 +152,7 @@ export const ProvisioningProgressBlock = ({
|
|||
|
||||
return (
|
||||
<div key={step} className="flex items-center gap-1">
|
||||
{/* eslint-disable tailwindcss/no-custom-classname -- theme CSS vars */}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
|
|
@ -76,6 +167,7 @@ export const ProvisioningProgressBlock = ({
|
|||
</span>
|
||||
{STEP_LABELS[step]}
|
||||
</Badge>
|
||||
{/* eslint-enable tailwindcss/no-custom-classname -- end theme CSS vars block */}
|
||||
{index < STEP_ORDER.filter((s) => s !== 'ready').length - 1 ? (
|
||||
<span className="text-[var(--color-text-muted)]">→</span>
|
||||
) : null}
|
||||
|
|
@ -83,6 +175,29 @@ export const ProvisioningProgressBlock = ({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{cliLogsTail ? (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setLogsOpen((v) => !v)}
|
||||
>
|
||||
{logsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
CLI output
|
||||
</button>
|
||||
{logsOpen ? (
|
||||
<pre
|
||||
ref={logsRef}
|
||||
className="hljs mt-1 max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]"
|
||||
// Safe: highlightedHtml is built from hljs.highlight() which only produces
|
||||
// hljs-* <span> tags, combined with escapeHtml() for non-JSON lines.
|
||||
// Input is CLI stdout/stderr from a local process, not user-supplied web content.
|
||||
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,18 +2,28 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
|
||||
import { GitBranch, Pencil, Play, Plus, Search, Trash2, UserPlus, X } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { ActiveTasksBlock } from './activity/ActiveTasksBlock';
|
||||
import { ActivityTimeline } from './activity/ActivityTimeline';
|
||||
import { PendingRepliesBlock } from './activity/PendingRepliesBlock';
|
||||
import { AddMemberDialog } from './dialogs/AddMemberDialog';
|
||||
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
|
||||
import { EditTeamDialog } from './dialogs/EditTeamDialog';
|
||||
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
|
||||
|
|
@ -24,6 +34,7 @@ import { KanbanBoard } from './kanban/KanbanBoard';
|
|||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||
import { MemberList } from './members/MemberList';
|
||||
import { MessageComposer } from './messages/MessageComposer';
|
||||
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||
|
|
@ -78,9 +89,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
defaultOwner: '',
|
||||
});
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
||||
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
||||
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
||||
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
|
||||
undefined
|
||||
|
|
@ -102,7 +117,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
projects,
|
||||
selectTeam,
|
||||
updateKanban,
|
||||
updateKanbanColumnOrder,
|
||||
updateTaskStatus,
|
||||
updateTaskOwner,
|
||||
sendTeamMessage,
|
||||
requestReview,
|
||||
createTeamTask,
|
||||
|
|
@ -113,6 +130,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sendMessageError,
|
||||
lastSendMessageResult,
|
||||
reviewActionError,
|
||||
addMember,
|
||||
removeMember,
|
||||
launchTeam,
|
||||
provisioningError,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -126,7 +145,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
projects: s.projects,
|
||||
selectTeam: s.selectTeam,
|
||||
updateKanban: s.updateKanban,
|
||||
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
|
||||
updateTaskStatus: s.updateTaskStatus,
|
||||
updateTaskOwner: s.updateTaskOwner,
|
||||
sendTeamMessage: s.sendTeamMessage,
|
||||
requestReview: s.requestReview,
|
||||
createTeamTask: s.createTeamTask,
|
||||
|
|
@ -137,6 +158,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sendMessageError: s.sendMessageError,
|
||||
lastSendMessageResult: s.lastSendMessageResult,
|
||||
reviewActionError: s.reviewActionError,
|
||||
addMember: s.addMember,
|
||||
removeMember: s.removeMember,
|
||||
launchTeam: s.launchTeam,
|
||||
provisioningError: s.provisioningError,
|
||||
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||||
|
|
@ -204,6 +227,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Resolve lead's git branch from project path
|
||||
const [leadBranch, setLeadBranch] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const projectPath = data?.config.projectPath?.trim();
|
||||
if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') {
|
||||
setLeadBranch(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void api.teams.getProjectBranch(projectPath).then(
|
||||
(branch) => {
|
||||
if (!cancelled) setLeadBranch(branch);
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setLeadBranch(null);
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [data?.config.projectPath]);
|
||||
|
||||
// Filter sessions to team-only using sessionHistory + leadSessionId
|
||||
const teamSessions = useMemo(() => {
|
||||
const sessionIds = new Set<string>();
|
||||
|
|
@ -313,6 +358,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return filterKanbanTasks(filteredTasks, query);
|
||||
}, [filteredTasks, kanbanSearch]);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
||||
[data?.members]
|
||||
);
|
||||
|
||||
const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]);
|
||||
|
||||
const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]);
|
||||
|
|
@ -354,12 +404,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
};
|
||||
|
||||
const handleDeleteTeam = useCallback((): void => {
|
||||
const confirmed = window.confirm(
|
||||
`Delete team "${teamName}"? This action is irreversible. All team data and tasks will be deleted.`
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
setDeleteConfirmOpen(true);
|
||||
}, []);
|
||||
|
||||
const confirmDeleteTeam = useCallback((): void => {
|
||||
setDeleteConfirmOpen(false);
|
||||
void (async () => {
|
||||
try {
|
||||
await deleteTeam(teamName);
|
||||
|
|
@ -475,46 +524,76 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
headerColorSet && 'relative z-10'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
|
||||
{data.config.description && (
|
||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||
{data.config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{!data.isAlive ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={() => setLaunchDialogOpen(true)}
|
||||
>
|
||||
<Play size={12} />
|
||||
Launch
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setEditDialogOpen(true)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={handleDeleteTeam}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setEditDialogOpen(true)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Edit team</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={handleDeleteTeam}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Delete team</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{(data.config.description || leadBranch) && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2',
|
||||
headerColorSet && 'relative z-10'
|
||||
)}
|
||||
>
|
||||
<p className="min-w-0 truncate text-xs text-[var(--color-text-muted)]">
|
||||
{data.config.description || ''}
|
||||
</p>
|
||||
{leadBranch ? (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-text-muted)]"
|
||||
title={leadBranch}
|
||||
>
|
||||
<GitBranch size={10} />
|
||||
<span className="max-w-32 truncate">{leadBranch}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!data.isAlive ? (
|
||||
<div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
|
||||
<span className="text-xs text-amber-200">Team is offline</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
onClick={() => setLaunchDialogOpen(true)}
|
||||
>
|
||||
<Play size={12} />
|
||||
Launch
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TeamProvisioningBanner teamName={teamName} />
|
||||
|
||||
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
|
||||
|
|
@ -528,7 +607,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen>
|
||||
<CollapsibleTeamSection
|
||||
title="Team"
|
||||
badge={activeMembers.length}
|
||||
defaultOpen
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddMemberDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
Member
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<MemberList
|
||||
members={data.members}
|
||||
memberTaskCounts={memberTaskCounts}
|
||||
|
|
@ -581,28 +678,6 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="relative mb-2">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks… (#id or text)"
|
||||
value={kanbanSearch}
|
||||
onChange={(e) => setKanbanSearch(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
/>
|
||||
{kanbanSearch && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setKanbanSearch('')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<KanbanBoard
|
||||
tasks={kanbanDisplayTasks}
|
||||
teamName={teamName}
|
||||
|
|
@ -610,8 +685,37 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
filter={kanbanFilter}
|
||||
sessions={teamSessions}
|
||||
leadSessionId={data.config.leadSessionId}
|
||||
members={data.members}
|
||||
members={activeMembers}
|
||||
onFilterChange={setKanbanFilter}
|
||||
toolbarLeft={
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks… (#id or text)"
|
||||
value={kanbanSearch}
|
||||
onChange={(e) => setKanbanSearch(e.target.value)}
|
||||
className="h-8 w-full min-w-[140px] max-w-[240px] rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
/>
|
||||
{kanbanSearch && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setKanbanSearch('')}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Clear search</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
onRequestReview={(taskId) => {
|
||||
void requestReview(teamName, taskId);
|
||||
}}
|
||||
|
|
@ -652,6 +756,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onCompleteTask={(taskId) => {
|
||||
void updateTaskStatus(teamName, taskId, 'completed');
|
||||
}}
|
||||
onColumnOrderChange={(columnId, orderedTaskIds) => {
|
||||
void updateKanbanColumnOrder(teamName, columnId, orderedTaskIds);
|
||||
}}
|
||||
onScrollToTask={(taskId) => {
|
||||
const el = document.querySelector(`[data-task-id="${taskId}"]`);
|
||||
if (el) {
|
||||
|
|
@ -692,23 +799,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onOpenChange={setMessagesFilterOpen}
|
||||
onApply={setMessagesFilter}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 gap-1.5 px-2.5 text-xs font-medium text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSendDialogRecipient(undefined);
|
||||
setReplyQuote(undefined);
|
||||
setSendDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={12} />
|
||||
Message
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MessageComposer
|
||||
teamName={teamName}
|
||||
members={activeMembers}
|
||||
isTeamAlive={data.isAlive}
|
||||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
onSend={(member, text, summary, attachments) => {
|
||||
const sentAtMs = Date.now();
|
||||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||
void sendTeamMessage(teamName, { member, text, summary, attachments }).catch(() => {
|
||||
setPendingRepliesByMember((prev) => {
|
||||
if (prev[member] !== sentAtMs) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[member];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PendingRepliesBlock
|
||||
members={data.members}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
|
|
@ -722,6 +834,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
/>
|
||||
<ActivityTimeline
|
||||
messages={filteredMessages}
|
||||
teamName={teamName}
|
||||
members={data.members}
|
||||
readState={{ readSet, getMessageKey: toMessageKey }}
|
||||
onMemberClick={setSelectedMember}
|
||||
|
|
@ -786,12 +899,17 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setSelectedMember(null);
|
||||
setSelectedTask(task);
|
||||
}}
|
||||
onRemoveMember={() => {
|
||||
const name = selectedMember?.name;
|
||||
if (!name) return;
|
||||
setRemoveMemberConfirm(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CreateTaskDialog
|
||||
open={createTaskDialog.open}
|
||||
teamName={teamName}
|
||||
members={data.members}
|
||||
members={activeMembers}
|
||||
tasks={data.tasks}
|
||||
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||
defaultSubject={createTaskDialog.defaultSubject}
|
||||
|
|
@ -812,6 +930,81 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onSaved={() => void selectTeam(teamName)}
|
||||
/>
|
||||
|
||||
<AddMemberDialog
|
||||
open={addMemberDialogOpen}
|
||||
teamName={teamName}
|
||||
existingNames={data.members.map((m) => m.name)}
|
||||
adding={addingMemberLoading}
|
||||
onClose={() => setAddMemberDialogOpen(false)}
|
||||
onAdd={(name, role) => {
|
||||
setAddingMemberLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
await addMember(teamName, { name, role });
|
||||
setAddMemberDialogOpen(false);
|
||||
} catch {
|
||||
// error shown via store
|
||||
} finally {
|
||||
setAddingMemberLoading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={removeMemberConfirm !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveMemberConfirm(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remove “{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
|
||||
open={launchDialogOpen}
|
||||
teamName={teamName}
|
||||
|
|
@ -826,7 +1019,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
<SendMessageDialog
|
||||
open={sendDialogOpen}
|
||||
members={data.members}
|
||||
members={activeMembers}
|
||||
defaultRecipient={sendDialogRecipient}
|
||||
quotedMessage={replyQuote}
|
||||
sending={sendingMessage}
|
||||
|
|
@ -860,7 +1053,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
teamName={teamName}
|
||||
kanbanTaskState={selectedTask ? data?.kanbanState.tasks[selectedTask.id] : undefined}
|
||||
taskMap={taskMap}
|
||||
members={data?.members ?? []}
|
||||
members={activeMembers}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onScrollToTask={(taskId) => {
|
||||
setSelectedTask(null);
|
||||
|
|
@ -871,6 +1064,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
setTimeout(() => el.classList.remove('ring-2', 'ring-blue-400/50'), 1500);
|
||||
}
|
||||
}}
|
||||
onOwnerChange={(taskId, owner) => {
|
||||
void updateTaskOwner(teamName, taskId, owner);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,17 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getBaseName } from '@renderer/utils/pathUtils';
|
||||
import { CheckCircle, Clock, Copy, FolderOpen, Play, Search, Square, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Play,
|
||||
Search,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||
|
|
@ -97,6 +107,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
|
||||
const {
|
||||
teams,
|
||||
teamsLoading,
|
||||
|
|
@ -204,6 +215,49 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
return result;
|
||||
}, [teams, searchQuery, currentProjectPath]);
|
||||
|
||||
// Live branch/worktree for team project paths (poll so it updates during process)
|
||||
const projectPathsToPoll = useMemo(() => {
|
||||
const byKey = new Map<string, string>();
|
||||
for (const team of filteredTeams) {
|
||||
const p = team.projectPath?.trim();
|
||||
if (p) {
|
||||
const key = normalizePath(p);
|
||||
if (!byKey.has(key)) byKey.set(key, p);
|
||||
}
|
||||
}
|
||||
return Array.from(byKey.entries());
|
||||
}, [filteredTeams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronMode || projectPathsToPoll.length === 0) return;
|
||||
let cancelled = false;
|
||||
const poll = async (): Promise<void> => {
|
||||
const next = new Map<string, string | null>();
|
||||
for (const [pathKey, actualPath] of projectPathsToPoll) {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const branch = await api.teams.getProjectBranch(actualPath);
|
||||
if (!cancelled) next.set(pathKey, branch);
|
||||
} catch {
|
||||
if (!cancelled) next.set(pathKey, null);
|
||||
}
|
||||
}
|
||||
if (!cancelled && next.size > 0) {
|
||||
setBranchByPath((prev) => {
|
||||
const m = new Map(prev);
|
||||
for (const [k, v] of next) m.set(k, v);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const interval = setInterval(poll, 6000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [electronMode, projectPathsToPoll]);
|
||||
|
||||
const handleDeleteTeam = useCallback(
|
||||
(teamName: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -509,9 +563,25 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
|
||||
{team.description || 'No description'}
|
||||
</p>
|
||||
<div className="mt-2 flex min-h-10 items-start gap-2">
|
||||
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
|
||||
{team.description || 'No description'}
|
||||
</p>
|
||||
{team.projectPath &&
|
||||
(() => {
|
||||
const branch = branchByPath.get(normalizePath(team.projectPath));
|
||||
if (!branch) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
|
||||
title={branch}
|
||||
>
|
||||
<GitBranch size={10} />
|
||||
<span className="max-w-24 truncate">{branch}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{team.members && team.members.length > 0 ? (
|
||||
team.members.map((m) => {
|
||||
|
|
|
|||
|
|
@ -142,6 +142,9 @@ export const TeamProvisioningBanner = ({
|
|||
message={progress.message}
|
||||
currentStepIndex={progressStepIndex >= 0 ? progressStepIndex : -1}
|
||||
loading
|
||||
startedAt={progress.startedAt}
|
||||
pid={progress.pid}
|
||||
cliLogsTail={progress.cliLogsTail}
|
||||
onCancel={
|
||||
canCancel
|
||||
? () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
CARD_BG,
|
||||
CARD_BORDER_STYLE,
|
||||
|
|
@ -16,7 +19,8 @@ import {
|
|||
} from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
|
||||
import { Bot, ChevronRight, ListPlus, MessageSquare, Reply } from 'lucide-react';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { AlertTriangle, ChevronRight, ListPlus, Reply } from 'lucide-react';
|
||||
|
||||
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
||||
|
||||
|
|
@ -27,6 +31,7 @@ type StructuredMessage = Record<string, unknown>;
|
|||
|
||||
interface ActivityItemProps {
|
||||
message: InboxMessage;
|
||||
teamName: string;
|
||||
memberRole?: string;
|
||||
memberColor?: string;
|
||||
recipientColor?: string;
|
||||
|
|
@ -126,6 +131,7 @@ function stripAgentBlocks(text: string): string {
|
|||
|
||||
export const ActivityItem = ({
|
||||
message,
|
||||
teamName,
|
||||
memberRole,
|
||||
memberColor,
|
||||
recipientColor,
|
||||
|
|
@ -135,7 +141,6 @@ export const ActivityItem = ({
|
|||
onReply,
|
||||
}: ActivityItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||
const recipientColors = message.to && recipientColor ? getTeamColorSet(recipientColor) : null;
|
||||
const formattedRole = formatAgentRole(memberRole);
|
||||
|
||||
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
||||
|
|
@ -143,10 +148,13 @@ export const ActivityItem = ({
|
|||
: new Date(message.timestamp).toLocaleString();
|
||||
|
||||
const structured = parseStructuredAgentMessage(message.text);
|
||||
const noiseLabel = structured ? getNoiseLabel(structured) : null;
|
||||
// Only flag agent messages as rate-limited, not user's own quotes
|
||||
const rateLimited = message.from !== 'user' && isRateLimitMessage(message.text);
|
||||
// Never collapse rate limit messages as noise — they must be visible
|
||||
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
||||
|
||||
// System/automated messages start collapsed
|
||||
const systemLabel = !structured ? getSystemMessageLabel(message.text) : null;
|
||||
// System/automated messages start collapsed (but not rate limits)
|
||||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||
|
||||
// Strip agent-only blocks from displayed text
|
||||
|
|
@ -184,9 +192,11 @@ export const ActivityItem = ({
|
|||
<article
|
||||
className="group overflow-hidden rounded-md"
|
||||
style={{
|
||||
backgroundColor: CARD_BG,
|
||||
border: CARD_BORDER_STYLE,
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
backgroundColor: rateLimited ? 'var(--tool-result-error-bg)' : CARD_BG,
|
||||
border: rateLimited ? '1px solid var(--tool-result-error-border)' : CARD_BORDER_STYLE,
|
||||
borderLeft: rateLimited
|
||||
? '3px solid var(--tool-result-error-text)'
|
||||
: `3px solid ${colors.border}`,
|
||||
}}
|
||||
>
|
||||
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
|
||||
|
|
@ -224,41 +234,12 @@ export const ActivityItem = ({
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{message.source === 'lead_session' || message.source === 'lead_process' ? (
|
||||
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||
) : (
|
||||
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
|
||||
)}
|
||||
|
||||
{/* Name badge — clickable to open member popup */}
|
||||
{onMemberNameClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMemberNameClick(message.from);
|
||||
}}
|
||||
>
|
||||
{message.from}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
>
|
||||
{message.from}
|
||||
</span>
|
||||
)}
|
||||
{/* Sender avatar + name badge */}
|
||||
<MemberBadge
|
||||
name={message.from}
|
||||
color={memberColor ?? message.color}
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
|
||||
{/* Role */}
|
||||
{formattedRole ? (
|
||||
|
|
@ -289,60 +270,24 @@ export const ActivityItem = ({
|
|||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Recipient — badge like sender, clickable to open member popup */}
|
||||
{message.to && message.to !== message.from && recipientColors ? (
|
||||
<span className="text-[10px]">
|
||||
<span style={{ color: CARD_ICON_MUTED }}>→ </span>
|
||||
{onMemberNameClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: recipientColors.badge,
|
||||
color: recipientColors.text,
|
||||
border: `1px solid ${recipientColors.border}40`,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMemberNameClick(message.to!);
|
||||
}}
|
||||
>
|
||||
{message.to}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: recipientColors.badge,
|
||||
color: recipientColors.text,
|
||||
border: `1px solid ${recipientColors.border}40`,
|
||||
}}
|
||||
>
|
||||
{message.to}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : message.to && message.to !== message.from ? (
|
||||
<span className="text-[10px]">
|
||||
<span style={{ color: CARD_ICON_MUTED }}>→ </span>
|
||||
{onMemberNameClick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-0.5 py-0 font-medium transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMemberNameClick(message.to!);
|
||||
}}
|
||||
>
|
||||
{message.to}
|
||||
</button>
|
||||
) : (
|
||||
<span style={{ color: CARD_ICON_MUTED }}>{message.to}</span>
|
||||
)}
|
||||
{/* Rate limit warning badge */}
|
||||
{rateLimited ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/20 px-1.5 py-0.5 text-[10px] font-medium text-red-400">
|
||||
<AlertTriangle size={10} />
|
||||
Rate Limited
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{/* Recipient — arrow + avatar + badge */}
|
||||
{message.to && message.to !== message.from ? (
|
||||
<>
|
||||
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
||||
→
|
||||
</span>
|
||||
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Summary */}
|
||||
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{summaryText}
|
||||
|
|
@ -351,32 +296,40 @@ export const ActivityItem = ({
|
|||
{/* Timestamp + reply + create task */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{onReply && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
title="Reply to message"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReply(message);
|
||||
}}
|
||||
>
|
||||
<Reply size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReply(message);
|
||||
}}
|
||||
>
|
||||
<Reply size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Reply to message</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onCreateTask && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
title="Create task from message"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCreateTask();
|
||||
}}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCreateTask();
|
||||
}}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Create task from message</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{timestamp}
|
||||
|
|
@ -404,8 +357,20 @@ export const ActivityItem = ({
|
|||
) : parsedReply ? (
|
||||
<ReplyQuoteBlock reply={parsedReply} />
|
||||
) : (
|
||||
<MarkdownViewer content={displayText ?? message.text} maxHeight="max-h-56" copyable />
|
||||
<MarkdownViewer
|
||||
content={displayText ?? message.text}
|
||||
maxHeight="max-h-56"
|
||||
copyable
|
||||
bare
|
||||
/>
|
||||
)}
|
||||
{message.attachments?.length && message.messageId ? (
|
||||
<AttachmentDisplay
|
||||
teamName={teamName}
|
||||
messageId={message.messageId}
|
||||
attachments={message.attachments}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
|||
|
||||
interface ActivityTimelineProps {
|
||||
messages: InboxMessage[];
|
||||
teamName: string;
|
||||
members?: ResolvedTeamMember[];
|
||||
/**
|
||||
* When provided, unread is derived from this set and getMessageKey.
|
||||
|
|
@ -25,6 +26,7 @@ const VIEWPORT_THRESHOLD = 0.15;
|
|||
|
||||
const MessageRowWithObserver = ({
|
||||
message,
|
||||
teamName,
|
||||
memberRole,
|
||||
memberColor,
|
||||
recipientColor,
|
||||
|
|
@ -35,6 +37,7 @@ const MessageRowWithObserver = ({
|
|||
onVisible,
|
||||
}: {
|
||||
message: InboxMessage;
|
||||
teamName: string;
|
||||
memberRole?: string;
|
||||
memberColor?: string;
|
||||
recipientColor?: string;
|
||||
|
|
@ -78,6 +81,7 @@ const MessageRowWithObserver = ({
|
|||
<div ref={ref} className="min-h-px">
|
||||
<ActivityItem
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
memberRole={memberRole}
|
||||
memberColor={memberColor}
|
||||
recipientColor={recipientColor}
|
||||
|
|
@ -92,6 +96,7 @@ const MessageRowWithObserver = ({
|
|||
|
||||
export const ActivityTimeline = ({
|
||||
messages,
|
||||
teamName,
|
||||
members,
|
||||
readState,
|
||||
onCreateTaskFromMessage,
|
||||
|
|
@ -161,6 +166,7 @@ export const ActivityTimeline = ({
|
|||
<MessageRowWithObserver
|
||||
key={messageKey}
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
memberRole={info?.role}
|
||||
memberColor={info?.color}
|
||||
recipientColor={recipientColor}
|
||||
|
|
|
|||
|
|
@ -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 { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { AlertTriangle, Search } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
|
@ -73,6 +74,8 @@ export const CreateTaskDialog = ({
|
|||
const [related, setRelated] = useState<string[]>([]);
|
||||
const [startImmediately, setStartImmediately] = useState(true);
|
||||
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
||||
const [blockedBySearch, setBlockedBySearch] = useState('');
|
||||
const [relatedSearch, setRelatedSearch] = useState('');
|
||||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
|
||||
if (open && !prevOpen) {
|
||||
|
|
@ -85,6 +88,8 @@ export const CreateTaskDialog = ({
|
|||
setRelated([]);
|
||||
setStartImmediately(isTeamAlive);
|
||||
promptDraft.clearDraft();
|
||||
setBlockedBySearch('');
|
||||
setRelatedSearch('');
|
||||
}
|
||||
if (open !== prevOpen) {
|
||||
setPrevOpen(open);
|
||||
|
|
@ -150,6 +155,16 @@ export const CreateTaskDialog = ({
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!isTeamAlive ? (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" />
|
||||
<p className="text-xs leading-relaxed text-amber-300">
|
||||
Team is offline. The task will be added to <strong>TODO</strong> — launch the
|
||||
team to start execution.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="task-subject">Subject</Label>
|
||||
|
|
@ -265,39 +280,63 @@ export const CreateTaskDialog = ({
|
|||
{availableTasks.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Blocked by tasks (optional)</Label>
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
{availableTasks.map((t) => {
|
||||
const isSelected = blockedBy.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-500/15 text-blue-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleBlockedBy(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-blue-400 bg-blue-500/30 text-blue-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
#{t.id}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{availableTasks.length > 3 ? (
|
||||
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={blockedBySearch}
|
||||
onChange={(e) => setBlockedBySearch(e.target.value)}
|
||||
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||
{availableTasks
|
||||
.filter(
|
||||
(t) =>
|
||||
!blockedBySearch ||
|
||||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
|
||||
t.id.includes(blockedBySearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = blockedBy.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-blue-500/15 text-blue-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleBlockedBy(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-blue-400 bg-blue-500/30 text-blue-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
#{t.id}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{blockedBy.length > 0 ? (
|
||||
<p className="text-[11px] text-yellow-300">
|
||||
|
|
@ -310,39 +349,63 @@ export const CreateTaskDialog = ({
|
|||
{availableTasks.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>Related tasks (optional)</Label>
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
{availableTasks.map((t) => {
|
||||
const isSelected = related.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={`related:${t.id}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-purple-500/15 text-purple-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleRelated(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-purple-400 bg-purple-500/30 text-purple-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
#{t.id}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
{availableTasks.length > 3 ? (
|
||||
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
value={relatedSearch}
|
||||
onChange={(e) => setRelatedSearch(e.target.value)}
|
||||
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="max-h-[108px] overflow-y-auto p-1.5">
|
||||
{availableTasks
|
||||
.filter(
|
||||
(t) =>
|
||||
!relatedSearch ||
|
||||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
|
||||
t.id.includes(relatedSearch)
|
||||
)
|
||||
.map((t) => {
|
||||
const isSelected = related.includes(t.id);
|
||||
return (
|
||||
<button
|
||||
key={`related:${t.id}`}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-purple-500/15 text-purple-300'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||
}`}
|
||||
onClick={() => toggleRelated(t.id)}
|
||||
>
|
||||
<span
|
||||
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||
isSelected
|
||||
? 'border-purple-400 bg-purple-500/30 text-purple-300'
|
||||
: 'border-[var(--color-border-emphasis)]'
|
||||
}`}
|
||||
>
|
||||
{isSelected ? '\u2713' : ''}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||
>
|
||||
#{t.id}
|
||||
</Badge>
|
||||
<span className="truncate">{t.subject}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{related.length > 0 ? (
|
||||
<p className="text-[11px] text-purple-300">
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
'blue',
|
||||
|
|
@ -173,6 +173,7 @@ function validateRequest(
|
|||
options?: { requireCwd?: boolean }
|
||||
): ValidationResult {
|
||||
const requireCwd = options?.requireCwd ?? true;
|
||||
// eslint-disable-next-line security/detect-unsafe-regex -- kebab-case pattern is linear, no ReDoS
|
||||
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
|
||||
return {
|
||||
valid: false,
|
||||
|
|
@ -261,6 +262,7 @@ export const CreateTeamDialog = ({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [launchTeam, setLaunchTeam] = useState(true);
|
||||
const [teamColor, setTeamColor] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
|
||||
const resetUIState = (): void => {
|
||||
setLocalError(null);
|
||||
|
|
@ -281,6 +283,7 @@ export const CreateTeamDialog = ({
|
|||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setLaunchTeam(true);
|
||||
setSelectedModel('');
|
||||
resetUIState();
|
||||
};
|
||||
|
||||
|
|
@ -460,6 +463,9 @@ export const CreateTeamDialog = ({
|
|||
[members]
|
||||
);
|
||||
|
||||
const effectiveModel =
|
||||
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
||||
|
||||
const request = useMemo<TeamCreateRequest>(
|
||||
() => ({
|
||||
teamName: teamName.trim(),
|
||||
|
|
@ -468,8 +474,9 @@ export const CreateTeamDialog = ({
|
|||
members: buildMembers(members),
|
||||
cwd: effectiveCwd,
|
||||
prompt: prompt.trim() || undefined,
|
||||
model: effectiveModel,
|
||||
}),
|
||||
[teamName, description, teamColor, members, effectiveCwd, prompt]
|
||||
[teamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel]
|
||||
);
|
||||
|
||||
const activeError = localError ?? provisioningError;
|
||||
|
|
@ -571,7 +578,7 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
|
|
@ -582,17 +589,31 @@ export const CreateTeamDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'failed' ? (
|
||||
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
|
||||
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium text-red-300">
|
||||
CLI environment is not available — launch is blocked
|
||||
</p>
|
||||
<p className="text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
||||
in PATH, then reopen this dialog.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -796,6 +817,23 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{launchTeam ? (
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Default (account setting)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{launchTeam ? (
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
|
||||
|
|
|
|||
|
|
@ -14,10 +14,17 @@ import {
|
|||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type {
|
||||
|
|
@ -91,6 +98,7 @@ export const LaunchTeamDialog = ({
|
|||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
|
||||
const resetFormState = (): void => {
|
||||
setLocalError(null);
|
||||
|
|
@ -101,6 +109,7 @@ export const LaunchTeamDialog = ({
|
|||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setSelectedModel('');
|
||||
};
|
||||
|
||||
// Warm up CLI on open
|
||||
|
|
@ -231,6 +240,7 @@ export const LaunchTeamDialog = ({
|
|||
teamName,
|
||||
cwd: effectiveCwd,
|
||||
prompt: promptDraft.value.trim() || undefined,
|
||||
model: selectedModel && selectedModel !== '__default__' ? selectedModel : undefined,
|
||||
});
|
||||
resetFormState();
|
||||
onClose();
|
||||
|
|
@ -252,7 +262,7 @@ export const LaunchTeamDialog = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Launch Team</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
|
|
@ -262,17 +272,31 @@ export const LaunchTeamDialog = ({
|
|||
</DialogHeader>
|
||||
|
||||
{prepareState === 'failed' ? (
|
||||
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
|
||||
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="mt-1 space-y-1">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium text-red-300">
|
||||
CLI environment is not available — launch is blocked
|
||||
</p>
|
||||
<p className="text-red-300/80">
|
||||
{prepareMessage ?? 'Failed to prepare environment'}
|
||||
</p>
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-amber-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
||||
in PATH, then reopen this dialog.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
@ -398,6 +422,21 @@ export const LaunchTeamDialog = ({
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Default (account setting)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeError ? (
|
||||
|
|
|
|||
|
|
@ -118,19 +118,24 @@ export const TaskCommentsSection = ({
|
|||
: formatDistanceToNow(date, { addSuffix: true });
|
||||
})()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||
onClick={() =>
|
||||
setReplyTo({
|
||||
author: comment.author,
|
||||
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex items-center gap-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-secondary)] group-hover:opacity-100"
|
||||
onClick={() =>
|
||||
setReplyTo({
|
||||
author: comment.author,
|
||||
text: parseMessageReply(comment.text)?.replyText ?? comment.text,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Reply size={11} />
|
||||
Reply
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Reply to comment</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -13,16 +14,23 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import {
|
||||
agentAvatarUrl,
|
||||
KANBAN_COLUMN_DISPLAY,
|
||||
TASK_STATUS_LABELS,
|
||||
TASK_STATUS_STYLES,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine, User } from 'lucide-react';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine } from 'lucide-react';
|
||||
|
||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||
|
||||
|
|
@ -37,6 +45,7 @@ interface TaskDetailDialogProps {
|
|||
members: ResolvedTeamMember[];
|
||||
onClose: () => void;
|
||||
onScrollToTask?: (taskId: string) => void;
|
||||
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||
}
|
||||
|
||||
export const TaskDetailDialog = ({
|
||||
|
|
@ -48,6 +57,7 @@ export const TaskDetailDialog = ({
|
|||
members,
|
||||
onClose,
|
||||
onScrollToTask,
|
||||
onOwnerChange,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||
|
||||
|
|
@ -101,10 +111,12 @@ export const TaskDetailDialog = ({
|
|||
)
|
||||
.map((t) => t.id);
|
||||
const ownerMember = currentTask.owner ? members.find((m) => m.name === currentTask.owner) : null;
|
||||
const isTodo = status === 'pending' && !kanbanColumn;
|
||||
const canReassign = isTodo && onOwnerChange;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-h-[85vh] min-w-0 overflow-y-auto overflow-x-hidden sm:max-w-4xl">
|
||||
<DialogContent className="min-w-0 sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
|
|
@ -125,31 +137,46 @@ export const TaskDetailDialog = ({
|
|||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{ownerMember ? (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1"
|
||||
style={{
|
||||
borderLeft: `3px solid ${getTeamColorSet(ownerMember.color ?? '').border}`,
|
||||
backgroundColor: getTeamColorSet(ownerMember.color ?? '').badge,
|
||||
{canReassign ? (
|
||||
<Select
|
||||
value={currentTask.owner ?? '__unassigned__'}
|
||||
onValueChange={(v) => {
|
||||
onOwnerChange(currentTask.id, v === '__unassigned__' ? null : v);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={agentAvatarUrl(ownerMember.name, 32)}
|
||||
alt={ownerMember.name}
|
||||
className="size-6 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 truncate font-medium text-[var(--color-text)]">
|
||||
{ownerMember.name}
|
||||
</span>
|
||||
</div>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[140px] text-xs">
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unassigned__">Unassigned</SelectItem>
|
||||
{members.map((m) => {
|
||||
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
|
||||
const memberColor = m.color ? getTeamColorSet(m.color) : null;
|
||||
return (
|
||||
<SelectItem key={m.name} value={m.name}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{memberColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: memberColor.border }}
|
||||
/>
|
||||
) : null}
|
||||
<span style={memberColor ? { color: memberColor.text } : undefined}>
|
||||
{m.name}
|
||||
</span>
|
||||
{role ? (
|
||||
<span className="text-[var(--color-text-muted)]">({role})</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : currentTask.owner ? (
|
||||
<MemberBadge name={currentTask.owner} color={ownerMember?.color} size="md" />
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-[var(--color-text-muted)]">
|
||||
<User size={12} />
|
||||
<span className="text-[var(--color-text-secondary)]">
|
||||
{currentTask.owner ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">—</span>
|
||||
)}
|
||||
</div>
|
||||
{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 { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
|
@ -18,6 +22,7 @@ import { KanbanFilterPopover } from './KanbanFilterPopover';
|
|||
import { KanbanTaskCard } from './KanbanTaskCard';
|
||||
|
||||
import type { KanbanFilterState } from './KanbanFilterPopover';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
|
|
@ -69,6 +74,10 @@ interface KanbanBoardProps {
|
|||
onCompleteTask: (taskId: string) => void;
|
||||
onScrollToTask?: (taskId: string) => void;
|
||||
onTaskClick?: (task: TeamTask) => void;
|
||||
/** Вызывается после изменения порядка задач в колонке (drag-and-drop). */
|
||||
onColumnOrderChange?: (columnId: KanbanColumnId, orderedTaskIds: string[]) => void;
|
||||
/** Слот слева в одной строке с фильтром и переключателем вида (например, поле поиска). */
|
||||
toolbarLeft?: React.ReactNode;
|
||||
}
|
||||
|
||||
type KanbanViewMode = 'grid' | 'columns';
|
||||
|
|
@ -99,6 +108,97 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Сортирует задачи колонки по сохранённому порядку; задачи без порядка — в конце. */
|
||||
function sortColumnTasksByOrder(columnTasks: TeamTask[], order?: string[]): TeamTask[] {
|
||||
if (!order?.length) {
|
||||
return columnTasks;
|
||||
}
|
||||
const byId = new Map(columnTasks.map((t) => [t.id, t]));
|
||||
const ordered: TeamTask[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of order) {
|
||||
const task = byId.get(id);
|
||||
if (task) {
|
||||
ordered.push(task);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
for (const task of columnTasks) {
|
||||
if (!seen.has(task.id)) {
|
||||
ordered.push(task);
|
||||
}
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
interface SortableKanbanTaskCardProps {
|
||||
task: TeamTask;
|
||||
columnId: KanbanColumnId;
|
||||
teamName: string;
|
||||
kanbanState: KanbanState;
|
||||
taskMap: Map<string, TeamTask>;
|
||||
members: ResolvedTeamMember[];
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onApprove: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
onMoveBackToDone: (taskId: string) => void;
|
||||
onStartTask: (taskId: string) => void;
|
||||
onCompleteTask: (taskId: string) => void;
|
||||
onScrollToTask?: (taskId: string) => void;
|
||||
onTaskClick?: (task: TeamTask) => void;
|
||||
}
|
||||
|
||||
const SortableKanbanTaskCard = ({
|
||||
task,
|
||||
columnId,
|
||||
teamName,
|
||||
kanbanState,
|
||||
taskMap,
|
||||
members,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
onMoveBackToDone,
|
||||
onStartTask,
|
||||
onCompleteTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
}: SortableKanbanTaskCardProps): React.JSX.Element => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
data: { type: 'kanban-task', columnId, taskId: task.id },
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading -- dnd-kit useSortable requires spreading attributes/listeners
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<KanbanTaskCard
|
||||
task={task}
|
||||
teamName={teamName}
|
||||
columnId={columnId}
|
||||
kanbanTaskState={kanbanState.tasks[task.id]}
|
||||
hasReviewers={kanbanState.reviewers.length > 0}
|
||||
taskMap={taskMap}
|
||||
members={members}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
onMoveBackToDone={onMoveBackToDone}
|
||||
onStartTask={onStartTask}
|
||||
onCompleteTask={onCompleteTask}
|
||||
onScrollToTask={onScrollToTask}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const KanbanBoard = ({
|
||||
tasks,
|
||||
teamName,
|
||||
|
|
@ -116,6 +216,8 @@ export const KanbanBoard = ({
|
|||
onCompleteTask,
|
||||
onScrollToTask,
|
||||
onTaskClick,
|
||||
onColumnOrderChange,
|
||||
toolbarLeft,
|
||||
}: KanbanBoardProps): React.JSX.Element => {
|
||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||
|
||||
|
|
@ -134,6 +236,45 @@ export const KanbanBoard = ({
|
|||
return result;
|
||||
}, [tasks, kanbanState]);
|
||||
|
||||
const groupedOrdered = useMemo(() => {
|
||||
const result = new Map<KanbanColumnId, TeamTask[]>();
|
||||
for (const column of COLUMNS) {
|
||||
const columnTasks = grouped.get(column.id) ?? [];
|
||||
const order = kanbanState.columnOrder?.[column.id];
|
||||
result.set(column.id, sortColumnTasksByOrder(columnTasks, order));
|
||||
}
|
||||
return result;
|
||||
}, [grouped, kanbanState.columnOrder]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!onColumnOrderChange || !over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const activeData = active.data.current;
|
||||
if (activeData?.type !== 'kanban-task') {
|
||||
return;
|
||||
}
|
||||
const columnId = activeData.columnId as KanbanColumnId;
|
||||
const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? [];
|
||||
const oldIndex = orderedIds.indexOf(active.id as string);
|
||||
const newIndex = orderedIds.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
const newOrder = arrayMove(orderedIds, oldIndex, newIndex);
|
||||
onColumnOrderChange(columnId, newOrder);
|
||||
},
|
||||
[onColumnOrderChange, groupedOrdered]
|
||||
);
|
||||
|
||||
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
|
||||
if (columnTasks.length === 0) {
|
||||
return (
|
||||
|
|
@ -142,6 +283,32 @@ export const KanbanBoard = ({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
if (onColumnOrderChange) {
|
||||
const itemIds = columnTasks.map((t) => t.id);
|
||||
return (
|
||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||
{columnTasks.map((task) => (
|
||||
<SortableKanbanTaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
columnId={columnId}
|
||||
teamName={teamName}
|
||||
kanbanState={kanbanState}
|
||||
taskMap={taskMap}
|
||||
members={members}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
onMoveBackToDone={onMoveBackToDone}
|
||||
onStartTask={onStartTask}
|
||||
onCompleteTask={onCompleteTask}
|
||||
onScrollToTask={onScrollToTask}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{columnTasks.map((task) => (
|
||||
|
|
@ -153,6 +320,7 @@ export const KanbanBoard = ({
|
|||
kanbanTaskState={kanbanState.tasks[task.id]}
|
||||
hasReviewers={kanbanState.reviewers.length > 0}
|
||||
taskMap={taskMap}
|
||||
members={members}
|
||||
onRequestReview={onRequestReview}
|
||||
onApprove={onApprove}
|
||||
onRequestChanges={onRequestChanges}
|
||||
|
|
@ -167,62 +335,65 @@ export const KanbanBoard = ({
|
|||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-end gap-2">
|
||||
<KanbanFilterPopover
|
||||
filter={filter}
|
||||
sessions={sessions}
|
||||
leadSessionId={leadSessionId}
|
||||
members={members}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-r-none px-2',
|
||||
viewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Grid view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
||||
viewMode === 'columns'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('columns')}
|
||||
aria-label="Columns view"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Columns view</TooltipContent>
|
||||
</Tooltip>
|
||||
const boardContent = (
|
||||
<>
|
||||
<div className={cn('mb-2 flex items-center gap-2', toolbarLeft == null && 'justify-end')}>
|
||||
{toolbarLeft != null && <div className="min-w-0 flex-1">{toolbarLeft}</div>}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<KanbanFilterPopover
|
||||
filter={filter}
|
||||
sessions={sessions}
|
||||
leadSessionId={leadSessionId}
|
||||
members={members}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-r-none px-2',
|
||||
viewMode === 'grid'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
>
|
||||
<LayoutGrid size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Grid view</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
|
||||
viewMode === 'columns'
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-muted)]'
|
||||
)}
|
||||
onClick={() => setViewMode('columns')}
|
||||
aria-label="Columns view"
|
||||
>
|
||||
<Columns3 size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Columns view</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnTasks = grouped.get(column.id) ?? [];
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
return (
|
||||
<KanbanColumn
|
||||
|
|
@ -241,7 +412,7 @@ export const KanbanBoard = ({
|
|||
) : (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{COLUMNS.map((column) => {
|
||||
const columnTasks = grouped.get(column.id) ?? [];
|
||||
const columnTasks = groupedOrdered.get(column.id) ?? [];
|
||||
const accent = COLUMN_ACCENTS[column.id];
|
||||
return (
|
||||
<div key={column.id} className="w-64 shrink-0">
|
||||
|
|
@ -259,6 +430,16 @@ export const KanbanBoard = ({
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (onColumnOrderChange) {
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
{boardContent}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
return boardContent;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { UnreadCommentsBadge } from '@renderer/components/team/UnreadCommentsBadge';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { ArrowLeftFromLine, ArrowRightFromLine, CheckCircle2, Play } from 'lucide-react';
|
||||
|
||||
import type { KanbanColumnId, KanbanTaskState, TeamTask } from '@shared/types';
|
||||
import type { KanbanColumnId, KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||
|
||||
interface KanbanTaskCardProps {
|
||||
task: TeamTask;
|
||||
|
|
@ -13,6 +14,7 @@ interface KanbanTaskCardProps {
|
|||
kanbanTaskState?: KanbanTaskState;
|
||||
hasReviewers: boolean;
|
||||
taskMap: Map<string, TeamTask>;
|
||||
members: ResolvedTeamMember[];
|
||||
onRequestReview: (taskId: string) => void;
|
||||
onApprove: (taskId: string) => void;
|
||||
onRequestChanges: (taskId: string) => void;
|
||||
|
|
@ -63,6 +65,7 @@ export const KanbanTaskCard = ({
|
|||
kanbanTaskState: _kanbanTaskState,
|
||||
hasReviewers,
|
||||
taskMap,
|
||||
members,
|
||||
onRequestReview,
|
||||
onApprove,
|
||||
onRequestChanges,
|
||||
|
|
@ -96,23 +99,21 @@ export const KanbanTaskCard = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
</Badge>
|
||||
<UnreadCommentsBadge
|
||||
unreadCount={unreadCount}
|
||||
totalCount={task.comments?.length ?? 0}
|
||||
/>
|
||||
</div>
|
||||
<h5 className="text-sm font-medium text-[var(--color-text)]">{task.subject}</h5>
|
||||
</div>
|
||||
<div className="mb-2 flex items-center gap-1">
|
||||
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px] font-normal">
|
||||
#{task.id}
|
||||
</Badge>
|
||||
{task.owner ? (
|
||||
<MemberBadge
|
||||
name={task.owner}
|
||||
color={members.find((m) => m.name === task.owner)?.color}
|
||||
/>
|
||||
) : null}
|
||||
<h5 className="min-w-0 truncate text-sm font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 text-xs text-[var(--color-text-muted)]">Owner: {task.owner ?? '\u2014'}</p>
|
||||
|
||||
{hasBlockedBy ? (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1">
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-yellow-300">
|
||||
|
|
@ -147,112 +148,118 @@ export const KanbanTaskCard = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{columnId === 'todo' ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
aria-label={`Start task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartTask(task.id);
|
||||
}}
|
||||
>
|
||||
<Play size={12} />
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
aria-label={`Complete task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
Complete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{columnId === 'in_progress' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
aria-label={`Complete task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
Complete
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{columnId === 'done' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Request review for task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestReview(task.id);
|
||||
}}
|
||||
>
|
||||
Request Review
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{columnId === 'review' ? (
|
||||
<div className="space-y-2">
|
||||
{!hasReviewers ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
{columnId === 'todo' ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
|
||||
aria-label={`Start task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStartTask(task.id);
|
||||
}}
|
||||
>
|
||||
<Play size={12} />
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
aria-label={`Complete task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={12} />
|
||||
Complete
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
|
||||
{columnId === 'in_progress' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Approve task ${task.id}`}
|
||||
className="gap-1"
|
||||
aria-label={`Complete task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprove(task.id);
|
||||
onCompleteTask(task.id);
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
<CheckCircle2 size={12} />
|
||||
Complete
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
aria-label={`Request changes for task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestChanges(task.id);
|
||||
}}
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
|
||||
{columnId === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Move task ${task.id} back to done`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveBackToDone(task.id);
|
||||
}}
|
||||
>
|
||||
Move back to DONE
|
||||
</Button>
|
||||
) : null}
|
||||
{columnId === 'done' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Request review for task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestReview(task.id);
|
||||
}}
|
||||
>
|
||||
Request Review
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{columnId === 'review' ? (
|
||||
<div className="space-y-2">
|
||||
{!hasReviewers ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">Manual review</p>
|
||||
) : null}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Approve task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApprove(task.id);
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
aria-label={`Request changes for task ${task.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRequestChanges(task.id);
|
||||
}}
|
||||
>
|
||||
Request Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{columnId === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`Move task ${task.id} back to done`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveBackToDone(task.id);
|
||||
}}
|
||||
>
|
||||
Move back to DONE
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { ListPlus, Loader2, MessageSquare } from 'lucide-react';
|
||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
|
|
@ -15,6 +16,7 @@ interface MemberCardProps {
|
|||
isTeamProvisioning?: boolean;
|
||||
currentTask?: TeamTaskWithKanban | null;
|
||||
isAwaitingReply?: boolean;
|
||||
isRemoved?: boolean;
|
||||
onOpenTask?: () => void;
|
||||
onClick?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
|
|
@ -29,6 +31,7 @@ export const MemberCard = ({
|
|||
isTeamProvisioning,
|
||||
currentTask,
|
||||
isAwaitingReply,
|
||||
isRemoved,
|
||||
onOpenTask,
|
||||
onClick,
|
||||
onSendMessage,
|
||||
|
|
@ -41,14 +44,12 @@ export const MemberCard = ({
|
|||
const inProgress = taskCounts?.inProgress ?? 0;
|
||||
const completed = taskCounts?.completed ?? 0;
|
||||
const totalTasks = pending + inProgress + completed;
|
||||
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
|
||||
|
||||
const progressPercent = Math.round(completedRatio * 100);
|
||||
const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded">
|
||||
<div className={isRemoved ? 'rounded opacity-50' : 'rounded'}>
|
||||
<div
|
||||
className="group relative cursor-pointer rounded-t px-2 py-1.5"
|
||||
className="group relative cursor-pointer rounded px-2 py-1.5"
|
||||
style={{
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
backgroundColor: colors.badge,
|
||||
|
|
@ -64,7 +65,7 @@ export const MemberCard = ({
|
|||
}
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 rounded-t transition-colors group-hover:bg-white/5" />
|
||||
<div className="pointer-events-none absolute inset-0 rounded transition-colors group-hover:bg-white/5" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
|
|
@ -80,6 +81,12 @@ export const MemberCard = ({
|
|||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
||||
<span className="shrink-0 font-medium text-[var(--color-text)]">{member.name}</span>
|
||||
{member.gitBranch ? (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
<GitBranch size={10} />
|
||||
{member.gitBranch}
|
||||
</span>
|
||||
) : null}
|
||||
{currentTask ? (
|
||||
<>
|
||||
<Loader2
|
||||
|
|
@ -133,55 +140,72 @@ export const MemberCard = ({
|
|||
})()}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
|
||||
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
|
||||
title={
|
||||
isRemoved
|
||||
? 'This member has been removed'
|
||||
: member.currentTaskId
|
||||
? `Current task: ${member.currentTaskId}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{presenceLabel}
|
||||
{isRemoved ? 'removed' : presenceLabel}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||
<div
|
||||
className="shrink-0"
|
||||
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
||||
>
|
||||
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
||||
</Badge>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
title="Send message"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSendMessage?.();
|
||||
}}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none"
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
title="Assign task"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAssignTask?.();
|
||||
}}
|
||||
>
|
||||
<ListPlus size={13} />
|
||||
</button>
|
||||
{member.taskCount} {member.taskCount === 1 ? 'task' : 'tasks'}
|
||||
</Badge>
|
||||
{totalTasks > 0 && (
|
||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isRemoved && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSendMessage?.();
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Send message</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAssignTask?.();
|
||||
}}
|
||||
>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Assign task</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="h-px rounded-b bg-[var(--color-border)]"
|
||||
role="progressbar"
|
||||
aria-valuenow={completed}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={totalTasks}
|
||||
aria-label={`Tasks ${completed}/${totalTasks} completed`}
|
||||
title={`${completed}/${totalTasks} tasks`}
|
||||
style={{
|
||||
background: `linear-gradient(to right, #10b981 ${progressPercent}%, var(--color-border) ${progressPercent}%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
|
||||
import { BarChart3, FileText, ListPlus, MessageSquare } from 'lucide-react';
|
||||
import { BarChart3, FileText, ListPlus, MessageSquare, UserMinus } from 'lucide-react';
|
||||
|
||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||
import { MemberDetailStats } from './MemberDetailStats';
|
||||
import { MemberDetailStats, type MemberDetailTab } from './MemberDetailStats';
|
||||
import { MemberLogsTab } from './MemberLogsTab';
|
||||
import { MemberMessagesTab } from './MemberMessagesTab';
|
||||
import { MemberStatsTab } from './MemberStatsTab';
|
||||
|
|
@ -26,6 +26,7 @@ interface MemberDetailDialogProps {
|
|||
onSendMessage: () => void;
|
||||
onAssignTask: () => void;
|
||||
onTaskClick: (task: TeamTaskWithKanban) => void;
|
||||
onRemoveMember?: () => void;
|
||||
}
|
||||
|
||||
export const MemberDetailDialog = ({
|
||||
|
|
@ -40,6 +41,7 @@ export const MemberDetailDialog = ({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
onTaskClick,
|
||||
onRemoveMember,
|
||||
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
||||
const memberTasks = useMemo(
|
||||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||
|
|
@ -61,28 +63,37 @@ export const MemberDetailDialog = ({
|
|||
[memberTasks]
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<MemberDetailTab>('tasks');
|
||||
|
||||
if (!member) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<DialogContent className="min-w-0 overflow-hidden sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<MemberDetailHeader
|
||||
member={member}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
<DialogContent className="min-w-0 sm:max-w-4xl">
|
||||
<div className="flex items-start gap-4">
|
||||
<DialogHeader className="shrink-0">
|
||||
<MemberDetailHeader
|
||||
member={member}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
/>
|
||||
</DialogHeader>
|
||||
|
||||
<MemberDetailStats
|
||||
totalTasks={memberTasks.length}
|
||||
inProgressTasks={inProgressTasks}
|
||||
completedTasks={completedTasks}
|
||||
messageCount={memberMessages.length}
|
||||
lastActiveAt={member.lastActiveAt}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<MemberDetailStats
|
||||
totalTasks={memberTasks.length}
|
||||
inProgressTasks={inProgressTasks}
|
||||
completedTasks={completedTasks}
|
||||
messageCount={memberMessages.length}
|
||||
lastActiveAt={member.lastActiveAt}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="tasks">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as MemberDetailTab)}
|
||||
className="min-w-0 overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="tasks" className="flex-1 gap-1.5">
|
||||
Tasks
|
||||
|
|
@ -113,7 +124,7 @@ export const MemberDetailDialog = ({
|
|||
<MemberTasksTab tasks={memberTasks} onTaskClick={onTaskClick} />
|
||||
</TabsContent>
|
||||
<TabsContent value="messages">
|
||||
<MemberMessagesTab messages={memberMessages} />
|
||||
<MemberMessagesTab messages={memberMessages} teamName={teamName} />
|
||||
</TabsContent>
|
||||
<TabsContent value="stats">
|
||||
<MemberStatsTab teamName={teamName} memberName={member.name} />
|
||||
|
|
@ -124,14 +135,33 @@ export const MemberDetailDialog = ({
|
|||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
||||
<MessageSquare size={14} />
|
||||
Send Message
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}>
|
||||
<ListPlus size={14} />
|
||||
Assign Task
|
||||
</Button>
|
||||
{member.removedAt ? (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
Removed {new Date(member.removedAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
||||
<MessageSquare size={14} />
|
||||
Send Message
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}>
|
||||
<ListPlus size={14} />
|
||||
Assign Task
|
||||
</Button>
|
||||
{onRemoveMember && member.agentType !== 'team-lead' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-red-400 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={onRemoveMember}
|
||||
>
|
||||
<UserMinus size={14} />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,49 @@
|
|||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export type MemberDetailTab = 'tasks' | 'messages' | 'stats' | 'logs';
|
||||
|
||||
interface MemberDetailStatsProps {
|
||||
totalTasks: number;
|
||||
inProgressTasks: number;
|
||||
completedTasks: number;
|
||||
messageCount: number;
|
||||
lastActiveAt: string | null;
|
||||
onTabChange?: (tab: MemberDetailTab) => void;
|
||||
}
|
||||
|
||||
const baseClasses =
|
||||
'rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1.5';
|
||||
const clickableClasses =
|
||||
'cursor-pointer transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-overlay)]';
|
||||
|
||||
const StatBlock = ({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
}): React.JSX.Element => (
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
|
||||
<p className="text-lg font-semibold text-[var(--color-text)]">{value}</p>
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p>
|
||||
{sub && <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
onClick?: () => void;
|
||||
}): React.JSX.Element => {
|
||||
const classes = onClick ? `${baseClasses} ${clickableClasses}` : baseClasses;
|
||||
const content = (
|
||||
<>
|
||||
<p className="text-base font-semibold leading-tight text-[var(--color-text)]">{value}</p>
|
||||
<p className="text-[10px] text-[var(--color-text-muted)]">{label}</p>
|
||||
{sub && <p className="mt-0.5 text-[9px] text-[var(--color-text-muted)]">{sub}</p>}
|
||||
</>
|
||||
);
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" className={classes} onClick={onClick}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return <div className={classes}>{content}</div>;
|
||||
};
|
||||
|
||||
export const MemberDetailStats = ({
|
||||
totalTasks,
|
||||
|
|
@ -30,21 +51,35 @@ export const MemberDetailStats = ({
|
|||
completedTasks,
|
||||
messageCount,
|
||||
lastActiveAt,
|
||||
onTabChange,
|
||||
}: MemberDetailStatsProps): React.JSX.Element => {
|
||||
const lastActive = lastActiveAt
|
||||
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div className="grid min-w-0 flex-1 grid-cols-4 gap-1.5">
|
||||
<StatBlock
|
||||
label="Tasks"
|
||||
value={totalTasks}
|
||||
sub={inProgressTasks > 0 ? `in progress: ${inProgressTasks}` : undefined}
|
||||
onClick={onTabChange ? () => onTabChange('tasks') : undefined}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Completed"
|
||||
value={completedTasks}
|
||||
onClick={onTabChange ? () => onTabChange('tasks') : undefined}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Messages"
|
||||
value={messageCount}
|
||||
onClick={onTabChange ? () => onTabChange('messages') : undefined}
|
||||
/>
|
||||
<StatBlock
|
||||
label="Activity"
|
||||
value={lastActive}
|
||||
onClick={onTabChange ? () => onTabChange('logs') : undefined}
|
||||
/>
|
||||
<StatBlock label="Completed" value={completedTasks} />
|
||||
<StatBlock label="Messages" value={messageCount} />
|
||||
<StatBlock label="Activity" value={lastActive} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
|
|||
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
|
||||
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
||||
import { createAgentBlockRegex } from '@shared/constants/agentBlocks';
|
||||
import { format } from 'date-fns';
|
||||
import { Bot, ChevronDown } from 'lucide-react';
|
||||
import { Bot, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { EnhancedChunk } from '@renderer/types/data';
|
||||
import type { AIGroup, UserGroup } from '@renderer/types/groups';
|
||||
|
|
@ -88,31 +90,66 @@ export const MemberExecutionLog = ({
|
|||
);
|
||||
};
|
||||
|
||||
/** Extract agent-only instruction blocks and human-visible text from a message. */
|
||||
function splitAgentBlocks(raw: string): { humanText: string; agentInfo: string[] } {
|
||||
const agentInfo: string[] = [];
|
||||
const regex = createAgentBlockRegex();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = regex.exec(raw)) !== null) {
|
||||
const content = m[0]
|
||||
.replace(/^```info_for_agent\n?/, '')
|
||||
.replace(/\n?```$/, '')
|
||||
.trim();
|
||||
if (content) agentInfo.push(content);
|
||||
}
|
||||
const humanText = raw.replace(createAgentBlockRegex(), '').trim();
|
||||
return { humanText, agentInfo };
|
||||
}
|
||||
|
||||
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
|
||||
const text = group.content.rawText ?? group.content.text ?? '';
|
||||
if (!text.trim()) {
|
||||
const { humanText, agentInfo } = useMemo(() => splitAgentBlocks(text), [text]);
|
||||
const [agentInfoOpen, setAgentInfoOpen] = useState(false);
|
||||
|
||||
if (!humanText && agentInfo.length === 0) {
|
||||
return (
|
||||
<div className="flex min-w-0 justify-end">
|
||||
<div className="min-w-0 max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--color-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
||||
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(group.timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">(empty)</div>
|
||||
</div>
|
||||
<div className="py-1 text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(group.timestamp, 'h:mm:ss a')} — (empty)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 justify-end">
|
||||
<div className="min-w-0 max-w-full rounded-2xl rounded-br-sm border border-[var(--chat-user-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
||||
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(group.timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
<div className="mt-2 min-w-0 break-words text-sm text-[var(--chat-user-text)]">
|
||||
<MarkdownViewer content={text} copyable />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1 overflow-hidden py-1">
|
||||
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{format(group.timestamp, 'h:mm:ss a')}
|
||||
</div>
|
||||
{humanText && (
|
||||
<div className="min-w-0 overflow-x-auto break-words text-xs text-[var(--chat-user-text)]">
|
||||
<MarkdownViewer content={humanText} copyable />
|
||||
</div>
|
||||
)}
|
||||
{agentInfo.length > 0 && (
|
||||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-left text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setAgentInfoOpen((v) => !v)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={10}
|
||||
className={`shrink-0 transition-transform ${agentInfoOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Bot size={10} className="shrink-0" />
|
||||
Agent instructions
|
||||
</button>
|
||||
{agentInfoOpen && (
|
||||
<pre className="overflow-x-auto border-t border-[var(--color-border)] px-2 py-1.5 text-[10px] leading-relaxed text-[var(--color-text-muted)]">
|
||||
{agentInfo.join('\n\n')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -149,23 +186,28 @@ const AIExecutionGroup = ({
|
|||
return (
|
||||
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
|
||||
{hasToggleContent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={onToggleExpanded}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
||||
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
Claude
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
|
||||
{enhanced.itemsSummary}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={onToggleExpanded}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
||||
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
Claude
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
|
||||
{enhanced.itemsSummary}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{expanded ? 'Collapse' : 'Expand'}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{hasToggleContent && expanded ? (
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ export const MemberList = ({
|
|||
onAssignTask,
|
||||
onOpenTask,
|
||||
}: MemberListProps): React.JSX.Element => {
|
||||
const activeMembers = members.filter((m) => !m.removedAt);
|
||||
const removedMembers = members.filter((m) => m.removedAt);
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
|
|
@ -38,29 +41,46 @@ export const MemberList = ({
|
|||
);
|
||||
}
|
||||
|
||||
const renderCard = (
|
||||
member: ResolvedTeamMember,
|
||||
index: number,
|
||||
isRemoved: boolean
|
||||
): React.JSX.Element => {
|
||||
const currentTask =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||
return (
|
||||
<MemberCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
memberColor={member.color ?? getMemberColor(index)}
|
||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
currentTask={isRemoved ? null : currentTask}
|
||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||
isRemoved={isRemoved}
|
||||
onOpenTask={currentTask && !isRemoved ? () => onOpenTask?.(currentTask) : undefined}
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
onAssignTask={() => onAssignTask?.(member)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{members.map((member, index) => {
|
||||
const currentTask =
|
||||
member.currentTaskId && taskMap ? (taskMap.get(member.currentTaskId) ?? null) : null;
|
||||
const awaitingReply = Boolean(pendingRepliesByMember?.[member.name]);
|
||||
return (
|
||||
<MemberCard
|
||||
key={member.name}
|
||||
member={member}
|
||||
memberColor={member.color ?? getMemberColor(index)}
|
||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
currentTask={currentTask}
|
||||
isAwaitingReply={awaitingReply}
|
||||
onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
onAssignTask={() => onAssignTask?.(member)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{activeMembers.map((member, index) => renderCard(member, index, false))}
|
||||
{removedMembers.length > 0 && (
|
||||
<>
|
||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
Removed ({removedMembers.length})
|
||||
</div>
|
||||
{removedMembers.map((member, index) =>
|
||||
renderCard(member, activeMembers.length + index, true)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { formatDuration } from '@renderer/utils/formatters';
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -207,35 +208,40 @@ const LogCard = ({
|
|||
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<button
|
||||
className="flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="truncate text-[var(--color-text)]" title={log.description}>
|
||||
{log.description}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{timeAgo}
|
||||
</span>
|
||||
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={10} />
|
||||
{log.messageCount}
|
||||
</span>
|
||||
{log.isOngoing && (
|
||||
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="flex w-full min-w-0 items-center gap-2 overflow-hidden px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
) : (
|
||||
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="truncate text-[var(--color-text)]" title={log.description}>
|
||||
{log.description}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{timeAgo}
|
||||
</span>
|
||||
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={10} />
|
||||
{log.messageCount}
|
||||
</span>
|
||||
{log.isOngoing && (
|
||||
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{expanded ? 'Hide details' : 'Show details'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-[var(--color-border)] px-3 py-2">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { InboxMessage } from '@shared/types';
|
|||
|
||||
interface MemberMessagesTabProps {
|
||||
messages: InboxMessage[];
|
||||
teamName: string;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ const MAX_MESSAGES = 100;
|
|||
|
||||
export const MemberMessagesTab = ({
|
||||
messages,
|
||||
teamName,
|
||||
onCreateTask,
|
||||
}: MemberMessagesTabProps): React.JSX.Element => {
|
||||
const displayMessages = messages.slice(0, MAX_MESSAGES);
|
||||
|
|
@ -26,7 +28,12 @@ export const MemberMessagesTab = ({
|
|||
return (
|
||||
<div className="max-h-[320px] space-y-2 overflow-y-auto">
|
||||
{displayMessages.map((msg, idx) => (
|
||||
<ActivityItem key={msg.messageId ?? idx} message={msg} onCreateTask={onCreateTask} />
|
||||
<ActivityItem
|
||||
key={msg.messageId ?? idx}
|
||||
message={msg}
|
||||
teamName={teamName}
|
||||
onCreateTask={onCreateTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
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 { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { Filter } from 'lucide-react';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
|
@ -93,22 +94,26 @@ export const MessagesFilterPopover = ({
|
|||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
aria-label="Filter messages"
|
||||
title="Filter"
|
||||
>
|
||||
<Filter size={14} />
|
||||
{activeCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
aria-label="Filter messages"
|
||||
>
|
||||
<Filter size={14} />
|
||||
{activeCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Filter messages</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent align="end" className="w-72 p-0">
|
||||
<div className="border-b border-[var(--color-border)] p-3">
|
||||
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ const DialogContent = React.forwardRef<
|
|||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 max-h-[min(80vh,24rem)] min-h-0 w-72 overflow-y-auto overflow-x-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-[var(--color-text)] shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
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
|
||||
void state.markNotificationRead(error.id);
|
||||
|
||||
// Team rate-limit notifications: open the team tab instead of a session tab
|
||||
if (error.source === 'rate-limit' && error.sessionId.startsWith('team:')) {
|
||||
const teamName = error.sessionId.slice('team:'.length);
|
||||
state.openTeamTab(teamName, error.context.cwd);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the navigation request with a fresh nonce
|
||||
const navRequest = createErrorNavigationRequest(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
syncFocusedPaneState,
|
||||
updatePane,
|
||||
} from '../utils/paneHelpers';
|
||||
import { getFullResetState } from '../utils/stateResetHelpers';
|
||||
import { getFullResetState, getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
||||
import type { AppState, SearchNavigationContext } from '../types';
|
||||
import type { PaneLayout } from '@renderer/types/panes';
|
||||
|
|
@ -384,8 +384,8 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
|||
(wt) => normalizePath(wt.path) === normalizedTeamPath
|
||||
);
|
||||
if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) {
|
||||
state.selectRepository(repo.id);
|
||||
state.selectWorktree(matchingWorktree.id);
|
||||
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
|
||||
void get().fetchSessionsInitial(matchingWorktree.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { getWorktreeNavigationState } from '../utils/stateResetHelpers';
|
||||
|
||||
const logger = createLogger('teamSlice');
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
|
|
@ -71,12 +75,20 @@ export interface TeamSlice {
|
|||
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
|
||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
updateKanbanColumnOrder: (
|
||||
teamName: string,
|
||||
columnId: KanbanColumnId,
|
||||
orderedTaskIds: string[]
|
||||
) => Promise<void>;
|
||||
createTeamTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||
startTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
||||
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
|
||||
addingComment: boolean;
|
||||
addCommentError: string | null;
|
||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
deleteTeam: (teamName: string) => Promise<void>;
|
||||
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
||||
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
||||
|
|
@ -180,14 +192,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
|
||||
const state = get();
|
||||
// Use display name from teams list or selected team data if available
|
||||
const teamSummary = state.teams.find((t) => t.teamName === teamName);
|
||||
const displayName = teamSummary?.displayName || state.selectedTeamData?.config.name || teamName;
|
||||
|
||||
const allTabs = state.getAllPaneTabs();
|
||||
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
||||
if (existing) {
|
||||
state.setActiveTab(existing.id);
|
||||
// Sync label in case display name changed
|
||||
if (existing.label !== displayName) {
|
||||
state.updateTabLabel(existing.id, displayName);
|
||||
}
|
||||
} else {
|
||||
state.openTab({
|
||||
type: 'team',
|
||||
label: teamName,
|
||||
label: displayName,
|
||||
teamName,
|
||||
});
|
||||
}
|
||||
|
|
@ -222,6 +242,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
selectedTeamError: null,
|
||||
});
|
||||
|
||||
// Sync tab label with the team's display name from config
|
||||
const displayName = data.config.name || teamName;
|
||||
const allTabs = get().getAllPaneTabs();
|
||||
const teamTab = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
|
||||
if (teamTab && teamTab.label !== displayName) {
|
||||
get().updateTabLabel(teamTab.id, displayName);
|
||||
}
|
||||
|
||||
// Auto-select the project associated with this team's cwd/projectPath.
|
||||
// Must search both flat projects and grouped repositoryGroups/worktrees
|
||||
// because the default viewMode is 'grouped' and flat projects may be empty.
|
||||
|
|
@ -244,8 +272,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
);
|
||||
if (matchingWorktree) {
|
||||
if (state.selectedWorktreeId !== matchingWorktree.id) {
|
||||
state.selectRepository(repo.id);
|
||||
state.selectWorktree(matchingWorktree.id);
|
||||
set(getWorktreeNavigationState(repo.id, matchingWorktree.id));
|
||||
void get().fetchSessionsInitial(matchingWorktree.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -330,6 +358,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
updateKanbanColumnOrder: async (
|
||||
teamName: string,
|
||||
columnId: KanbanColumnId,
|
||||
orderedTaskIds: string[]
|
||||
) => {
|
||||
await unwrapIpc('team:updateKanbanColumnOrder', () =>
|
||||
api.teams.updateKanbanColumnOrder(teamName, columnId, orderedTaskIds)
|
||||
);
|
||||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
sendTeamMessage: async (teamName: string, request: SendMessageRequest) => {
|
||||
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
|
||||
try {
|
||||
|
|
@ -382,6 +421,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
updateTaskOwner: async (teamName: string, taskId: string, owner: string | null) => {
|
||||
await unwrapIpc('team:updateTaskOwner', () =>
|
||||
api.teams.updateTaskOwner(teamName, taskId, owner)
|
||||
);
|
||||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
addTaskComment: async (teamName, taskId, text) => {
|
||||
set({ addingComment: true, addCommentError: null });
|
||||
try {
|
||||
|
|
@ -398,6 +444,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||
await unwrapIpc('team:addMember', () => api.teams.addMember(teamName, request));
|
||||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
removeMember: async (teamName: string, memberName: string) => {
|
||||
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
||||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
deleteTeam: async (teamName: string) => {
|
||||
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
||||
const state = get();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,22 @@ export function getSessionResetState(): Partial<AppState> {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically navigate to a specific worktree.
|
||||
* Use instead of selectRepository() + selectWorktree() to avoid race condition
|
||||
* (two competing fetchSessionsInitial calls where the stale response can overwrite).
|
||||
*/
|
||||
export function getWorktreeNavigationState(repoId: string, worktreeId: string): Partial<AppState> {
|
||||
return {
|
||||
selectedRepositoryId: repoId,
|
||||
selectedWorktreeId: worktreeId,
|
||||
selectedProjectId: worktreeId,
|
||||
activeProjectId: worktreeId,
|
||||
sidebarCollapsed: false,
|
||||
...getSessionResetState(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full state reset (session + project + repository + conversation).
|
||||
* Used when closing all tabs or resetting to initial state.
|
||||
|
|
|
|||
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';
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -14,8 +14,11 @@ import type {
|
|||
TriggerTestResult,
|
||||
} from './notifications';
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
AttachmentFileData,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
SendMessageRequest,
|
||||
|
|
@ -354,7 +357,13 @@ export interface TeamsAPI {
|
|||
createTask: (teamName: string, request: CreateTaskRequest) => Promise<TeamTask>;
|
||||
requestReview: (teamName: string, taskId: string) => Promise<void>;
|
||||
updateKanban: (teamName: string, taskId: string, patch: UpdateKanbanPatch) => Promise<void>;
|
||||
updateKanbanColumnOrder: (
|
||||
teamName: string,
|
||||
columnId: KanbanColumnId,
|
||||
orderedTaskIds: string[]
|
||||
) => Promise<void>;
|
||||
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
|
||||
updateTaskOwner: (teamName: string, taskId: string, owner: string | null) => Promise<void>;
|
||||
startTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
processSend: (teamName: string, message: string) => Promise<void>;
|
||||
processAlive: (teamName: string) => Promise<boolean>;
|
||||
|
|
@ -371,7 +380,11 @@ export interface TeamsAPI {
|
|||
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
||||
getAllTasks: () => Promise<GlobalTask[]>;
|
||||
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||
onProvisioningProgress: (
|
||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||
|
|
|
|||
|
|
@ -262,6 +262,8 @@ export interface AppConfig {
|
|||
defaultTab: 'dashboard' | 'last-session';
|
||||
/** Optional custom Claude root folder (auto-detected when null) */
|
||||
claudeRootPath: string | null;
|
||||
/** Agent communication language ('system' = use OS locale) */
|
||||
agentLanguage: string;
|
||||
};
|
||||
/** Display and UI settings */
|
||||
display: {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ export interface TeamMember {
|
|||
role?: string;
|
||||
color?: string;
|
||||
joinedAt?: number;
|
||||
cwd?: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
||||
export interface TeamConfig {
|
||||
|
|
@ -80,6 +82,25 @@ export interface TeamTaskWithKanban extends TeamTask {
|
|||
kanbanColumn?: 'review' | 'approved';
|
||||
}
|
||||
|
||||
export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||
|
||||
export interface AttachmentMeta {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: AttachmentMediaType;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface AttachmentPayload extends AttachmentMeta {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface AttachmentFileData {
|
||||
id: string;
|
||||
data: string;
|
||||
mimeType: AttachmentMediaType;
|
||||
}
|
||||
|
||||
export interface InboxMessage {
|
||||
from: string;
|
||||
to?: string;
|
||||
|
|
@ -89,7 +110,8 @@ export interface InboxMessage {
|
|||
summary?: string;
|
||||
color?: string;
|
||||
messageId?: string;
|
||||
source?: 'inbox' | 'lead_session' | 'lead_process';
|
||||
source?: 'inbox' | 'lead_session' | 'lead_process' | 'user_sent';
|
||||
attachments?: AttachmentMeta[];
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
|
|
@ -97,10 +119,12 @@ export interface SendMessageRequest {
|
|||
text: string;
|
||||
summary?: string;
|
||||
from?: string;
|
||||
attachments?: AttachmentPayload[];
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
deliveredToInbox: boolean;
|
||||
deliveredViaStdin?: boolean;
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +143,8 @@ export interface KanbanState {
|
|||
teamName: string;
|
||||
reviewers: string[];
|
||||
tasks: Record<string, KanbanTaskState>;
|
||||
/** Порядок id задач по колонкам для отображения на канбан-доске (drag-and-drop). */
|
||||
columnOrder?: Partial<Record<KanbanColumnId, string[]>>;
|
||||
}
|
||||
|
||||
export type UpdateKanbanPatch =
|
||||
|
|
@ -136,6 +162,10 @@ export interface ResolvedTeamMember {
|
|||
color?: string;
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
cwd?: string;
|
||||
/** Set only when member's git branch differs from the lead's branch. */
|
||||
gitBranch?: string;
|
||||
removedAt?: number;
|
||||
}
|
||||
|
||||
export interface TeamData {
|
||||
|
|
@ -153,6 +183,7 @@ export interface TeamLaunchRequest {
|
|||
teamName: string;
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface TeamLaunchResponse {
|
||||
|
|
@ -199,6 +230,7 @@ export interface TeamCreateRequest {
|
|||
members: TeamProvisioningMemberInput[];
|
||||
cwd: string;
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface TeamCreateConfigRequest {
|
||||
|
|
@ -290,3 +322,12 @@ export interface MemberFullStats {
|
|||
sessionCount: number;
|
||||
computedAt: string;
|
||||
}
|
||||
|
||||
export interface AddMemberRequest {
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface RemoveMemberRequest {
|
||||
name: string;
|
||||
}
|
||||
|
|
|
|||
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_REQUEST_REVIEW: 'team:requestReview',
|
||||
TEAM_UPDATE_KANBAN: 'team:updateKanban',
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER: 'team:updateKanbanColumnOrder',
|
||||
TEAM_UPDATE_TASK_STATUS: 'team:updateTaskStatus',
|
||||
TEAM_UPDATE_TASK_OWNER: 'team:updateTaskOwner',
|
||||
TEAM_PROCESS_SEND: 'team:processSend',
|
||||
TEAM_PROCESS_ALIVE: 'team:processAlive',
|
||||
TEAM_ALIVE_LIST: 'team:aliveList',
|
||||
|
|
@ -28,6 +30,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
TEAM_START_TASK: 'team:startTask',
|
||||
TEAM_GET_ALL_TASKS: 'team:getAllTasks',
|
||||
TEAM_ADD_TASK_COMMENT: 'team:addTaskComment',
|
||||
TEAM_ADD_MEMBER: 'team:addMember',
|
||||
TEAM_REMOVE_MEMBER: 'team:removeMember',
|
||||
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
|
||||
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
|
||||
}));
|
||||
|
||||
import {
|
||||
|
|
@ -54,8 +60,13 @@ import {
|
|||
TEAM_START_TASK,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_GET_ATTACHMENTS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
} from '../../../src/preload/constants/ipcChannels';
|
||||
import {
|
||||
initializeTeamHandlers,
|
||||
|
|
@ -82,6 +93,7 @@ describe('ipc teams handlers', () => {
|
|||
createTask: vi.fn(async () => ({ id: '1', subject: 'Test', status: 'pending' })),
|
||||
requestReview: vi.fn(async () => undefined),
|
||||
updateKanban: vi.fn(async () => undefined),
|
||||
updateKanbanColumnOrder: vi.fn(async () => undefined),
|
||||
updateTaskStatus: vi.fn(async () => undefined),
|
||||
startTask: vi.fn(async () => undefined),
|
||||
addTaskComment: vi.fn(async () => ({
|
||||
|
|
@ -90,6 +102,8 @@ describe('ipc teams handlers', () => {
|
|||
text: 'test comment',
|
||||
createdAt: new Date().toISOString(),
|
||||
})),
|
||||
addMember: vi.fn(async () => undefined),
|
||||
removeMember: vi.fn(async () => undefined),
|
||||
};
|
||||
const provisioningService = {
|
||||
prepareForProvisioning: vi.fn(async () => ({
|
||||
|
|
@ -135,6 +149,7 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(true);
|
||||
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(true);
|
||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(true);
|
||||
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(true);
|
||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(true);
|
||||
expect(handlers.has(TEAM_START_TASK)).toBe(true);
|
||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(true);
|
||||
|
|
@ -148,6 +163,8 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
|
||||
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
|
||||
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(true);
|
||||
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
||||
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns success false on invalid sendMessage args', async () => {
|
||||
|
|
@ -212,6 +229,7 @@ describe('ipc teams handlers', () => {
|
|||
it('dedups live lead replies when lead_session already has same text', async () => {
|
||||
service.getTeamData.mockResolvedValueOnce({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
|
|
@ -301,6 +319,64 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('addMember', () => {
|
||||
it('calls service on valid input', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.addMember).toHaveBeenCalledWith('my-team', {
|
||||
name: 'alice',
|
||||
role: 'developer',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid team name', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, '../bad', {
|
||||
name: 'alice',
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid member name', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
name: '../bad',
|
||||
})) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing payload', async () => {
|
||||
const handler = handlers.get(TEAM_ADD_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', null)) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMember', () => {
|
||||
it('calls service on valid input', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', 'alice')) as { success: boolean };
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.removeMember).toHaveBeenCalledWith('my-team', 'alice');
|
||||
});
|
||||
|
||||
it('rejects invalid team name', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
const result = (await handler({} as never, '../bad', 'alice')) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid member name', async () => {
|
||||
const handler = handlers.get(TEAM_REMOVE_MEMBER)!;
|
||||
const result = (await handler({} as never, 'my-team', '../bad')) as { success: boolean };
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTeam prompt validation', () => {
|
||||
it('accepts valid prompt in team create request', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE)!;
|
||||
|
|
@ -342,6 +418,7 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_SEND_MESSAGE)).toBe(false);
|
||||
expect(handlers.has(TEAM_REQUEST_REVIEW)).toBe(false);
|
||||
expect(handlers.has(TEAM_UPDATE_KANBAN)).toBe(false);
|
||||
expect(handlers.has(TEAM_UPDATE_KANBAN_COLUMN_ORDER)).toBe(false);
|
||||
expect(handlers.has(TEAM_UPDATE_TASK_STATUS)).toBe(false);
|
||||
expect(handlers.has(TEAM_START_TASK)).toBe(false);
|
||||
expect(handlers.has(TEAM_PROCESS_SEND)).toBe(false);
|
||||
|
|
@ -355,5 +432,9 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
|
||||
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
|
||||
expect(handlers.has(TEAM_ADD_TASK_COMMENT)).toBe(false);
|
||||
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(false);
|
||||
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(false);
|
||||
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
||||
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue