commit
82f06ef773
86 changed files with 5523 additions and 507 deletions
|
|
@ -8,6 +8,7 @@ Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x
|
|||
## Commands
|
||||
Always use pnpm (not npm/yarn) for this project.
|
||||
Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel.
|
||||
When running build/typecheck/test commands, pipe through `tail -20` to avoid flooding the context window (e.g. `pnpm typecheck 2>&1 | tail -20`).
|
||||
|
||||
- `pnpm install` - Install dependencies
|
||||
- `pnpm dev` - Dev server with hot reload
|
||||
|
|
|
|||
131
README.md
131
README.md
|
|
@ -14,52 +14,87 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>100% free, open source. No API keys. No configuration.</sub>
|
||||
<sub>100% free, open source. No API keys. No configuration. Runs entirely locally.</sub>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [What is this](#what-is-this)
|
||||
- [Quick start](#quick-start)
|
||||
- [Installation](#installation)
|
||||
- [FAQ](#faq)
|
||||
- [Development](#development)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Links](#links)
|
||||
- [Contributing](#contributing)
|
||||
- [Security](#security)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## What is this
|
||||
|
||||
A new approach to task management with AI agents.
|
||||
**Claude Agent Teams UI** is a desktop app that turns Claude Code's "Orchestrate Teams" feature into a full task management experience. Create agent teams, watch them work on a kanban board, review their code changes, and stay in control — all running locally on your machine.
|
||||
|
||||
- **Assemble your team** — create agent teams with different roles that work autonomously in parallel
|
||||
- **Agents talk to each other** — they communicate, create and manage their own tasks, and leave comments
|
||||
- **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own
|
||||
- **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment
|
||||
- **Full tool visibility** — inspect exactly which tools an agent used to complete each task
|
||||
- **Live process section** — see which agents are running processes and open URLs directly in the browser
|
||||
- **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work
|
||||
### Who is this for
|
||||
|
||||
- **Developers** who want AI agents to handle tasks in parallel while they oversee progress
|
||||
- **Teams** using Claude Code and needing a shared task board, code review workflow, and team messaging
|
||||
- **Anyone** who wants to browse and analyze Claude Code session history without running agents
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Create a team** — Define roles (e.g. lead, frontend, backend) and a provisioning prompt. The app spawns Claude Code sessions as autonomous team members.
|
||||
2. **Tasks flow automatically** — Agents create tasks, assign each other, move cards, leave comments, and send messages. You see everything on the kanban board in real time.
|
||||
3. **Review like in Cursor** — When a task is done, you see the diff, approve, reject, or request changes. Agents get notified and can fix issues.
|
||||
4. **Stay in control** — Send a direct message to any agent, add a comment on a task, or use quick actions on cards whenever you need to clarify or add work.
|
||||
|
||||
### Key features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Kanban board** | Tasks move through columns as agents work. Drag-and-drop, filters, quick actions. |
|
||||
| **Code review** | Full diff view per task. Approve, reject, or request changes. Agents get notified. |
|
||||
| **Team messaging** | Agents send direct messages. Inbox, notifications, clarification flags. |
|
||||
| **Tool visibility** | See exactly which tools agents used (Bash, Read, Edit, etc.) for each task. |
|
||||
| **Live processes** | See which agents are running. Open URLs directly in the browser. |
|
||||
| **Session analysis** | Deep breakdown of each Claude session: commands, reasoning, subprocesses. |
|
||||
|
||||
<details>
|
||||
<summary><strong>More features</strong></summary>
|
||||
|
||||
<br />
|
||||
|
||||
- **Recent tasks across projects** — browse the latest completed tasks from all your projects in one place
|
||||
- **Deep session analysis** — detailed breakdown of what happened in each Claude session: bash commands, reasoning, subprocesses
|
||||
- **Smart task-to-log matching** — automatically links Claude session logs to specific tasks based on status change timestamps, even when a task moves back and forth between states
|
||||
- **Solo mode** — a one-member team: a single agent that creates its own tasks, leaves comments, and shows live progress on the kanban board — saves tokens compared to a full team and can be expanded to a full team at any time
|
||||
- **Zero-setup onboarding** — built-in Claude Code installation and authentication, ready to go out of the box
|
||||
- **Built-in code editor** — edit project files with Git support and other essential features without leaving the app
|
||||
- **Branch strategy control** — choose via prompt whether all agents work on a single branch or each gets its own git worktree
|
||||
- **Team member stats** — global performance statistics for every member of the team
|
||||
- **Attach code context** — reference files or code snippets in your messages, just like in Cursor
|
||||
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
|
||||
- **Recent tasks across projects** — Browse the latest completed tasks from all your projects in one place
|
||||
- **Solo mode** — One-member team: a single agent that creates its own tasks and shows live progress. Saves tokens; can expand to a full team anytime
|
||||
- **Advanced context monitoring** — Token breakdown: user messages, CLAUDE.md, tool outputs, thinking text, team coordination. See usage, context window %, and cost per category
|
||||
- **Smart task-to-log matching** — Links Claude session logs to tasks by status change timestamps, even when tasks move back and forth
|
||||
- **Zero-setup onboarding** — Built-in Claude Code installation and authentication
|
||||
- **Built-in code editor** — Edit project files with Git support without leaving the app
|
||||
- **Branch strategy** — Choose via prompt: single branch or git worktree per agent
|
||||
- **Team member stats** — Global performance statistics per member
|
||||
- **Attach code context** — Reference files or snippets in messages, like in Cursor
|
||||
- **Notification system** — Configurable alerts when tasks complete, agents need attention, or errors occur
|
||||
- **MCP integration** — Built-in [mcp-server](./mcp-server) for Cursor, Claude Desktop, and other MCP clients. 13 tools for task CRUD, kanban, reviews, and messaging — see [mcp-server/README.md](./mcp-server/README.md)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
### Tech stack
|
||||
|
||||
<!--
|
||||
<p align="center">
|
||||
<video src="https://github.com/user-attachments/assets/2b420b2c-c4af-4d10-a679-c83269f8ee99">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</p>
|
||||
Electron, React 18, TypeScript 5, Tailwind CSS 3, Zustand 4. Data from `~/.claude/` (session logs, todos, tasks). No cloud backend — everything runs locally.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
1. **Download** the app for your platform (see [Installation](#installation))
|
||||
2. **Launch** — On first run, the setup wizard will install and authenticate Claude Code
|
||||
3. **Create a team** — Pick a project, define roles, write a provisioning prompt
|
||||
4. **Watch** — Agents spawn, create tasks, and work. You see it all on the kanban board
|
||||
|
||||
---
|
||||
-->
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -101,6 +136,8 @@ No prerequisites — Claude Code can be installed and configured directly from t
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
**System requirements:** macOS 10.15+, Windows 10+, or Linux (glibc 2.28+). Node.js is not required for the desktop app.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
|
@ -114,19 +151,19 @@ No. The app includes built-in installation and authentication — just launch an
|
|||
<details>
|
||||
<summary><strong>Does it read or upload my code?</strong></summary>
|
||||
<br />
|
||||
No. Everything runs locally on your machine. The app reads Claude Code's session logs from <code>~/.claude/</code> — your source code is never sent anywhere.
|
||||
No. Everything runs locally. The app reads Claude Code's session logs from <code>~/.claude/</code> — your source code is never sent anywhere.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can agents communicate with each other?</strong></summary>
|
||||
<br />
|
||||
Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated automatically through Claude Code's team protocol.
|
||||
Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated through Claude Code's team protocol.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Is it free?</strong></summary>
|
||||
<br />
|
||||
Yes, completely free and open source. The app itself requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
|
||||
Yes, completely free and open source. The app requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -138,13 +175,13 @@ Yes. Every task shows a full diff view where you can accept, reject, or comment
|
|||
<details>
|
||||
<summary><strong>What happens if an agent gets stuck?</strong></summary>
|
||||
<br />
|
||||
You can send a direct message to any agent at any time to course-correct, or stop and restart it from the process dashboard. If an agent needs your input, you'll get a notification and the task will be marked with a distinct badge on the board so you won't miss it.
|
||||
Send a direct message to course-correct, or stop and restart from the process dashboard. If an agent needs your input, you'll get a notification and the task will show a distinct badge on the board.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I use it just to view past sessions without running agents?</strong></summary>
|
||||
<br />
|
||||
Yes. The app works as a session viewer too — browse, search, and analyze any Claude Code session history.
|
||||
Yes. The app works as a session viewer — browse, search, and analyze any Claude Code session history.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
@ -171,9 +208,9 @@ pnpm install
|
|||
pnpm dev
|
||||
```
|
||||
|
||||
The app auto-discovers your Claude Code projects from `~/.claude/`.
|
||||
The app auto-discovers Claude Code projects from `~/.claude/`.
|
||||
|
||||
#### Build for Distribution
|
||||
### Build for distribution
|
||||
|
||||
```bash
|
||||
pnpm dist:mac:arm64 # macOS Apple Silicon (.dmg)
|
||||
|
|
@ -183,32 +220,48 @@ pnpm dist:linux # Linux (AppImage/.deb/.rpm/.pacman)
|
|||
pnpm dist # macOS + Windows + Linux
|
||||
```
|
||||
|
||||
#### Scripts
|
||||
### Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Development with hot reload |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Lint (no auto-fix) |
|
||||
| `pnpm lint:fix` | Lint and auto-fix |
|
||||
| `pnpm format` | Format code with Prettier |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm test:coverage` | Coverage report |
|
||||
| `pnpm test:coverage:critical` | Critical path coverage |
|
||||
| `pnpm check` | Full quality gate (types + lint + test + build) |
|
||||
| `pnpm fix` | Lint fix + format |
|
||||
| `pnpm quality` | Full check + format check + knip |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## TODO
|
||||
## Roadmap
|
||||
|
||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [ ] CLI runtime: Run in headless/console environments (web UI), e.g. VPS, remote server
|
||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||
- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills)
|
||||
- [ ] Install skills, MCP, and integrations via an intuitive UI, and only for selected agents
|
||||
- [ ] Planning mode to organize agent plans before execution
|
||||
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
|
||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- [Homepage](https://github.com/777genius/claude_agent_teams_ui)
|
||||
- [Releases](https://github.com/777genius/claude_agent_teams_ui/releases)
|
||||
- [Issues](https://github.com/777genius/claude_agent_teams_ui/issues)
|
||||
- [MCP Server](./mcp-server) — Use Claude Agent Teams UI tools from Cursor, Claude Desktop, and other MCP clients
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](.github/CODE_OF_CONDUCT.md).
|
||||
|
|
@ -219,4 +272,4 @@ IPC handlers validate all inputs with strict path containment checks. File reads
|
|||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
|
|
|||
467
docs/research/model-agnostic-proxy.md
Normal file
467
docs/research/model-agnostic-proxy.md
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
# Model-Agnostic Proxy для Claude Code Agent Teams
|
||||
|
||||
Дата исследования: 2026-03-06
|
||||
|
||||
## Цель
|
||||
|
||||
Сделать Claude Code Agent Teams model-agnostic: lead остаётся на Claude, а teammates могут работать на GPT-4o, Gemini, DeepSeek, Kimi K2.5 и других моделях через прокси, который транслирует Anthropic Messages API в формат целевого провайдера.
|
||||
|
||||
## Ключевой механизм
|
||||
|
||||
Claude Code обращается к Anthropic Messages API (`/v1/messages`). Переменная `ANTHROPIC_BASE_URL` позволяет перенаправить запросы на локальный прокси. Прокси:
|
||||
|
||||
1. Принимает запрос в формате Anthropic Messages API
|
||||
2. Транслирует в формат целевого провайдера (OpenAI Chat Completions и др.)
|
||||
3. Пересылает провайдеру
|
||||
4. Получает SSE-стрим ответа
|
||||
5. Транслирует обратно в формат Anthropic SSE-событий
|
||||
6. Отдаёт Claude Code CLI как будто это ответ от Claude
|
||||
|
||||
Team tools (TeamCreate, TaskCreate, SendMessage, TaskGet, TaskList, TaskUpdate, TeamDelete) исполняются **локально Claude Code CLI**. LLM только генерирует `tool_use` блоки. Значит прокси не нужно знать о team-семантике — достаточно корректно транслировать tool_use формат.
|
||||
|
||||
---
|
||||
|
||||
## Исследованные проекты
|
||||
|
||||
### 1. HydraTeams
|
||||
|
||||
- **URL**: https://github.com/Pickle-Pixel/HydraTeams
|
||||
- **Назначение**: Прокси-переводчик API специально для Agent Teams
|
||||
- **Язык**: TypeScript (~580 строк, 8 файлов)
|
||||
- **Зависимости**: Zero runtime dependencies (только Node.js builtins)
|
||||
- **Лицензия**: Не указана явно
|
||||
- **Stars**: 33 | **Forks**: 9 | **Commits**: 4 | **Создан**: 2026-02-08
|
||||
|
||||
#### Архитектура
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts (35) — точка входа, banner, graceful shutdown
|
||||
proxy.ts (280) — HTTP-сервер, маршрутизация, retry
|
||||
config.ts (95) — CLI-аргументы, env vars, Codex JWT
|
||||
logger.ts (190) — логирование, идентификация сессий
|
||||
translators/
|
||||
types.ts (120) — интерфейсы Anthropic + OpenAI
|
||||
messages.ts (85) — конвертация истории сообщений
|
||||
request.ts (65) — Anthropic req -> OpenAI Chat Completions req
|
||||
request-responses.ts (145) — Anthropic req -> ChatGPT Responses API req
|
||||
response.ts (185) — OpenAI SSE -> Anthropic SSE
|
||||
response-responses.ts (235) — Responses API SSE -> Anthropic SSE
|
||||
```
|
||||
|
||||
#### Два конвейера трансляции
|
||||
|
||||
1. **OpenAI Chat Completions API** (`--provider openai`) — для GPT-4o, GPT-4o-mini, o3-mini
|
||||
2. **ChatGPT Responses API** (`--provider chatgpt`) — для ChatGPT Subscription ($0 дополнительных затрат)
|
||||
|
||||
#### Lead vs Teammate детекция
|
||||
|
||||
- Lead: маркер `<!-- hydra:lead -->` в CLAUDE.md (попадает в system prompt)
|
||||
- Teammate: фраза `"the user interacts primarily with the team lead"` в system prompt
|
||||
- Lead-запросы passthrough на настоящий Anthropic API
|
||||
- Teammate-запросы транслируются на целевую модель
|
||||
|
||||
#### Что работает правильно
|
||||
|
||||
- Tool definitions: `input_schema` -> `parameters`, `tool_choice` маппинг корректен
|
||||
- tool_use блоки ассистанта -> `tool_calls` формат OpenAI
|
||||
- tool_result -> `role: "tool"` сообщения
|
||||
- Streaming: StreamState отслеживает blockIndex, activeToolCalls, textBlockStarted
|
||||
- Правильная SSE-последовательность: message_start -> content_block_start -> content_block_delta -> content_block_stop -> message_delta -> message_stop
|
||||
- Retry с exponential backoff на 429 (до 5 попыток)
|
||||
|
||||
#### Найденные баги (code review)
|
||||
|
||||
| # | Баг | Серьёзность |
|
||||
|---|-----|-------------|
|
||||
| 1 | **response-responses.ts**: `content_index` используется вместо Anthropic `blockIndex` в `response.output_text.done` — content_block_stop уйдёт с неправильным index | Высокая |
|
||||
| 2 | **proxy.ts**: `shouldPassthrough()` получает `parsed.system` (может быть массив `AnthropicSystemBlock[]`) как string — `count_tokens` passthrough для lead не сработает | Средняя |
|
||||
| 3 | **proxy.ts**: Non-streaming `JSON.parse(tc.function.arguments)` — если OpenAI вернёт невалидный JSON, exception убьёт весь запрос | Средняя |
|
||||
| 4 | **proxy.ts**: `shouldPassthrough("*")` означает "все Claude модели", а не "всё" — контринтуитивно | Низкая |
|
||||
| 5 | **logger.ts**: Warmup-запросы определяются по `toolCount === 0` — может ложно классифицировать обычные запросы | Низкая |
|
||||
|
||||
#### Критические проблемы
|
||||
|
||||
| Проблема | Серьёзность |
|
||||
|----------|-------------|
|
||||
| 0 тестов | Критично |
|
||||
| Нет таймаутов на upstream — fetch() без AbortController, зависнет навсегда | Критично |
|
||||
| Слушает на 0.0.0.0 — доступен в сети, релеит auth headers | Критично |
|
||||
| Teammate детекция по строке "the user interacts primarily with the team lead" — сломается при обновлении Claude Code | Высокая |
|
||||
| Spoofed model hardcoded `claude-sonnet-4-5-20250929` — устареет | Средняя |
|
||||
| Token counting = `JSON.length / 4` — грубая заглушка | Средняя |
|
||||
| Нет extended thinking — thinking блоки игнорируются | Средняя |
|
||||
| Нет image/multimodal — молча теряются | Низкая |
|
||||
|
||||
#### Безопасность
|
||||
|
||||
- Сервер слушает на 0.0.0.0 (все интерфейсы) — в сети это дыра
|
||||
- Passthrough релеит auth headers (x-api-key, authorization, cookie) к api.anthropic.com
|
||||
- JWT парсинг без валидации подписи
|
||||
- Нет rate limiting на входящие запросы
|
||||
- Логи могут содержать API-ключи в ответах об ошибках
|
||||
|
||||
#### Итоговые оценки
|
||||
|
||||
| Аспект | Оценка |
|
||||
|--------|:------:|
|
||||
| Архитектура | 7/10 |
|
||||
| API трансляция | 6/10 |
|
||||
| Lead/Teammate детекция | 5/10 |
|
||||
| Обработка ошибок | 4/10 |
|
||||
| Streaming | 7/10 |
|
||||
| Безопасность | 3/10 |
|
||||
| Production readiness | 3/10 |
|
||||
| **Общая** | **5/10** |
|
||||
|
||||
---
|
||||
|
||||
### 2. free-claude-code
|
||||
|
||||
- **URL**: https://github.com/Alishahryar1/free-claude-code
|
||||
- **Назначение**: Прокси для использования Claude Code с бесплатными моделями
|
||||
- **Язык**: Python (FastAPI + uvicorn)
|
||||
- **Stars**: 814 | **Forks**: 95 | **Issues**: 4 | **Создан**: 2026-01-28
|
||||
- **Лицензия**: MIT
|
||||
- **Тесты**: 85+ файлов, pytest, GitHub Actions CI
|
||||
|
||||
#### Архитектура
|
||||
|
||||
```
|
||||
server.py — точка входа (uvicorn)
|
||||
api/
|
||||
app.py — FastAPI factory + lifespan
|
||||
routes.py — POST /v1/messages, GET /health
|
||||
detection.py — эвристики определения типа запроса
|
||||
optimization_handlers.py — 5 fast-path перехватчиков
|
||||
request_utils.py — подсчёт токенов (tiktoken cl100k_base)
|
||||
models/anthropic.py — Pydantic модели Anthropic request
|
||||
providers/
|
||||
base.py — BaseProvider (ABC)
|
||||
openai_compat.py — OpenAICompatibleProvider (основная логика)
|
||||
common/
|
||||
message_converter.py — Anthropic <-> OpenAI конвертер (~200 строк)
|
||||
sse_builder.py — SSE event builder Anthropic формат (~300 строк)
|
||||
think_parser.py — парсер <think> тегов (~80 строк)
|
||||
heuristic_tool_parser.py — парсер tool calls из текста
|
||||
nvidia_nim/, open_router/, lmstudio/ — конкретные провайдеры
|
||||
```
|
||||
|
||||
#### Провайдеры и модели
|
||||
|
||||
##### NVIDIA NIM (бесплатно, 40 req/min)
|
||||
|
||||
**Tier S (флагманы):**
|
||||
|
||||
| Model ID | Thinking | Tool Calling |
|
||||
|----------|:--------:|:------------:|
|
||||
| `moonshotai/kimi-k2.5` | Да | Native |
|
||||
| `qwen/qwen3-coder-480b-a35b-instruct` | Да | Native |
|
||||
| `z-ai/glm5` | Да | Native |
|
||||
| `deepseek-ai/deepseek-v3.2` | Да | Native |
|
||||
| `mistralai/mistral-large-3-675b-instruct` | Да | Native |
|
||||
| `minimaxai/minimax-m2.5` | Да | Native |
|
||||
|
||||
**Tier A:**
|
||||
|
||||
| Model ID | Thinking | Tool Calling |
|
||||
|----------|:--------:|:------------:|
|
||||
| `z-ai/glm4.7` | Да | Native |
|
||||
| `mistralai/devstral-2-123b-instruct` | Да | Native |
|
||||
| `openai/gpt-oss-120b` | Да | Native |
|
||||
| `meta/llama-3.1-405b-instruct` | Нет | Native |
|
||||
|
||||
**Tier B (быстрые):**
|
||||
|
||||
| Model ID | Thinking | Tool Calling |
|
||||
|----------|:--------:|:------------:|
|
||||
| `qwen/qwen2.5-coder-32b-instruct` | Нет | Native |
|
||||
| `stepfun-ai/step-3.5-flash` | Да | Native |
|
||||
| `meta/llama-3.3-70b-instruct` | Нет | Native |
|
||||
|
||||
Всего в каталоге NIM **185 моделей**, все бесплатные. max_tokens: 81920.
|
||||
|
||||
##### OpenRouter (free модели с суффиксом `:free`)
|
||||
|
||||
**С Tool Calling + Thinking:**
|
||||
|
||||
| Model ID | Context | Max Output |
|
||||
|----------|:-------:|:----------:|
|
||||
| `openai/gpt-oss-120b:free` | 131K | 131K |
|
||||
| `stepfun/step-3.5-flash:free` | 256K | 256K |
|
||||
| `qwen/qwen3-coder:free` | 262K | 262K |
|
||||
| `qwen/qwen3-235b-a22b-thinking-2507:free` | 131K | — |
|
||||
| `z-ai/glm-4.5-air:free` | 131K | 96K |
|
||||
|
||||
**С Tool Calling, без Thinking:**
|
||||
|
||||
| Model ID | Context |
|
||||
|----------|:-------:|
|
||||
| `meta-llama/llama-3.3-70b-instruct:free` | 128K |
|
||||
| `mistralai/mistral-small-3.1-24b-instruct:free` | 128K |
|
||||
| `google/gemma-3-27b-it:free` | 131K |
|
||||
|
||||
Всего **28 бесплатных моделей** + мета-роутер `openrouter/free`.
|
||||
|
||||
##### LM Studio (полностью локально, без лимитов)
|
||||
|
||||
| Модель | VRAM | Качество кода |
|
||||
|--------|:----:|:---:|
|
||||
| `unsloth/MiniMax-M2.5-GGUF` | 48GB+ | 7/10 |
|
||||
| `unsloth/Qwen3-Coder-480B-A35B-Instruct-GGUF` | 48GB+ | 8/10 |
|
||||
| `unsloth/Qwen3.5-35B-A3B-GGUF` | 24GB | 6/10 |
|
||||
| `unsloth/GLM-4.7-Flash-GGUF` | 24GB | 6/10 |
|
||||
| `unsloth/Qwen2.5-Coder-32B-Instruct-GGUF` | 24GB | 6/10 |
|
||||
|
||||
#### Что лучше чем HydraTeams
|
||||
|
||||
| Аспект | free-claude-code | HydraTeams |
|
||||
|--------|:---:|:---:|
|
||||
| Тесты | **85+ файлов**, CI | 0 |
|
||||
| Thinking blocks | **Два пути** (native + `<think>` парсер) | Нет |
|
||||
| Token counting | **tiktoken** (реальный подсчёт) | `JSON.length / 4` |
|
||||
| Provider abstraction | **ABC + наследование** | Hardcoded if/else |
|
||||
| Error handling | **Graceful shutdown**, rate limiter | Базовый try/catch |
|
||||
| Heuristic tool parser | **Есть** (для моделей без native tool use) | Нет |
|
||||
|
||||
#### Проблемы для Agent Teams
|
||||
|
||||
1. **Task tool patching** — принудительно ставит `run_in_background=False` в трёх местах. Опасная мина для team coordination
|
||||
2. **Optimization interceptors** — 5 эвристик могут ложно сработать на teammate messages
|
||||
3. **Общий rate limiter** — singleton `GlobalRateLimiter` (40 req/min, max_concurrency=5). 5 teammates = мгновенный bottleneck. Один 429 блокирует ВСЕХ
|
||||
4. **Python 3.14 requirement** — ещё в beta, проблема для bundling
|
||||
5. **tiktoken** требует Rust-скомпилированный .so — кросс-платформенная сборка сложная
|
||||
6. **Нет lead/teammate разделения** — все запросы на один провайдер
|
||||
|
||||
#### Bundling с Electron
|
||||
|
||||
| Критерий | free-claude-code (Python) | HydraTeams (TypeScript) |
|
||||
|----------|:---:|:---:|
|
||||
| Bundling в Electron | PyInstaller ~100-150MB, Python 3.14, Rust deps | Прямо в main process, 0 deps |
|
||||
| Размер | ~50MB минимум | ~580 строк, КБ |
|
||||
| Кросс-платформа | tiktoken .so для каждой платформы | Нативный Node.js |
|
||||
| Запуск | Child process + Python runtime | Просто import |
|
||||
|
||||
#### Переиспользуемое ядро (~900 строк Python)
|
||||
|
||||
- `message_converter.py` (~200 строк) — полностью независим
|
||||
- `sse_builder.py` (~300 строк) — почти независим (убрать Task patching)
|
||||
- `think_parser.py` (~80 строк) — полностью независим
|
||||
- `openai_compat.py` (~250 строк) — stream_response логика
|
||||
|
||||
#### Итоговые оценки
|
||||
|
||||
| Критерий | Оценка |
|
||||
|----------|:------:|
|
||||
| Качество кода | 7/10 |
|
||||
| Тесты | 8/10 |
|
||||
| Bundling с Electron | 2/10 |
|
||||
| Agent Teams совместимость | 3/10 |
|
||||
| Адаптация под наш стек | 3/10 |
|
||||
|
||||
---
|
||||
|
||||
### 3. LiteLLM Proxy
|
||||
|
||||
- **URL**: https://github.com/BerriAI/litellm
|
||||
- **Лицензия**: MIT (enterprise-фичи проприетарные)
|
||||
- **Stars**: ~38,000 | **Forks**: ~6,200
|
||||
- **Язык**: Python
|
||||
- **Провайдеры**: 100+ (OpenAI, Gemini, Bedrock, Azure, Groq, DeepSeek...)
|
||||
|
||||
#### Как работает с Claude Code
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_BASE_URL="http://0.0.0.0:4000"
|
||||
export ANTHROPIC_AUTH_TOKEN="$LITELLM_MASTER_KEY"
|
||||
claude --model gpt-4o
|
||||
```
|
||||
|
||||
Нативный Anthropic SDK-совместимый endpoint `/messages`. Полная трансляция tool_use, streaming SSE.
|
||||
|
||||
#### Почему НЕ подходит для Electron
|
||||
|
||||
| Проблема | Детали |
|
||||
|----------|--------|
|
||||
| Python | 70+ зависимостей, Prisma с Node binaries, grpcio |
|
||||
| Размер | ~2 ГБ (Docker-образ) |
|
||||
| RAM | Рекомендация 8 ГБ для production |
|
||||
| Bundling | Никто никогда не бандлил с desktop app |
|
||||
|
||||
#### Известные баги с Claude Code
|
||||
|
||||
- [#21446](https://github.com/BerriAI/litellm/issues/21446) — Gemini не работает через LiteLLM
|
||||
- [#14194](https://github.com/BerriAI/litellm/issues/14194) — Bedrock thinking + tools конфликтуют
|
||||
- [#12222](https://github.com/BerriAI/litellm/issues/12222) — Gemini падает на tools с optional args
|
||||
- [#18730](https://github.com/BerriAI/litellm/issues/18730) — Concurrent requests обходят rate limits
|
||||
|
||||
**Вердикт: enterprise-серверный gateway, не для десктопа. 1/10 для нашего кейса.**
|
||||
|
||||
---
|
||||
|
||||
### 4. Bifrost (Maxim AI)
|
||||
|
||||
- **URL**: https://github.com/maximhq/bifrost
|
||||
- **Лицензия**: Apache 2.0
|
||||
- **Stars**: ~2,700 | **Commits**: 3,341
|
||||
- **Язык**: Go (11 мкс overhead при 5000 RPS)
|
||||
- **Провайдеры**: 20+ (OpenAI, Gemini, Bedrock, Azure, Mistral, Ollama...)
|
||||
|
||||
#### Bundling с Electron
|
||||
|
||||
Bifrost компилируется в единый статический Go-бинарник. npm-пакет `@maximhq/bifrost` скачивает prebuilt binary с CDN под нужную платформу.
|
||||
|
||||
```
|
||||
https://downloads.getmaxim.ai/bifrost/{version}/{platform}/{arch}/bifrost-http
|
||||
```
|
||||
|
||||
Поддерживаемые платформы:
|
||||
- darwin/arm64 (macOS Apple Silicon)
|
||||
- darwin/amd64 (macOS Intel)
|
||||
- linux/amd64, linux/386
|
||||
- windows/amd64, windows/arm64
|
||||
|
||||
Размер: ~30-60 MB. Запуск: `child_process.spawn(bifrostBinaryPath)`.
|
||||
|
||||
**Bundling: 9/10** — скачать бинарник, положить рядом, запустить как child process.
|
||||
|
||||
#### Проблемы для Agent Teams
|
||||
|
||||
| Issue | Описание | Статус |
|
||||
|-------|----------|--------|
|
||||
| [#1164](https://github.com/maximhq/bifrost/issues/1164) | Parallel tool calls через Bedrock не работают | Открыт |
|
||||
| [#1829](https://github.com/maximhq/bifrost/issues/1829) | Streaming tool call deltas мёрджатся | Закрыт |
|
||||
| [#1804](https://github.com/maximhq/bifrost/issues/1804) | Streaming tool calls с агентскими клиентами не работают | Открыт |
|
||||
| [#828](https://github.com/maximhq/bifrost/issues/828) | Goroutine leak при context cancellation | Открыт |
|
||||
| [#1613](https://github.com/maximhq/bifrost/issues/1613) | SSE streaming от Gemini ломается | Открыт |
|
||||
|
||||
Не использует anthropic-go-sdk, дублирует типы вручную (Discussion #1259).
|
||||
|
||||
**Никто не тестировал Bifrost с Agent Teams.**
|
||||
|
||||
#### Итоговые оценки
|
||||
|
||||
| Критерий | Оценка |
|
||||
|----------|:------:|
|
||||
| Bundling с Electron | 9/10 |
|
||||
| Agent Teams (passthrough) | 8/10 |
|
||||
| Agent Teams (трансляция) | 4/10 |
|
||||
| Зрелость | 7/10 |
|
||||
|
||||
---
|
||||
|
||||
## Сравнение качества моделей vs Claude
|
||||
|
||||
| Модель | Кодинг | vs Sonnet | vs Opus |
|
||||
|--------|:------:|:---------:|:-------:|
|
||||
| Kimi K2.5 (NIM) | 8/10 | ~80% | ~60% |
|
||||
| Qwen3 Coder 480B (NIM) | 8/10 | ~80% | ~60% |
|
||||
| GLM-5 (NIM) | 7/10 | ~70% | ~50% |
|
||||
| GPT-OSS 120B (NIM/OR) | 7/10 | ~70% | ~50% |
|
||||
| GLM-4.7 (NIM) | 7/10 | ~65% | ~45% |
|
||||
| Step 3.5 Flash (OR) | 6/10 | ~55% | ~35% |
|
||||
| Llama 3.3 70B (OR) | 5/10 | ~45% | ~30% |
|
||||
|
||||
Ни одна бесплатная модель не дотягивает до Claude по качеству агентного кодинга.
|
||||
|
||||
---
|
||||
|
||||
## Юридические аспекты
|
||||
|
||||
### Что разрешено Anthropic
|
||||
|
||||
- `ANTHROPIC_BASE_URL` — **официально поддерживается** для LLM Gateway
|
||||
- Использование прокси с собственными API-ключами других провайдеров — легально
|
||||
- Документация описывает LLM Gateway конфигурацию: endpoint должен реализовывать Anthropic Messages API
|
||||
|
||||
### Что запрещено
|
||||
|
||||
- Использование OAuth-токенов от Claude Free/Pro/Max подписок в сторонних продуктах
|
||||
- Anthropic активно блокирует несанкционированное использование подписочных токенов
|
||||
- Использование Claude для обучения конкурирующих моделей (Section D.4 Commercial Terms)
|
||||
|
||||
### ChatGPT backend API (HydraTeams)
|
||||
|
||||
- `chatgpt.com/backend-api/codex/responses` — недокументированный API
|
||||
- Нарушение ToS ChatGPT при автоматизации через backend API
|
||||
- Может быть заблокирован в любой момент
|
||||
|
||||
---
|
||||
|
||||
## Влияние на наш проект (Claude DevTools)
|
||||
|
||||
JSONL формат сессий **не меняется** — Claude Code CLI генерирует одинаковую структуру независимо от backend-модели. TeamCreate, TaskCreate, SendMessage и прочие team tools остаются теми же. Парсинг и chunk building будет работать без изменений.
|
||||
|
||||
Единственное потенциальное отличие — metadata о модели в сообщениях (model field).
|
||||
|
||||
---
|
||||
|
||||
## Рекомендация по реализации
|
||||
|
||||
### Лучший путь: форк HydraTeams + hardening
|
||||
|
||||
HydraTeams — единственный проект заточенный под Agent Teams, на TypeScript (наш стек), zero deps, встраивается в Electron main process.
|
||||
|
||||
**Что нужно починить обязательно:**
|
||||
1. Localhost-only binding (`127.0.0.1`)
|
||||
2. AbortController + таймауты на upstream fetch
|
||||
3. Баги с indices в response-responses.ts
|
||||
4. Тесты на трансляцию (unit-тесты на каждый translator)
|
||||
5. Убрать hardcoded spoofModel -> сделать конфигурируемым
|
||||
|
||||
**Что заимствовать из free-claude-code:**
|
||||
1. ThinkTagParser — переписать на TS (~50 строк)
|
||||
2. Provider abstraction — интерфейс `TranslationProvider`
|
||||
3. Структуру тестов
|
||||
4. Heuristic tool parser для моделей без native tool calling
|
||||
|
||||
**Что переосмыслить:**
|
||||
- Lead/teammate детекция — маркер по строке хрупкий. Мы знаем роли из TeamDataService, можно передавать через env var `HYDRA_ROLE=lead|teammate`
|
||||
|
||||
### Оценка трудозатрат
|
||||
|
||||
~2-3 дня на hardening + тесты. Итого: TypeScript-пакет ~800-1000 строк с тестами, встраиваемый в Electron main process.
|
||||
|
||||
### Альтернативы
|
||||
|
||||
| Вариант | Надёжность | Уверенность |
|
||||
|---------|:----------:|:-----------:|
|
||||
| Форк HydraTeams + hardening | 7/10 | 8/10 |
|
||||
| Свой proxy с нуля на TS (вдохновлённый обоими) | 8/10 | 7/10 |
|
||||
| Bifrost binary + thin TS translator | 5/10 | 6/10 |
|
||||
| LiteLLM как Docker sidecar | 6/10 | 7/10 |
|
||||
| free-claude-code (Python child process) | 3/10 | 8/10 |
|
||||
|
||||
---
|
||||
|
||||
## Другие найденные проекты
|
||||
|
||||
| Проект | Описание | Применимость |
|
||||
|--------|----------|:---:|
|
||||
| [1rgs/claude-code-proxy](https://github.com/1rgs/claude-code-proxy) | На базе LiteLLM, BIG/SMALL model маппинг | 5/10 |
|
||||
| [fuergaosi233/claude-code-proxy](https://github.com/fuergaosi233/claude-code-proxy) | Anthropic -> OpenAI конвертер | 4/10 |
|
||||
| [nielspeter/claude-code-proxy](https://github.com/nielspeter/claude-code-proxy) | Легковесный бинарник, OpenRouter | 4/10 |
|
||||
| [9router](https://github.com/decolua/9router) | Smart router с fallback-каскадом | 5/10 |
|
||||
| [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) | MCP-сервер, реимплементация Agent Teams | 3/10 |
|
||||
|
||||
---
|
||||
|
||||
## Архитектурная схема целевого решения
|
||||
|
||||
```
|
||||
[Lead Agent Process]
|
||||
ANTHROPIC_BASE_URL=http://127.0.0.1:{port}
|
||||
HYDRA_ROLE=lead
|
||||
|
|
||||
v
|
||||
[Proxy (TypeScript, в Electron main process)]
|
||||
if role=lead --> passthrough к api.anthropic.com
|
||||
if role=teammate --> трансляция к целевому провайдеру
|
||||
|
|
||||
v
|
||||
[Teammate 1] --> OpenAI API (GPT-4o)
|
||||
[Teammate 2] --> NVIDIA NIM (Kimi K2.5)
|
||||
[Teammate 3] --> Local (LM Studio)
|
||||
```
|
||||
|
||||
Stream-json протокол (stdin/stdout между lead и teammates) **не затрагивается** — прокси работает на уровне HTTP API запросов к LLM.
|
||||
301
mcp-server/README.md
Normal file
301
mcp-server/README.md
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
# @claude-team/mcp-server
|
||||
|
||||
**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.**
|
||||
|
||||
Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui)
|
||||
- [Quick start](#quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Tools Reference](#tools-reference)
|
||||
- [Data Storage](#data-storage)
|
||||
- [Development](#development)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which:
|
||||
|
||||
- Stores task data as JSON under `~/.claude/tasks/{teamName}/`
|
||||
- Stores team config under `~/.claude/teams/{teamName}/`
|
||||
- Is installed automatically when you run Claude Agent Teams UI at least once
|
||||
|
||||
### Use cases
|
||||
|
||||
| Scenario | Description |
|
||||
|----------|-------------|
|
||||
| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat |
|
||||
| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use |
|
||||
| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐
|
||||
│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │
|
||||
│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │
|
||||
│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │
|
||||
└─────────────────────┘ └──────────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Claude Agent Teams UI
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. |
|
||||
| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. |
|
||||
| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. |
|
||||
|
||||
Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`)
|
||||
2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build`
|
||||
3. Add to Cursor MCP config (`~/.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Restart Cursor. The 13 tools will appear for the AI assistant.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20+**
|
||||
- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`)
|
||||
|
||||
If `teamctl.js` is missing, the server throws at startup:
|
||||
|
||||
```
|
||||
teamctl.js not found at ~/.claude/tools/teamctl.js.
|
||||
Make sure Claude Agent Teams UI has been run at least once,
|
||||
or set the TEAMCTL_PATH environment variable.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From the monorepo (development)
|
||||
|
||||
```bash
|
||||
cd mcp-server
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### As a dependency
|
||||
|
||||
```bash
|
||||
pnpm add @claude-team/mcp-server
|
||||
# or
|
||||
npm install @claude-team/mcp-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cursor
|
||||
|
||||
Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With global install (`pnpm link` or `npm link`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "team-mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` |
|
||||
|
||||
---
|
||||
|
||||
## Tools Reference
|
||||
|
||||
All tools require a `team` parameter (team name, folder under `~/.claude/teams/`).
|
||||
|
||||
### Task CRUD
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` |
|
||||
| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) |
|
||||
| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) |
|
||||
| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals |
|
||||
| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` |
|
||||
|
||||
### Task collaboration
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` |
|
||||
| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected |
|
||||
| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board |
|
||||
| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB |
|
||||
|
||||
### Kanban board
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` |
|
||||
| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` |
|
||||
|
||||
### Code review
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) |
|
||||
|
||||
### Messaging
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications |
|
||||
|
||||
---
|
||||
|
||||
## Data Storage
|
||||
|
||||
| Location | Contents |
|
||||
|----------|----------|
|
||||
| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) |
|
||||
| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes |
|
||||
|
||||
Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes).
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm build` | Build with tsup → `dist/index.js` |
|
||||
| `pnpm dev` | Run with tsx (no build) |
|
||||
| `pnpm test` | Run Vitest tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm typecheck` | TypeScript check |
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
mcp-server/
|
||||
├── src/
|
||||
│ ├── index.ts # FastMCP server, registers all tools
|
||||
│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess
|
||||
│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout
|
||||
│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.)
|
||||
│ └── tools/
|
||||
│ ├── index.ts # registerAllTools()
|
||||
│ ├── task-create.ts
|
||||
│ ├── task-get.ts
|
||||
│ ├── ...
|
||||
│ └── message-send.ts
|
||||
├── test/
|
||||
│ ├── tools/ # Per-tool tests
|
||||
│ ├── teamctl-runner.test.ts
|
||||
│ ├── output-parser.test.ts
|
||||
│ └── schemas.test.ts
|
||||
├── package.json
|
||||
├── tsup.config.ts
|
||||
└── vitest.config.ts
|
||||
```
|
||||
|
||||
### Adding a new tool
|
||||
|
||||
1. Create `src/tools/your-tool.ts` with `register(server, runner)`
|
||||
2. Add to `ALL_TOOLS` in `src/tools/index.ts`
|
||||
3. Add Zod parameters and map to `teamctl` CLI args
|
||||
4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts`
|
||||
5. Add tests in `test/tools/your-tool.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required):
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` |
|
||||
| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes |
|
||||
| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it |
|
||||
| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project: [AGPL-3.0](../LICENSE).
|
||||
31
mcp-server/package.json
Normal file
31
mcp-server/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@claude-team/mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for managing Claude Agent Teams kanban board and tasks",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"team-mcp-server": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastmcp": "^3.34.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.1.4",
|
||||
"@types/node": "^22.15.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
20
mcp-server/src/index.ts
Normal file
20
mcp-server/src/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { FastMCP } from 'fastmcp';
|
||||
import { TeamctlRunner } from './teamctl-runner.js';
|
||||
import { registerAllTools } from './tools/index.js';
|
||||
|
||||
const server = new FastMCP({
|
||||
name: 'claude-team-tools',
|
||||
version: '1.0.0',
|
||||
instructions: `MCP server for managing Claude Agent Teams kanban board and tasks.
|
||||
|
||||
Provides 13 tools for task CRUD, kanban board management, code reviews, and team messaging.
|
||||
All operations are backed by teamctl.js — the battle-tested CLI tool from Claude Agent Teams UI.
|
||||
|
||||
Data is stored as JSON files in ~/.claude/tasks/{teamName}/ and ~/.claude/teams/{teamName}/.`,
|
||||
});
|
||||
|
||||
const runner = new TeamctlRunner();
|
||||
|
||||
registerAllTools(server, runner);
|
||||
|
||||
server.start({ transportType: 'stdio' });
|
||||
52
mcp-server/src/output-parser.ts
Normal file
52
mcp-server/src/output-parser.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Parses teamctl stdout into structured results.
|
||||
*
|
||||
* teamctl outputs in three formats:
|
||||
* 1. JSON — task create/get/list, attach, message send, kanban reviewers list
|
||||
* 2. "OK ..." text — status changes, comments, links, kanban moves, reviews
|
||||
* 3. Plain text — task briefing (multi-line human-readable report)
|
||||
*/
|
||||
|
||||
/** Parse JSON from teamctl stdout (task create/get/list, attach, message send) */
|
||||
export function parseJsonOutput<T = unknown>(stdout: string): T {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Empty output from teamctl (expected JSON)');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse teamctl JSON output: ${trimmed.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse "OK ..." acknowledgment lines from teamctl */
|
||||
export function parseOkOutput(stdout: string): string {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.startsWith('OK ')) {
|
||||
return trimmed.slice(3); // Strip "OK " prefix, keep the rest
|
||||
}
|
||||
// Some commands output just "OK\n"
|
||||
if (trimmed === 'OK') {
|
||||
return 'OK';
|
||||
}
|
||||
// Return as-is if format is unexpected — don't throw
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Return plain text as-is (briefing, help output) */
|
||||
export function parseTextOutput(stdout: string): string {
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format teamctl stderr into a user-friendly error message.
|
||||
* teamctl writes errors to stderr via `die(message)` and exits with code 1.
|
||||
*/
|
||||
export function formatError(stderr: string, stdout: string): string {
|
||||
const msg = stderr.trim() || stdout.trim();
|
||||
if (!msg) return 'Unknown teamctl error';
|
||||
return msg;
|
||||
}
|
||||
115
mcp-server/src/schemas.ts
Normal file
115
mcp-server/src/schemas.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { z } from 'zod';
|
||||
import { sep, isAbsolute } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Matches teamctl's `isSafePathSegment()`:
|
||||
* rejects empty, '.', '..', and strings containing '/', '\\', '\0', or '..'
|
||||
*/
|
||||
const safePathSegment = (label: string) =>
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.trim().length > 0 &&
|
||||
v !== '.' &&
|
||||
v !== '..' &&
|
||||
!v.includes('/') &&
|
||||
!v.includes('\\') &&
|
||||
!v.includes('\0') &&
|
||||
!v.includes('..'),
|
||||
{ message: `Invalid ${label}: must be a safe path segment` },
|
||||
);
|
||||
|
||||
/** Team name — folder inside `~/.claude/teams/` */
|
||||
export const teamNameSchema = safePathSegment('team name').describe(
|
||||
'Team name (folder in ~/.claude/teams/)',
|
||||
);
|
||||
|
||||
/** Numeric task ID produced by teamctl's highwatermark counter */
|
||||
export const taskIdSchema = z
|
||||
.string()
|
||||
.regex(/^\d{1,10}$/, 'Task ID must be a positive integer (e.g. "1", "42")')
|
||||
.describe('Numeric task ID');
|
||||
|
||||
/** Team member name — folder inside inboxes, safe path segment */
|
||||
export const memberNameSchema = safePathSegment('member name').describe(
|
||||
'Team member name',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums — match teamctl's normalizeStatus / normalizeColumn / etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const taskStatusSchema = z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'deleted',
|
||||
]);
|
||||
|
||||
export const kanbanColumnSchema = z
|
||||
.enum(['review', 'approved'])
|
||||
.describe('Kanban column to move task to');
|
||||
|
||||
export const clarificationSchema = z
|
||||
.enum(['lead', 'user', 'clear'])
|
||||
.describe('Who needs to clarify: lead, user, or clear the flag');
|
||||
|
||||
export const linkTypeSchema = z
|
||||
.enum(['blocked-by', 'blocks', 'related'])
|
||||
.describe('Relationship type between tasks');
|
||||
|
||||
export const linkOperationSchema = z.enum(['link', 'unlink']);
|
||||
|
||||
export const reviewDecisionSchema = z.enum(['approve', 'request-changes']);
|
||||
|
||||
export const reviewerOperationSchema = z.enum(['list', 'add', 'remove']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Comma-separated task IDs sent as a single CLI argument */
|
||||
export const taskIdsArraySchema = z
|
||||
.array(taskIdSchema)
|
||||
.describe('Array of task IDs (e.g. ["1", "3"])');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File / attachment schemas — defence-in-depth for CLI arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Absolute file path without traversal sequences */
|
||||
export const filePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((p) => isAbsolute(p), { message: 'Path must be absolute' })
|
||||
.refine((p) => !p.split(sep).includes('..'), {
|
||||
message: 'Path must not contain traversal sequences (..)',
|
||||
})
|
||||
.refine((p) => !p.includes('\0'), {
|
||||
message: 'Path must not contain null bytes',
|
||||
});
|
||||
|
||||
/** Safe filename — no path separators, no null bytes, reasonable length */
|
||||
export const safeFilenameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine(
|
||||
(f) => !f.includes('/') && !f.includes('\\') && !f.includes('\0'),
|
||||
{ message: 'Filename must not contain path separators or null bytes' },
|
||||
);
|
||||
|
||||
/** MIME type — standard type/subtype format */
|
||||
export const mimeTypeSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/,
|
||||
'Invalid MIME type format (expected type/subtype)',
|
||||
);
|
||||
155
mcp-server/src/teamctl-runner.ts
Normal file
155
mcp-server/src/teamctl-runner.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TeamctlResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export interface ITeamctlRunner {
|
||||
execute(args: string[]): Promise<TeamctlResult>;
|
||||
}
|
||||
|
||||
export interface TeamctlRunnerOptions {
|
||||
/** Explicit path to teamctl.js. Falls back to TEAMCTL_PATH env, then default. */
|
||||
teamctlPath?: string;
|
||||
/** Max concurrent subprocess calls (default: 5) */
|
||||
maxConcurrent?: number;
|
||||
/** Subprocess timeout in ms (default: 10 000) */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semaphore — limits concurrent subprocess spawns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Semaphore {
|
||||
private current = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
if (this.current < this.max) {
|
||||
this.current++;
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(() => {
|
||||
this.current++;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.current--;
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TeamctlRunner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TeamctlRunner implements ITeamctlRunner {
|
||||
readonly teamctlPath: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly semaphore: Semaphore;
|
||||
|
||||
constructor(options?: TeamctlRunnerOptions) {
|
||||
this.teamctlPath = resolveTeamctlPath(options?.teamctlPath);
|
||||
this.timeoutMs = options?.timeoutMs ?? 10_000;
|
||||
this.semaphore = new Semaphore(options?.maxConcurrent ?? 5);
|
||||
|
||||
// Fail fast if teamctl.js doesn't exist
|
||||
if (!existsSync(this.teamctlPath)) {
|
||||
throw new Error(
|
||||
`teamctl.js not found at ${this.teamctlPath}. ` +
|
||||
'Make sure Claude Agent Teams UI has been run at least once, ' +
|
||||
'or set the TEAMCTL_PATH environment variable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args: string[]): Promise<TeamctlResult> {
|
||||
await this.semaphore.acquire();
|
||||
try {
|
||||
return await this.spawn(args);
|
||||
} finally {
|
||||
this.semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
private spawn(args: string[]): Promise<TeamctlResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [this.teamctlPath, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: this.timeoutMs,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
|
||||
let settled = false;
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`Failed to spawn teamctl: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
||||
const stderr = Buffer.concat(stderrChunks).toString('utf-8');
|
||||
|
||||
if (signal === 'SIGTERM') {
|
||||
const partial = stdout.slice(0, 500) || stderr.slice(0, 500);
|
||||
reject(
|
||||
new Error(
|
||||
`teamctl timed out after ${this.timeoutMs}ms` +
|
||||
(partial ? `. Partial output: ${partial}` : ''),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveTeamctlPath(explicit?: string): string {
|
||||
if (explicit) return explicit;
|
||||
|
||||
const fromEnv = process.env['TEAMCTL_PATH'];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
// Default: ~/.claude/tools/teamctl.js
|
||||
return join(homedir(), '.claude', 'tools', 'teamctl.js');
|
||||
}
|
||||
42
mcp-server/src/tools/index.ts
Normal file
42
mcp-server/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
|
||||
import { register as taskCreate } from './task-create.js';
|
||||
import { register as taskSetStatus } from './task-set-status.js';
|
||||
import { register as taskSetOwner } from './task-set-owner.js';
|
||||
import { register as taskGet } from './task-get.js';
|
||||
import { register as taskList } from './task-list.js';
|
||||
import { register as taskComment } from './task-comment.js';
|
||||
import { register as taskLink } from './task-link.js';
|
||||
import { register as taskBriefing } from './task-briefing.js';
|
||||
import { register as taskAttach } from './task-attach.js';
|
||||
import { register as kanbanMove } from './kanban-move.js';
|
||||
import { register as kanbanReviewers } from './kanban-reviewers.js';
|
||||
import { register as reviewAction } from './review-action.js';
|
||||
import { register as messageSend } from './message-send.js';
|
||||
|
||||
const ALL_TOOLS = [
|
||||
taskCreate,
|
||||
taskSetStatus,
|
||||
taskSetOwner,
|
||||
taskGet,
|
||||
taskList,
|
||||
taskComment,
|
||||
taskLink,
|
||||
taskBriefing,
|
||||
taskAttach,
|
||||
kanbanMove,
|
||||
kanbanReviewers,
|
||||
reviewAction,
|
||||
messageSend,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Register all 13 MCP tools with the server.
|
||||
* Each tool wraps a teamctl CLI command via the runner.
|
||||
*/
|
||||
export function registerAllTools(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
for (const register of ALL_TOOLS) {
|
||||
register(server, runner);
|
||||
}
|
||||
}
|
||||
45
mcp-server/src/tools/kanban-move.ts
Normal file
45
mcp-server/src/tools/kanban-move.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, kanbanColumnSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'kanban_move',
|
||||
description: `Move a task to a kanban column or clear it from the kanban board.
|
||||
|
||||
Columns: "review" (awaiting code review) or "approved" (review passed).
|
||||
Use operation "clear" to remove a task from the kanban board (returns to status-based display).
|
||||
Moving to a kanban column also sets the task status to "completed".`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
operation: z.enum(['set-column', 'clear']).describe('"set-column" to move, "clear" to remove from kanban'),
|
||||
column: kanbanColumnSchema.optional().describe('Target column (required for set-column)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
let cliArgs: string[];
|
||||
|
||||
if (args.operation === 'set-column') {
|
||||
if (!args.column) {
|
||||
throw new UserError('column is required when operation is "set-column"');
|
||||
}
|
||||
cliArgs = ['--team', args.team, 'kanban', 'set-column', args.task_id, args.column];
|
||||
} else {
|
||||
cliArgs = ['--team', args.team, 'kanban', 'clear', args.task_id];
|
||||
}
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to update kanban: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
46
mcp-server/src/tools/kanban-reviewers.ts
Normal file
46
mcp-server/src/tools/kanban-reviewers.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput, parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema, reviewerOperationSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'kanban_reviewers',
|
||||
description: `Manage the kanban board's reviewer list.
|
||||
|
||||
Operations:
|
||||
- "list": returns JSON array of reviewer names
|
||||
- "add": adds a reviewer (name required)
|
||||
- "remove": removes a reviewer (name required)`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
operation: reviewerOperationSchema.describe('"list", "add", or "remove"'),
|
||||
name: memberNameSchema.optional().describe('Reviewer name (required for add/remove)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
if (args.operation !== 'list' && !args.name) {
|
||||
throw new UserError(`name is required for "${args.operation}" operation`);
|
||||
}
|
||||
|
||||
const cliArgs = ['--team', args.team, 'kanban', 'reviewers', args.operation];
|
||||
if (args.name) cliArgs.push(args.name);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to manage reviewers: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
|
||||
// "list" returns JSON array, "add"/"remove" return "OK ..." text
|
||||
if (args.operation === 'list') {
|
||||
return parseJsonOutput(result.stdout);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
39
mcp-server/src/tools/message-send.ts
Normal file
39
mcp-server/src/tools/message-send.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'message_send',
|
||||
description: `Send an inbox message to a team member. Returns delivery confirmation JSON.
|
||||
|
||||
Messages appear in the member's inbox and can trigger notifications.
|
||||
The "source" field is automatically stripped for security — external callers cannot impersonate system notifications.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
to: memberNameSchema.describe('Recipient member name'),
|
||||
text: z.string().min(1).max(10000).describe('Message text'),
|
||||
summary: z.string().max(200).optional().describe('Short summary for notification preview'),
|
||||
from: memberNameSchema.optional().describe('Sender name'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'message', 'send', '--to', args.to, '--text', args.text];
|
||||
|
||||
if (args.summary) cliArgs.push('--summary', args.summary);
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to send message: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
50
mcp-server/src/tools/review-action.ts
Normal file
50
mcp-server/src/tools/review-action.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema, reviewDecisionSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'review_action',
|
||||
description: `Approve a task or request changes.
|
||||
|
||||
- "approve": marks the task as approved in kanban, optionally posts a review comment
|
||||
- "request-changes": removes from kanban, resets status to "in_progress", notifies the task owner with the change request comment`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
decision: reviewDecisionSchema.describe('"approve" or "request-changes"'),
|
||||
comment: z.string().max(5000).optional().describe('Review comment (required for request-changes)'),
|
||||
from: memberNameSchema.optional().describe('Reviewer name'),
|
||||
notify_owner: z.boolean().optional().describe('Notify the task owner (for approve)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
if (args.decision === 'request-changes' && !args.comment) {
|
||||
throw new UserError('comment is required when requesting changes');
|
||||
}
|
||||
|
||||
const cliArgs = ['--team', args.team, 'review', args.decision, args.task_id];
|
||||
|
||||
// approve uses --note for optional comment; request-changes uses --comment
|
||||
if (args.decision === 'request-changes' && args.comment) {
|
||||
cliArgs.push('--comment', args.comment);
|
||||
} else if (args.decision === 'approve' && args.comment) {
|
||||
cliArgs.push('--note', args.comment);
|
||||
}
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
if (args.notify_owner) cliArgs.push('--notify-owner');
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to ${args.decision}: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
42
mcp-server/src/tools/task-attach.ts
Normal file
42
mcp-server/src/tools/task-attach.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema, filePathSchema, safeFilenameSchema, mimeTypeSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_attach',
|
||||
description: `Attach a file to a task. Returns attachment metadata JSON.
|
||||
|
||||
Supports copy (default) or hardlink mode. MIME type is auto-detected from file content (PNG, JPEG, GIF, WebP, PDF, ZIP) with fallback to application/octet-stream. Max file size: 20 MB.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
file: filePathSchema.describe('Absolute path to the file to attach'),
|
||||
filename: safeFilenameSchema.optional().describe('Override stored filename'),
|
||||
mime_type: mimeTypeSchema.optional().describe('Override MIME type (auto-detected by default)'),
|
||||
mode: z.enum(['copy', 'link']).optional().describe('Storage mode: copy (default) or hardlink'),
|
||||
from: memberNameSchema.optional().describe('Uploader name'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'attach', args.task_id, '--file', args.file];
|
||||
|
||||
if (args.filename) cliArgs.push('--filename', args.filename);
|
||||
if (args.mime_type) cliArgs.push('--mime-type', args.mime_type);
|
||||
if (args.mode) cliArgs.push('--mode', args.mode);
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to attach file: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
32
mcp-server/src/tools/task-briefing.ts
Normal file
32
mcp-server/src/tools/task-briefing.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseTextOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_briefing',
|
||||
description: `Generate a text briefing for a team member showing their assigned tasks vs the team board.
|
||||
|
||||
Returns a human-readable multi-line report. Automatically filters out internal bookkeeping tasks.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
member: memberNameSchema.describe('Member name to generate briefing for'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'briefing', '--for', args.member];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to get briefing: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseTextOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
34
mcp-server/src/tools/task-comment.ts
Normal file
34
mcp-server/src/tools/task-comment.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_comment',
|
||||
description: `Add a comment to a task. Sends an inbox notification to the task owner (unless the commenter is the owner).`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
text: z.string().min(1).max(10000).describe('Comment text'),
|
||||
from: memberNameSchema.optional().describe('Author name (skips self-notification)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'comment', args.task_id, '--text', args.text];
|
||||
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to add comment: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
52
mcp-server/src/tools/task-create.ts
Normal file
52
mcp-server/src/tools/task-create.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema, taskIdsArraySchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
description: `Create a new task in a team's task board. Returns the created task JSON.
|
||||
|
||||
Behavior:
|
||||
- If owner is set and no blockers → status defaults to "in_progress"
|
||||
- If blocked_by specified → status defaults to "pending" (even with owner)
|
||||
- If notify is true, sends an inbox notification to the assigned owner`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
subject: z.string().min(1).max(500).describe('Task title'),
|
||||
description: z.string().max(5000).optional().describe('Detailed task description'),
|
||||
owner: memberNameSchema.optional().describe('Assign to a team member'),
|
||||
blocked_by: taskIdsArraySchema.optional().describe('Task IDs that block this task'),
|
||||
related: taskIdsArraySchema.optional().describe('Related (non-blocking) task IDs'),
|
||||
status: z.enum(['pending', 'in_progress']).optional().describe('Initial status override'),
|
||||
active_form: z.string().max(200).optional().describe('Active form hint for CLI display'),
|
||||
notify: z.boolean().optional().describe('Send inbox notification to owner'),
|
||||
from: memberNameSchema.optional().describe('Author name for notifications'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'create', '--subject', args.subject];
|
||||
|
||||
if (args.description) cliArgs.push('--description', args.description);
|
||||
if (args.owner) cliArgs.push('--owner', args.owner);
|
||||
if (args.blocked_by?.length) cliArgs.push('--blocked-by', args.blocked_by.join(','));
|
||||
if (args.related?.length) cliArgs.push('--related', args.related.join(','));
|
||||
if (args.status) cliArgs.push('--status', args.status);
|
||||
if (args.active_form) cliArgs.push('--activeForm', args.active_form);
|
||||
if (args.notify) cliArgs.push('--notify');
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to create task: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
30
mcp-server/src/tools/task-get.ts
Normal file
30
mcp-server/src/tools/task-get.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_get',
|
||||
description: `Get a single task by its ID. Returns the full task JSON including status, owner, comments, dependencies, work intervals, and status history.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'get', args.task_id];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to get task: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
51
mcp-server/src/tools/task-link.ts
Normal file
51
mcp-server/src/tools/task-link.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import {
|
||||
teamNameSchema,
|
||||
taskIdSchema,
|
||||
linkTypeSchema,
|
||||
linkOperationSchema,
|
||||
} from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_link',
|
||||
description: `Link or unlink task dependencies.
|
||||
|
||||
Relationship types:
|
||||
- "blocked-by": this task is blocked by the target
|
||||
- "blocks": this task blocks the target
|
||||
- "related": non-blocking relationship
|
||||
|
||||
Links are bidirectional: linking A blocked-by B also sets B blocks A.
|
||||
Circular dependencies are detected and rejected.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
operation: linkOperationSchema.describe('"link" to add, "unlink" to remove'),
|
||||
relationship: linkTypeSchema,
|
||||
target_id: taskIdSchema.describe('The other task ID to link/unlink'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = [
|
||||
'--team', args.team,
|
||||
'task', args.operation,
|
||||
args.task_id,
|
||||
`--${args.relationship}`, args.target_id,
|
||||
];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to ${args.operation} tasks: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
31
mcp-server/src/tools/task-list.ts
Normal file
31
mcp-server/src/tools/task-list.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_list',
|
||||
description: `List all tasks for a team. Returns a JSON array of task objects.
|
||||
|
||||
Note: includes internal bookkeeping tasks (metadata._internal). Filter client-side if needed.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'list'];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to list tasks: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
38
mcp-server/src/tools/task-set-owner.ts
Normal file
38
mcp-server/src/tools/task-set-owner.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_set_owner',
|
||||
description: `Assign a task to a team member or clear the assignment.
|
||||
|
||||
Pass owner="clear" to unassign. Optionally sends an inbox notification to the new owner.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
owner: z.union([memberNameSchema, z.literal('clear')]).describe('Member name to assign, or "clear" to unassign'),
|
||||
notify: z.boolean().optional().describe('Send inbox notification to new owner'),
|
||||
from: memberNameSchema.optional().describe('Author name for notification'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'set-owner', args.task_id, args.owner];
|
||||
|
||||
if (args.notify) cliArgs.push('--notify');
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to set owner: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
35
mcp-server/src/tools/task-set-status.ts
Normal file
35
mcp-server/src/tools/task-set-status.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, taskStatusSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_set_status',
|
||||
description: `Change the status of a task.
|
||||
|
||||
Valid transitions: pending → in_progress → completed → deleted (and back).
|
||||
Shortcuts: status "in_progress" is equivalent to "task start", "completed" to "task complete".
|
||||
Records status history and tracks work intervals (time spent in_progress).`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
status: taskStatusSchema.describe('New status for the task'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'set-status', args.task_id, args.status];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to set status: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
76
mcp-server/test/output-parser.test.ts
Normal file
76
mcp-server/test/output-parser.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseJsonOutput,
|
||||
parseOkOutput,
|
||||
parseTextOutput,
|
||||
formatError,
|
||||
} from '../src/output-parser.js';
|
||||
|
||||
describe('parseJsonOutput', () => {
|
||||
it('parses valid JSON object', () => {
|
||||
const input = '{"id":"42","subject":"Fix bug"}\n';
|
||||
expect(parseJsonOutput(input)).toEqual({ id: '42', subject: 'Fix bug' });
|
||||
});
|
||||
|
||||
it('parses valid JSON array', () => {
|
||||
const input = '[{"id":"1"},{"id":"2"}]\n';
|
||||
expect(parseJsonOutput(input)).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
const input = ' \n {"ok":true} \n ';
|
||||
expect(parseJsonOutput(input)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('throws on empty output', () => {
|
||||
expect(() => parseJsonOutput('')).toThrow('Empty output');
|
||||
expect(() => parseJsonOutput(' \n ')).toThrow('Empty output');
|
||||
});
|
||||
|
||||
it('throws on invalid JSON', () => {
|
||||
expect(() => parseJsonOutput('not json')).toThrow('Failed to parse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOkOutput', () => {
|
||||
it('strips "OK " prefix', () => {
|
||||
expect(parseOkOutput('OK task #1 status=completed\n')).toBe('task #1 status=completed');
|
||||
});
|
||||
|
||||
it('handles bare "OK"', () => {
|
||||
expect(parseOkOutput('OK\n')).toBe('OK');
|
||||
});
|
||||
|
||||
it('returns as-is for unexpected format', () => {
|
||||
expect(parseOkOutput('Something else')).toBe('Something else');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(parseOkOutput(' OK kanban #1 cleared \n')).toBe('kanban #1 cleared');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextOutput', () => {
|
||||
it('trims and returns text', () => {
|
||||
const briefing = '=== Task Briefing for alice ===\nTask #1: Fix bug\n';
|
||||
expect(parseTextOutput(briefing)).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(parseTextOutput('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatError', () => {
|
||||
it('uses stderr when available', () => {
|
||||
expect(formatError('Task not found: #42\n', '')).toBe('Task not found: #42');
|
||||
});
|
||||
|
||||
it('falls back to stdout', () => {
|
||||
expect(formatError('', 'Unexpected error\n')).toBe('Unexpected error');
|
||||
});
|
||||
|
||||
it('returns default for empty', () => {
|
||||
expect(formatError('', '')).toBe('Unknown teamctl error');
|
||||
});
|
||||
});
|
||||
220
mcp-server/test/schemas.test.ts
Normal file
220
mcp-server/test/schemas.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
teamNameSchema,
|
||||
taskIdSchema,
|
||||
memberNameSchema,
|
||||
taskStatusSchema,
|
||||
kanbanColumnSchema,
|
||||
clarificationSchema,
|
||||
linkTypeSchema,
|
||||
linkOperationSchema,
|
||||
reviewDecisionSchema,
|
||||
reviewerOperationSchema,
|
||||
taskIdsArraySchema,
|
||||
filePathSchema,
|
||||
safeFilenameSchema,
|
||||
mimeTypeSchema,
|
||||
} from '../src/schemas.js';
|
||||
|
||||
describe('teamNameSchema', () => {
|
||||
it('accepts valid team names', () => {
|
||||
expect(teamNameSchema.parse('acme')).toBe('acme');
|
||||
expect(teamNameSchema.parse('my-team')).toBe('my-team');
|
||||
expect(teamNameSchema.parse('My_Team')).toBe('My_Team');
|
||||
expect(teamNameSchema.parse('team123')).toBe('team123');
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => teamNameSchema.parse('')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => teamNameSchema.parse('..')).toThrow();
|
||||
expect(() => teamNameSchema.parse('.')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a/b')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a\\b')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a..b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => teamNameSchema.parse('a\0b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => teamNameSchema.parse('a'.repeat(129))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskIdSchema', () => {
|
||||
it('accepts numeric IDs', () => {
|
||||
expect(taskIdSchema.parse('1')).toBe('1');
|
||||
expect(taskIdSchema.parse('42')).toBe('42');
|
||||
expect(taskIdSchema.parse('1234567890')).toBe('1234567890');
|
||||
});
|
||||
|
||||
it('accepts zero', () => {
|
||||
expect(taskIdSchema.parse('0')).toBe('0');
|
||||
});
|
||||
|
||||
it('accepts leading zeros', () => {
|
||||
expect(taskIdSchema.parse('007')).toBe('007');
|
||||
});
|
||||
|
||||
it('rejects non-numeric', () => {
|
||||
expect(() => taskIdSchema.parse('abc')).toThrow();
|
||||
expect(() => taskIdSchema.parse('')).toThrow();
|
||||
expect(() => taskIdSchema.parse('1.5')).toThrow();
|
||||
expect(() => taskIdSchema.parse('-1')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => taskIdSchema.parse('12345678901')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memberNameSchema', () => {
|
||||
it('accepts valid member names', () => {
|
||||
expect(memberNameSchema.parse('alice')).toBe('alice');
|
||||
expect(memberNameSchema.parse('bob-smith')).toBe('bob-smith');
|
||||
expect(memberNameSchema.parse('user_1')).toBe('user_1');
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => memberNameSchema.parse('..')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a/b')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a\\b')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a..b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => memberNameSchema.parse('a\0b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => memberNameSchema.parse('a'.repeat(129))).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => memberNameSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enum schemas', () => {
|
||||
it('taskStatusSchema accepts valid values', () => {
|
||||
expect(taskStatusSchema.parse('pending')).toBe('pending');
|
||||
expect(taskStatusSchema.parse('in_progress')).toBe('in_progress');
|
||||
expect(taskStatusSchema.parse('completed')).toBe('completed');
|
||||
expect(taskStatusSchema.parse('deleted')).toBe('deleted');
|
||||
expect(() => taskStatusSchema.parse('invalid')).toThrow();
|
||||
});
|
||||
|
||||
it('kanbanColumnSchema accepts valid values', () => {
|
||||
expect(kanbanColumnSchema.parse('review')).toBe('review');
|
||||
expect(kanbanColumnSchema.parse('approved')).toBe('approved');
|
||||
expect(() => kanbanColumnSchema.parse('todo')).toThrow();
|
||||
expect(() => kanbanColumnSchema.parse('')).toThrow();
|
||||
});
|
||||
|
||||
it('clarificationSchema accepts valid values', () => {
|
||||
expect(clarificationSchema.parse('lead')).toBe('lead');
|
||||
expect(clarificationSchema.parse('user')).toBe('user');
|
||||
expect(clarificationSchema.parse('clear')).toBe('clear');
|
||||
expect(() => clarificationSchema.parse('nobody')).toThrow();
|
||||
});
|
||||
|
||||
it('linkTypeSchema accepts valid values', () => {
|
||||
expect(linkTypeSchema.parse('blocked-by')).toBe('blocked-by');
|
||||
expect(linkTypeSchema.parse('blocks')).toBe('blocks');
|
||||
expect(linkTypeSchema.parse('related')).toBe('related');
|
||||
});
|
||||
|
||||
it('linkOperationSchema accepts valid values', () => {
|
||||
expect(linkOperationSchema.parse('link')).toBe('link');
|
||||
expect(linkOperationSchema.parse('unlink')).toBe('unlink');
|
||||
});
|
||||
|
||||
it('reviewDecisionSchema accepts valid values', () => {
|
||||
expect(reviewDecisionSchema.parse('approve')).toBe('approve');
|
||||
expect(reviewDecisionSchema.parse('request-changes')).toBe('request-changes');
|
||||
});
|
||||
|
||||
it('reviewerOperationSchema accepts valid values', () => {
|
||||
expect(reviewerOperationSchema.parse('list')).toBe('list');
|
||||
expect(reviewerOperationSchema.parse('add')).toBe('add');
|
||||
expect(reviewerOperationSchema.parse('remove')).toBe('remove');
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskIdsArraySchema', () => {
|
||||
it('accepts valid arrays', () => {
|
||||
expect(taskIdsArraySchema.parse(['1', '2', '3'])).toEqual(['1', '2', '3']);
|
||||
expect(taskIdsArraySchema.parse([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects arrays with invalid IDs', () => {
|
||||
expect(() => taskIdsArraySchema.parse(['abc'])).toThrow();
|
||||
expect(() => taskIdsArraySchema.parse(['1', 'bad'])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filePathSchema', () => {
|
||||
it('accepts absolute paths', () => {
|
||||
expect(filePathSchema.parse('/home/user/file.txt')).toBe('/home/user/file.txt');
|
||||
expect(filePathSchema.parse('/tmp/attachment.pdf')).toBe('/tmp/attachment.pdf');
|
||||
});
|
||||
|
||||
it('rejects relative paths', () => {
|
||||
expect(() => filePathSchema.parse('relative/path.txt')).toThrow();
|
||||
expect(() => filePathSchema.parse('./file.txt')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => filePathSchema.parse('/home/user/../etc/passwd')).toThrow();
|
||||
expect(() => filePathSchema.parse('/home/../../../etc/shadow')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => filePathSchema.parse('/home/user/\0evil')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => filePathSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeFilenameSchema', () => {
|
||||
it('accepts valid filenames', () => {
|
||||
expect(safeFilenameSchema.parse('report.pdf')).toBe('report.pdf');
|
||||
expect(safeFilenameSchema.parse('my-file_v2.tar.gz')).toBe('my-file_v2.tar.gz');
|
||||
});
|
||||
|
||||
it('rejects path separators', () => {
|
||||
expect(() => safeFilenameSchema.parse('../../evil')).toThrow();
|
||||
expect(() => safeFilenameSchema.parse('dir/file')).toThrow();
|
||||
expect(() => safeFilenameSchema.parse('dir\\file')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => safeFilenameSchema.parse('file\0name')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => safeFilenameSchema.parse('a'.repeat(256))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mimeTypeSchema', () => {
|
||||
it('accepts valid MIME types', () => {
|
||||
expect(mimeTypeSchema.parse('application/pdf')).toBe('application/pdf');
|
||||
expect(mimeTypeSchema.parse('image/png')).toBe('image/png');
|
||||
expect(mimeTypeSchema.parse('text/plain')).toBe('text/plain');
|
||||
expect(mimeTypeSchema.parse('application/octet-stream')).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('rejects invalid formats', () => {
|
||||
expect(() => mimeTypeSchema.parse('invalid')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('/pdf')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('application/')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
78
mcp-server/test/teamctl-runner.test.ts
Normal file
78
mcp-server/test/teamctl-runner.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TeamctlRunner } from '../src/teamctl-runner.js';
|
||||
import type { ITeamctlRunner, TeamctlResult } from '../src/teamctl-runner.js';
|
||||
|
||||
// We can't easily test the real subprocess without teamctl.js installed,
|
||||
// so we test the interface contract and error handling.
|
||||
|
||||
describe('TeamctlRunner', () => {
|
||||
it('throws if teamctl.js does not exist', () => {
|
||||
expect(
|
||||
() => new TeamctlRunner({ teamctlPath: '/nonexistent/teamctl.js' }),
|
||||
).toThrow('teamctl.js not found');
|
||||
});
|
||||
|
||||
it('resolves path from TEAMCTL_PATH env', () => {
|
||||
const original = process.env['TEAMCTL_PATH'];
|
||||
try {
|
||||
process.env['TEAMCTL_PATH'] = '/tmp/test-teamctl.js';
|
||||
// Will throw because file doesn't exist, but we can check the error message
|
||||
expect(
|
||||
() => new TeamctlRunner(),
|
||||
).toThrow('/tmp/test-teamctl.js');
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env['TEAMCTL_PATH'] = original;
|
||||
} else {
|
||||
delete process.env['TEAMCTL_PATH'];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mock runner for tool tests
|
||||
export function createMockRunner(
|
||||
responses: Map<string, TeamctlResult> | TeamctlResult,
|
||||
): ITeamctlRunner {
|
||||
return {
|
||||
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
|
||||
if (responses instanceof Map) {
|
||||
const key = args.join(' ');
|
||||
const result = responses.get(key);
|
||||
if (result) return result;
|
||||
// Fallback: check if any key is a prefix
|
||||
for (const [k, v] of responses) {
|
||||
if (key.startsWith(k)) return v;
|
||||
}
|
||||
return { stdout: '', stderr: 'No mock for: ' + key, exitCode: 1 };
|
||||
}
|
||||
return responses;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ITeamctlRunner interface', () => {
|
||||
it('mock runner returns success', async () => {
|
||||
const runner = createMockRunner({
|
||||
stdout: '{"id":"1","subject":"Test"}\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await runner.execute(['--team', 'test', 'task', 'create']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toHaveProperty('id', '1');
|
||||
});
|
||||
|
||||
it('mock runner returns error', async () => {
|
||||
const runner = createMockRunner({
|
||||
stdout: '',
|
||||
stderr: 'Task not found: #99\n',
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
const result = await runner.execute(['--team', 'test', 'task', 'get', '99']);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Task not found');
|
||||
});
|
||||
});
|
||||
42
mcp-server/test/tools/kanban-move.test.ts
Normal file
42
mcp-server/test/tools/kanban-move.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/kanban-move.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('kanban_move', () => {
|
||||
function setup(response = ok('OK kanban #1 column=review\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('kanban_move')! };
|
||||
}
|
||||
|
||||
it('builds set-column CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', operation: 'set-column', column: 'review' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'set-column', '1', 'review',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds clear CLI args', async () => {
|
||||
const { runner, tool } = setup(ok('OK kanban #1 cleared\n'));
|
||||
await tool.execute({ team: 'acme', task_id: '1', operation: 'clear' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'clear', '1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws UserError when set-column called without column', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', operation: 'set-column' }),
|
||||
).rejects.toThrow('column is required');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '99', operation: 'clear' }),
|
||||
).rejects.toThrow('Failed to update kanban');
|
||||
});
|
||||
});
|
||||
61
mcp-server/test/tools/kanban-reviewers.test.ts
Normal file
61
mcp-server/test/tools/kanban-reviewers.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/kanban-reviewers.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('kanban_reviewers', () => {
|
||||
function setup(response = ok('["alice","bob"]')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('kanban_reviewers')! };
|
||||
}
|
||||
|
||||
it('builds list CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', operation: 'list' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'reviewers', 'list',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns JSON array for list', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', operation: 'list' });
|
||||
expect(result).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('builds add CLI args with name', async () => {
|
||||
const { runner, tool } = setup(ok('OK reviewer added\n'));
|
||||
await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'reviewers', 'add', 'charlie',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns OK text for add/remove', async () => {
|
||||
const { tool } = setup(ok('OK reviewer added\n'));
|
||||
const result = await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
|
||||
expect(result).toBe('reviewer added');
|
||||
});
|
||||
|
||||
it('throws UserError when add called without name', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'add' }),
|
||||
).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('throws UserError when remove called without name', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'remove' }),
|
||||
).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('reviewer not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'remove', name: 'nobody' }),
|
||||
).rejects.toThrow('Failed to manage reviewers');
|
||||
});
|
||||
});
|
||||
48
mcp-server/test/tools/message-send.test.ts
Normal file
48
mcp-server/test/tools/message-send.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/message-send.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('message_send', () => {
|
||||
const deliveryJson = '{"deliveredToInbox":true,"messageId":"msg_abc"}';
|
||||
|
||||
function setup(response = ok(deliveryJson)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('message_send')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'message', 'send', '--to', 'alice', '--text', 'Hello!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes summary and from flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', to: 'alice', text: 'Task done',
|
||||
summary: 'Completed task #1', from: 'bob',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--summary');
|
||||
expect(args[args.indexOf('--summary') + 1]).toBe('Completed task #1');
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('bob');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
|
||||
expect(result).toEqual({ deliveredToInbox: true, messageId: 'msg_abc' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('recipient inbox not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', to: 'nobody', text: 'Hi' }),
|
||||
).rejects.toThrow('Failed to send message');
|
||||
});
|
||||
});
|
||||
28
mcp-server/test/tools/register-all.test.ts
Normal file
28
mcp-server/test/tools/register-all.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { registerAllTools } from '../../src/tools/index.js';
|
||||
import { createMockRunner, createMockServer } from './test-helpers.js';
|
||||
|
||||
describe('registerAllTools', () => {
|
||||
it('registers exactly 13 tools', () => {
|
||||
const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const { server, tools } = createMockServer();
|
||||
registerAllTools(server, runner);
|
||||
expect(tools.size).toBe(13);
|
||||
});
|
||||
|
||||
it('registers all expected tool names', () => {
|
||||
const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const { server, tools } = createMockServer();
|
||||
registerAllTools(server, runner);
|
||||
|
||||
const expected = [
|
||||
'task_create', 'task_set_status', 'task_set_owner',
|
||||
'task_get', 'task_list', 'task_comment', 'task_link',
|
||||
'task_briefing', 'task_attach', 'kanban_move',
|
||||
'kanban_reviewers', 'review_action', 'message_send',
|
||||
];
|
||||
for (const name of expected) {
|
||||
expect(tools.has(name), `missing tool: ${name}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
66
mcp-server/test/tools/review-action.test.ts
Normal file
66
mcp-server/test/tools/review-action.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/review-action.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('review_action', () => {
|
||||
function setup(response = ok('OK review #1 approved\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('review_action')! };
|
||||
}
|
||||
|
||||
it('builds approve CLI args (no comment)', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', decision: 'approve' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'review', 'approve', '1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds approve CLI args with --note (not --comment)', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', decision: 'approve', comment: 'LGTM' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
// approve uses --note, NOT --comment
|
||||
expect(args).toContain('--note');
|
||||
expect(args[args.indexOf('--note') + 1]).toBe('LGTM');
|
||||
expect(args).not.toContain('--comment');
|
||||
});
|
||||
|
||||
it('builds request-changes CLI args with --comment (not --note)', async () => {
|
||||
const { runner, tool } = setup(ok('OK review #1 requested changes\n'));
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', decision: 'request-changes', comment: 'Fix tests',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--comment');
|
||||
expect(args[args.indexOf('--comment') + 1]).toBe('Fix tests');
|
||||
expect(args).not.toContain('--note');
|
||||
});
|
||||
|
||||
it('throws when request-changes has no comment', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', decision: 'request-changes' }),
|
||||
).rejects.toThrow('comment is required when requesting changes');
|
||||
});
|
||||
|
||||
it('includes from and notify_owner flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', decision: 'approve',
|
||||
from: 'alice', notify_owner: true,
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--from');
|
||||
expect(args).toContain('--notify-owner');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not in review'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }),
|
||||
).rejects.toThrow('Failed to approve');
|
||||
});
|
||||
});
|
||||
49
mcp-server/test/tools/task-attach.test.ts
Normal file
49
mcp-server/test/tools/task-attach.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-attach.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_attach', () => {
|
||||
const attachJson = '{"id":"att_123","filename":"report.pdf","mimeType":"application/pdf"}';
|
||||
|
||||
function setup(response = ok(attachJson)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_attach')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'attach', '1', '--file', '/tmp/report.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all optional flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', file: '/tmp/file.pdf',
|
||||
filename: 'renamed.pdf', mime_type: 'application/pdf',
|
||||
mode: 'link', from: 'alice',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--filename');
|
||||
expect(args).toContain('--mime-type');
|
||||
expect(args).toContain('--mode');
|
||||
expect(args).toContain('--from');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
|
||||
expect(result).toEqual({ id: 'att_123', filename: 'report.pdf', mimeType: 'application/pdf' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('file too large'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', file: '/tmp/huge.bin' }),
|
||||
).rejects.toThrow('Failed to attach file');
|
||||
});
|
||||
});
|
||||
33
mcp-server/test/tools/task-briefing.test.ts
Normal file
33
mcp-server/test/tools/task-briefing.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-briefing.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_briefing', () => {
|
||||
const briefingText = '=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]\n';
|
||||
|
||||
function setup(response = ok(briefingText)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_briefing')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', member: 'alice' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'briefing', '--for', 'alice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns trimmed plain text', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', member: 'alice' });
|
||||
expect(result).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('member not found'));
|
||||
await expect(tool.execute({ team: 'acme', member: 'nobody' })).rejects.toThrow('Failed to get briefing');
|
||||
});
|
||||
});
|
||||
33
mcp-server/test/tools/task-comment.test.ts
Normal file
33
mcp-server/test/tools/task-comment.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-comment.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_comment', () => {
|
||||
function setup(response = ok('OK comment added to task #1\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_comment')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', text: 'Looking good!' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'comment', '1', '--text', 'Looking good!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes from flag', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', text: 'Done', from: 'alice' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('alice');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not found'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '99', text: 'Hi' })).rejects.toThrow('Failed to add comment');
|
||||
});
|
||||
});
|
||||
65
mcp-server/test/tools/task-create.test.ts
Normal file
65
mcp-server/test/tools/task-create.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-create.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_create', () => {
|
||||
function setup(response = ok('{"id":"1","subject":"Fix bug","status":"in_progress"}')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_create')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', subject: 'Fix bug' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'create', '--subject', 'Fix bug',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all optional flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme',
|
||||
subject: 'Big task',
|
||||
description: 'Details here',
|
||||
owner: 'alice',
|
||||
blocked_by: ['1', '2'],
|
||||
related: ['3'],
|
||||
status: 'pending',
|
||||
active_form: 'Fixing bug',
|
||||
notify: true,
|
||||
from: 'bob',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--description');
|
||||
expect(args).toContain('--owner');
|
||||
expect(args).toContain('--blocked-by');
|
||||
expect(args[args.indexOf('--blocked-by') + 1]).toBe('1,2');
|
||||
expect(args).toContain('--related');
|
||||
expect(args[args.indexOf('--related') + 1]).toBe('3');
|
||||
expect(args).toContain('--status');
|
||||
expect(args).toContain('--activeForm');
|
||||
expect(args).toContain('--notify');
|
||||
expect(args).toContain('--from');
|
||||
});
|
||||
|
||||
it('skips empty blocked_by array', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', subject: 'Task', blocked_by: [] });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).not.toContain('--blocked-by');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', subject: 'Fix bug' });
|
||||
expect(result).toEqual({ id: '1', subject: 'Fix bug', status: 'in_progress' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('team not found'));
|
||||
await expect(tool.execute({ team: 'bad', subject: 'X' })).rejects.toThrow('Failed to create task');
|
||||
});
|
||||
});
|
||||
29
mcp-server/test/tools/task-get.test.ts
Normal file
29
mcp-server/test/tools/task-get.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-get.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_get', () => {
|
||||
function setup(response = ok('{"id":"42","subject":"Test","status":"pending"}')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_get')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '42' });
|
||||
expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'get', '42']);
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '42' });
|
||||
expect(result).toEqual({ id: '42', subject: 'Test', status: 'pending' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('Task not found: #99'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '99' })).rejects.toThrow('Failed to get task');
|
||||
});
|
||||
});
|
||||
44
mcp-server/test/tools/task-link.test.ts
Normal file
44
mcp-server/test/tools/task-link.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-link.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_link', () => {
|
||||
function setup(response = ok('OK task #1 blocked-by #2\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_link')! };
|
||||
}
|
||||
|
||||
it('builds link CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'link',
|
||||
relationship: 'blocked-by', target_id: '2',
|
||||
});
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'link', '1', '--blocked-by', '2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds unlink CLI args', async () => {
|
||||
const { runner, tool } = setup(ok('OK task #1 unlinked from #2\n'));
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'unlink',
|
||||
relationship: 'related', target_id: '3',
|
||||
});
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'unlink', '1', '--related', '3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('circular dependency'));
|
||||
await expect(
|
||||
tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'link',
|
||||
relationship: 'blocked-by', target_id: '1',
|
||||
}),
|
||||
).rejects.toThrow('Failed to link tasks');
|
||||
});
|
||||
});
|
||||
29
mcp-server/test/tools/task-list.test.ts
Normal file
29
mcp-server/test/tools/task-list.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-list.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_list', () => {
|
||||
function setup(response = ok('[{"id":"1"},{"id":"2"}]')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_list')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme' });
|
||||
expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'list']);
|
||||
});
|
||||
|
||||
it('returns parsed JSON array', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme' });
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('team dir not found'));
|
||||
await expect(tool.execute({ team: 'bad' })).rejects.toThrow('Failed to list tasks');
|
||||
});
|
||||
});
|
||||
42
mcp-server/test/tools/task-set-owner.test.ts
Normal file
42
mcp-server/test/tools/task-set-owner.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-set-owner.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_set_owner', () => {
|
||||
function setup(response = ok('OK task #1 owner=alice\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_set_owner')! };
|
||||
}
|
||||
|
||||
it('builds args for assignment', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'alice' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-owner', '1', 'alice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds args for clear', async () => {
|
||||
const { runner, tool } = setup(ok('OK task #1 owner=cleared\n'));
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'clear' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-owner', '1', 'clear',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes notify and from flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'alice', notify: true, from: 'bob' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--notify');
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('bob');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('member not found'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '1', owner: 'nobody' })).rejects.toThrow('Failed to set owner');
|
||||
});
|
||||
});
|
||||
31
mcp-server/test/tools/task-set-status.test.ts
Normal file
31
mcp-server/test/tools/task-set-status.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-set-status.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_set_status', () => {
|
||||
function setup(response = ok('OK task #1 status=completed\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_set_status')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-status', '1', 'completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns parsed OK text', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
|
||||
expect(result).toBe('task #1 status=completed');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('invalid transition'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '1', status: 'deleted' })).rejects.toThrow('Failed to set status');
|
||||
});
|
||||
});
|
||||
58
mcp-server/test/tools/test-helpers.ts
Normal file
58
mcp-server/test/tools/test-helpers.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { vi } from 'vitest';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner, TeamctlResult } from '../../src/teamctl-runner.js';
|
||||
|
||||
/**
|
||||
* Creates a mock ITeamctlRunner that records calls and returns predetermined results.
|
||||
*/
|
||||
export function createMockRunner(
|
||||
response: TeamctlResult | ((args: string[]) => TeamctlResult),
|
||||
): ITeamctlRunner & { execute: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
|
||||
return typeof response === 'function' ? response(args) : response;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Success response helpers */
|
||||
export const ok = (stdout: string): TeamctlResult => ({
|
||||
stdout,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
export const fail = (stderr: string): TeamctlResult => ({
|
||||
stdout: '',
|
||||
stderr,
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* Captures registered tools via a mock FastMCP-like server.
|
||||
* Returns a map of tool name → { execute function, parameters schema }.
|
||||
*/
|
||||
export interface CapturedTool {
|
||||
name: string;
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
parameters: unknown;
|
||||
}
|
||||
|
||||
export function createMockServer(): {
|
||||
server: FastMCP;
|
||||
tools: Map<string, CapturedTool>;
|
||||
} {
|
||||
const tools = new Map<string, CapturedTool>();
|
||||
|
||||
const server = {
|
||||
addTool: (def: { name: string; execute: CapturedTool['execute']; parameters: unknown }) => {
|
||||
tools.set(def.name, {
|
||||
name: def.name,
|
||||
execute: def.execute,
|
||||
parameters: def.parameters,
|
||||
});
|
||||
},
|
||||
} as unknown as FastMCP;
|
||||
|
||||
return { server, tools };
|
||||
}
|
||||
21
mcp-server/tsconfig.json
Normal file
21
mcp-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
14
mcp-server/tsup.config.ts
Normal file
14
mcp-server/tsup.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
10
mcp-server/vitest.config.ts
Normal file
10
mcp-server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
testTimeout: 15_000,
|
||||
},
|
||||
});
|
||||
1108
pnpm-lock.yaml
1108
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,2 +1,4 @@
|
|||
packages:
|
||||
- mcp-server
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -198,7 +198,8 @@ async function handleGetSessionsByIds(
|
|||
async function handleGetSessionDetail(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectId: string,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
options?: { bypassCache?: boolean }
|
||||
): Promise<SessionDetail | null> {
|
||||
try {
|
||||
const validatedProject = validateProjectId(projectId);
|
||||
|
|
@ -220,7 +221,7 @@ async function handleGetSessionDetail(
|
|||
// Check cache first
|
||||
let sessionDetail = dataCache.get(cacheKey);
|
||||
|
||||
if (sessionDetail) {
|
||||
if (sessionDetail && !options?.bypassCache) {
|
||||
return sessionDetail;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ async function handleGetSubagentDetail(
|
|||
_event: IpcMainInvokeEvent,
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
subagentId: string
|
||||
subagentId: string,
|
||||
options?: { bypassCache?: boolean }
|
||||
): Promise<SubagentDetail | null> {
|
||||
try {
|
||||
const validatedProject = validateProjectId(projectId);
|
||||
|
|
@ -85,7 +86,7 @@ async function handleGetSubagentDetail(
|
|||
// Check cache first
|
||||
let subagentDetail = dataCache.getSubagent(cacheKey);
|
||||
|
||||
if (subagentDetail) {
|
||||
if (subagentDetail && !options?.bypassCache) {
|
||||
return subagentDetail;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -402,11 +402,13 @@ async function handleGetData(
|
|||
}
|
||||
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const leadSessionTextFingerprints = new Set<string>();
|
||||
|
||||
// Collect text fingerprints from ALL non-live messages (inbox, lead_session, sentMessages)
|
||||
// so we can dedup lead_process live messages against them.
|
||||
const existingTextFingerprints = new Set<string>();
|
||||
for (const msg of data.messages) {
|
||||
if ((msg as { source?: unknown }).source !== 'lead_session') continue;
|
||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
||||
leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
||||
existingTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
||||
}
|
||||
|
||||
const keyFor = (m: {
|
||||
|
|
@ -421,14 +423,24 @@ async function handleGetData(
|
|||
return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`;
|
||||
};
|
||||
|
||||
// Text-based fingerprints for lead_process messages to catch duplicates
|
||||
// with different messageIds (e.g. lead-turn-* vs lead-sendmsg-* with same text)
|
||||
const leadProcessTextFingerprints = new Set<string>();
|
||||
|
||||
const merged: typeof data.messages = [];
|
||||
const seen = new Set<string>();
|
||||
for (const msg of [...data.messages, ...live]) {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process') {
|
||||
if ((msg as { source?: unknown }).source === 'lead_process' && !msg.to) {
|
||||
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||
if (leadSessionTextFingerprints.has(fp)) {
|
||||
// Skip if same text already exists from any source (inbox, lead_session, etc.)
|
||||
if (existingTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
// Dedup lead_process messages with same text but different messageIds
|
||||
if (leadProcessTextFingerprints.has(fp)) {
|
||||
continue;
|
||||
}
|
||||
leadProcessTextFingerprints.add(fp);
|
||||
}
|
||||
const key = keyFor(msg);
|
||||
if (seen.has(key)) continue;
|
||||
|
|
|
|||
|
|
@ -9,8 +9,18 @@ import type { FileLineStats, MemberFullStats } from '@shared/types';
|
|||
|
||||
const logger = createLogger('Service:MemberStatsComputer');
|
||||
|
||||
function isValidFilePath(value: string): boolean {
|
||||
return value.length > 0 && value !== 'null' && value !== 'undefined' && value !== 'None';
|
||||
const TRAILING_PUNCT_CHARS = new Set([';', '.', ',']);
|
||||
const INVALID_NAMES = new Set(['null', 'undefined', 'None', 'false', 'true', '']);
|
||||
|
||||
function stripTrailingPunct(s: string): string {
|
||||
let end = s.length;
|
||||
while (end > 0 && TRAILING_PUNCT_CHARS.has(s[end - 1])) end--;
|
||||
return end === s.length ? s : s.slice(0, end);
|
||||
}
|
||||
|
||||
export function isValidFilePath(value: string): boolean {
|
||||
const cleaned = stripTrailingPunct(value.trim());
|
||||
return cleaned.length > 1 && !INVALID_NAMES.has(cleaned) && cleaned.includes('/');
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
|
@ -73,11 +83,19 @@ export class MemberStatsComputer {
|
|||
.filter(isValidFilePath)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
// Also filter perFileStats keys to exclude invalid paths
|
||||
const filteredFileStats: Record<string, FileLineStats> = {};
|
||||
for (const [fp, fls] of Object.entries(perFileStats)) {
|
||||
if (isValidFilePath(fp)) {
|
||||
filteredFileStats[fp] = fls;
|
||||
}
|
||||
}
|
||||
|
||||
const stats: MemberFullStats = {
|
||||
linesAdded,
|
||||
linesRemoved,
|
||||
filesTouched: validFiles,
|
||||
fileStats: perFileStats,
|
||||
fileStats: filteredFileStats,
|
||||
toolUsage,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
|
|
@ -121,18 +139,24 @@ export class MemberStatsComputer {
|
|||
// Track last known content per file for accurate Write/NotebookEdit diffs
|
||||
const fileLastContent = new Map<string, string>();
|
||||
|
||||
const cleanPath = (fp: string): string => stripTrailingPunct(fp.trim());
|
||||
|
||||
const trackFile = (fp: string): void => {
|
||||
if (typeof fp === 'string' && isValidFilePath(fp)) filesTouchedSet.add(fp);
|
||||
if (typeof fp === 'string') {
|
||||
const cleaned = cleanPath(fp);
|
||||
if (isValidFilePath(cleaned)) filesTouchedSet.add(cleaned);
|
||||
}
|
||||
};
|
||||
|
||||
const addFileLines = (fp: string, added: number, removed: number): void => {
|
||||
if (!isValidFilePath(fp)) return;
|
||||
const existing = perFileStats[fp];
|
||||
const cleaned = cleanPath(fp);
|
||||
if (!isValidFilePath(cleaned)) return;
|
||||
const existing = perFileStats[cleaned];
|
||||
if (existing) {
|
||||
existing.added += added;
|
||||
existing.removed += removed;
|
||||
} else {
|
||||
perFileStats[fp] = { added, removed };
|
||||
perFileStats[cleaned] = { added, removed };
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -865,6 +865,7 @@ function sendInboxMessage(paths, teamName, flags) {
|
|||
summary,
|
||||
messageId,
|
||||
};
|
||||
if (flags.source) payload.source = flags.source;
|
||||
|
||||
var lastErr;
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
|
|
@ -915,6 +916,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
|
|||
text: inboxText,
|
||||
summary: 'Approved #' + String(taskId),
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -957,6 +959,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
text: inboxText,
|
||||
summary: 'Fix request for #' + String(taskId),
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1282,6 +1285,7 @@ async function main() {
|
|||
text: parts.join('\n'),
|
||||
summary: 'New task #' + String(task.id) + ' assigned',
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1319,6 +1323,7 @@ async function main() {
|
|||
text: 'Comment on task #' + String(result.taskId) + ' "' + String(result.subject) + '":\n\n' + (typeof args.flags.text === 'string' ? args.flags.text.trim() : ''),
|
||||
summary: 'Comment on #' + String(result.taskId),
|
||||
from: from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
} catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
|
@ -1396,6 +1401,7 @@ async function main() {
|
|||
text: parts.join('\n'),
|
||||
summary: 'Task #' + String(task.id) + ' assigned',
|
||||
from,
|
||||
source: 'system_notification',
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
|
@ -1493,7 +1499,10 @@ async function main() {
|
|||
|
||||
if (domain === 'message') {
|
||||
if (action === 'send') {
|
||||
const result = sendInboxMessage(paths, teamName, args.flags);
|
||||
// Strip source from agent flags — only internal callers may set it
|
||||
var msgFlags = Object.assign({}, args.flags);
|
||||
delete msgFlags.source;
|
||||
const result = sendInboxMessage(paths, teamName, msgFlags);
|
||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,15 @@ import {
|
|||
} from '@main/utils/pathDecoder';
|
||||
import { isProcessAlive } from '@main/utils/processHealth';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import {
|
||||
AGENT_BLOCK_CLOSE,
|
||||
AGENT_BLOCK_OPEN,
|
||||
stripAgentBlocks,
|
||||
} from '@shared/constants/agentBlocks';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
|
@ -54,13 +59,14 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamTaskWithKanban,
|
||||
ToolCallMeta,
|
||||
UpdateKanbanPatch,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamDataService');
|
||||
|
||||
const MIN_TEXT_LENGTH = 30;
|
||||
const MAX_LEAD_TEXTS = 50;
|
||||
const MAX_LEAD_TEXTS = 150;
|
||||
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||
const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const TASK_MAP_YIELD_EVERY = 250;
|
||||
|
|
@ -283,6 +289,7 @@ export class TeamDataService {
|
|||
|
||||
// Dedup: if a lead_process message text is also present in lead_session, prefer lead_session.
|
||||
// This avoids double-rendering when we persist lead process messages and later load the lead JSONL.
|
||||
// Exception: lead_process messages with `to` field are captured SendMessage — never dedup those.
|
||||
if (leadTexts.length > 0 && sentMessages.length > 0) {
|
||||
const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n');
|
||||
const leadSessionFingerprints = new Set<string>();
|
||||
|
|
@ -292,17 +299,67 @@ export class TeamDataService {
|
|||
}
|
||||
messages = messages.filter((m) => {
|
||||
if (m.source !== 'lead_process') return true;
|
||||
// Captured SendMessage messages (with recipient) are real messages — never dedup
|
||||
if (m.to) return true;
|
||||
const fp = `${m.from}\0${normalizeText(m.text ?? '')}`;
|
||||
return !leadSessionFingerprints.has(fp);
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich messages without leadSessionId: assign current session for lead_process/user_sent.
|
||||
// lead_process messages surviving dedup are from the current session;
|
||||
// user_sent messages written before this feature lack the field.
|
||||
if (config.leadSessionId) {
|
||||
for (const msg of messages) {
|
||||
if (!msg.leadSessionId && (msg.source === 'lead_process' || msg.source === 'user_sent')) {
|
||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||
// session ID (by timestamp). This avoids the old forward-only propagation bug where
|
||||
// messages between two sessions always inherited the *earlier* session, causing a
|
||||
// spurious "New session" divider even when the message is chronologically closer to
|
||||
// the later session.
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
|
||||
// Collect indices of messages that already have a leadSessionId (anchors).
|
||||
const anchors: { index: number; time: number; sessionId: string }[] = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].leadSessionId) {
|
||||
anchors.push({
|
||||
index: i,
|
||||
time: Date.parse(messages[i].timestamp),
|
||||
sessionId: messages[i].leadSessionId!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (anchors.length > 0) {
|
||||
// For each message without leadSessionId, find the closest anchor by timestamp
|
||||
// and inherit its sessionId.
|
||||
let anchorIdx = 0;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].leadSessionId) {
|
||||
// Advance anchorIdx to track current position for efficient lookup
|
||||
while (anchorIdx < anchors.length - 1 && anchors[anchorIdx].index < i) {
|
||||
anchorIdx++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const msgTime = Date.parse(messages[i].timestamp);
|
||||
|
||||
// Find closest anchor by timestamp (binary-search-like scan from current position)
|
||||
let bestAnchor = anchors[0];
|
||||
let bestDist = Math.abs(msgTime - bestAnchor.time);
|
||||
for (const anchor of anchors) {
|
||||
const dist = Math.abs(msgTime - anchor.time);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestAnchor = anchor;
|
||||
} else if (dist > bestDist && anchor.time > msgTime) {
|
||||
// Anchors are sorted by index (asc time) — once distance grows past the
|
||||
// message time, further anchors will only be farther.
|
||||
break;
|
||||
}
|
||||
}
|
||||
messages[i].leadSessionId = bestAnchor.sessionId;
|
||||
}
|
||||
} else if (config.leadSessionId) {
|
||||
// No anchors at all — fall back to config.leadSessionId for everything.
|
||||
for (const msg of messages) {
|
||||
msg.leadSessionId = config.leadSessionId;
|
||||
}
|
||||
}
|
||||
|
|
@ -1103,7 +1160,19 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async sendMessage(teamName: string, request: SendMessageRequest): Promise<SendMessageResult> {
|
||||
return this.inboxWriter.sendMessage(teamName, request);
|
||||
// Enrich with leadSessionId so session boundary separators work
|
||||
let enrichedRequest = request;
|
||||
if (!enrichedRequest.leadSessionId) {
|
||||
try {
|
||||
const config = await this.configReader.getConfig(teamName);
|
||||
if (config?.leadSessionId) {
|
||||
enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId };
|
||||
}
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
}
|
||||
return this.inboxWriter.sendMessage(teamName, enrichedRequest);
|
||||
}
|
||||
|
||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||
|
|
@ -1357,13 +1426,29 @@ export class TeamDataService {
|
|||
|
||||
const projectId = encodePath(config.projectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
|
||||
let jsonlPath = path.join(getProjectsBasePath(), baseDir, `${config.leadSessionId}.jsonl`);
|
||||
|
||||
try {
|
||||
await fs.promises.access(jsonlPath, fs.constants.F_OK);
|
||||
} catch {
|
||||
logger.debug(`Lead session JSONL not found: ${jsonlPath}`);
|
||||
return [];
|
||||
// Claude Code encodes underscores as hyphens in project directory names;
|
||||
// our encodePath only handles slashes. Try the underscore-to-hyphen variant.
|
||||
const altBaseDir = baseDir.replace(/_/g, '-');
|
||||
if (altBaseDir !== baseDir) {
|
||||
const altPath = path.join(
|
||||
getProjectsBasePath(),
|
||||
altBaseDir,
|
||||
`${config.leadSessionId}.jsonl`
|
||||
);
|
||||
try {
|
||||
await fs.promises.access(altPath, fs.constants.F_OK);
|
||||
jsonlPath = altPath;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
|
||||
|
|
@ -1374,6 +1459,7 @@ export class TeamDataService {
|
|||
const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB
|
||||
|
||||
const textsReversed: InboxMessage[] = [];
|
||||
const seenMessageIds = new Set<string>();
|
||||
const handle = await fs.promises.open(jsonlPath, 'r');
|
||||
try {
|
||||
const stat = await handle.stat();
|
||||
|
|
@ -1410,20 +1496,69 @@ export class TeamDataService {
|
|||
const timestamp =
|
||||
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const block of content as Record<string, unknown>[]) {
|
||||
if (block.type !== 'text' || typeof block.text !== 'string') continue;
|
||||
const text = block.text.trim();
|
||||
if (text.length < MIN_TEXT_LENGTH) continue;
|
||||
textsReversed.push({
|
||||
from: leadName,
|
||||
text,
|
||||
timestamp,
|
||||
read: true,
|
||||
source: 'lead_session',
|
||||
leadSessionId: config.leadSessionId,
|
||||
});
|
||||
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
||||
textParts.push(block.text);
|
||||
}
|
||||
if (textParts.length === 0) continue;
|
||||
|
||||
const combined = stripAgentBlocks(textParts.join('\n')).trim();
|
||||
if (combined.length < MIN_TEXT_LENGTH) continue;
|
||||
|
||||
// Collect tool_use details from following lines (text and tool_use are separate in JSONL).
|
||||
// tool_result (type=user) lines are interleaved between tool_use lines — skip them.
|
||||
const toolCallsList: ToolCallMeta[] = [];
|
||||
const lookaheadLimit = Math.min(i + 200, lines.length);
|
||||
for (let j = i + 1; j < lookaheadLimit; j++) {
|
||||
const tLine = lines[j]?.trim();
|
||||
if (!tLine) continue;
|
||||
let tMsg: Record<string, unknown>;
|
||||
try {
|
||||
tMsg = JSON.parse(tLine) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines
|
||||
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
|
||||
const tContent = tMessage.content;
|
||||
if (!Array.isArray(tContent)) continue;
|
||||
const tBlocks = tContent as Record<string, unknown>[];
|
||||
if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
|
||||
for (const b of tBlocks) {
|
||||
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
|
||||
const input = (b.input ?? {}) as Record<string, unknown>;
|
||||
toolCallsList.push({
|
||||
name: b.name,
|
||||
preview: extractToolPreview(b.name, input),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
|
||||
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
|
||||
|
||||
// Stable messageId: timestamp + text prefix (survives tail-scan range changes)
|
||||
const textPrefix = combined
|
||||
.slice(0, 50)
|
||||
.replace(/[^\p{L}\p{N}]/gu, '')
|
||||
.slice(0, 20);
|
||||
|
||||
const messageId = `lead-session-${timestamp}-${textPrefix}`;
|
||||
if (seenMessageIds.has(messageId)) continue;
|
||||
seenMessageIds.add(messageId);
|
||||
|
||||
textsReversed.push({
|
||||
from: leadName,
|
||||
text: combined,
|
||||
timestamp,
|
||||
read: true,
|
||||
source: 'lead_session',
|
||||
leadSessionId: config.leadSessionId,
|
||||
messageId,
|
||||
toolSummary,
|
||||
toolCalls,
|
||||
});
|
||||
if (textsReversed.length >= MAX_LEAD_TEXTS) break;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ export class TeamInboxReader {
|
|||
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||
color: typeof row.color === 'string' ? row.color : undefined,
|
||||
messageId: typeof row.messageId === 'string' ? row.messageId : undefined,
|
||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export class TeamInboxWriter {
|
|||
messageId,
|
||||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||
...(request.source && { source: request.source }),
|
||||
...(request.leadSessionId && { leadSessionId: request.leadSessionId }),
|
||||
};
|
||||
|
||||
await withInboxLock(inboxPath, async () => {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage';
|
|||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { createCliAutoSuffixNameGuard } from '@shared/utils/teamMemberName';
|
||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
|
|
@ -49,6 +50,7 @@ import type {
|
|||
TeamProvisioningProgress,
|
||||
TeamProvisioningState,
|
||||
TeamTask,
|
||||
ToolCallMeta,
|
||||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamProvisioning');
|
||||
|
|
@ -60,7 +62,7 @@ const STDOUT_RING_LIMIT = 64 * 1024;
|
|||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||
const PROBE_CACHE_TTL_MS = 10 * 60_000;
|
||||
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 60000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
|
|
@ -79,8 +81,6 @@ const PREFLIGHT_PING_ARGS = [
|
|||
'haiku',
|
||||
'--max-turns',
|
||||
'1',
|
||||
'--max-budget-usd',
|
||||
'0.05',
|
||||
'--no-session-persistence',
|
||||
] as const;
|
||||
const PREFLIGHT_EXPECTED = 'PONG';
|
||||
|
|
@ -163,15 +163,10 @@ 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[];
|
||||
/** Monotonic counter for stream-json turns (incremented on result). */
|
||||
leadTurnSeq: number;
|
||||
/** Stable timestamp used for the current aggregated lead turn message. */
|
||||
leadTurnMessageTimestamp: string | null;
|
||||
/** Monotonic counter for individual lead assistant messages. */
|
||||
leadMsgSeq: number;
|
||||
/** Accumulated tool_use details between text messages. */
|
||||
pendingToolCalls: ToolCallMeta[];
|
||||
/** Throttle timestamp for emitting inbox refresh events for lead text. */
|
||||
lastLeadTextEmitMs: number;
|
||||
/**
|
||||
|
|
@ -1752,9 +1747,8 @@ export class TeamProvisioningService {
|
|||
isLaunch: false,
|
||||
fsPhase: 'waiting_config',
|
||||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
leadTurnSeq: 0,
|
||||
leadTurnMessageTimestamp: null,
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCalls: [],
|
||||
lastLeadTextEmitMs: 0,
|
||||
silentUserDmForward: null,
|
||||
silentUserDmForwardClearHandle: null,
|
||||
|
|
@ -2053,9 +2047,8 @@ export class TeamProvisioningService {
|
|||
isLaunch: true,
|
||||
fsPhase: 'waiting_members',
|
||||
leadRelayCapture: null,
|
||||
directReplyParts: [],
|
||||
leadTurnSeq: 0,
|
||||
leadTurnMessageTimestamp: null,
|
||||
leadMsgSeq: 0,
|
||||
pendingToolCalls: [],
|
||||
lastLeadTextEmitMs: 0,
|
||||
silentUserDmForward: null,
|
||||
silentUserDmForwardClearHandle: null,
|
||||
|
|
@ -2493,8 +2486,6 @@ export class TeamProvisioningService {
|
|||
},
|
||||
};
|
||||
run.leadRelayCapture = capture;
|
||||
// Clear any direct reply parts — relay capture takes priority
|
||||
run.directReplyParts = [];
|
||||
});
|
||||
|
||||
try {
|
||||
|
|
@ -2797,6 +2788,16 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
pushLiveLeadProcessMessage(teamName: string, message: InboxMessage): void {
|
||||
// Enrich with leadSessionId if missing — needed for session boundary separators
|
||||
if (!message.leadSessionId) {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (runId) {
|
||||
const run = this.runs.get(runId);
|
||||
if (run?.detectedSessionId) {
|
||||
message.leadSessionId = run.detectedSessionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
const MAX = 100;
|
||||
const list = this.liveLeadProcessMessages.get(teamName) ?? [];
|
||||
const id = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
|
|
@ -2876,7 +2877,7 @@ export class TeamProvisioningService {
|
|||
const hasSendMessageToUser = (content ?? []).some((part) => {
|
||||
if (!part || typeof part !== 'object') return false;
|
||||
if (part.type !== 'tool_use' || part.name !== 'SendMessage') return false;
|
||||
const input = (part as Record<string, unknown>).input;
|
||||
const input = part.input;
|
||||
if (!input || typeof input !== 'object') return false;
|
||||
return (input as Record<string, unknown>).recipient === 'user';
|
||||
});
|
||||
|
|
@ -2885,7 +2886,7 @@ export class TeamProvisioningService {
|
|||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||
.map((part) => part.text as string);
|
||||
if (textParts.length > 0) {
|
||||
const text = textParts.join('');
|
||||
const text = textParts.join('\n');
|
||||
// Auth failures sometimes show up as assistant text (e.g. "401", "Please run /login")
|
||||
// rather than stderr or a result.subtype=error. Detect early to avoid false "ready".
|
||||
this.handleAuthFailureInOutput(run, text, 'assistant');
|
||||
|
|
@ -2908,34 +2909,37 @@ export class TeamProvisioningService {
|
|||
clearTimeout(capture.idleHandle);
|
||||
}
|
||||
capture.idleHandle = setTimeout(() => {
|
||||
const combined = capture.textParts.join('').trim();
|
||||
const combined = capture.textParts.join('\n').trim();
|
||||
capture.resolveOnce(combined);
|
||||
}, capture.idleMs);
|
||||
}
|
||||
} else if (run.provisioningComplete) {
|
||||
// Accumulate assistant text for a single "live lead turn" message in Messages.
|
||||
// If the same assistant message includes SendMessage(to:"user"), prefer the captured
|
||||
// SendMessage and avoid duplicating it as a separate lead text entry.
|
||||
// Push each assistant text block as a separate live message (per-message pattern).
|
||||
// When the same assistant message includes SendMessage(to:"user"), skip text —
|
||||
// captureSendMessageToUser() handles it separately.
|
||||
if (!run.silentUserDmForward && !hasSendMessageToUser) {
|
||||
run.directReplyParts.push(text);
|
||||
const raw = run.directReplyParts.join('');
|
||||
const cleanText = stripAgentBlocks(raw).trim();
|
||||
if (cleanText.length >= TeamProvisioningService.LEAD_TEXT_MIN_LENGTH) {
|
||||
const cleanText = stripAgentBlocks(text).trim();
|
||||
if (cleanText.length > 0) {
|
||||
run.leadMsgSeq += 1;
|
||||
const leadName =
|
||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||
'team-lead';
|
||||
if (!run.leadTurnMessageTimestamp) {
|
||||
run.leadTurnMessageTimestamp = nowIso();
|
||||
}
|
||||
const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`;
|
||||
const messageId = `lead-turn-${run.runId}-${run.leadMsgSeq}`;
|
||||
// Attach accumulated tool call details from preceding tool_use messages, then reset.
|
||||
const toolCalls =
|
||||
run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
|
||||
const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
|
||||
run.pendingToolCalls = [];
|
||||
const leadMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
text: cleanText,
|
||||
timestamp: run.leadTurnMessageTimestamp,
|
||||
timestamp: nowIso(),
|
||||
read: true,
|
||||
summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
|
||||
messageId,
|
||||
source: 'lead_process',
|
||||
toolSummary,
|
||||
toolCalls,
|
||||
};
|
||||
this.pushLiveLeadProcessMessage(run.teamName, leadMsg);
|
||||
|
||||
|
|
@ -2952,13 +2956,24 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
}
|
||||
} else if (hasSendMessageToUser) {
|
||||
run.directReplyParts = [];
|
||||
run.leadTurnMessageTimestamp = null;
|
||||
this.removeLiveLeadProcessMessage(
|
||||
run.teamName,
|
||||
`lead-turn-${run.runId}-${run.leadTurnSeq}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate tool_use details from tool-only messages (text + tool_use are separate in stream-json).
|
||||
// These details will be attached to the next text message as toolCalls/toolSummary.
|
||||
if (run.provisioningComplete) {
|
||||
for (const block of content ?? []) {
|
||||
if (
|
||||
block?.type === 'tool_use' &&
|
||||
typeof block.name === 'string' &&
|
||||
block.name !== 'SendMessage'
|
||||
) {
|
||||
const input = (block.input ?? {}) as Record<string, unknown>;
|
||||
run.pendingToolCalls.push({
|
||||
name: block.name,
|
||||
preview: extractToolPreview(block.name, input),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3098,49 +3113,8 @@ export class TeamProvisioningService {
|
|||
}
|
||||
if (run.leadRelayCapture) {
|
||||
const capture = run.leadRelayCapture;
|
||||
const combined = capture.textParts.join('').trim();
|
||||
const combined = capture.textParts.join('\n').trim();
|
||||
capture.resolveOnce(combined);
|
||||
} else if (run.provisioningComplete && run.directReplyParts.length > 0) {
|
||||
// Finalize the current live lead turn message (single messageId per turn).
|
||||
const rawReply = run.directReplyParts.join('').trim();
|
||||
run.directReplyParts = [];
|
||||
const leadName =
|
||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||
'team-lead';
|
||||
const replyText = stripAgentBlocks(rawReply).trim();
|
||||
const finalText =
|
||||
replyText.length > 0
|
||||
? replyText
|
||||
: rawReply.length > 0
|
||||
? '(Message received and processed)'
|
||||
: '';
|
||||
if (finalText.length > 0) {
|
||||
if (!run.leadTurnMessageTimestamp) {
|
||||
run.leadTurnMessageTimestamp = nowIso();
|
||||
}
|
||||
const messageId = `lead-turn-${run.runId}-${run.leadTurnSeq}`;
|
||||
const replyMsg: InboxMessage = {
|
||||
from: leadName,
|
||||
text: finalText,
|
||||
timestamp: run.leadTurnMessageTimestamp,
|
||||
read: true,
|
||||
summary: finalText.length > 60 ? finalText.slice(0, 57) + '...' : finalText,
|
||||
messageId,
|
||||
source: 'lead_process',
|
||||
};
|
||||
this.pushLiveLeadProcessMessage(run.teamName, replyMsg);
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'inbox',
|
||||
teamName: run.teamName,
|
||||
detail: 'lead-turn-final',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Turn boundary: advance lead turn sequence.
|
||||
if (run.provisioningComplete) {
|
||||
run.leadTurnSeq += 1;
|
||||
run.leadTurnMessageTimestamp = null;
|
||||
run.directReplyParts = [];
|
||||
}
|
||||
// Clear silent relay flag after any successful turn.
|
||||
run.silentUserDmForward = null;
|
||||
|
|
@ -3158,12 +3132,6 @@ export class TeamProvisioningService {
|
|||
if (run.leadRelayCapture) {
|
||||
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||
}
|
||||
// Turn boundary: advance lead turn sequence.
|
||||
if (run.provisioningComplete) {
|
||||
run.leadTurnSeq += 1;
|
||||
run.leadTurnMessageTimestamp = null;
|
||||
run.directReplyParts = [];
|
||||
}
|
||||
// Clear silent relay flag after any errored turn.
|
||||
run.silentUserDmForward = null;
|
||||
if (run.silentUserDmForwardClearHandle) {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,20 @@ export class TeamSentMessagesStore {
|
|||
attachments: Array.isArray(row.attachments) ? row.attachments : undefined,
|
||||
source: typeof row.source === 'string' ? (row.source as InboxMessage['source']) : undefined,
|
||||
leadSessionId: typeof row.leadSessionId === 'string' ? row.leadSessionId : undefined,
|
||||
toolSummary: typeof row.toolSummary === 'string' ? row.toolSummary : undefined,
|
||||
toolCalls: Array.isArray(row.toolCalls)
|
||||
? (row.toolCalls as unknown[])
|
||||
.filter(
|
||||
(tc): tc is { name: string; preview?: string } =>
|
||||
tc != null &&
|
||||
typeof tc === 'object' &&
|
||||
typeof (tc as Record<string, unknown>).name === 'string'
|
||||
)
|
||||
.map((tc) => ({
|
||||
name: tc.name,
|
||||
preview: typeof tc.preview === 'string' ? tc.preview : undefined,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -347,14 +347,18 @@ const electronAPI: ElectronAPI = {
|
|||
ipcRenderer.invoke('search-sessions', projectId, query, maxResults),
|
||||
searchAllProjects: (query: string, maxResults?: number) =>
|
||||
ipcRenderer.invoke('search-all-projects', query, maxResults),
|
||||
getSessionDetail: (projectId: string, sessionId: string) =>
|
||||
ipcRenderer.invoke('get-session-detail', projectId, sessionId),
|
||||
getSessionDetail: (projectId: string, sessionId: string, options?: { bypassCache?: boolean }) =>
|
||||
ipcRenderer.invoke('get-session-detail', projectId, sessionId, options),
|
||||
getSessionMetrics: (projectId: string, sessionId: string) =>
|
||||
ipcRenderer.invoke('get-session-metrics', projectId, sessionId),
|
||||
getWaterfallData: (projectId: string, sessionId: string) =>
|
||||
ipcRenderer.invoke('get-waterfall-data', projectId, sessionId),
|
||||
getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) =>
|
||||
ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId),
|
||||
getSubagentDetail: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
subagentId: string,
|
||||
options?: { bypassCache?: boolean }
|
||||
) => ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId, options),
|
||||
getSessionGroups: (projectId: string, sessionId: string) =>
|
||||
ipcRenderer.invoke('get-session-groups', projectId, sessionId),
|
||||
getSessionsByIds: (projectId: string, sessionIds: string[], options?: SessionsByIdsOptions) =>
|
||||
|
|
|
|||
|
|
@ -246,7 +246,11 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
return this.get<SearchSessionsResult>(`/api/search?${params}`);
|
||||
};
|
||||
|
||||
getSessionDetail = (projectId: string, sessionId: string): Promise<SessionDetail | null> =>
|
||||
getSessionDetail = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
_options?: { bypassCache?: boolean }
|
||||
): Promise<SessionDetail | null> =>
|
||||
this.get<SessionDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
|
||||
);
|
||||
|
|
@ -264,7 +268,8 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getSubagentDetail = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
subagentId: string
|
||||
subagentId: string,
|
||||
_options?: { bypassCache?: boolean }
|
||||
): Promise<SubagentDetail | null> =>
|
||||
this.get<SubagentDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ const SCROLL_THRESHOLD = 300;
|
|||
/** Must match the `w-80` (320px) context panel width used in the layout below. */
|
||||
const CONTEXT_PANEL_WIDTH_PX = 320;
|
||||
|
||||
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
|
||||
import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
|
||||
import { ChatHistoryItem } from './ChatHistoryItem';
|
||||
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
|
||||
|
|
@ -190,6 +192,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
||||
}, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]);
|
||||
|
||||
const visibleContextPercentLabel = useMemo(() => {
|
||||
const visibleTokens = sumContextInjectionTokens(allContextInjections);
|
||||
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
|
||||
}, [allContextInjections, lastAiGroupTotalTokens]);
|
||||
|
||||
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
|
||||
const [isNavigationHighlight, setIsNavigationHighlight] = useState(false);
|
||||
const navigationHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -828,7 +835,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Context ({allContextInjections.length})
|
||||
{visibleContextPercentLabel ?? `Context (${allContextInjections.length})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useSortable } from '@dnd-kit/sortable';
|
|||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
|
|
@ -67,12 +68,17 @@ export const SortableTab = ({
|
|||
)
|
||||
);
|
||||
|
||||
const teamColor = useStore((s) => {
|
||||
const teamColorSet = useStore((s) => {
|
||||
if (tab.type !== 'team' || !tab.teamName) return null;
|
||||
const team = s.teamByName[tab.teamName];
|
||||
return team?.color ?? null;
|
||||
const explicitColor =
|
||||
team?.color ??
|
||||
(s.selectedTeamName === tab.teamName ? s.selectedTeamData?.config.color : undefined);
|
||||
if (explicitColor) return getTeamColorSet(explicitColor);
|
||||
// Fallback: deterministic color derived from display name
|
||||
const displayName = team?.displayName ?? tab.label;
|
||||
return nameColorSet(displayName);
|
||||
});
|
||||
const teamColorSet = teamColor ? getTeamColorSet(teamColor) : null;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id,
|
||||
|
|
@ -96,8 +102,15 @@ export const SortableTab = ({
|
|||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'var(--color-surface-overlay)'
|
||||
: 'transparent',
|
||||
color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
: teamColorSet
|
||||
? teamColorSet.badge
|
||||
: 'transparent',
|
||||
color:
|
||||
isActive || isHovered
|
||||
? 'var(--color-text)'
|
||||
: teamColorSet
|
||||
? teamColorSet.text
|
||||
: 'var(--color-text-muted)',
|
||||
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined,
|
||||
|
|
@ -125,7 +138,7 @@ export const SortableTab = ({
|
|||
role="tab"
|
||||
tabIndex={0}
|
||||
aria-selected={isActive}
|
||||
className="group flex min-w-0 max-w-[200px] shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
|
||||
className="group flex shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5"
|
||||
style={style}
|
||||
onClick={(e) => onTabClick(tab.id, e)}
|
||||
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
||||
|
|
@ -150,7 +163,11 @@ export const SortableTab = ({
|
|||
<Pin className="size-3 shrink-0 text-blue-400" />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-sm">{tab.label}</span>
|
||||
<span
|
||||
className={`${tab.label.length > 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
{isTeamTab && (
|
||||
<TeamTabSectionNav
|
||||
teamName={tab.teamName!}
|
||||
|
|
@ -188,7 +205,7 @@ export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 max-w-[200px] items-center gap-2 rounded-md border-2 px-3 py-1.5"
|
||||
className="flex shrink-0 items-center gap-2 rounded-md border-2 px-3 py-1.5"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-accent, #6366f1)',
|
||||
|
|
@ -198,7 +215,11 @@ export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => {
|
|||
}}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="truncate text-sm">{tab.label}</span>
|
||||
<span
|
||||
className={`${tab.label.length > 20 ? 'max-w-[200px] truncate' : ''} whitespace-nowrap text-sm`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -352,7 +352,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
Gives users a reliable window-drag target regardless of how many tabs are open.
|
||||
Only applied on the leftmost pane in Electron to match the TabBar drag region logic. */}
|
||||
<div
|
||||
className="min-w-[48px] flex-1 self-stretch"
|
||||
className="min-w-[48px] shrink-0 self-stretch"
|
||||
style={
|
||||
{
|
||||
WebkitAppRegion: isElectronMode() && isLeftmostPane ? 'drag' : undefined,
|
||||
|
|
|
|||
|
|
@ -330,7 +330,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
badge={badge}
|
||||
headerExtra={headerExtra}
|
||||
defaultOpen
|
||||
contentClassName="pt-0"
|
||||
// Prevent scroll anchoring from "pulling" the parent container when logs update.
|
||||
contentClassName="pt-0 [overflow-anchor:none]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 pb-2">
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
|
|
@ -404,7 +405,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
cliLogsTail={filteredText}
|
||||
order="newest-first"
|
||||
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
||||
className="max-h-[320px] p-2"
|
||||
className="max-h-[213px] p-2"
|
||||
containerRefCallback={(el) => {
|
||||
logContainerRef.current = el;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ import { Bot, ChevronRight } from 'lucide-react';
|
|||
|
||||
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
|
||||
|
||||
type CliLogsOrder = 'oldest-first' | 'newest-first';
|
||||
|
||||
interface CliLogsRichViewProps {
|
||||
cliLogsTail: string;
|
||||
order?: 'oldest-first' | 'newest-first';
|
||||
order?: CliLogsOrder;
|
||||
onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void;
|
||||
containerRefCallback?: (el: HTMLDivElement | null) => void;
|
||||
/** Optional local search query override for inline highlighting */
|
||||
|
|
@ -108,7 +110,7 @@ const StreamGroup = ({
|
|||
<div className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
|
|
@ -121,14 +123,19 @@ const StreamGroup = ({
|
|||
<Bot size={13} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
{searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||
? highlightQueryInText(group.summary, searchQueryOverride, `${group.id}:group-summary`, {
|
||||
forceAllActive: true,
|
||||
})
|
||||
? highlightQueryInText(
|
||||
group.summary,
|
||||
searchQueryOverride,
|
||||
`${group.id}:group-summary`,
|
||||
{
|
||||
forceAllActive: true,
|
||||
}
|
||||
)
|
||||
: group.summary}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-[var(--color-border)] p-2">
|
||||
<div className="border-t border-[var(--color-border)] p-1.5">
|
||||
<DisplayItemList
|
||||
items={group.items}
|
||||
onItemClick={handleItemClick}
|
||||
|
|
@ -151,6 +158,8 @@ export const CliLogsRichView = ({
|
|||
className,
|
||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToEdgeRef = useRef(true);
|
||||
const lastOrderRef = useRef<CliLogsOrder>(order);
|
||||
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
|
||||
|
|
@ -168,15 +177,38 @@ export const CliLogsRichView = ({
|
|||
return expanded;
|
||||
}, [groups, collapsedGroupIds]);
|
||||
|
||||
// Auto-scroll to bottom on new content
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const computeShouldStickToEdge = useCallback(
|
||||
(el: HTMLDivElement): boolean => {
|
||||
// Small threshold makes it feel "sticky" but still allows reading slightly away from the edge
|
||||
const thresholdPx = 16;
|
||||
if (order === 'newest-first') {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
} else {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
return el.scrollTop <= thresholdPx;
|
||||
}
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
return distanceFromBottom <= thresholdPx;
|
||||
},
|
||||
[order]
|
||||
);
|
||||
|
||||
// Auto-scroll only when user is pinned to the edge.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// If the sort order changes, always snap once (expectation: show the "newest edge").
|
||||
if (lastOrderRef.current !== order) {
|
||||
lastOrderRef.current = order;
|
||||
stickToEdgeRef.current = true;
|
||||
}
|
||||
|
||||
if (!stickToEdgeRef.current) return;
|
||||
|
||||
if (order === 'newest-first') {
|
||||
el.scrollTop = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [cliLogsTail, order]);
|
||||
|
||||
const handleGroupToggle = useCallback((groupId: string) => {
|
||||
|
|
@ -218,7 +250,12 @@ export const CliLogsRichView = ({
|
|||
)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
onScroll?.({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{hasContent ? (
|
||||
|
|
@ -242,10 +279,15 @@ export const CliLogsRichView = ({
|
|||
scrollRef.current = el;
|
||||
containerRefCallback?.(el);
|
||||
}}
|
||||
className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}
|
||||
className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight });
|
||||
stickToEdgeRef.current = computeShouldStickToEdge(el);
|
||||
onScroll?.({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{visibleGroups.map((group) =>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ interface CollapsibleTeamSectionProps {
|
|||
badge?: string | number;
|
||||
/** Secondary badge (e.g. unread count). Shown next to main badge when defined. */
|
||||
secondaryBadge?: number;
|
||||
/** Element rendered immediately after secondary badge (e.g. mark-all-read button). */
|
||||
afterBadge?: React.ReactNode;
|
||||
/** Extra element rendered inline after badges (e.g. notification icon). */
|
||||
headerExtra?: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
|
|
@ -40,6 +42,7 @@ export const CollapsibleTeamSection = ({
|
|||
icon,
|
||||
badge,
|
||||
secondaryBadge,
|
||||
afterBadge,
|
||||
headerExtra,
|
||||
defaultOpen = true,
|
||||
forceOpen,
|
||||
|
|
@ -109,6 +112,7 @@ export const CollapsibleTeamSection = ({
|
|||
{secondaryBadge} new
|
||||
</Badge>
|
||||
)}
|
||||
{afterBadge}
|
||||
{headerExtra}
|
||||
</div>
|
||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||
|
|
|
|||
|
|
@ -456,16 +456,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
// Keep lead-session context fresh in the background while the team tab is active.
|
||||
// This keeps the button value current even when the panel is closed.
|
||||
// For offline teams: fetch once on mount so the percentage shows immediately.
|
||||
// For alive teams: fetch on mount + periodic refresh every 30s.
|
||||
useEffect(() => {
|
||||
if (!isThisTabActive) return;
|
||||
if (!tabId || !projectId || !leadSessionId) return;
|
||||
|
||||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
||||
|
||||
if (!data?.isAlive) return;
|
||||
|
||||
const tick = (): void => {
|
||||
const id = window.setInterval(() => {
|
||||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 30_000);
|
||||
}, 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
|
||||
|
||||
|
|
@ -574,9 +577,24 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
return result;
|
||||
}, [data, timeWindow, kanbanFilter.selectedOwners]);
|
||||
|
||||
const activeMembers = useMemo(
|
||||
() => (data?.members ?? []).filter((m) => !m.removedAt),
|
||||
[data?.members]
|
||||
);
|
||||
|
||||
const leadMemberName = useMemo(
|
||||
() => activeMembers.find((m) => m.agentType === 'team-lead')?.name,
|
||||
[activeMembers]
|
||||
);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
if (!data) return [];
|
||||
let list = data.messages;
|
||||
// Temporarily hide lead→user messages from the UI
|
||||
// (notifications and other processing still receive them via data.messages)
|
||||
if (leadMemberName) {
|
||||
list = list.filter((m) => !(m.to?.trim() === 'user' && m.from?.trim() === leadMemberName));
|
||||
}
|
||||
if (timeWindow) {
|
||||
list = list.filter((m) => {
|
||||
const ts = new Date(m.timestamp).getTime();
|
||||
|
|
@ -603,7 +621,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
});
|
||||
}
|
||||
return list;
|
||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery]);
|
||||
}, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]);
|
||||
|
||||
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
||||
const messagesUnreadCount = useMemo(
|
||||
|
|
@ -627,11 +645,6 @@ 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 activeTeammateCount = useMemo(
|
||||
() => activeMembers.filter((m) => m.agentType !== 'team-lead' && m.name !== 'team-lead').length,
|
||||
[activeMembers]
|
||||
|
|
@ -1415,44 +1428,44 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
? messagesUnreadCount
|
||||
: undefined
|
||||
}
|
||||
headerExtra={
|
||||
<>
|
||||
afterBadge={
|
||||
messagesUnreadCount > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void window.electronAPI.openExternal(
|
||||
'https://github.com/777genius/claude-notifications-go'
|
||||
);
|
||||
handleMarkAllRead();
|
||||
}}
|
||||
>
|
||||
<Bell size={12} />
|
||||
</Button>
|
||||
<CheckCheck size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
|
||||
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
||||
</Tooltip>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-[11px] text-blue-400 transition-colors hover:bg-blue-500/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAllRead();
|
||||
}}
|
||||
>
|
||||
<CheckCheck size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Mark all as read</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
headerExtra={
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void window.electronAPI.openExternal(
|
||||
'https://github.com/777genius/claude-notifications-go'
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Bell size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Desktop notifications plugin</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
defaultOpen
|
||||
action={
|
||||
|
|
@ -1649,7 +1662,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
currentName={data.config.name}
|
||||
currentDescription={data.config.description ?? ''}
|
||||
currentColor={data.config.color ?? ''}
|
||||
currentMembers={data.members}
|
||||
currentMembers={data.members.filter((m) => m.agentType !== 'team-lead')}
|
||||
projectPath={data.config.projectPath}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onSaved={() => void selectTeam(teamName)}
|
||||
|
|
|
|||
|
|
@ -561,9 +561,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
void fetchTeams();
|
||||
}}
|
||||
>
|
||||
{teamsLoading ? (
|
||||
<RotateCcw className="size-3.5 animate-spin" />
|
||||
) : null}
|
||||
{teamsLoading ? <RotateCcw className="size-3.5 animate-spin" /> : null}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -671,7 +669,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
key={team.teamName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
|
||||
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
|
||||
matchesCurrentProject
|
||||
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
|
||||
: 'border-[var(--color-border)]'
|
||||
|
|
@ -695,7 +693,11 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
style={{ backgroundColor: teamColorSet.badge }}
|
||||
/>
|
||||
) : null}
|
||||
<div className={teamColorSet ? 'relative z-10' : undefined}>
|
||||
<div
|
||||
className={
|
||||
teamColorSet ? 'relative z-10 flex flex-1 flex-col' : 'flex flex-1 flex-col'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
|
||||
|
|
@ -779,6 +781,8 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
Members: {team.memberCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
{(() => {
|
||||
const tc = taskCountsByTeam.get(team.teamName);
|
||||
const pending = tc?.pending ?? 0;
|
||||
|
|
@ -831,8 +835,8 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{renderTeamRecentPaths(team, status)}
|
||||
</div>
|
||||
{renderTeamRecentPaths(team, status)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||
import { groupTimelineItems, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||
|
||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||
import type { TimelineItem } from './LeadThoughtsGroup';
|
||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
interface ActivityTimelineProps {
|
||||
messages: InboxMessage[];
|
||||
|
|
@ -134,11 +134,6 @@ export const ActivityTimeline = ({
|
|||
const isInitializedRef = useRef(false);
|
||||
const prevVisibleCountRef = useRef(visibleCount);
|
||||
|
||||
// Track whether the user was seeing ALL messages (no hidden ones).
|
||||
// If so, auto-expand when new messages push count past the limit,
|
||||
// so previously visible messages don't silently disappear.
|
||||
const wasShowingAllRef = useRef(messages.length <= MESSAGES_PAGE_SIZE);
|
||||
|
||||
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||
if (members) {
|
||||
|
|
@ -169,22 +164,33 @@ export const ActivityTimeline = ({
|
|||
if (member) onMemberClick?.(member);
|
||||
};
|
||||
|
||||
const hiddenCount = Math.max(0, messages.length - visibleCount);
|
||||
// Pagination counts only significant (non-thought) messages so that lead thoughts
|
||||
// don't consume the page limit — they collapse into a single visual group anyway.
|
||||
const { visibleMessages, hiddenCount } = useMemo(() => {
|
||||
const total = messages.length;
|
||||
if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
|
||||
|
||||
// Auto-expand when user was seeing all and new messages arrive — derived state sync.
|
||||
// Reading/updating ref during render is intentional (React docs: derived state sync).
|
||||
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
|
||||
let significantSeen = 0;
|
||||
let cutoff = total;
|
||||
for (let i = 0; i < total; i++) {
|
||||
if (!isLeadThought(messages[i])) {
|
||||
significantSeen++;
|
||||
if (significantSeen > visibleCount) {
|
||||
cutoff = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wasShowingAll = wasShowingAllRef.current;
|
||||
if (wasShowingAll && hiddenCount > 0) {
|
||||
setVisibleCount(messages.length);
|
||||
}
|
||||
wasShowingAllRef.current = hiddenCount === 0;
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
|
||||
[messages, visibleCount, hiddenCount]
|
||||
);
|
||||
const significantTotal =
|
||||
significantSeen +
|
||||
(cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
|
||||
const hidden = Math.max(0, significantTotal - visibleCount);
|
||||
return {
|
||||
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
|
||||
hiddenCount: hidden,
|
||||
};
|
||||
}, [messages, visibleCount]);
|
||||
|
||||
// Group consecutive lead thoughts into collapsible blocks.
|
||||
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]);
|
||||
|
|
@ -209,6 +215,7 @@ export const ActivityTimeline = ({
|
|||
}, [timelineItems]);
|
||||
|
||||
// Determine which items are "new" (should animate).
|
||||
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
|
||||
|
||||
const newItemKeys = useMemo(() => {
|
||||
const getItemKey = (item: TimelineItem): string => {
|
||||
|
|
@ -280,25 +287,50 @@ export const ActivityTimeline = ({
|
|||
? item.group.thoughts[0].leadSessionId
|
||||
: item.message.leadSessionId;
|
||||
|
||||
// Pin the newest thought group (if first) so it stays at the top and doesn't jump.
|
||||
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||
const startIndex = pinnedThoughtGroup ? 1 : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{timelineItems.map((item, index) => {
|
||||
{/* Pinned (newest) thought group — always at top */}
|
||||
{pinnedThoughtGroup &&
|
||||
(() => {
|
||||
const { group } = pinnedThoughtGroup;
|
||||
const firstThought = group.thoughts[0];
|
||||
const info = memberInfo.get(firstThought.from);
|
||||
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
|
||||
return (
|
||||
<LeadThoughtsGroupRow
|
||||
key={itemKey}
|
||||
group={group}
|
||||
memberColor={info?.color}
|
||||
canBeLive={true}
|
||||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(0)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Remaining items */}
|
||||
{timelineItems.slice(startIndex).map((item, index) => {
|
||||
const realIndex = index + startIndex;
|
||||
|
||||
// Session boundary separator (messages sorted desc — new on top)
|
||||
let sessionSeparator: React.JSX.Element | null = null;
|
||||
if (index > 0) {
|
||||
const prevSessionId = getItemSessionId(timelineItems[index - 1]);
|
||||
if (realIndex > 0) {
|
||||
const prevSessionId = getItemSessionId(timelineItems[realIndex - 1]);
|
||||
const currSessionId = getItemSessionId(item);
|
||||
if (prevSessionId && currSessionId && prevSessionId !== currSessionId) {
|
||||
sessionSeparator = (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ paddingTop: 30, paddingBottom: 30 }}
|
||||
style={{ paddingTop: 90, paddingBottom: 90 }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||
<span className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
||||
New session
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||
<div className="h-px flex-1 bg-blue-400/30" />
|
||||
<span className="whitespace-nowrap text-[11px] text-blue-400">New session</span>
|
||||
<div className="h-px flex-1 bg-blue-400/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -315,8 +347,10 @@ export const ActivityTimeline = ({
|
|||
<LeadThoughtsGroupRow
|
||||
group={group}
|
||||
memberColor={info?.color}
|
||||
canBeLive={false}
|
||||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(realIndex)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -342,7 +376,7 @@ export const ActivityTimeline = ({
|
|||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
isNew={newItemKeys.has(messageKey)}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
zebraShade={zebraShadeSet.has(realIndex)}
|
||||
memberColorMap={colorMap}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
|
|
@ -377,7 +411,7 @@ export const ActivityTimeline = ({
|
|||
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">
|
||||
+{hiddenCount} older
|
||||
</span>
|
||||
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
|
||||
<span className="h-3 w-px bg-blue-400/30" />
|
||||
<button
|
||||
onClick={handleShowMore}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text)]"
|
||||
|
|
@ -386,7 +420,7 @@ export const ActivityTimeline = ({
|
|||
</button>
|
||||
{hiddenCount > MESSAGES_PAGE_SIZE && (
|
||||
<>
|
||||
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
|
||||
<span className="h-3 w-px bg-blue-400/30" />
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
CARD_BG,
|
||||
CARD_BG_ZEBRA,
|
||||
CARD_BORDER_STYLE,
|
||||
CARD_ICON_MUTED,
|
||||
CARD_TEXT_LIGHT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
||||
|
||||
export interface LeadThoughtGroup {
|
||||
type: 'lead-thoughts';
|
||||
|
|
@ -22,7 +27,7 @@ export interface LeadThoughtGroup {
|
|||
*/
|
||||
export function isLeadThought(msg: InboxMessage): boolean {
|
||||
if (msg.source === 'lead_session') return true;
|
||||
if (msg.source === 'lead_process' && msg.messageId?.startsWith('lead-text-')) return true;
|
||||
if (msg.source === 'lead_process') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -31,8 +36,8 @@ export type TimelineItem =
|
|||
| { type: 'lead-thoughts'; group: LeadThoughtGroup; originalIndices: number[] };
|
||||
|
||||
/**
|
||||
* Group consecutive lead thoughts into collapsible blocks.
|
||||
* Single thoughts remain as regular messages.
|
||||
* Group consecutive lead thoughts into compact blocks.
|
||||
* Even a single thought gets its own group (rendered as LeadThoughtsGroupRow).
|
||||
*/
|
||||
export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
||||
const result: TimelineItem[] = [];
|
||||
|
|
@ -41,19 +46,11 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
|||
|
||||
const flushThoughts = (): void => {
|
||||
if (pendingThoughts.length === 0) return;
|
||||
if (pendingThoughts.length === 1) {
|
||||
result.push({
|
||||
type: 'message',
|
||||
message: pendingThoughts[0],
|
||||
originalIndex: pendingIndices[0],
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
type: 'lead-thoughts',
|
||||
group: { type: 'lead-thoughts', thoughts: pendingThoughts },
|
||||
originalIndices: pendingIndices,
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
type: 'lead-thoughts',
|
||||
group: { type: 'lead-thoughts', thoughts: pendingThoughts },
|
||||
originalIndices: pendingIndices,
|
||||
});
|
||||
pendingThoughts = [];
|
||||
pendingIndices = [];
|
||||
};
|
||||
|
|
@ -73,7 +70,7 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
|||
}
|
||||
|
||||
const VIEWPORT_THRESHOLD = 0.15;
|
||||
const LIVE_WINDOW_MS = 10_000;
|
||||
const LIVE_WINDOW_MS = 5_000;
|
||||
const AUTO_SCROLL_THRESHOLD = 30;
|
||||
|
||||
interface LeadThoughtsGroupRowProps {
|
||||
|
|
@ -81,6 +78,10 @@ interface LeadThoughtsGroupRowProps {
|
|||
memberColor?: string;
|
||||
isNew?: boolean;
|
||||
onVisible?: (message: InboxMessage) => void;
|
||||
/** When false, the live indicator is always off (for historical thought groups). */
|
||||
canBeLive?: boolean;
|
||||
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||
zebraShade?: boolean;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
|
|
@ -101,15 +102,84 @@ function isRecentTimestamp(timestamp: string): boolean {
|
|||
return Date.now() - t <= LIVE_WINDOW_MS;
|
||||
}
|
||||
|
||||
const ToolSummaryTooltipContent = ({
|
||||
toolCalls,
|
||||
toolSummary,
|
||||
}: Readonly<{
|
||||
toolCalls?: ToolCallMeta[];
|
||||
toolSummary?: string;
|
||||
}>): JSX.Element => {
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
return (
|
||||
<div className="flex max-h-[300px] flex-col gap-0.5 overflow-y-auto">
|
||||
<div className="mb-0.5 text-[10px] text-text-secondary">
|
||||
{toolCalls.length} {toolCalls.length === 1 ? 'tool call' : 'tool calls'}
|
||||
</div>
|
||||
{toolCalls.map((tc, i) => {
|
||||
const isAgent = tc.name === 'Agent' || tc.name === 'TaskCreate';
|
||||
return (
|
||||
<div key={i} className={isAgent ? 'mt-0.5' : 'flex items-baseline gap-2'}>
|
||||
<span className={`shrink-0 font-semibold ${isAgent ? 'text-violet-400' : ''}`}>
|
||||
{isAgent ? '🤖 ' : ''}
|
||||
{tc.name}
|
||||
</span>
|
||||
{tc.preview && (
|
||||
<span
|
||||
className={`text-text-secondary ${isAgent ? 'mt-0.5 block text-[10px]' : 'truncate'}`}
|
||||
>
|
||||
{tc.preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolSummary) {
|
||||
const parsed = parseToolSummary(toolSummary);
|
||||
if (parsed) {
|
||||
const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="mb-0.5 text-[10px] text-text-secondary">
|
||||
{parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
|
||||
</div>
|
||||
{sorted.map(([name, count]) => (
|
||||
<div key={name} className="flex justify-between gap-3">
|
||||
<span>{name}</span>
|
||||
<span className="text-text-secondary">×{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <span>{toolSummary ?? ''}</span>;
|
||||
};
|
||||
|
||||
export const LeadThoughtsGroupRow = ({
|
||||
group,
|
||||
memberColor,
|
||||
isNew,
|
||||
onVisible,
|
||||
canBeLive,
|
||||
zebraShade,
|
||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isUserScrolledUpRef = useRef(false);
|
||||
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
||||
const leadActivity = useStore((s) => {
|
||||
const teamName = s.selectedTeamName;
|
||||
return teamName ? s.leadActivityByTeam[teamName] : undefined;
|
||||
});
|
||||
const leadContextUpdatedAt = useStore((s) => {
|
||||
const teamName = s.selectedTeamName;
|
||||
return teamName ? s.leadContextByTeam[teamName]?.updatedAt : undefined;
|
||||
});
|
||||
|
||||
const colors = getTeamColorSet(memberColor ?? '');
|
||||
const { thoughts } = group;
|
||||
|
|
@ -121,14 +191,45 @@ export const LeadThoughtsGroupRow = ({
|
|||
// Chronological order for rendering (oldest at top, newest at bottom)
|
||||
const chronologicalThoughts = useMemo(() => [...thoughts].reverse(), [thoughts]);
|
||||
|
||||
// Live indicator: newest thought is from lead_process and recent
|
||||
// Aggregate tool usage across all thoughts in this group
|
||||
const totalToolSummary = useMemo(() => {
|
||||
const merged: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const t of thoughts) {
|
||||
const parsed = parseToolSummary(t.toolSummary);
|
||||
if (!parsed) continue;
|
||||
total += parsed.total;
|
||||
for (const [name, count] of Object.entries(parsed.byName)) {
|
||||
merged[name] = (merged[name] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
if (total === 0) return null;
|
||||
return formatToolSummary({ total, byName: merged });
|
||||
}, [thoughts]);
|
||||
|
||||
// Aggregate all toolCalls across thoughts for header tooltip
|
||||
const allToolCalls = useMemo(() => {
|
||||
const calls: ToolCallMeta[] = [];
|
||||
for (const t of thoughts) {
|
||||
if (t.toolCalls) calls.push(...t.toolCalls);
|
||||
}
|
||||
return calls.length > 0 ? calls : undefined;
|
||||
}, [thoughts]);
|
||||
|
||||
// Live = process alive AND (lead is in active turn OR context recently updated OR fresh thought)
|
||||
const computeIsLive = useCallback(
|
||||
() => newest.source === 'lead_process' && isRecentTimestamp(newest.timestamp),
|
||||
[newest.source, newest.timestamp]
|
||||
() =>
|
||||
canBeLive !== false &&
|
||||
isTeamAlive &&
|
||||
(leadActivity === 'active' ||
|
||||
(leadContextUpdatedAt ? isRecentTimestamp(leadContextUpdatedAt) : false) ||
|
||||
isRecentTimestamp(newest.timestamp)),
|
||||
[canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
|
||||
);
|
||||
const [isLive, setIsLive] = useState(computeIsLive);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
|
||||
setIsLive(computeIsLive());
|
||||
const id = window.setInterval(() => setIsLive(computeIsLive()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
|
|
@ -157,13 +258,12 @@ export const LeadThoughtsGroupRow = ({
|
|||
return () => observer.disconnect();
|
||||
}, [onVisible, thoughts]);
|
||||
|
||||
// Auto-scroll to bottom when new thoughts arrive
|
||||
// Auto-scroll when new thoughts arrive
|
||||
useEffect(() => {
|
||||
if (isUserScrolledUpRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (!el || isUserScrolledUpRef.current) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [thoughts.length]);
|
||||
}, [chronologicalThoughts]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
|
|
@ -173,14 +273,17 @@ export const LeadThoughtsGroupRow = ({
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}
|
||||
style={{ overflowAnchor: 'none' }}
|
||||
>
|
||||
<article
|
||||
className="group rounded-md [overflow:clip]"
|
||||
style={{
|
||||
backgroundColor: CARD_BG,
|
||||
backgroundColor: zebraShade ? CARD_BG_ZEBRA : CARD_BG,
|
||||
border: CARD_BORDER_STYLE,
|
||||
borderLeft: `3px solid ${colors.border}`,
|
||||
opacity: isLive ? undefined : 0.75,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
|
|
@ -199,31 +302,97 @@ export const LeadThoughtsGroupRow = ({
|
|||
{thoughts.length} thoughts
|
||||
</span>
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formatTime(oldest.timestamp)}–{formatTime(newest.timestamp)}
|
||||
{formatTime(oldest.timestamp) === formatTime(newest.timestamp)
|
||||
? formatTime(oldest.timestamp)
|
||||
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
|
||||
</span>
|
||||
{totalToolSummary && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{totalToolSummary}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[420px] font-mono text-[11px]">
|
||||
<ToolSummaryTooltipContent
|
||||
toolCalls={allToolCalls}
|
||||
toolSummary={totalToolSummary}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable body — fixed height, always visible */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="space-y-px border-t px-3 py-1.5"
|
||||
className="border-t"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto',
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--scrollbar-thumb) transparent',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{chronologicalThoughts.map((thought, idx) => (
|
||||
<div key={thought.messageId ?? idx} className="flex gap-2 py-0.5 text-[11px]">
|
||||
<span className="shrink-0 font-mono" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formatTimeWithSec(thought.timestamp)}
|
||||
</span>
|
||||
<span className="flex-1 leading-relaxed" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{thought.text.length > 300 ? thought.text.slice(0, 297) + '...' : thought.text}
|
||||
</span>
|
||||
<div key={thought.messageId ?? idx} className="thought-expand-in">
|
||||
{idx > 0 && (
|
||||
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
|
||||
<hr
|
||||
className="flex-1 border-0"
|
||||
style={{
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="shrink-0 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{formatTimeWithSec(thought.timestamp)}
|
||||
</span>
|
||||
<hr
|
||||
className="flex-1 border-0"
|
||||
style={{
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-[11px]">
|
||||
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
<MarkdownViewer
|
||||
content={thought.text.replace(/\n/g, ' \n')}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{thought.toolSummary && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
🔧 {thought.toolSummary}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-[420px] font-mono text-[11px]"
|
||||
>
|
||||
<ToolSummaryTooltipContent
|
||||
toolCalls={thought.toolCalls}
|
||||
toolSummary={thought.toolSummary}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AlertCircle } from 'lucide-react';
|
||||
import { AlertCircle, X } from 'lucide-react';
|
||||
|
||||
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ interface AttachmentPreviewListProps {
|
|||
attachments: AttachmentPayload[];
|
||||
onRemove: (id: string) => void;
|
||||
error?: string | null;
|
||||
onDismissError?: () => void;
|
||||
/** When true, previews are overlaid with a disabled indicator (recipient doesn't support attachments). */
|
||||
disabled?: boolean;
|
||||
/** Hint text shown when disabled and attachments are present. */
|
||||
|
|
@ -18,6 +19,7 @@ export const AttachmentPreviewList = ({
|
|||
attachments,
|
||||
onRemove,
|
||||
error,
|
||||
onDismissError,
|
||||
disabled,
|
||||
disabledHint,
|
||||
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||
|
|
@ -49,7 +51,16 @@ export const AttachmentPreviewList = ({
|
|||
{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>
|
||||
<p className="flex-1 text-[11px] text-red-400">{error}</p>
|
||||
{onDismissError ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300"
|
||||
onClick={onDismissError}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
|||
import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||
|
||||
|
|
|
|||
|
|
@ -219,7 +219,6 @@ export const TaskAttachments = ({
|
|||
void handlePreview(att);
|
||||
}}
|
||||
onDelete={() => {
|
||||
// eslint-disable-next-line sonarjs/void-use -- void needed to mark floating promise
|
||||
void handleDelete(att.id, att.mimeType);
|
||||
}}
|
||||
onDataLoaded={handleThumbLoaded}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { useStore } from '@renderer/store';
|
|||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,82 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
|
||||
// --- Provider SVG Icons (real brand logos from Simple Icons, monochrome currentColor) ---
|
||||
|
||||
/** Anthropic — official "A" lettermark (Simple Icons) */
|
||||
const AnthropicIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M17.304 3.541h-3.672l6.696 16.918H24Zm-10.608 0L0 20.459h3.744l1.37-3.553h7.005l1.369 3.553h3.744L10.536 3.541Zm-.371 10.223 2.291-5.946 2.292 5.946Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** OpenAI — official hexagonal knot logo (Simple Icons) */
|
||||
const OpenAIIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.998 5.998 0 0 0-3.992 2.9 6.042 6.042 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365 2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.612-1.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Google Gemini — official sparkle/star mark (Simple Icons) */
|
||||
const GoogleIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className}>
|
||||
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Local — server rack icon */
|
||||
const LocalIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<rect x="3" y="3" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="3" y="14" width="18" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="7" cy="6.5" r="1" fill="currentColor" />
|
||||
<circle cx="7" cy="17.5" r="1" fill="currentColor" />
|
||||
<line
|
||||
x1="10.5"
|
||||
y1="6.5"
|
||||
x2="17.5"
|
||||
y2="6.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="10.5"
|
||||
y1="17.5"
|
||||
x2="17.5"
|
||||
y2="17.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- Provider definitions ---
|
||||
|
||||
interface ProviderDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
comingSoon: boolean;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderDef[] = [
|
||||
{ id: 'anthropic', label: 'Anthropic', icon: AnthropicIcon, comingSoon: false },
|
||||
{ id: 'openai', label: 'OpenAI', icon: OpenAIIcon, comingSoon: true },
|
||||
{ id: 'google', label: 'Google', icon: GoogleIcon, comingSoon: true },
|
||||
{ id: 'local', label: 'Local', icon: LocalIcon, comingSoon: true },
|
||||
];
|
||||
|
||||
const ACTIVE_PROVIDER = PROVIDERS[0];
|
||||
|
||||
// --- Model options (Anthropic only for now) ---
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{ value: '', label: 'Default (account setting)' },
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
|
|
@ -35,28 +107,132 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
value,
|
||||
onValueChange,
|
||||
id,
|
||||
}) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label htmlFor={id} className="label-optional shrink-0">
|
||||
Model (optional)
|
||||
</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{MODEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
}) => {
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
if (!dropdownOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent): void => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const ProviderIcon = ACTIVE_PROVIDER.icon;
|
||||
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||
Model (optional)
|
||||
</Label>
|
||||
<div ref={containerRef} className="relative inline-block">
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{/* Provider button */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-[3px] px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
'mr-0.5 border-r border-[var(--color-border)] pr-2.5',
|
||||
dropdownOpen
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
||||
)}
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
<ProviderIcon className="size-3.5" />
|
||||
<span>{ACTIVE_PROVIDER.label}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3 transition-transform duration-200',
|
||||
dropdownOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Model pills */}
|
||||
{MODEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Provider dropdown */}
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
className="absolute bottom-full left-0 z-50 mb-1 min-w-[220px] overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
}}
|
||||
>
|
||||
{PROVIDERS.map((provider, index) => {
|
||||
const Icon = provider.icon;
|
||||
const isActive = provider.id === ACTIVE_PROVIDER.id;
|
||||
const isFirst = index === 0;
|
||||
const prevWasActive = index > 0 && !PROVIDERS[index - 1].comingSoon;
|
||||
|
||||
return (
|
||||
<React.Fragment key={provider.id}>
|
||||
{prevWasActive && !isFirst && (
|
||||
<div
|
||||
className="mx-2 my-1 border-t"
|
||||
style={{ borderColor: 'var(--color-border-subtle)' }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={provider.comingSoon}
|
||||
onClick={() => {
|
||||
if (!provider.comingSoon) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs transition-colors duration-100',
|
||||
isActive && 'bg-indigo-500/10 text-indigo-400',
|
||||
provider.comingSoon && 'cursor-not-allowed opacity-40',
|
||||
!isActive && !provider.comingSoon && 'hover:bg-white/5'
|
||||
)}
|
||||
style={
|
||||
!isActive && !provider.comingSoon
|
||||
? { color: 'var(--color-text-secondary)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="flex-1">{provider.label}</span>
|
||||
{provider.comingSoon && (
|
||||
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
{isActive && <Check className="size-3.5 shrink-0" />}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { RoleSelect } from '@renderer/components/team/RoleSelect';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
|
|
@ -9,7 +10,7 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -48,6 +49,7 @@ export const MemberDraftRow = ({
|
|||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const memberColorSet = getTeamColorSet(getMemberColor(index));
|
||||
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
|
||||
// Pre-warm file list cache when workflow section is expanded
|
||||
useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null);
|
||||
|
|
@ -162,6 +164,19 @@ export const MemberDraftRow = ({
|
|||
Workflow
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1"
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
>
|
||||
{modelExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Model
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -200,6 +215,20 @@ export const MemberDraftRow = ({
|
|||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{modelExpanded && (
|
||||
<div className="space-y-2 md:col-span-3">
|
||||
<div className="pointer-events-none opacity-40">
|
||||
<TeamModelSelector value="" onValueChange={() => {}} />
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
Claude Code doesn't support per-member model selection yet — all teammates
|
||||
inherit the team launch model. We plan to solve this via a local proxy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
|
||||
import {
|
||||
SubagentRecentMessagesPreview,
|
||||
type SubagentPreviewMessage,
|
||||
SubagentRecentMessagesPreview,
|
||||
} from '@renderer/components/team/members/SubagentRecentMessagesPreview';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { formatDuration } from '@renderer/utils/formatters';
|
||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
|
|
@ -18,9 +20,6 @@ import {
|
|||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
|
||||
|
||||
import type { EnhancedChunk } from '@renderer/types/data';
|
||||
import type { MemberLogSummary } from '@shared/types';
|
||||
|
||||
|
|
@ -58,21 +57,78 @@ export const MemberLogsTab = ({
|
|||
showLeadPreview = false,
|
||||
onPreviewOnlineChange,
|
||||
}: MemberLogsTabProps): React.JSX.Element => {
|
||||
const MIN_REFRESH_VISIBLE_MS = 250;
|
||||
const intervalsKey = useMemo(
|
||||
() => (taskWorkIntervals ? JSON.stringify(taskWorkIntervals) : ''),
|
||||
[taskWorkIntervals]
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const refreshCountRef = useRef(0);
|
||||
const refreshBeganAtRef = useRef<number | null>(null);
|
||||
const refreshHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const expandedIdRef = useRef<string | null>(null);
|
||||
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [previewChunks, setPreviewChunks] = useState<EnhancedChunk[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (refreshHideTimeoutRef.current) {
|
||||
clearTimeout(refreshHideTimeoutRef.current);
|
||||
refreshHideTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
expandedIdRef.current = expandedId;
|
||||
}, [expandedId]);
|
||||
|
||||
const beginRefreshing = useCallback((): void => {
|
||||
if (refreshCountRef.current === 0) {
|
||||
refreshBeganAtRef.current = Date.now();
|
||||
if (refreshHideTimeoutRef.current) {
|
||||
clearTimeout(refreshHideTimeoutRef.current);
|
||||
refreshHideTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
refreshCountRef.current += 1;
|
||||
if (isMountedRef.current) setRefreshing(true);
|
||||
}, []);
|
||||
|
||||
const endRefreshing = useCallback((): void => {
|
||||
refreshCountRef.current = Math.max(0, refreshCountRef.current - 1);
|
||||
if (refreshCountRef.current > 0) {
|
||||
if (isMountedRef.current) setRefreshing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const beganAt = refreshBeganAtRef.current;
|
||||
refreshBeganAtRef.current = null;
|
||||
const elapsed = beganAt ? Date.now() - beganAt : Number.POSITIVE_INFINITY;
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
if (elapsed >= MIN_REFRESH_VISIBLE_MS) {
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, MIN_REFRESH_VISIBLE_MS - elapsed);
|
||||
refreshHideTimeoutRef.current = setTimeout(() => {
|
||||
refreshHideTimeoutRef.current = null;
|
||||
if (!isMountedRef.current) return;
|
||||
if (refreshCountRef.current === 0) setRefreshing(false);
|
||||
}, remaining);
|
||||
}, []);
|
||||
|
||||
const getRowId = useCallback((log: MemberLogSummary): string => {
|
||||
return log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
|
|
@ -174,6 +230,7 @@ export const MemberLogsTab = ({
|
|||
const shouldAutoRefresh = taskId != null && taskStatus === 'in_progress';
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
let didBeginRefreshing = false;
|
||||
try {
|
||||
if (taskId == null && !memberName) {
|
||||
if (!cancelled) setLogs([]);
|
||||
|
|
@ -182,7 +239,8 @@ export const MemberLogsTab = ({
|
|||
if (!hasLoadedRef.current) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setRefreshing(true);
|
||||
beginRefreshing();
|
||||
didBeginRefreshing = true;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
|
|
@ -194,10 +252,22 @@ export const MemberLogsTab = ({
|
|||
intervals: taskWorkIntervals,
|
||||
})
|
||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||
const nextLogs = Array.isArray(result) ? [...result] : [];
|
||||
|
||||
if (!cancelled) {
|
||||
setLogs(Array.isArray(result) ? [...result] : []);
|
||||
setLogs(nextLogs);
|
||||
hasLoadedRef.current = true;
|
||||
}
|
||||
|
||||
// Keep expanded session details in sync with the same refresh
|
||||
// cadence as the summary (counts/titles) while "Updating..." is shown.
|
||||
if (!cancelled && didBeginRefreshing) {
|
||||
try {
|
||||
await refreshExpandedDetailFromLogs(nextLogs);
|
||||
} catch {
|
||||
// Keep last successful detail view; avoid flicker on transient failures.
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
|
|
@ -205,7 +275,7 @@ export const MemberLogsTab = ({
|
|||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
if (didBeginRefreshing) endRefreshing();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -222,17 +292,45 @@ export const MemberLogsTab = ({
|
|||
}, [teamName, memberName, taskId, taskOwner, taskStatus, intervalsKey]);
|
||||
|
||||
const fetchDetailForLog = useCallback(
|
||||
async (log: MemberLogSummary): Promise<EnhancedChunk[] | null> => {
|
||||
async (
|
||||
log: MemberLogSummary,
|
||||
options?: { bypassCache?: boolean }
|
||||
): Promise<EnhancedChunk[] | null> => {
|
||||
if (log.kind === 'subagent') {
|
||||
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
|
||||
return (d?.chunks ?? null) as EnhancedChunk[] | null;
|
||||
const d = await api.getSubagentDetail(
|
||||
log.projectId,
|
||||
log.sessionId,
|
||||
log.subagentId,
|
||||
options
|
||||
);
|
||||
return d?.chunks ?? null;
|
||||
}
|
||||
const d = await api.getSessionDetail(log.projectId, log.sessionId);
|
||||
const d = await api.getSessionDetail(log.projectId, log.sessionId, options);
|
||||
return (d?.chunks ?? null) as unknown as EnhancedChunk[] | null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const refreshExpandedDetailFromLogs = useCallback(
|
||||
async (nextLogs: MemberLogSummary[]): Promise<void> => {
|
||||
const rowId = expandedIdRef.current;
|
||||
if (!rowId) return;
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId);
|
||||
if (!nextExpanded) return;
|
||||
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return;
|
||||
|
||||
const next = await fetchDetailForLog(nextExpanded, { bypassCache: true });
|
||||
if (!isMountedRef.current) return;
|
||||
// Ensure new reference so memoized transforms update.
|
||||
setDetailChunks(next ? [...next] : null);
|
||||
},
|
||||
[fetchDetailForLog, getRowId, taskId, taskStatus]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowPreview) {
|
||||
setPreviewChunks(null);
|
||||
|
|
@ -269,12 +367,15 @@ export const MemberLogsTab = ({
|
|||
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
beginRefreshing();
|
||||
try {
|
||||
const next = await fetchDetailForLog(previewLog);
|
||||
const next = await fetchDetailForLog(previewLog, { bypassCache: true });
|
||||
if (cancelled) return;
|
||||
setPreviewChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
// keep last successful preview
|
||||
} finally {
|
||||
endRefreshing();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
|
|
@ -282,31 +383,46 @@ export const MemberLogsTab = ({
|
|||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [fetchDetailForLog, previewLog, shouldShowPreview, taskStatus]);
|
||||
}, [
|
||||
beginRefreshing,
|
||||
endRefreshing,
|
||||
fetchDetailForLog,
|
||||
previewLog,
|
||||
shouldShowPreview,
|
||||
taskStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress';
|
||||
if (!expandedLogSummary) return;
|
||||
if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return;
|
||||
// When task logs are auto-refreshing, the summary refresh loop also refreshes
|
||||
// expanded details to keep everything in sync (and avoid duplicate requests).
|
||||
if (shouldAutoRefreshSummary) return;
|
||||
if (!expandedLogSummary.isOngoing) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
const refreshDetail = async (): Promise<void> => {
|
||||
beginRefreshing();
|
||||
try {
|
||||
const next = await fetchDetailForLog(expandedLogSummary);
|
||||
const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true });
|
||||
if (cancelled) return;
|
||||
// Ensure new reference so memoized transforms update.
|
||||
setDetailChunks(next ? [...next] : null);
|
||||
} catch {
|
||||
// Keep last successful data; avoid flicker during transient errors.
|
||||
} finally {
|
||||
endRefreshing();
|
||||
}
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
const interval = setInterval(() => void refreshDetail(), 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
|
||||
}, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
async (log: MemberLogSummary) => {
|
||||
|
|
@ -321,7 +437,11 @@ export const MemberLogsTab = ({
|
|||
setDetailChunks(null);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const chunks = await fetchDetailForLog(log);
|
||||
const shouldBypassCache = log.isOngoing || taskStatus === 'in_progress';
|
||||
const chunks = await fetchDetailForLog(
|
||||
log,
|
||||
shouldBypassCache ? { bypassCache: true } : undefined
|
||||
);
|
||||
setDetailChunks(chunks ? [...chunks] : null);
|
||||
} catch {
|
||||
setDetailChunks(null);
|
||||
|
|
@ -329,7 +449,7 @@ export const MemberLogsTab = ({
|
|||
setDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[expandedId, fetchDetailForLog, getRowId]
|
||||
[expandedId, fetchDetailForLog, getRowId, taskStatus]
|
||||
);
|
||||
|
||||
if (loading && logs.length === 0) {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export const MessageComposer = ({
|
|||
addFiles,
|
||||
removeAttachment,
|
||||
clearAttachments,
|
||||
clearError: clearAttachmentError,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
} = useAttachments({ persistenceKey: `compose:${teamName}:attachments` });
|
||||
|
|
@ -155,7 +156,7 @@ export const MessageComposer = ({
|
|||
// const leadContext = useStore((s) =>
|
||||
// isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const supportsAttachments = isLeadRecipient && !!isTeamAlive;
|
||||
const supportsAttachments = isLeadRecipient;
|
||||
const canAttach = supportsAttachments && canAddMore;
|
||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||
const canSend =
|
||||
|
|
@ -262,104 +263,6 @@ export const MessageComposer = ({
|
|||
<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)]"
|
||||
>
|
||||
{recipient ? (
|
||||
<MemberBadge
|
||||
name={recipient}
|
||||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
)}
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-56 p-1.5"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
setRecipientSearch('');
|
||||
setTimeout(() => recipientSearchRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{members.length > 5 && (
|
||||
<div className="relative mb-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
ref={recipientSearchRef}
|
||||
type="text"
|
||||
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
placeholder="Search..."
|
||||
value={recipientSearch}
|
||||
onChange={(e) => setRecipientSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
|
||||
{(() => {
|
||||
const query = recipientSearch.toLowerCase().trim();
|
||||
const filtered = query
|
||||
? members.filter((m) => m.name.toLowerCase().includes(query))
|
||||
: members;
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return filtered.map((m) => {
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
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);
|
||||
setRecipientSearch('');
|
||||
}}
|
||||
>
|
||||
<MemberBadge
|
||||
name={m.name}
|
||||
color={resolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={m.name === 'user'}
|
||||
/>
|
||||
{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
|
||||
|
|
@ -397,24 +300,125 @@ export const MessageComposer = ({
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{!isTeamAlive ? (
|
||||
<span className="ml-auto text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
</span>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{!isTeamAlive ? (
|
||||
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
||||
Team offline
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<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)]"
|
||||
>
|
||||
{recipient ? (
|
||||
<MemberBadge
|
||||
name={recipient}
|
||||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
)}
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-56 p-1.5"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
setRecipientSearch('');
|
||||
setTimeout(() => recipientSearchRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
{members.length > 5 && (
|
||||
<div className="relative mb-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
|
||||
/>
|
||||
<input
|
||||
ref={recipientSearchRef}
|
||||
type="text"
|
||||
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
|
||||
placeholder="Search..."
|
||||
value={recipientSearch}
|
||||
onChange={(e) => setRecipientSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-48 space-y-0.5 overflow-y-auto">
|
||||
{/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
|
||||
{(() => {
|
||||
const query = recipientSearch.toLowerCase().trim();
|
||||
const filtered = query
|
||||
? members.filter((m) => m.name.toLowerCase().includes(query))
|
||||
: members;
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return filtered.map((m) => {
|
||||
const resolvedColor = colorMap.get(m.name);
|
||||
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);
|
||||
setRecipientSearch('');
|
||||
}}
|
||||
>
|
||||
<MemberBadge
|
||||
name={m.name}
|
||||
color={resolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={m.name === 'user'}
|
||||
/>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentPreviewList
|
||||
attachments={attachments}
|
||||
onRemove={removeAttachment}
|
||||
error={attachmentError}
|
||||
onDismissError={clearAttachmentError}
|
||||
disabled={attachmentsBlocked}
|
||||
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
|
||||
/>
|
||||
|
||||
<MentionableTextarea
|
||||
id={`compose-${teamName}`}
|
||||
placeholder="Write a message... (Enter to send)"
|
||||
placeholder="Write a message... (Enter to send, Shift+Enter for new line)"
|
||||
value={draft.value}
|
||||
onValueChange={draft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
|
|
@ -455,9 +459,6 @@ export const MessageComposer = ({
|
|||
}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-[var(--color-text-muted)] opacity-70">
|
||||
Mention "create a task" to add it to the board
|
||||
</span>
|
||||
{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" />
|
||||
|
|
|
|||
|
|
@ -595,11 +595,32 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
}
|
||||
: style;
|
||||
|
||||
// --- Hint text ---
|
||||
const defaultHintText = enableFiles
|
||||
? 'Use @ to mention team members or search files'
|
||||
: 'Use @ to mention team members';
|
||||
const resolvedHintText = hintText ?? defaultHintText;
|
||||
// --- Rotating tips ---
|
||||
const rotatingTips = React.useMemo(
|
||||
() => [
|
||||
'Tip: Use @ to mention team members or search files',
|
||||
'Tip: Mention "create a task" to add it to the board',
|
||||
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
|
||||
],
|
||||
[]
|
||||
);
|
||||
const [tipIndex, setTipIndex] = React.useState(0);
|
||||
const [tipVisible, setTipVisible] = React.useState(true);
|
||||
|
||||
const advanceTip = React.useCallback(() => {
|
||||
setTipIndex((prev) => (prev + 1) % rotatingTips.length);
|
||||
setTipVisible(true);
|
||||
}, [rotatingTips.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTipVisible(false);
|
||||
setTimeout(advanceTip, 300);
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [advanceTip]);
|
||||
|
||||
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
|
||||
const showHintRow = showHint && (suggestions.length > 0 || enableFiles);
|
||||
const showFooter = showHintRow || footerRight;
|
||||
|
||||
|
|
@ -683,7 +704,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
{showFooter ? (
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
{showHintRow ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">{resolvedHintText}</span>
|
||||
<span
|
||||
className="text-[10px] text-[var(--color-text-muted)] transition-opacity duration-300"
|
||||
style={{ opacity: tipVisible ? 1 : 0 }}
|
||||
>
|
||||
{resolvedHintText}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -31,23 +31,25 @@ const DialogContent = React.forwardRef<
|
|||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
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}
|
||||
>
|
||||
<div className="sticky top-0 z-10 -mr-6 -mt-6 h-0 w-full overflow-visible">
|
||||
<DialogPrimitive.Close className="absolute right-0 top-0 rounded-sm p-1.5 opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
|
||||
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="pointer-events-auto relative">
|
||||
<DialogPrimitive.Close className="absolute -right-4 -top-4 z-10 rounded-full bg-[var(--color-surface-raised)] p-1.5 opacity-70 shadow-lg ring-1 ring-[var(--color-border)] transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-[var(--color-border-emphasis)] disabled:pointer-events-none">
|
||||
<X className="size-4 text-[var(--color-text-muted)]" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid w-full max-w-lg 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 sm:rounded-lg',
|
||||
'max-h-[90vh] min-h-0 overflow-y-auto overflow-x-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface UseAttachmentsReturn {
|
|||
addFiles: (files: FileList | File[]) => Promise<void>;
|
||||
removeAttachment: (id: string) => void;
|
||||
clearAttachments: () => void;
|
||||
clearError: () => void;
|
||||
handlePaste: (event: React.ClipboardEvent) => void;
|
||||
handleDrop: (event: React.DragEvent) => void;
|
||||
}
|
||||
|
|
@ -110,7 +111,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
|||
// Transitioning to non-persistent context: flush pending save and clear stale state
|
||||
flushPending();
|
||||
attachmentsRef.current = [];
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset on key transition
|
||||
setAttachments([]);
|
||||
return;
|
||||
}
|
||||
|
|
@ -120,7 +120,6 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
|||
flushPending();
|
||||
// Clear stale attachments from previous persistenceKey before loading
|
||||
attachmentsRef.current = [];
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync reset before async load
|
||||
setAttachments([]);
|
||||
void (async () => {
|
||||
const raw = await draftStorage.loadDraft(persistenceKey);
|
||||
|
|
@ -221,6 +220,10 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
|||
[schedulePersist]
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const clearAttachments = useCallback(() => {
|
||||
if (timerRef.current != null) {
|
||||
clearTimeout(timerRef.current);
|
||||
|
|
@ -282,6 +285,7 @@ export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsR
|
|||
addFiles,
|
||||
removeAttachment,
|
||||
clearAttachments,
|
||||
clearError,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -642,6 +642,22 @@ body {
|
|||
animation: chat-message-enter 350ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes thought-expand {
|
||||
from {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
to {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.thought-expand-in {
|
||||
animation: thought-expand 350ms ease-out both;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
animation: skeleton-fade-in 0.4s ease-out both;
|
||||
position: relative;
|
||||
|
|
@ -734,6 +750,21 @@ body {
|
|||
background-color: #12131a;
|
||||
}
|
||||
|
||||
/* CLI Logs compact density — reduces item height inside CliLogsRichView */
|
||||
.cli-logs-compact [role='button'] {
|
||||
padding-top: 0.2rem;
|
||||
padding-bottom: 0.2rem;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.cli-logs-compact [role='button'] .text-sm {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
.cli-logs-compact [role='button'] .text-xs {
|
||||
font-size: 0.625rem;
|
||||
line-height: 0.875rem;
|
||||
}
|
||||
|
||||
:root.light .checkerboard-bg {
|
||||
background-image:
|
||||
linear-gradient(45deg, #e2e8f0 25%, transparent 25%),
|
||||
|
|
|
|||
|
|
@ -602,6 +602,26 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (get().selectedTeamName !== teamName) {
|
||||
return;
|
||||
}
|
||||
// Eagerly patch teamByName with color/displayName from detailed data
|
||||
// so that tab color renders immediately without waiting for fetchTeams()
|
||||
const prevByName = get().teamByName;
|
||||
const existingEntry = prevByName[teamName];
|
||||
const configColor = data.config.color;
|
||||
if (configColor && (!existingEntry || existingEntry?.color !== configColor)) {
|
||||
const patched: TeamSummary = existingEntry
|
||||
? { ...existingEntry, color: configColor, displayName: data.config.name || teamName }
|
||||
: {
|
||||
teamName,
|
||||
displayName: data.config.name || teamName,
|
||||
description: data.config.description ?? '',
|
||||
color: configColor,
|
||||
memberCount: data.members.length,
|
||||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
};
|
||||
set({ teamByName: { ...prevByName, [teamName]: patched } });
|
||||
}
|
||||
|
||||
set({
|
||||
selectedTeamName: teamName,
|
||||
selectedTeamData: data,
|
||||
|
|
|
|||
|
|
@ -614,13 +614,18 @@ export interface ElectronAPI {
|
|||
maxResults?: number
|
||||
) => Promise<SearchSessionsResult>;
|
||||
searchAllProjects: (query: string, maxResults?: number) => Promise<SearchSessionsResult>;
|
||||
getSessionDetail: (projectId: string, sessionId: string) => Promise<SessionDetail | null>;
|
||||
getSessionDetail: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
options?: { bypassCache?: boolean }
|
||||
) => Promise<SessionDetail | null>;
|
||||
getSessionMetrics: (projectId: string, sessionId: string) => Promise<SessionMetrics | null>;
|
||||
getWaterfallData: (projectId: string, sessionId: string) => Promise<WaterfallData | null>;
|
||||
getSubagentDetail: (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
subagentId: string
|
||||
subagentId: string,
|
||||
options?: { bypassCache?: boolean }
|
||||
) => Promise<SubagentDetail | null>;
|
||||
getSessionGroups: (projectId: string, sessionId: string) => Promise<ConversationGroup[]>;
|
||||
getSessionsByIds: (
|
||||
|
|
|
|||
|
|
@ -186,6 +186,14 @@ export interface AttachmentFileData {
|
|||
mimeType: AttachmentMediaType;
|
||||
}
|
||||
|
||||
/** Lightweight metadata for a single tool call (for UI display in tooltips). */
|
||||
export interface ToolCallMeta {
|
||||
/** Tool name, e.g. "Read", "Bash", "Grep" */
|
||||
name: string;
|
||||
/** Human-readable preview extracted from input args, e.g. "index.ts", "grep -r foo" */
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface InboxMessage {
|
||||
from: string;
|
||||
to?: string;
|
||||
|
|
@ -199,6 +207,10 @@ export interface InboxMessage {
|
|||
attachments?: AttachmentMeta[];
|
||||
/** Lead session ID that produced this message (for session boundary detection). */
|
||||
leadSessionId?: string;
|
||||
/** Tool usage summary from assistant message, e.g. "3 tools (2 Read, Bash)" */
|
||||
toolSummary?: string;
|
||||
/** Structured tool call details for tooltip display. */
|
||||
toolCalls?: ToolCallMeta[];
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
|
|
@ -208,6 +220,8 @@ export interface SendMessageRequest {
|
|||
from?: string;
|
||||
attachments?: AttachmentPayload[];
|
||||
source?: InboxMessage['source'];
|
||||
/** Lead session ID for session boundary detection. */
|
||||
leadSessionId?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
|
|
|
|||
119
src/shared/utils/toolSummary.ts
Normal file
119
src/shared/utils/toolSummary.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { ToolCallMeta } from '@shared/types';
|
||||
|
||||
export interface ToolSummaryData {
|
||||
total: number;
|
||||
byName: Record<string, number>;
|
||||
}
|
||||
|
||||
export function buildToolSummary(content: Record<string, unknown>[]): string | undefined {
|
||||
const counts = new Map<string, number>();
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === 'object' &&
|
||||
block.type === 'tool_use' &&
|
||||
typeof block.name === 'string'
|
||||
) {
|
||||
counts.set(block.name, (counts.get(block.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
|
||||
if (total === 0) return undefined;
|
||||
const parts = Array.from(counts.entries())
|
||||
.map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
|
||||
.join(', ');
|
||||
return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
|
||||
}
|
||||
|
||||
export function parseToolSummary(summary: string | undefined): ToolSummaryData | null {
|
||||
if (!summary) return null;
|
||||
const match = /^(\d+)\s+tools?\s+\(([^)]+)\)$/.exec(summary);
|
||||
if (!match) return null;
|
||||
const byName: Record<string, number> = {};
|
||||
for (const part of match[2].split(', ')) {
|
||||
const m = /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part);
|
||||
if (m) {
|
||||
byName[m[2]] = parseInt(m[1], 10);
|
||||
} else {
|
||||
byName[part] = 1;
|
||||
}
|
||||
}
|
||||
return { total: parseInt(match[1], 10), byName };
|
||||
}
|
||||
|
||||
export function formatToolSummary(data: ToolSummaryData): string {
|
||||
const parts = Object.entries(data.byName)
|
||||
.map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
|
||||
.join(', ');
|
||||
return `${data.total} ${data.total === 1 ? 'tool' : 'tools'} (${parts})`;
|
||||
}
|
||||
|
||||
/** Format tool summary directly from a Map<toolName, count>. */
|
||||
export function formatToolSummaryFromMap(counts: Map<string, number>): string | undefined {
|
||||
const total = Array.from(counts.values()).reduce((a, b) => a + b, 0);
|
||||
if (total === 0) return undefined;
|
||||
const parts = Array.from(counts.entries())
|
||||
.map(([name, count]) => (count === 1 ? name : `${count} ${name}`))
|
||||
.join(', ');
|
||||
return `${total} ${total === 1 ? 'tool' : 'tools'} (${parts})`;
|
||||
}
|
||||
|
||||
/** Format tool summary from an array of ToolCallMeta. */
|
||||
export function formatToolSummaryFromCalls(calls: ToolCallMeta[]): string | undefined {
|
||||
if (calls.length === 0) return undefined;
|
||||
const counts = new Map<string, number>();
|
||||
for (const c of calls) counts.set(c.name, (counts.get(c.name) ?? 0) + 1);
|
||||
return formatToolSummaryFromMap(counts);
|
||||
}
|
||||
|
||||
function baseName(filePath: string): string {
|
||||
return filePath.split(/[/\\]/).pop() ?? filePath;
|
||||
}
|
||||
|
||||
function truncateStr(str: string, max: number): string {
|
||||
return str.length <= max ? str : str.slice(0, max) + '...';
|
||||
}
|
||||
|
||||
/** Extract a short human-readable preview from tool_use input arguments. */
|
||||
export function extractToolPreview(
|
||||
name: string,
|
||||
input: Record<string, unknown>
|
||||
): string | undefined {
|
||||
switch (name) {
|
||||
case 'Read':
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
return typeof input.file_path === 'string' ? baseName(input.file_path) : undefined;
|
||||
case 'Bash':
|
||||
return typeof input.description === 'string'
|
||||
? truncateStr(input.description, 60)
|
||||
: typeof input.command === 'string'
|
||||
? truncateStr(input.command, 60)
|
||||
: undefined;
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return typeof input.pattern === 'string' ? truncateStr(input.pattern, 40) : undefined;
|
||||
case 'Agent':
|
||||
case 'TaskCreate':
|
||||
return typeof input.prompt === 'string'
|
||||
? input.prompt
|
||||
: typeof input.description === 'string'
|
||||
? input.description
|
||||
: undefined;
|
||||
case 'WebFetch':
|
||||
if (typeof input.url === 'string') {
|
||||
try {
|
||||
return new URL(input.url).hostname;
|
||||
} catch {
|
||||
return truncateStr(input.url, 40);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
case 'WebSearch':
|
||||
return typeof input.query === 'string' ? truncateStr(input.query, 40) : undefined;
|
||||
default: {
|
||||
const v = input.name ?? input.path ?? input.file ?? input.query ?? input.command;
|
||||
return typeof v === 'string' ? truncateStr(v, 50) : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -274,7 +274,6 @@ describe('ipc teams handlers', () => {
|
|||
messages: [
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'user',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:00.000Z',
|
||||
read: true,
|
||||
|
|
@ -287,7 +286,6 @@ describe('ipc teams handlers', () => {
|
|||
provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([
|
||||
{
|
||||
from: 'team-lead',
|
||||
to: 'user',
|
||||
text: 'Hello there',
|
||||
timestamp: '2026-02-23T10:00:01.000Z',
|
||||
read: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,49 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { estimateBashLinesChanged } from '../../../../src/main/services/team/MemberStatsComputer';
|
||||
import {
|
||||
estimateBashLinesChanged,
|
||||
isValidFilePath,
|
||||
} from '../../../../src/main/services/team/MemberStatsComputer';
|
||||
|
||||
describe('isValidFilePath', () => {
|
||||
it('rejects null-like values', () => {
|
||||
expect(isValidFilePath('null')).toBe(false);
|
||||
expect(isValidFilePath('null;')).toBe(false);
|
||||
expect(isValidFilePath('null;,')).toBe(false);
|
||||
expect(isValidFilePath('undefined')).toBe(false);
|
||||
expect(isValidFilePath('None')).toBe(false);
|
||||
expect(isValidFilePath('false')).toBe(false);
|
||||
expect(isValidFilePath('true')).toBe(false);
|
||||
expect(isValidFilePath('')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects paths without slash', () => {
|
||||
expect(isValidFilePath('somefile')).toBe(false);
|
||||
expect(isValidFilePath('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects very short paths', () => {
|
||||
expect(isValidFilePath('/')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid file paths', () => {
|
||||
expect(isValidFilePath('/tmp/file.txt')).toBe(true);
|
||||
expect(isValidFilePath('/Users/dev/project/src/main.ts')).toBe(true);
|
||||
expect(isValidFilePath('./src/index.ts')).toBe(true);
|
||||
expect(isValidFilePath('src/utils/helper.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('strips trailing punctuation before validation', () => {
|
||||
expect(isValidFilePath('/tmp/file.txt;')).toBe(true);
|
||||
expect(isValidFilePath('/tmp/file.txt,')).toBe(true);
|
||||
expect(isValidFilePath('/tmp/file.txt.')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles whitespace', () => {
|
||||
expect(isValidFilePath(' /tmp/file.txt ')).toBe(true);
|
||||
expect(isValidFilePath(' null ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateBashLinesChanged', () => {
|
||||
it('returns zero for simple non-writing commands', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue