Merge pull request #17 from 777genius/dev

Dev
This commit is contained in:
Илия 2026-03-06 17:25:19 +02:00 committed by GitHub
commit 82f06ef773
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
86 changed files with 5523 additions and 507 deletions

View file

@ -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
View file

@ -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)

View 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
View 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: 1128 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
View 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
View 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' });

View 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
View 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)',
);

View 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');
}

View 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);
}
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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);
},
});
}

View 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');
});
});

View 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();
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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);
}
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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');
});
});

View 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
View 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
View 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',
},
});

View 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,
},
});

File diff suppressed because it is too large Load diff

View file

@ -1,2 +1,4 @@
packages:
- mcp-server
ignoredBuiltDependencies:
- esbuild

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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 };
}
};

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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,
});
}

View file

@ -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 () => {

View file

@ -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 userlead 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) {

View file

@ -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,
});
}

View file

@ -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) =>

View file

@ -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)}`

View file

@ -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>
)}

View file

@ -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>
);
};

View file

@ -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,

View file

@ -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;
}}

View file

@ -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) =>

View file

@ -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>}

View file

@ -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)}

View file

@ -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>
);

View file

@ -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)]"

View file

@ -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>

View file

@ -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>

View file

@ -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';

View file

@ -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}

View file

@ -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';

View file

@ -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>
);
);
};

View file

@ -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&apos;t support per-member model selection yet &mdash; all teammates
inherit the team launch model. We plan to solve this via a local proxy.
</p>
</div>
</div>
)}
</div>
);
};

View file

@ -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) {

View file

@ -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 &quot;create a task&quot; 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" />

View file

@ -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 />
)}

View file

@ -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;

View file

@ -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,
};

View file

@ -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%),

View file

@ -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,

View file

@ -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: (

View file

@ -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 {

View 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;
}
}
}

View file

@ -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,

View file

@ -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', () => {