feat: use agent teams

This commit is contained in:
iliya 2026-02-22 15:41:18 +02:00 committed by Илия
parent 3ce0ba098a
commit 66da7b318e
113 changed files with 7552 additions and 1271 deletions

View file

@ -1,4 +1,4 @@
# claude-devtools
# Claude Agent Teams UI
Electron app that visualizes Claude Code session execution

View file

@ -1,6 +1,6 @@
# Contributing
Thanks for contributing to claude-devtools.
Thanks for contributing to Claude Agent Teams UI.
## Prerequisites
- Node.js 20+

View file

@ -1,11 +1,11 @@
# =============================================================================
# claude-devtools standalone Docker image
# Claude Agent Teams UI standalone Docker image
#
# Runs the HTTP server without Electron, serving the full UI over HTTP.
# Mount your ~/.claude directory to make session data available.
#
# Build: docker build -t claude-devtools .
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
# Build: docker build -t claude-agent-teams-ui .
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
# =============================================================================
FROM node:20-slim AS builder

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 claude-devtools contributors
Copyright (c) 2026 Claude Agent Teams UI contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -1,14 +1,8 @@
<p align="center">
<img src="resources/icons/png/1024x1024.png" alt="claude-devtools" width="120" />
<img src="resources/icons/png/1024x1024.png" alt="Claude Agent Teams UI" width="120" />
</p>
<h1 align="center">claude-devtools</h1>
<p align="center">
<a href="https://www.producthunt.com/products/claude-devtools?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-claude-devtools" target="_blank" rel="noopener noreferrer">
<img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1080673&theme=light" alt="claude-devtools - See everything Claude Code hides from your terminal | Product Hunt" width="250" height="54" />
</a>
</p>
<h1 align="center">Claude Agent Teams UI</h1>
<p align="center">
<strong><code>Terminal tells you nothing. This shows you everything.</code></strong>
@ -16,36 +10,27 @@
A desktop app that reconstructs exactly what Claude Code did — every file path, every tool call, every token — from the raw session logs already on your machine.
</p>
<p align="center">
<a href="https://claude-dev.tools"><img src="https://img.shields.io/badge/Website-claude--dev.tools-blue?style=flat-square" alt="Website" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases/latest"><img src="https://img.shields.io/github/v/release/matt1398/claude-devtools?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml"><img src="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases"><img src="https://img.shields.io/github/downloads/matt1398/claude-devtools/total?style=flat-square&color=green" alt="Downloads" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases"><img src="https://img.shields.io/github/downloads/777genius/claude_agent_teams_ui/total?style=flat-square&color=green" alt="Downloads" /></a>&nbsp;
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows%20%7C%20Docker-lightgrey?style=flat-square" alt="Platform" />
</p>
<br />
<p align="center">
<a href="https://claude-dev.tools">
<img src="https://img.shields.io/badge/Website-claude--dev.tools-171717?logo=googlechrome&logoColor=white&style=flat" alt="Website" height="30" />
</a>&nbsp;&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases/latest">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
<img src="https://img.shields.io/badge/macOS-Download-black?logo=apple&logoColor=white&style=flat" alt="Download for macOS" height="30" />
</a>&nbsp;&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases/latest">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
<img src="https://img.shields.io/badge/Linux-Download-FCC624?logo=linux&logoColor=black&style=flat" alt="Download for Linux" height="30" />
</a>&nbsp;&nbsp;
<a href="https://github.com/matt1398/claude-devtools/releases/latest">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest">
<img src="https://img.shields.io/badge/Windows-Download-0078D4?logo=windows&logoColor=white&style=flat" alt="Download for Windows" height="30" />
</a>&nbsp;&nbsp;
<a href="#docker--standalone-deployment">
<img src="https://img.shields.io/badge/Docker-Deploy-2496ED?logo=docker&logoColor=white&style=flat" alt="Deploy with Docker" height="30" />
</a>&nbsp;&nbsp;
<a href="#installation">
<img src="https://img.shields.io/badge/Homebrew-Install-FBB040?logo=homebrew&logoColor=white&style=flat" alt="Install with Homebrew" height="30" />
</a>
</p>
@ -65,20 +50,14 @@
## Installation
### Homebrew (macOS)
```bash
brew install --cask claude-devtools
```
### Direct Download
| Platform | Download | Notes |
|----------|----------|-------|
| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/matt1398/claude-devtools/releases/latest) | Download the `arm64` asset. Drag to Applications. On first launch: right-click → Open |
| **macOS** (Intel) | [`.dmg`](https://github.com/matt1398/claude-devtools/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open |
| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/matt1398/claude-devtools/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). |
| **Windows** | [`.exe`](https://github.com/matt1398/claude-devtools/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
| **macOS** (Apple Silicon) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `arm64` asset. Drag to Applications. On first launch: right-click → Open |
| **macOS** (Intel) | [`.dmg`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Download the `x64` asset. Drag to Applications. On first launch: right-click → Open |
| **Linux** | [`.AppImage` / `.deb` / `.rpm` / `.pacman`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Choose the package format for your distro (portable AppImage or native package manager format). |
| **Windows** | [`.exe`](https://github.com/777genius/claude_agent_teams_ui/releases/latest) | Standard installer. May trigger SmartScreen — click "More info" → "Run anyway" |
| **Docker** | `docker compose up` | Open `http://localhost:3456`. See [Docker / Standalone Deployment](#docker--standalone-deployment) for details. |
The app reads session logs from `~/.claude/` — the data is already on your machine. No setup, no API keys, no login.
@ -93,11 +72,11 @@ Recent Claude Code updates replaced detailed tool output with opaque summaries.
**There is no middle ground in the CLI.** You either see too little or too much.
claude-devtools restores the information that was taken away — structured, searchable, and without a single modification to Claude Code itself. It reads the raw session logs from `~/.claude/` and reconstructs the full execution trace: every file path that was read, every regex that was searched, every diff that was applied, every token that was consumed — organized into a visual interface you can actually reason about.
Claude Agent Teams UI restores the information that was taken away — structured, searchable, and without a single modification to Claude Code itself. It reads the raw session logs from `~/.claude/` and reconstructs the full execution trace: every file path that was read, every regex that was searched, every diff that was applied, every token that was consumed — organized into a visual interface you can actually reason about.
### The wrapper problem.
There are many GUI wrappers for Claude Code — Conductor, Craft Agents, Vibe Kanban, 1Code, ccswitch, and others. I tried them all. None of them solved the actual problem:
There are many GUI wrappers for Claude Code — Conductor, Craft Agents, Vibe Kanban, 1Code, ccswitch, and others. None of them solved the actual problem:
**They wrap Claude Code.** They inject their own prompts, add their own abstractions, and change how Claude behaves. If you love the terminal — and I do — you don't want that. You want Claude Code exactly as it is.
@ -107,7 +86,7 @@ There are many GUI wrappers for Claude Code — Conductor, Craft Agents, Vibe Ka
**You can't monitor what matters.** Want to know when Claude reads `.env`? When a single tool call exceeds 4K tokens of context? When a teammate sends a shutdown request? You'd have to wire up hooks manually, every time, for every project.
**claude-devtools takes a different approach.** It doesn't wrap or modify Claude Code at all. It reads the session logs that already exist on your machine (`~/.claude/`) and turns them into a rich, interactive interface — regardless of whether the session ran in the terminal, in an IDE, or through another tool.
**Claude Agent Teams UI takes a different approach.** It doesn't wrap or modify Claude Code at all. It reads the session logs that already exist on your machine (`~/.claude/`) and turns them into a rich, interactive interface — regardless of whether the session ran in the terminal, in an IDE, or through another tool.
> Zero configuration. No API keys. Works with every session you've ever run.
@ -119,7 +98,7 @@ There are many GUI wrappers for Claude Code — Conductor, Craft Agents, Vibe Ka
<img width="100%" alt="context" src="https://github.com/user-attachments/assets/9ff4a5a7-bcf6-47fb-8ca5-d4021540804b" />
Claude Code doesn't expose what's actually in the context window. claude-devtools reverse-engineers it.
Claude Code doesn't expose what's actually in the context window. Claude Agent Teams UI reverse-engineers it.
The engine walks each turn of the session and reconstructs the full set of context injections — **CLAUDE.md files** (broken down by global, project, and directory-level), **skill activations**, **@-mentioned files**, **tool call inputs and outputs**, **extended thinking**, **team coordination overhead**, and **user prompt text**.
@ -131,9 +110,9 @@ The result is a per-turn breakdown of estimated token attribution across 7 categ
**See the moment your context hits the limit.**
When Claude Code hits its context limit, it silently compresses your conversation and continues. Most tools don't even notice this happened.
When Claude Code hits its context limit, it silently compresses your conversation and continues. Most tools don't even notice this happened.
claude-devtools detects these compaction boundaries, measures the token delta before and after, and visualizes how your context fills, compresses, and refills over the course of a session. You can see exactly what was in the window at any point, and how the composition shifted after each compaction event.
Claude Agent Teams UI detects these compaction boundaries, measures the token delta before and after, and visualizes how your context fills, compresses, and refills over the course of a session. You can see exactly what was in the window at any point, and how the composition shifted after each compaction event.
### :bell: Custom Notification Triggers
@ -159,7 +138,7 @@ Every tool call is paired with its result in an expandable card. Specialized vie
### :busts_in_silhouette: Team & Subagent Visualization
Claude Code now spawns subagents via the Task tool and coordinates entire teams via `TeamCreate`, `SendMessage`, and `TaskUpdate`. In the terminal, all of this collapses into an unreadable stream. claude-devtools untangles it.
Claude Code now spawns subagents via the Task tool and coordinates entire teams via `TeamCreate`, `SendMessage`, and `TaskUpdate`. In the terminal, all of this collapses into an unreadable stream. Claude Agent Teams UI untangles it.
- **Subagent sessions** are resolved from Task tool calls and rendered as expandable inline cards — each with its own tool trace, token metrics, duration, and cost. Nested subagents (agents spawning agents) render as a recursive tree.
- **Teammate messages** — sent via `SendMessage` with color and summary metadata — are detected and rendered as distinct color-coded cards, separated from regular user messages. Each teammate is identified by name and assigned color.
@ -174,26 +153,17 @@ Hit **Cmd+K** for a Spotlight-style command palette. Search across all sessions
Connect to any remote machine over SSH and inspect Claude Code sessions running there — same interface, no compromise.
claude-devtools parses your `~/.ssh/config` for host aliases, supports agent forwarding, private keys, and password auth, then opens an SFTP channel to stream session logs from the remote `~/.claude/` directory. Each SSH host gets its own isolated service context with independent caches, file watchers, and parsers. Switching between local and remote workspaces is instant — the app snapshots your current state to IndexedDB before the switch and restores it when you return, tabs and all.
Claude Agent Teams UI parses your `~/.ssh/config` for host aliases, supports agent forwarding, private keys, and password auth, then opens an SFTP channel to stream session logs from the remote `~/.claude/` directory. Each SSH host gets its own isolated service context with independent caches, file watchers, and parsers. Switching between local and remote workspaces is instant — the app snapshots your current state to IndexedDB before the switch and restores it when you return, tabs and all.
### :bar_chart: Multi-Pane Layout
Open multiple sessions side-by-side. Drag-and-drop tabs between panes, split views, and compare sessions in parallel — like a proper IDE for your AI conversations.
### :hammer_and_wrench: Rich Tool Call Inspector
Every tool call is paired with its result in an expandable card. Specialized viewers render each tool natively:
- **Read** calls show syntax-highlighted code with line numbers
- **Edit** calls show inline diffs with added/removed highlighting
- **Bash** calls show command output
- **Subagent** calls show the full execution tree, expandable in-place
---
## What the CLI Hides vs. What claude-devtools Shows
## What the CLI Hides vs. What Claude Agent Teams UI Shows
| What you see in the terminal | What claude-devtools shows you |
| What you see in the terminal | What Claude Agent Teams UI shows you |
|------------------------------|-------------------------------|
| `Read 3 files` | Exact file paths, syntax-highlighted content with line numbers |
| `Searched for 1 pattern` | The regex pattern, every matching file, and the matched lines |
@ -208,7 +178,7 @@ Every tool call is paired with its result in an expandable card. Specialized vie
## Docker / Standalone Deployment
Run claude-devtools without Electron — in Docker, on a remote server, or anywhere Node.js runs.
Run Claude Agent Teams UI without Electron — in Docker, on a remote server, or anywhere Node.js runs.
### Quick Start (Docker Compose)
@ -221,8 +191,8 @@ Open `http://localhost:3456` in your browser.
### Quick Start (Docker)
```bash
docker build -t claude-devtools .
docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
docker build -t claude-agent-teams-ui .
docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
```
### Quick Start (Node.js)
@ -248,7 +218,7 @@ node dist-standalone/index.cjs
- **Custom Claude root path.** If your `.claude` directory is not at `~/.claude`, update the volume mount to point to the correct location:
```bash
# Example: Claude root at /home/user/custom-claude-dir
docker run -p 3456:3456 -v /home/user/custom-claude-dir:/data/.claude:ro claude-devtools
docker run -p 3456:3456 -v /home/user/custom-claude-dir:/data/.claude:ro claude-agent-teams-ui
# Or with docker compose, set the CLAUDE_DIR env variable:
CLAUDE_DIR=/home/user/custom-claude-dir docker compose up
@ -259,7 +229,7 @@ node dist-standalone/index.cjs
The standalone server has **zero** outbound network calls. For maximum isolation:
```bash
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
```
See [SECURITY.md](SECURITY.md) for a full audit of network activity.
@ -276,8 +246,8 @@ See [SECURITY.md](SECURITY.md) for a full audit of network activity.
**Prerequisites:** Node.js 20+, pnpm 10+
```bash
git clone https://github.com/matt1398/claude-devtools.git
cd claude-devtools
git clone https://github.com/777genius/claude_agent_teams_ui.git
cd claude_agent_teams_ui
pnpm install
pnpm dev
```
@ -315,6 +285,10 @@ pnpm dist # macOS + Windows + Linux
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines. Please read our [Code of Conduct](CODE_OF_CONDUCT.md).
## Acknowledgements
Based on [claude-devtools](https://github.com/matt1398/claude-devtools) by matt1398.
## Security
IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](SECURITY.md) for details.

View file

@ -2,7 +2,7 @@
## Network Activity
claude-devtools makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind.
Claude Agent Teams UI makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind.
| Network activity | When | Mode | User-initiated |
|---|---|---|---|
@ -26,8 +26,8 @@ In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-update
For maximum trust, run the Docker container with `--network none`:
```bash
docker build -t claude-devtools .
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
docker build -t claude-agent-teams-ui .
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-agent-teams-ui
```
Or with Docker Compose, uncomment `network_mode: "none"` in `docker-compose.yml`.

View file

@ -1,5 +1,5 @@
# =============================================================================
# claude-devtools — Docker Compose
# Claude Agent Teams UI — Docker Compose
#
# Quick start:
# docker compose up
@ -13,7 +13,7 @@
# =============================================================================
services:
claude-devtools:
claude-agent-teams-ui:
build: .
ports:
- "3456:3456"

View file

@ -1,6 +1,6 @@
# Team Management Feature
Интерфейс для управления командами тиммейтов Claude Code внутри claude-devtools (Electron).
Интерфейс для управления командами тиммейтов Claude Code внутри Claude Agent Teams UI (Electron).
## Что делает
@ -26,6 +26,11 @@
### 1. Messaging: Inbox-файлы
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
### 1.1 Roster source: members.meta.json + inboxes
- `config.json` не используется как полный реестр участников (он может содержать только team-lead и служебные поля CLI).
- Источник метаданных участников (role/color/agentType): `members.meta.json`.
- Источник runtime-состава и адресации сообщений: `inboxes/{member}.json`.
### 2. Kanban Storage: Собственный файл
Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`, а не в task metadata. Причина: metadata может быть перезаписан агентом при TaskUpdate. Подробности: [kanban-design.md](./kanban-design.md)
@ -89,7 +94,8 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
```
~/.claude/
├── teams/{teamName}/
│ ├── config.json # Конфиг команды (members НЕПОЛНЫЙ!)
│ ├── config.json # Конфиг команды (lead + служебные поля)
│ ├── members.meta.json # Роли/цвета/типы участников (teammates)
│ └── inboxes/{memberName}.json # Inbox каждого участника
└── tasks/{teamName}/
├── {id}.json # Файл задачи
@ -97,4 +103,6 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
└── .highwatermark # Последний ID задачи
```
**ВАЖНО**: config.json members содержит только team-lead. Полный список участников реконструируем из inbox-файлов.
**ВАЖНО**:
- `config.json` не является source-of-truth для полного roster.
- Полный roster для UI формируется как `members.meta.json + inbox filenames (+ lead из config)`.

View file

@ -33,7 +33,7 @@ claude # Новая независимая сессия
---
## ЧАСТЬ 2: Существующая инфраструктура в claude-devtools
## ЧАСТЬ 2: Существующая инфраструктура в Claude Agent Teams UI
### Уже реализовано (можно переиспользовать)

View file

@ -1,20 +1,19 @@
{
"name": "claude-devtools",
"name": "claude-agent-teams-ui",
"type": "module",
"version": "0.1.0",
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
"license": "MIT",
"author": {
"name": "claude-devtools contributors",
"email": "matt1398@users.noreply.github.com"
"name": "Claude Agent Teams UI contributors"
},
"homepage": "https://github.com/matt1398/claude-devtools",
"homepage": "https://github.com/777genius/claude_agent_teams_ui",
"repository": {
"type": "git",
"url": "https://github.com/matt1398/claude-devtools.git"
"url": "https://github.com/777genius/claude_agent_teams_ui.git"
},
"bugs": {
"url": "https://github.com/matt1398/claude-devtools/issues"
"url": "https://github.com/777genius/claude_agent_teams_ui/issues"
},
"main": "dist-electron/main/index.cjs",
"scripts": {
@ -54,12 +53,14 @@
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-virtual": "^3.10.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -126,7 +127,7 @@
},
"build": {
"appId": "com.claudecode.context",
"productName": "claude-devtools",
"productName": "Claude Agent Teams UI",
"directories": {
"output": "release"
},

View file

@ -23,6 +23,9 @@ importers:
'@fastify/static':
specifier: ^9.0.0
version: 9.0.0
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-collapsible':
specifier: ^1.1.12
version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -41,6 +44,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@tanstack/react-virtual':
specifier: ^3.10.8
version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -1017,6 +1023,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.12':
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
peerDependencies:
@ -1218,6 +1237,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@ -1249,6 +1281,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@ -5846,6 +5891,22 @@ snapshots:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
@ -6039,6 +6100,23 @@ snapshots:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/number': 1.1.1
@ -6082,6 +6160,22 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.27
'@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-direction': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.27
'@types/react-dom': 18.3.7(@types/react@18.3.27)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.27)(react@18.3.1)':
dependencies:
react: 18.3.1

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

After

Width:  |  Height:  |  Size: 83 KiB

50
resources/icon.svg Normal file
View file

@ -0,0 +1,50 @@
<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#18181b"/>
<stop offset="100%" stop-color="#111113"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="48%" r="38%">
<stop offset="0%" stop-color="#7c3aed" stop-opacity="0.12"/>
<stop offset="100%" stop-color="#7c3aed" stop-opacity="0"/>
</radialGradient>
<linearGradient id="edge1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#818cf8"/>
<stop offset="100%" stop-color="#a78bfa"/>
</linearGradient>
<linearGradient id="edge2" x1="1" y1="0" x2="0.3" y2="1">
<stop offset="0%" stop-color="#a78bfa"/>
<stop offset="100%" stop-color="#c084fc"/>
</linearGradient>
<linearGradient id="edge3" x1="0.7" y1="1" x2="0" y2="0">
<stop offset="0%" stop-color="#c084fc"/>
<stop offset="100%" stop-color="#818cf8"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="1024" height="1024" rx="228" fill="url(#bg)"/>
<!-- Subtle central glow -->
<circle cx="512" cy="470" r="320" fill="url(#glow)"/>
<!-- Triangle edges (connections between agents) -->
<line x1="354" y1="340" x2="670" y2="340" stroke="url(#edge1)" stroke-width="30" stroke-linecap="round" opacity="0.4"/>
<line x1="670" y1="340" x2="512" y2="640" stroke="url(#edge2)" stroke-width="30" stroke-linecap="round" opacity="0.4"/>
<line x1="512" y1="640" x2="354" y2="340" stroke="url(#edge3)" stroke-width="30" stroke-linecap="round" opacity="0.4"/>
<!-- Agent nodes — outer glow rings -->
<circle cx="354" cy="340" r="96" fill="#818cf8" opacity="0.12"/>
<circle cx="670" cy="340" r="96" fill="#a78bfa" opacity="0.12"/>
<circle cx="512" cy="640" r="104" fill="#c084fc" opacity="0.12"/>
<!-- Agent nodes — solid circles -->
<circle cx="354" cy="340" r="72" fill="#818cf8"/>
<circle cx="670" cy="340" r="72" fill="#a78bfa"/>
<circle cx="512" cy="640" r="80" fill="#c084fc"/>
<!-- Inner highlights (cores) -->
<circle cx="354" cy="340" r="28" fill="#e0e7ff"/>
<circle cx="670" cy="340" r="28" fill="#ede9fe"/>
<circle cx="512" cy="640" r="32" fill="#f3e8ff"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 710 B

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 115 KiB

View file

@ -1,5 +1,5 @@
/**
* Main process entry point for claude-devtools.
* Main process entry point for Claude Agent Teams UI.
*
* Responsibilities:
* - Initialize Electron app and main window
@ -63,11 +63,14 @@ import { HttpServer } from './services/infrastructure/HttpServer';
import {
configManager,
LocalFileSystemProvider,
MemberStatsComputer,
NotificationManager,
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
TeamAgentToolsInstaller,
TeamDataService,
TeamMemberLogsFinder,
TeamProvisioningService,
UpdaterService,
} from './services';
@ -286,6 +289,12 @@ function initializeServices(): void {
updaterService = new UpdaterService();
teamDataService = new TeamDataService();
teamProvisioningService = new TeamProvisioningService();
const teamMemberLogsFinder = new TeamMemberLogsFinder();
const memberStatsComputer = new MemberStatsComputer(teamMemberLogsFinder);
// Fire-and-forget: warm up CLI and install teamctl.js at startup
void teamProvisioningService.warmup();
void new TeamAgentToolsInstaller().ensureInstalled();
httpServer = new HttpServer();
// Initialize IPC handlers with registry
@ -295,6 +304,8 @@ function initializeServices(): void {
sshConnectionManager,
teamDataService,
teamProvisioningService,
teamMemberLogsFinder,
memberStatsComputer,
{
rewire: rewireContextEvents,
full: onContextSwitched,
@ -467,7 +478,7 @@ function createWindow(): void {
backgroundColor: '#1a1a1a',
...(isLinux ? {} : { titleBarStyle: 'hidden' as const }),
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
title: 'claude-devtools',
title: 'Claude Agent Teams UI',
});
// Load the renderer

View file

@ -53,10 +53,12 @@ import { registerValidationHandlers, removeValidationHandlers } from './validati
import { registerWindowHandlers, removeWindowHandlers } from './window';
import type {
MemberStatsComputer,
ServiceContext,
ServiceContextRegistry,
SshConnectionManager,
TeamDataService,
TeamMemberLogsFinder,
TeamProvisioningService,
UpdaterService,
} from '../services';
@ -70,6 +72,8 @@ export function initializeIpcHandlers(
sshManager: SshConnectionManager,
teamDataService: TeamDataService,
teamProvisioningService: TeamProvisioningService,
teamMemberLogsFinder: TeamMemberLogsFinder,
memberStatsComputer: MemberStatsComputer,
contextCallbacks: {
rewire: (context: ServiceContext) => void;
full: (context: ServiceContext) => void;
@ -84,7 +88,12 @@ export function initializeIpcHandlers(
initializeUpdaterHandlers(updater);
initializeSshHandlers(sshManager, registry, contextCallbacks.rewire);
initializeContextHandlers(registry, contextCallbacks.rewire);
initializeTeamHandlers(teamDataService, teamProvisioningService);
initializeTeamHandlers(
teamDataService,
teamProvisioningService,
teamMemberLogsFinder,
memberStatsComputer
);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
});

View file

@ -2,9 +2,14 @@ import {
TEAM_ALIVE_LIST,
TEAM_CANCEL_PROVISIONING,
TEAM_CREATE,
TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS,
TEAM_GET_DATA,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_LAUNCH,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
@ -13,6 +18,7 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
@ -24,20 +30,33 @@ import * as path from 'path';
import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards';
import type { TeamDataService, TeamProvisioningService } from '../services';
import type {
MemberStatsComputer,
TeamDataService,
TeamMemberLogsFinder,
TeamProvisioningService,
} from '../services';
import type {
CreateTaskRequest,
GlobalTask,
IpcResult,
MemberFullStats,
MemberLogSummary,
SendMessageRequest,
SendMessageResult,
TeamConfig,
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
UpdateKanbanPatch,
} from '@shared/types';
@ -45,13 +64,19 @@ const logger = createLogger('IPC:teams');
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
let memberStatsComputer: MemberStatsComputer | null = null;
export function initializeTeamHandlers(
service: TeamDataService,
provisioningService: TeamProvisioningService
provisioningService: TeamProvisioningService,
logsFinder?: TeamMemberLogsFinder,
statsComputer?: MemberStatsComputer
): void {
teamDataService = service;
teamProvisioningService = provisioningService;
teamMemberLogsFinder = logsFinder ?? null;
memberStatsComputer = statsComputer ?? null;
}
export function registerTeamHandlers(ipcMain: IpcMain): void {
@ -59,6 +84,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_GET_DATA, handleGetData);
ipcMain.handle(TEAM_PREPARE_PROVISIONING, handlePrepareProvisioning);
ipcMain.handle(TEAM_CREATE, handleCreateTeam);
ipcMain.handle(TEAM_LAUNCH, handleLaunchTeam);
ipcMain.handle(TEAM_PROVISIONING_STATUS, handleProvisioningStatus);
ipcMain.handle(TEAM_CANCEL_PROVISIONING, handleCancelProvisioning);
ipcMain.handle(TEAM_SEND_MESSAGE, handleSendMessage);
@ -70,6 +96,11 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
logger.info('Team handlers registered');
}
@ -78,6 +109,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_GET_DATA);
ipcMain.removeHandler(TEAM_PREPARE_PROVISIONING);
ipcMain.removeHandler(TEAM_CREATE);
ipcMain.removeHandler(TEAM_LAUNCH);
ipcMain.removeHandler(TEAM_PROVISIONING_STATUS);
ipcMain.removeHandler(TEAM_CANCEL_PROVISIONING);
ipcMain.removeHandler(TEAM_SEND_MESSAGE);
@ -89,6 +121,11 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_PROCESS_SEND);
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
ipcMain.removeHandler(TEAM_ALIVE_LIST);
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
}
function getTeamDataService(): TeamDataService {
@ -131,7 +168,11 @@ async function handleGetData(
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('getData', () => getTeamDataService().getTeamData(validated.value!));
return wrapTeamHandler('getData', async () => {
const data = await getTeamDataService().getTeamData(validated.value!);
const isAlive = getTeamProvisioningService().isTeamAlive(validated.value!);
return { ...data, isAlive };
});
}
async function handleDeleteTeam(
@ -145,6 +186,32 @@ async function handleDeleteTeam(
return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!));
}
async function handleUpdateConfig(
_event: IpcMainInvokeEvent,
teamName: unknown,
updates: unknown
): Promise<IpcResult<TeamConfig>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
if (!updates || typeof updates !== 'object') {
return { success: false, error: 'Invalid updates object' };
}
const { name, description, color } = updates as TeamUpdateConfigRequest;
return wrapTeamHandler('updateConfig', async () => {
const result = await getTeamDataService().updateConfig(validated.value!, {
name,
description,
color,
});
if (!result) {
throw new Error('Team config not found');
}
return result;
});
}
function isProvisioningTeamName(teamName: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(teamName) && teamName.length <= 64;
}
@ -223,14 +290,20 @@ async function validateProvisioningRequest(
return { valid: false, error: 'cwd must be a directory' };
}
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { valid: false, error: 'prompt must be a string' };
}
return {
valid: true,
value: {
teamName,
displayName: payload.displayName?.trim() || undefined,
description: payload.description?.trim() || undefined,
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
members,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
},
};
}
@ -256,6 +329,60 @@ async function handleCreateTeam(
);
}
async function handleLaunchTeam(
event: IpcMainInvokeEvent,
request: unknown
): Promise<IpcResult<TeamLaunchResponse>> {
if (!request || typeof request !== 'object') {
return { success: false, error: 'Invalid team launch request' };
}
const payload = request as Partial<TeamLaunchRequest>;
const validatedTeamName = validateTeamName(payload.teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
return { success: false, error: 'cwd is required' };
}
const cwd = payload.cwd.trim();
if (!path.isAbsolute(cwd)) {
return { success: false, error: 'cwd must be an absolute path' };
}
try {
const stat = await fs.promises.stat(cwd);
if (!stat.isDirectory()) {
return { success: false, error: 'cwd must be a directory' };
}
} catch {
return { success: false, error: 'cwd does not exist' };
}
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { success: false, error: 'prompt must be a string' };
}
return wrapTeamHandler('launch', () =>
getTeamProvisioningService().launchTeam(
{
teamName: validatedTeamName.value!,
cwd,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
},
(progress) => {
try {
event.sender.send(TEAM_PROVISIONING_PROGRESS, progress);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn(`Failed to emit launch provisioning progress: ${message}`);
}
}
)
);
}
async function handlePrepareProvisioning(
_event: IpcMainInvokeEvent,
cwd: unknown
@ -396,6 +523,14 @@ async function handleCreateTask(
return { success: false, error: 'blockedBy must be an array of task ID strings' };
}
}
if (payload.prompt !== undefined) {
if (typeof payload.prompt !== 'string') {
return { success: false, error: 'prompt must be a string' };
}
if (payload.prompt.length > 5000) {
return { success: false, error: 'prompt exceeds max length (5000)' };
}
}
return wrapTeamHandler('createTask', () =>
getTeamDataService().createTask(validatedTeamName.value!, {
@ -403,6 +538,7 @@ async function handleCreateTask(
description: payload.description?.trim(),
owner: payload.owner?.trim() || undefined,
blockedBy: payload.blockedBy,
prompt: payload.prompt?.trim() || undefined,
})
);
}
@ -517,6 +653,115 @@ async function handleProcessAlive(
);
}
async function handleCreateConfig(
_event: IpcMainInvokeEvent,
request: unknown
): Promise<IpcResult<void>> {
if (!request || typeof request !== 'object') {
return { success: false, error: 'Invalid create config request' };
}
const payload = request as Partial<TeamCreateConfigRequest>;
if (typeof payload.teamName !== 'string' || payload.teamName.trim().length === 0) {
return { success: false, error: 'teamName is required' };
}
const teamName = payload.teamName.trim();
if (!isProvisioningTeamName(teamName)) {
return { success: false, error: 'teamName must be kebab-case [a-z0-9-], max 64 chars' };
}
if (!Array.isArray(payload.members) || payload.members.length === 0) {
return { success: false, error: 'members must contain at least one member' };
}
const seenNames = new Set<string>();
const members: TeamCreateConfigRequest['members'] = [];
for (const member of payload.members) {
if (!member || typeof member !== 'object') {
return { success: false, error: 'member must be object' };
}
const nameValidation = validateMemberName((member as { name?: unknown }).name);
if (!nameValidation.valid) {
return { success: false, error: nameValidation.error ?? 'Invalid member name' };
}
const memberName = nameValidation.value!;
if (seenNames.has(memberName)) {
return { success: false, error: 'member names must be unique' };
}
seenNames.add(memberName);
const role = (member as { role?: unknown }).role;
if (role !== undefined && typeof role !== 'string') {
return { success: false, error: 'member role must be string' };
}
members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined });
}
return wrapTeamHandler('createConfig', () =>
getTeamDataService().createTeamConfig({
teamName,
displayName: payload.displayName?.trim() || undefined,
description: payload.description?.trim() || undefined,
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
members,
})
);
}
function getTeamMemberLogsFinder(): TeamMemberLogsFinder {
if (!teamMemberLogsFinder) {
throw new Error('Team member logs finder is not initialized');
}
return teamMemberLogsFinder;
}
async function handleGetMemberLogs(
_event: IpcMainInvokeEvent,
teamName: unknown,
memberName: unknown
): Promise<IpcResult<MemberLogSummary[]>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vMember = validateMemberName(memberName);
if (!vMember.valid) {
return { success: false, error: vMember.error ?? 'Invalid memberName' };
}
return wrapTeamHandler('getMemberLogs', () =>
getTeamMemberLogsFinder().findMemberLogs(vTeam.value!, vMember.value!)
);
}
function getMemberStatsComputer(): MemberStatsComputer {
if (!memberStatsComputer) {
throw new Error('Member stats computer is not initialized');
}
return memberStatsComputer;
}
async function handleGetMemberStats(
_event: IpcMainInvokeEvent,
teamName: unknown,
memberName: unknown
): Promise<IpcResult<MemberFullStats>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vMember = validateMemberName(memberName);
if (!vMember.valid) {
return { success: false, error: vMember.error ?? 'Invalid memberName' };
}
return wrapTeamHandler('getMemberStats', () =>
getMemberStatsComputer().getStats(vTeam.value!, vMember.value!)
);
}
async function handleAliveList(_event: IpcMainInvokeEvent): Promise<IpcResult<string[]>> {
return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams());
}
async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise<IpcResult<GlobalTask[]>> {
return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks());
}

View file

@ -75,11 +75,17 @@ export class ChunkBuilder {
*
* All chunk types are INDEPENDENT - no pairing between User and AI.
*/
buildChunks(messages: ParsedMessage[], subagents: Process[] = []): EnhancedChunk[] {
buildChunks(
messages: ParsedMessage[],
subagents: Process[] = [],
options?: { includeSidechain?: boolean }
): EnhancedChunk[] {
const chunks: EnhancedChunk[] = [];
// Filter to main thread messages (non-sidechain)
const mainMessages = messages.filter((m) => !m.isSidechain);
const mainMessages = options?.includeSidechain
? messages
: messages.filter((m) => !m.isSidechain);
logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`);
// Classify each message into categories using MessageClassifier
@ -440,7 +446,7 @@ export class ChunkBuilder {
subagentId,
sessionParser,
subagentResolver,
(messages, subagents) => this.buildChunks(messages, subagents),
(messages, subagents) => this.buildChunks(messages, subagents, { includeSidechain: true }),
fsProvider,
projectsDir
);

View file

@ -14,6 +14,7 @@ import {
type SemanticStepGroup,
type SubagentDetail,
} from '@main/types';
import { extractBaseDir } from '@main/utils/pathDecoder';
import { countTokens } from '@main/utils/tokenizer';
import { createLogger } from '@shared/utils/logger';
import * as path from 'path';
@ -30,8 +31,8 @@ import type { SessionParser } from '../parsing/SessionParser';
* Build detailed information for a specific subagent.
* Used for drill-down modal to show subagent's internal execution.
*
* @param projectId - Project ID
* @param _sessionId - Parent session ID (currently unused, kept for API consistency)
* @param projectId - Project ID (may contain :: for composite IDs)
* @param sessionId - Parent session ID (used in subagent path construction)
* @param subagentId - Subagent ID to load
* @param sessionParser - SessionParser instance for parsing subagent file
* @param subagentResolver - SubagentResolver instance for nested subagents
@ -42,7 +43,7 @@ import type { SessionParser } from '../parsing/SessionParser';
*/
export async function buildSubagentDetail(
projectId: string,
_sessionId: string, // Unused but kept for API consistency
sessionId: string,
subagentId: string,
sessionParser: SessionParser,
subagentResolver: SubagentResolver,
@ -52,9 +53,12 @@ export async function buildSubagentDetail(
): Promise<SubagentDetail | null> {
try {
// Construct path to subagent JSONL file
// projectId may be composite (e.g. "baseDir::suffix"), extract base dir
const baseDir = extractBaseDir(projectId);
const subagentPath = path.join(
projectsDir,
projectId,
baseDir,
sessionId,
'subagents',
`agent-${subagentId}.jsonl`
);

View file

@ -46,8 +46,12 @@ async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
return null;
}
let cachedPath: string | null | undefined;
export class ClaudeBinaryResolver {
static async resolve(): Promise<string | null> {
if (cachedPath !== undefined) return cachedPath;
const platformBinaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude';
const fromPath = await resolveFromPathEnv(platformBinaryName);
if (fromPath) {
@ -66,10 +70,12 @@ export class ClaudeBinaryResolver {
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates();
for (const candidate of [...candidates, ...nvmCandidates]) {
if (await isExecutable(candidate)) {
return candidate;
cachedPath = candidate;
return cachedPath;
}
}
// Don't cache null — CLI may be installed later without app restart
return null;
}
}

View file

@ -0,0 +1,236 @@
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import * as readline from 'readline';
import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { MemberFullStats } from '@shared/types';
const logger = createLogger('Service:MemberStatsComputer');
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface CacheEntry {
stats: MemberFullStats;
timestamp: number;
}
export class MemberStatsComputer {
private cache = new Map<string, CacheEntry>();
constructor(private readonly logsFinder: TeamMemberLogsFinder) {}
async getStats(teamName: string, memberName: string): Promise<MemberFullStats> {
const cacheKey = `${teamName}:${memberName}`;
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
return cached.stats;
}
const paths = await this.logsFinder.findMemberLogPaths(teamName, memberName);
let linesAdded = 0;
let linesRemoved = 0;
const filesTouchedSet = new Set<string>();
const toolUsage: Record<string, number> = {};
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let messageCount = 0;
let totalDurationMs = 0;
for (const filePath of paths) {
const fileStats = await this.parseFile(filePath);
linesAdded += fileStats.linesAdded;
linesRemoved += fileStats.linesRemoved;
for (const f of fileStats.filesTouched) filesTouchedSet.add(f);
for (const [tool, count] of Object.entries(fileStats.toolUsage)) {
toolUsage[tool] = (toolUsage[tool] ?? 0) + count;
}
inputTokens += fileStats.inputTokens;
outputTokens += fileStats.outputTokens;
cacheReadTokens += fileStats.cacheReadTokens;
messageCount += fileStats.messageCount;
totalDurationMs += fileStats.durationMs;
}
const stats: MemberFullStats = {
linesAdded,
linesRemoved,
filesTouched: [...filesTouchedSet].sort((a, b) => a.localeCompare(b)),
toolUsage,
inputTokens,
outputTokens,
cacheReadTokens,
costUsd: 0,
tasksCompleted: 0,
messageCount,
totalDurationMs,
sessionCount: paths.length,
computedAt: new Date().toISOString(),
};
this.cache.set(cacheKey, { stats, timestamp: Date.now() });
return stats;
}
private async parseFile(filePath: string): Promise<{
linesAdded: number;
linesRemoved: number;
filesTouched: string[];
toolUsage: Record<string, number>;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
messageCount: number;
durationMs: number;
}> {
let linesAdded = 0;
let linesRemoved = 0;
const filesTouchedSet = new Set<string>();
const toolUsage: Record<string, number> = {};
let inputTokens = 0;
let outputTokens = 0;
let cacheReadTokens = 0;
let messageCount = 0;
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof msg.timestamp === 'string') {
if (!firstTimestamp) firstTimestamp = msg.timestamp;
lastTimestamp = msg.timestamp;
}
// Count messages
const role = this.extractRole(msg);
if (role) messageCount++;
// Extract token usage
const usage = this.extractUsage(msg);
if (usage) {
inputTokens += usage.inputTokens;
outputTokens += usage.outputTokens;
cacheReadTokens += usage.cacheReadTokens;
}
// Extract tool_use blocks from assistant messages
if (role === 'assistant') {
const content = this.extractContent(msg);
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === 'object' &&
(block as Record<string, unknown>).type === 'tool_use'
) {
const toolBlock = block as Record<string, unknown>;
const rawName = typeof toolBlock.name === 'string' ? toolBlock.name : 'unknown';
const toolName = rawName.startsWith('proxy_') ? rawName.slice(6) : rawName;
toolUsage[toolName] = (toolUsage[toolName] ?? 0) + 1;
const input = toolBlock.input as Record<string, unknown> | undefined;
if (!input) continue;
// Track files
if (typeof input.file_path === 'string') {
filesTouchedSet.add(input.file_path);
}
if (typeof input.path === 'string' && toolName === 'Read') {
filesTouchedSet.add(input.path);
}
// Count lines for Edit
if (toolName === 'Edit') {
const oldStr = typeof input.old_string === 'string' ? input.old_string : '';
const newStr = typeof input.new_string === 'string' ? input.new_string : '';
const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0;
if (newLines > oldLines) linesAdded += newLines - oldLines;
if (oldLines > newLines) linesRemoved += oldLines - newLines;
}
// Count lines for Write
if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : '';
if (writeContent) {
linesAdded += writeContent.split('\n').length;
}
}
}
}
}
}
} catch {
// Skip malformed lines
}
}
rl.close();
stream.destroy();
} catch (err) {
logger.debug(`Failed to parse file ${filePath}: ${String(err)}`);
}
let durationMs = 0;
if (firstTimestamp && lastTimestamp) {
durationMs = Math.max(
0,
new Date(lastTimestamp).getTime() - new Date(firstTimestamp).getTime()
);
}
return {
linesAdded,
linesRemoved,
filesTouched: [...filesTouchedSet],
toolUsage,
inputTokens,
outputTokens,
cacheReadTokens,
messageCount,
durationMs,
};
}
private extractRole(msg: Record<string, unknown>): string | null {
if (typeof msg.role === 'string') return msg.role;
if (msg.message && typeof msg.message === 'object') {
const inner = msg.message as Record<string, unknown>;
if (typeof inner.role === 'string') return inner.role;
}
return null;
}
private extractContent(msg: Record<string, unknown>): unknown[] | null {
const content = msg.content ?? (msg.message as Record<string, unknown> | undefined)?.content;
if (Array.isArray(content)) return content as unknown[];
return null;
}
private extractUsage(
msg: Record<string, unknown>
): { inputTokens: number; outputTokens: number; cacheReadTokens: number } | null {
const usage = (msg.usage ?? (msg.message as Record<string, unknown> | undefined)?.usage) as
| Record<string, unknown>
| undefined;
if (!usage || typeof usage !== 'object') return null;
return {
inputTokens: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
cacheReadTokens:
typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0,
};
}
}

View file

@ -1,11 +1,11 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { getToolsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
const TOOL_FILE_NAME = 'teamctl.js';
const TOOL_VERSION = 2;
const TOOL_VERSION = 3;
function buildTeamCtlScript(): string {
const script = String.raw`#!/usr/bin/env node
@ -100,23 +100,15 @@ function getClaudeDir(flags) {
}
function inferClaudeDirFromScriptPath() {
// Expected: <claudeDir>/teams/<teamName>/tools/teamctl.js
// Expected: <claudeDir>/tools/teamctl.js
const toolsDir = path.dirname(__filename);
const teamDir = path.dirname(toolsDir);
const teamsDir = path.dirname(teamDir);
if (path.basename(teamsDir) !== 'teams') return null;
const claudeDir = path.dirname(teamsDir);
return claudeDir || null;
if (path.basename(toolsDir) !== 'tools') return null;
return path.dirname(toolsDir) || null;
}
function inferTeamNameFromScriptPath() {
// Expected: ~/.claude/teams/<teamName>/tools/teamctl.js
const toolsDir = path.dirname(__filename);
const teamDir = path.dirname(toolsDir);
const parent = path.basename(path.dirname(teamDir));
if (parent !== 'teams') return null;
const teamName = path.basename(teamDir);
return teamName || null;
// From ~/.claude/tools/ the team name cannot be inferred — --team is required
return null;
}
function getTeamName(flags) {
@ -467,14 +459,14 @@ async function main() {
' "' +
String(task.subject) +
'".\n\n' +
'When you start: node "$HOME/.claude/teams/' +
'When you start: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
String(teamName) +
'/tools/${TOOL_FILE_NAME}" task start ' +
' task start ' +
String(task.id) +
'\n' +
'When done: node "$HOME/.claude/teams/' +
'When done: node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' +
String(teamName) +
'/tools/${TOOL_FILE_NAME}" task complete ' +
' task complete ' +
String(task.id) +
'\n';
sendInboxMessage(paths, teamName, {
@ -589,8 +581,8 @@ main().catch((err) => {
}
export class TeamAgentToolsInstaller {
async ensureInstalled(teamName: string): Promise<string> {
const toolsDir = path.join(getTeamsBasePath(), teamName, 'tools');
async ensureInstalled(): Promise<string> {
const toolsDir = getToolsBasePath();
const toolPath = path.join(toolsDir, TOOL_FILE_NAME);
await fs.promises.mkdir(toolsDir, { recursive: true });
@ -604,7 +596,7 @@ export class TeamAgentToolsInstaller {
}
}
if (current && current.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) {
if (current?.includes(`TOOL_VERSION = ${TOOL_VERSION}`)) {
return toolPath;
}

View file

@ -3,11 +3,17 @@ import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import type { TeamConfig, TeamSummary } from '@shared/types';
const logger = createLogger('Service:TeamConfigReader');
export class TeamConfigReader {
constructor(
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
) {}
async listTeams(): Promise<TeamSummary[]> {
const teamsDir = getTeamsBasePath();
@ -34,14 +40,66 @@ export class TeamConfigReader {
continue;
}
const memberCount = Array.isArray(config.members) ? config.members.length : 0;
const memberNames = new Set<string>();
if (Array.isArray(config.members)) {
for (const member of config.members) {
if (typeof member?.name === 'string' && member.name.trim().length > 0) {
memberNames.add(member.name.trim());
}
}
}
try {
const metaMembers = await this.membersMetaStore.getMembers(entry.name);
for (const member of metaMembers) {
if (member.name.trim().length > 0) {
memberNames.add(member.name.trim());
}
}
} catch {
logger.debug(`Failed to read members.meta.json for team: ${entry.name}`);
}
const inboxDir = path.join(teamsDir, entry.name, 'inboxes');
try {
const inboxEntries = await fs.promises.readdir(inboxDir);
for (const inbox of inboxEntries) {
if (!inbox.endsWith('.json') || inbox.startsWith('.')) {
continue;
}
const inboxName = inbox.slice(0, -'.json'.length).trim();
if (inboxName.length > 0) {
memberNames.add(inboxName);
}
}
} catch {
// Inbox folder may not exist yet.
}
const memberCount = memberNames.size;
summaries.push({
teamName: entry.name,
displayName: config.name,
description: typeof config.description === 'string' ? config.description : '',
color:
typeof config.color === 'string' && config.color.trim().length > 0
? config.color
: undefined,
memberCount,
taskCount: 0,
lastActivity: null,
projectPath:
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined,
leadSessionId:
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId
: undefined,
projectPathHistory: Array.isArray(config.projectPathHistory)
? config.projectPathHistory
: undefined,
sessionHistory: Array.isArray(config.sessionHistory) ? config.sessionHistory : undefined,
});
} catch {
logger.debug(`Skipping team dir without valid config: ${entry.name}`);
@ -64,4 +122,26 @@ export class TeamConfigReader {
return null;
}
}
async updateConfig(
teamName: string,
updates: { name?: string; description?: string; color?: string }
): Promise<TeamConfig | null> {
const config = await this.getConfig(teamName);
if (!config) {
return null;
}
if (updates.name !== undefined && updates.name.trim() !== '') {
config.name = updates.name.trim();
}
if (updates.description !== undefined) {
config.description = updates.description.trim() || undefined;
}
if (updates.color !== undefined) {
config.color = updates.color.trim() || undefined;
}
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
return config;
}
}

View file

@ -1,23 +1,36 @@
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import {
encodePath,
extractBaseDir,
getProjectsBasePath,
getTasksBasePath,
getTeamsBasePath,
} from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { atomicWriteAsync } from './atomicWrite';
import { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamInboxWriter } from './TeamInboxWriter';
import { TeamKanbanManager } from './TeamKanbanManager';
import { TeamMemberResolver } from './TeamMemberResolver';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
import type {
CreateTaskRequest,
GlobalTask,
InboxMessage,
KanbanState,
KanbanTaskState,
SendMessageRequest,
SendMessageResult,
TeamConfig,
TeamCreateConfigRequest,
TeamData,
TeamSummary,
TeamTask,
@ -25,6 +38,11 @@ import type {
UpdateKanbanPatch,
} from '@shared/types';
const logger = createLogger('Service:TeamDataService');
const MIN_TEXT_LENGTH = 30;
const MAX_LEAD_TEXTS = 50;
export class TeamDataService {
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
@ -34,13 +52,45 @@ export class TeamDataService {
private readonly taskWriter: TeamTaskWriter = new TeamTaskWriter(),
private readonly memberResolver: TeamMemberResolver = new TeamMemberResolver(),
private readonly kanbanManager: TeamKanbanManager = new TeamKanbanManager(),
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller()
private readonly toolsInstaller: TeamAgentToolsInstaller = new TeamAgentToolsInstaller(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
) {}
async listTeams(): Promise<TeamSummary[]> {
return this.configReader.listTeams();
}
async getAllTasks(): Promise<GlobalTask[]> {
const [rawTasks, teams] = await Promise.all([
this.taskReader.getAllTasks(),
this.configReader.listTeams(),
]);
const teamInfoMap = new Map<string, { displayName: string; projectPath?: string }>();
for (const team of teams) {
teamInfoMap.set(team.teamName, {
displayName: team.displayName,
projectPath: team.projectPath,
});
}
return rawTasks.map((task) => {
const info = teamInfoMap.get(task.teamName);
return {
...task,
teamDisplayName: info?.displayName ?? task.teamName,
projectPath: task.projectPath ?? info?.projectPath,
};
});
}
async updateConfig(
teamName: string,
updates: { name?: string; description?: string; color?: string }
): Promise<TeamConfig | null> {
return this.configReader.updateConfig(teamName, updates);
}
async deleteTeam(teamName: string): Promise<void> {
const teamsDir = path.join(getTeamsBasePath(), teamName);
await fs.promises.rm(teamsDir, { recursive: true, force: true });
@ -80,6 +130,23 @@ export class TeamDataService {
warnings.push('Messages failed to load');
}
try {
const leadTexts = await this.extractLeadSessionTexts(config);
if (leadTexts.length > 0) {
messages = [...messages, ...leadTexts];
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
}
} catch {
warnings.push('Lead session texts failed to load');
}
let metaMembers: TeamConfig['members'] = [];
try {
metaMembers = await this.membersMetaStore.getMembers(teamName);
} catch {
warnings.push('Member metadata failed to load');
}
let kanbanState: KanbanState = {
teamName,
reviewers: [],
@ -102,7 +169,13 @@ export class TeamDataService {
}
}
const members = this.memberResolver.resolveMembers(config, inboxNames, tasks, messages);
const members = this.memberResolver.resolveMembers(
config,
metaMembers,
inboxNames,
tasks,
messages
);
return {
teamName,
config,
@ -119,16 +192,33 @@ export class TeamDataService {
const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? [];
let description = request.description
? `${request.subject}\n\n${request.description}`
: request.subject;
if (request.prompt?.trim()) {
description = description
? `${description}\n\n---\nPrompt: ${request.prompt.trim()}`
: `Prompt: ${request.prompt.trim()}`;
}
let projectPath: string | undefined;
try {
const config = await this.configReader.getConfig(teamName);
projectPath = config?.projectPath;
} catch {
/* best-effort */
}
const task: TeamTask = {
id: nextId,
subject: request.subject,
description: request.description
? `${request.subject}\n\n${request.description}`
: request.subject,
description,
owner: request.owner,
status: request.owner ? 'in_progress' : 'pending',
blocks: [],
blockedBy,
projectPath,
};
await this.taskWriter.createTask(teamName, task);
@ -140,14 +230,14 @@ export class TeamDataService {
if (request.owner) {
try {
const toolPath = await this.toolsInstaller.ensureInstalled(teamName);
const toolPath = await this.toolsInstaller.ensureInstalled();
await this.sendMessage(teamName, {
member: request.owner,
text:
`New task assigned to you: #${task.id} "${task.subject}".\n\n` +
`Update task status using:\n` +
`node "${toolPath}" task start ${task.id}\n` +
`node "${toolPath}" task complete ${task.id}\n\n` +
`node "${toolPath}" --team ${teamName} task start ${task.id}\n` +
`node "${toolPath}" --team ${teamName} task complete ${task.id}\n\n` +
`Help:\n` +
`node "${toolPath}" --help`,
summary: `New task #${task.id} assigned`,
@ -178,15 +268,15 @@ export class TeamDataService {
}
try {
const toolPath = await this.toolsInstaller.ensureInstalled(teamName);
const toolPath = await this.toolsInstaller.ensureInstalled();
await this.sendMessage(teamName, {
member: reviewer,
text:
`Please review task #${taskId}.\n\n` +
`When approved, move it to APPROVED:\n` +
`node "${toolPath}" review approve ${taskId}\n\n` +
`node "${toolPath}" --team ${teamName} review approve ${taskId}\n\n` +
`If changes are needed:\n` +
`node "${toolPath}" review request-changes ${taskId} --comment "..."`,
`node "${toolPath}" --team ${teamName} review request-changes ${taskId} --comment "..."`,
summary: `Review request for #${taskId}`,
});
} catch (error) {
@ -197,6 +287,116 @@ export class TeamDataService {
}
}
async createTeamConfig(request: TeamCreateConfigRequest): Promise<void> {
const teamDir = path.join(getTeamsBasePath(), request.teamName);
const configPath = path.join(teamDir, 'config.json');
try {
await fs.promises.access(configPath, fs.constants.F_OK);
throw new Error(`Team already exists: ${request.teamName}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
const tasksDir = path.join(getTasksBasePath(), request.teamName);
await fs.promises.mkdir(teamDir, { recursive: true });
await fs.promises.mkdir(tasksDir, { recursive: true });
const memberColors = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const;
const joinedAt = Date.now();
const config = {
name: request.displayName?.trim() || request.teamName,
description: request.description?.trim() || undefined,
color: request.color?.trim() || undefined,
};
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
await this.membersMetaStore.writeMembers(
request.teamName,
request.members.map((member, index) => ({
name: member.name,
role: member.role?.trim() || undefined,
agentType: 'general-purpose',
color: memberColors[index % memberColors.length],
joinedAt,
}))
);
}
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {
if (!config.leadSessionId || !config.projectPath) {
return [];
}
const projectId = encodePath(config.projectPath);
const baseDir = extractBaseDir(projectId);
const 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 [];
}
const leadName = config.members?.find((m) => m.agentType === 'team-lead')?.name ?? 'team-lead';
const texts: InboxMessage[] = [];
const stream = fs.createReadStream(jsonlPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
try {
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
let msg: Record<string, unknown>;
try {
msg = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (msg.type !== 'assistant') continue;
const message = (msg.message ?? msg) as Record<string, unknown>;
const content = message.content;
if (!Array.isArray(content)) continue;
const timestamp =
typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
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;
texts.push({
from: leadName,
text,
timestamp,
read: true,
source: 'lead_session',
});
}
}
} finally {
rl.close();
stream.destroy();
}
// Keep only the last N texts
if (texts.length > MAX_LEAD_TEXTS) {
return texts.slice(-MAX_LEAD_TEXTS);
}
return texts;
}
async updateKanban(teamName: string, taskId: string, patch: UpdateKanbanPatch): Promise<void> {
if (patch.op !== 'request_changes') {
await this.kanbanManager.updateTask(teamName, taskId, patch);

View file

@ -0,0 +1,542 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as readline from 'readline';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types';
const logger = createLogger('Service:TeamMemberLogsFinder');
const MAX_LINES_TO_SCAN = 30;
function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0) {
const ch = value.charCodeAt(end - 1);
// '/' or '\'
if (ch === 47 || ch === 92) {
end--;
continue;
}
break;
}
return end === value.length ? value : value.slice(0, end);
}
export class TeamMemberLogsFinder {
constructor(
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly inboxReader: TeamInboxReader = new TeamInboxReader(),
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore()
) {}
async findMemberLogs(teamName: string, memberName: string): Promise<MemberLogSummary[]> {
const discovery = await this.discoverMemberFiles(teamName, memberName);
if (!discovery) return [];
const { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember } = discovery;
const results: MemberLogSummary[] = [];
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
if (isLeadMember && config.leadSessionId) {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
const leadSummary = await this.parseLeadSessionSummary(
leadJsonl,
projectId,
config.leadSessionId,
leadMemberName
);
if (leadSummary) {
results.push(leadSummary);
}
}
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let files: string[];
try {
files = await fs.readdir(subagentsDir);
} catch {
continue;
}
for (const file of files) {
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
if (file.startsWith('agent-acompact')) continue;
const filePath = path.join(subagentsDir, file);
const summary = await this.parseSubagentSummary(
filePath,
projectId,
sessionId,
file,
memberName,
knownMembers
);
if (summary) results.push(summary);
}
}
return results.sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
);
}
/**
* Returns absolute paths to all JSONL files belonging to the specified member.
* Uses the same discovery logic as findMemberLogs but collects file paths.
*/
async findMemberLogPaths(teamName: string, memberName: string): Promise<string[]> {
const discovery = await this.discoverMemberFiles(teamName, memberName);
if (!discovery) return [];
const { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember } = discovery;
const paths: string[] = [];
if (isLeadMember && config.leadSessionId) {
const leadJsonl = path.join(projectDir, `${config.leadSessionId}.jsonl`);
try {
await fs.access(leadJsonl);
paths.push(leadJsonl);
} catch {
// File doesn't exist
}
}
for (const sessionId of sessionIds) {
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
let files: string[];
try {
files = await fs.readdir(subagentsDir);
} catch {
continue;
}
for (const file of files) {
if (!file.startsWith('agent-') || !file.endsWith('.jsonl')) continue;
if (file.startsWith('agent-acompact')) continue;
const filePath = path.join(subagentsDir, file);
// Quick attribution check — reuse parseSubagentSummary to verify membership
const summary = await this.parseSubagentSummary(
filePath,
projectId,
sessionId,
file,
memberName,
knownMembers
);
if (summary) paths.push(filePath);
}
}
return paths;
}
private async discoverMemberFiles(
teamName: string,
memberName: string
): Promise<{
projectDir: string;
projectId: string;
config: NonNullable<Awaited<ReturnType<TeamConfigReader['getConfig']>>>;
sessionIds: string[];
knownMembers: Set<string>;
isLeadMember: boolean;
} | null> {
const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) {
logger.debug(`No projectPath for team "${teamName}"`);
return null;
}
const normalizedProjectPath = trimTrailingSlashes(config.projectPath);
const projectId = encodePath(normalizedProjectPath);
const baseDir = extractBaseDir(projectId);
const projectDir = path.join(getProjectsBasePath(), baseDir);
const leadMemberName =
config.members?.find((m) => m?.agentType === 'team-lead')?.name?.trim() || 'team-lead';
const isLeadMember = leadMemberName.toLowerCase() === memberName.trim().toLowerCase();
let sessionIds: string[];
if (config.leadSessionId) {
const leadDir = path.join(projectDir, config.leadSessionId);
try {
const stat = await fs.stat(leadDir);
if (stat.isDirectory()) {
sessionIds = [config.leadSessionId];
} else {
logger.debug(`leadSessionId dir is not a directory: ${leadDir}`);
sessionIds = await this.listSessionDirs(projectDir);
}
} catch {
logger.debug(`leadSessionId dir not found: ${leadDir}, falling back to full scan`);
sessionIds = await this.listSessionDirs(projectDir);
}
} else {
sessionIds = await this.listSessionDirs(projectDir);
}
const knownMembers = new Set<string>(
(config.members ?? [])
.map((member) => member.name?.trim().toLowerCase())
.filter((name): name is string => Boolean(name && name.length > 0))
);
try {
const metaMembers = await this.membersMetaStore.getMembers(teamName);
for (const member of metaMembers) {
const normalized = member.name.trim().toLowerCase();
if (normalized.length > 0) {
knownMembers.add(normalized);
}
}
} catch {
// Best-effort enrichment.
}
try {
const inboxMembers = await this.inboxReader.listInboxNames(teamName);
for (const memberNameFromInbox of inboxMembers) {
const normalized = memberNameFromInbox.trim().toLowerCase();
if (normalized.length > 0) {
knownMembers.add(normalized);
}
}
} catch {
// Best-effort enrichment.
}
return { projectDir, projectId, config, sessionIds, knownMembers, isLeadMember };
}
private async listSessionDirs(projectDir: string): Promise<string[]> {
try {
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
return dirEntries.filter((e) => e.isDirectory()).map((e) => e.name);
} catch {
logger.debug(`Cannot read project dir: ${projectDir}`);
return [];
}
}
private async parseSubagentSummary(
filePath: string,
projectId: string,
sessionId: string,
fileName: string,
targetMember: string,
knownMembers: Set<string>
): Promise<MemberSubagentLogSummary | null> {
const subagentId = fileName.replace(/^agent-/, '').replace(/\.jsonl$/, '');
const lines: string[] = [];
try {
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let count = 0;
for await (const line of rl) {
if (count >= MAX_LINES_TO_SCAN) break;
const trimmed = line.trim();
if (trimmed) {
lines.push(trimmed);
count++;
}
}
rl.close();
stream.destroy();
} catch {
return null;
}
if (lines.length === 0) return null;
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
let messageCount = 0;
let description = '';
const targetLower = targetMember.toLowerCase();
// Multi-signal member detection with priority levels:
// 3 = routing sender (highest — directly identifies the agent)
// 2 = "You are {name}" spawn prompt (high — reliable identification)
// 1 = text-based fallback (low — may match wrong member from teammate_id etc.)
let detectedMember: string | null = null;
let detectionPriority = 0;
for (const line of lines) {
try {
const msg = JSON.parse(line) as Record<string, unknown>;
const role = this.extractRole(msg);
const textContent = this.extractTextContent(msg);
// Skip warmup messages
if (role === 'user' && textContent?.trim() === 'Warmup') {
return null;
}
// Track timestamps
if (typeof msg.timestamp === 'string') {
if (!firstTimestamp) firstTimestamp = msg.timestamp;
lastTimestamp = msg.timestamp;
}
messageCount++;
// Extract description from first user message
if (role === 'user' && !description && textContent) {
description = textContent.slice(0, 200);
}
// --- Multi-signal member detection ---
// Higher priority signals override lower priority ones
const detection = this.detectMemberFromMessage(msg, knownMembers);
if (detection && detection.priority > detectionPriority) {
detectedMember = detection.name;
detectionPriority = detection.priority;
}
// Check toolUseResult routing (highest priority — directly identifies the agent)
if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') {
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
| Record<string, unknown>
| undefined;
if (routing && typeof routing.sender === 'string') {
const sender = routing.sender.toLowerCase();
if (knownMembers.has(sender)) {
detectedMember = routing.sender;
detectionPriority = 3;
}
}
}
} catch {
// Skip malformed lines
}
}
// Match: the detected member must match the target member
if (detectedMember?.toLowerCase() !== targetLower) {
return null;
}
if (!firstTimestamp) {
// Fallback: use file mtime
try {
const stat = await fs.stat(filePath);
firstTimestamp = stat.mtime.toISOString();
lastTimestamp = firstTimestamp;
} catch {
firstTimestamp = new Date().toISOString();
lastTimestamp = firstTimestamp;
}
}
const startTime = new Date(firstTimestamp);
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
const durationMs = endTime.getTime() - startTime.getTime();
// Check if the file might still be active (modified recently)
let isOngoing = false;
try {
const stat = await fs.stat(filePath);
const ageMs = Date.now() - stat.mtimeMs;
isOngoing = ageMs < 60_000; // Active within last minute
} catch {
// ignore
}
return {
kind: 'subagent',
subagentId,
sessionId,
projectId,
description: description || `Subagent ${subagentId}`,
memberName: targetMember,
startTime: firstTimestamp,
durationMs: Math.max(0, durationMs),
messageCount,
isOngoing,
};
}
/**
* Detects the member name from a parsed JSONL message using multiple signals.
* Returns a detection result with the name and a priority level:
* 3 = routing sender (highest, handled outside this method)
* 2 = "You are {name}" spawn prompt
* 1 = text-based fallback (single member match or task assignment context)
*/
private detectMemberFromMessage(
msg: Record<string, unknown>,
knownMembers: Set<string>
): { name: string; priority: number } | null {
const text = this.extractTextContent(msg);
if (!text) return null;
// Signal 1 (priority 2): "You are {name}, a {role}" pattern (spawn prompt)
const youAreMatch = /\bYou are (\w[\w-]*),\s+a\s+/i.exec(text);
if (youAreMatch) {
const name = youAreMatch[1].toLowerCase();
if (knownMembers.has(name)) {
return { name: youAreMatch[1], priority: 2 };
}
}
// Signal 2 (priority 1): Task assignment — look for member name in the task content
if (text.includes('New task assigned to you') || text.includes('task assigned')) {
for (const name of knownMembers) {
const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i');
if (regex.test(text)) {
return { name: findOriginalCase(text, name), priority: 1 };
}
}
}
// Signal 3 (priority 1): General fallback — check if exactly one known member
// name appears in the first user message content (word-boundary match)
if (msg.role === 'user') {
const matches: string[] = [];
for (const name of knownMembers) {
const regex = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i');
if (regex.test(text)) {
matches.push(name);
}
}
// Only attribute if exactly one member matches (avoid ambiguity)
if (matches.length === 1) {
return { name: findOriginalCase(text, matches[0]), priority: 1 };
}
}
return null;
}
private extractTextContent(msg: Record<string, unknown>): string | null {
if (typeof msg.content === 'string') {
return msg.content;
}
if (Array.isArray(msg.content)) {
const textParts = (msg.content as Record<string, unknown>[])
.filter((part) => part.type === 'text' && typeof part.text === 'string')
.map((part) => part.text as string);
if (textParts.length > 0) return textParts.join(' ');
}
// Also check message wrapper
if (msg.message && typeof msg.message === 'object') {
return this.extractTextContent(msg.message as Record<string, unknown>);
}
return null;
}
private extractRole(msg: Record<string, unknown>): string | null {
if (typeof msg.role === 'string') {
return msg.role;
}
if (msg.message && typeof msg.message === 'object') {
const inner = msg.message as Record<string, unknown>;
if (typeof inner.role === 'string') {
return inner.role;
}
}
return null;
}
private async parseLeadSessionSummary(
jsonlPath: string,
projectId: string,
sessionId: string,
memberName: string
): Promise<MemberLogSummary | null> {
try {
await fs.access(jsonlPath);
} catch {
return null;
}
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
let messageCount = 0;
try {
const stream = createReadStream(jsonlPath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
let count = 0;
for await (const line of rl) {
if (count >= MAX_LINES_TO_SCAN) break;
const trimmed = line.trim();
if (!trimmed) continue;
count++;
messageCount++;
try {
const msg = JSON.parse(trimmed) as Record<string, unknown>;
if (typeof msg.timestamp === 'string') {
if (!firstTimestamp) firstTimestamp = msg.timestamp;
lastTimestamp = msg.timestamp;
}
} catch {
// ignore
}
}
rl.close();
stream.destroy();
} catch {
// ignore
}
if (!firstTimestamp) {
try {
const stat = await fs.stat(jsonlPath);
firstTimestamp = stat.mtime.toISOString();
lastTimestamp = firstTimestamp;
} catch {
firstTimestamp = new Date().toISOString();
lastTimestamp = firstTimestamp;
}
}
const startTime = new Date(firstTimestamp);
const endTime = lastTimestamp ? new Date(lastTimestamp) : startTime;
const durationMs = endTime.getTime() - startTime.getTime();
let isOngoing = false;
try {
const stat = await fs.stat(jsonlPath);
const ageMs = Date.now() - stat.mtimeMs;
isOngoing = ageMs < 60_000;
} catch {
// ignore
}
return {
kind: 'lead_session',
sessionId,
projectId,
description: 'Lead session',
memberName,
startTime: firstTimestamp,
durationMs: Math.max(0, durationMs),
messageCount,
isOngoing,
};
}
}
function findOriginalCase(text: string, lowerName: string): string {
const regex = new RegExp(`\\b(${escapeRegex(lowerName)})\\b`, 'i');
const match = regex.exec(text);
return match ? match[1] : lowerName;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View file

@ -9,6 +9,7 @@ import type {
export class TeamMemberResolver {
resolveMembers(
config: TeamConfig,
metaMembers: TeamConfig['members'],
inboxNames: string[],
tasks: TeamTask[],
messages: InboxMessage[]
@ -23,29 +24,45 @@ export class TeamMemberResolver {
}
}
if (Array.isArray(metaMembers)) {
for (const member of metaMembers) {
if (typeof member?.name === 'string' && member.name.trim() !== '') {
names.add(member.name.trim());
}
}
}
for (const inboxName of inboxNames) {
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
names.add(inboxName.trim());
}
}
for (const task of tasks) {
if (typeof task.owner === 'string' && task.owner.trim() !== '') {
names.add(task.owner.trim());
}
}
for (const message of messages) {
if (typeof message.from === 'string' && message.from.trim() !== '') {
names.add(message.from.trim());
}
}
const configMemberMap = new Map<string, { agentType?: string }>();
const configMemberMap = new Map<
string,
{ agentType?: string; role?: string; color?: string }
>();
if (Array.isArray(config.members)) {
for (const m of config.members) {
if (typeof m?.name === 'string' && m.name.trim() !== '') {
configMemberMap.set(m.name.trim(), { agentType: m.agentType });
configMemberMap.set(m.name.trim(), {
agentType: m.agentType,
role: m.role,
color: m.color,
});
}
}
}
const metaMemberMap = new Map<string, { agentType?: string; role?: string; color?: string }>();
if (Array.isArray(metaMembers)) {
for (const member of metaMembers) {
if (typeof member?.name === 'string' && member.name.trim() !== '') {
metaMemberMap.set(member.name.trim(), {
agentType: member.agentType,
role: member.role,
color: member.color,
});
}
}
}
@ -58,6 +75,7 @@ export class TeamMemberResolver {
const latestMessage = memberMessages[0] ?? null;
const status = this.resolveStatus(latestMessage);
const configMember = configMemberMap.get(name);
const metaMember = metaMemberMap.get(name);
members.push({
name,
status,
@ -65,8 +83,9 @@ export class TeamMemberResolver {
taskCount: ownedTasks.length,
messageCount: memberMessages.length,
lastActiveAt: latestMessage?.timestamp ?? null,
color: latestMessage?.color,
agentType: configMember?.agentType,
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role,
});
}

View file

@ -0,0 +1,94 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import * as fs from 'fs';
import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { TeamMember } from '@shared/types';
interface TeamMembersMetaFile {
version: 1;
members: TeamMember[];
}
function normalizeMember(member: TeamMember): TeamMember | null {
const trimmedName = member.name?.trim();
if (!trimmedName) {
return null;
}
return {
name: trimmedName,
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
agentType:
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
joinedAt: typeof member.joinedAt === 'number' ? member.joinedAt : undefined,
agentId: typeof member.agentId === 'string' ? member.agentId : undefined,
};
}
export class TeamMembersMetaStore {
private getMetaPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'members.meta.json');
}
async getMembers(teamName: string): Promise<TeamMember[]> {
const metaPath = this.getMetaPath(teamName);
let raw: string;
try {
raw = await fs.promises.readFile(metaPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return [];
}
if (!parsed || typeof parsed !== 'object') {
return [];
}
const file = parsed as Partial<TeamMembersMetaFile>;
if (!Array.isArray(file.members)) {
return [];
}
const deduped = new Map<string, TeamMember>();
for (const item of file.members) {
if (!item || typeof item !== 'object') {
continue;
}
const normalized = normalizeMember(item);
if (!normalized) {
continue;
}
deduped.set(normalized.name, normalized);
}
return Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name));
}
async writeMembers(teamName: string, members: TeamMember[]): Promise<void> {
const deduped = new Map<string, TeamMember>();
for (const member of members) {
const normalized = normalizeMember(member);
if (!normalized) {
continue;
}
deduped.set(normalized.name, normalized);
}
const payload: TeamMembersMetaFile = {
version: 1,
members: Array.from(deduped.values()).sort((a, b) => a.name.localeCompare(b.name)),
};
await atomicWriteAsync(this.getMetaPath(teamName), JSON.stringify(payload, null, 2));
}
}

File diff suppressed because it is too large Load diff

View file

@ -77,8 +77,23 @@ export class TeamTaskReader {
: typeof parsed.title === 'string'
? parsed.title
: '';
// Resolve createdAt: prefer JSON field, fallback to fs.stat
let createdAt: string | undefined;
if (typeof parsed.createdAt === 'string') {
createdAt = parsed.createdAt;
} else {
try {
const stat = await fs.promises.stat(taskPath);
const bt = stat.birthtime.getTime();
createdAt = (bt > 0 ? stat.birthtime : stat.mtime).toISOString();
} catch {
/* leave undefined */
}
}
const task: TeamTask = {
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined,
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
@ -90,6 +105,8 @@ export class TeamTaskReader {
: 'pending',
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
createdAt,
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
};
if (task.status === 'deleted') {
continue;
@ -102,4 +119,33 @@ export class TeamTaskReader {
return tasks;
}
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
const tasksBase = getTasksBasePath();
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(tasksBase, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
const result: (TeamTask & { teamName: string })[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
try {
const tasks = await this.getTasks(entry.name);
for (const task of tasks) {
result.push({ ...task, teamName: entry.name });
}
} catch {
logger.debug(`Skipping tasks dir: ${entry.name}`);
}
}
return result;
}
}

View file

@ -28,6 +28,7 @@ export class TeamTaskWriter {
description: task.description ?? '',
blocks: task.blocks ?? [],
blockedBy: task.blockedBy ?? [],
createdAt: task.createdAt ?? new Date().toISOString(),
};
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));

View file

@ -1,11 +1,14 @@
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
export { MemberStatsComputer } from './MemberStatsComputer';
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
export { TeamConfigReader } from './TeamConfigReader';
export { TeamDataService } from './TeamDataService';
export { TeamInboxReader } from './TeamInboxReader';
export { TeamInboxWriter } from './TeamInboxWriter';
export { TeamKanbanManager } from './TeamKanbanManager';
export { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
export { TeamMemberResolver } from './TeamMemberResolver';
export { TeamMembersMetaStore } from './TeamMembersMetaStore';
export { TeamProvisioningService } from './TeamProvisioningService';
export { TeamTaskReader } from './TeamTaskReader';
export { TeamTaskWriter } from './TeamTaskWriter';

View file

@ -1,5 +1,5 @@
/**
* Standalone (non-Electron) entry point for claude-devtools.
* Standalone (non-Electron) entry point for Claude Agent Teams UI.
*
* Runs the HTTP server + API without Electron, suitable for Docker
* or any headless/remote environment. The renderer is served as

View file

@ -1,5 +1,5 @@
/**
* Chunk and visualization types for claude-devtools.
* Chunk and visualization types for Claude Agent Teams UI.
*
* This module contains:
* - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk)

View file

@ -1,5 +1,5 @@
/**
* Domain/business entity types for claude-devtools.
* Domain/business entity types for Claude Agent Teams UI.
*
* These types represent the application's domain model:
* - Projects and sessions

View file

@ -1,5 +1,5 @@
/**
* Parsed message types and type guards for claude-devtools.
* Parsed message types and type guards for Claude Agent Teams UI.
*
* ParsedMessage is the application's internal representation after parsing
* raw JSONL entries. This module also contains type guards for classifying

View file

@ -321,3 +321,10 @@ export function getTeamsBasePath(): string {
export function getTasksBasePath(): string {
return path.join(getClaudeBasePath(), 'tasks');
}
/**
* Get the tools directory path (~/.claude/tools).
*/
export function getToolsBasePath(): string {
return path.join(getClaudeBasePath(), 'tools');
}

View file

@ -197,6 +197,9 @@ export const TEAM_CHANGE = 'team:change';
/** Create new team by provisioning through CLI */
export const TEAM_CREATE = 'team:create';
/** Launch existing offline team */
export const TEAM_LAUNCH = 'team:launch';
/** Warm up provisioning runtime before create */
export const TEAM_PREPARE_PROVISIONING = 'team:prepareProvisioning';
@ -226,3 +229,18 @@ export const TEAM_DELETE_TEAM = 'team:deleteTeam';
/** Get list of teams with live CLI processes */
export const TEAM_ALIVE_LIST = 'team:aliveList';
/** Create team config without provisioning CLI */
export const TEAM_CREATE_CONFIG = 'team:createConfig';
/** Get member subagent logs */
export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs';
/** Update team config (name, description) */
export const TEAM_UPDATE_CONFIG = 'team:updateConfig';
/** Get aggregated member stats */
export const TEAM_GET_MEMBER_STATS = 'team:getMemberStats';
/** Get all tasks across all teams */
export const TEAM_GET_ALL_TASKS = 'team:getAllTasks';

View file

@ -22,9 +22,14 @@ import {
TEAM_CANCEL_PROVISIONING,
TEAM_CHANGE,
TEAM_CREATE,
TEAM_CREATE_CONFIG,
TEAM_CREATE_TASK,
TEAM_DELETE_TEAM,
TEAM_GET_ALL_TASKS,
TEAM_GET_DATA,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_LAUNCH,
TEAM_LIST,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
@ -33,6 +38,7 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REQUEST_REVIEW,
TEAM_SEND_MESSAGE,
TEAM_UPDATE_CONFIG,
TEAM_UPDATE_KANBAN,
TEAM_UPDATE_TASK_STATUS,
UPDATER_CHECK,
@ -78,8 +84,11 @@ import type {
ContextInfo,
CreateTaskRequest,
ElectronAPI,
GlobalTask,
HttpServerStatus,
IpcResult,
MemberFullStats,
MemberLogSummary,
NotificationTrigger,
SendMessageRequest,
SendMessageResult,
@ -90,14 +99,19 @@ import type {
SshConnectionStatus,
SshLastConnection,
TeamChangeEvent,
TeamConfig,
TeamCreateConfigRequest,
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
TriggerTestResult,
UpdateKanbanPatch,
WslClaudeRootCandidate,
@ -502,6 +516,9 @@ const electronAPI: ElectronAPI = {
createTeam: async (request: TeamCreateRequest) => {
return invokeIpcWithResult<TeamCreateResponse>(TEAM_CREATE, request);
},
launchTeam: async (request: TeamLaunchRequest) => {
return invokeIpcWithResult<TeamLaunchResponse>(TEAM_LAUNCH, request);
},
getProvisioningStatus: async (runId: string) => {
return invokeIpcWithResult<TeamProvisioningProgress>(TEAM_PROVISIONING_STATUS, runId);
},
@ -532,6 +549,21 @@ const electronAPI: ElectronAPI = {
aliveList: async () => {
return invokeIpcWithResult<string[]>(TEAM_ALIVE_LIST);
},
createConfig: async (request: TeamCreateConfigRequest) => {
return invokeIpcWithResult<void>(TEAM_CREATE_CONFIG, request);
},
getMemberLogs: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberLogSummary[]>(TEAM_GET_MEMBER_LOGS, teamName, memberName);
},
getMemberStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);
},
getAllTasks: async () => {
return invokeIpcWithResult<GlobalTask[]>(TEAM_GET_ALL_TASKS);
},
updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => {
return invokeIpcWithResult<TeamConfig>(TEAM_UPDATE_CONFIG, teamName, updates);
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
ipcRenderer.on(
TEAM_CHANGE,

View file

@ -17,6 +17,7 @@ import type {
CreateTaskRequest,
ElectronAPI,
FileChangeEvent,
GlobalTask,
HttpServerAPI,
HttpServerStatus,
NotificationsAPI,
@ -43,6 +44,8 @@ import type {
TeamCreateRequest,
TeamCreateResponse,
TeamData,
TeamLaunchRequest,
TeamLaunchResponse,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamsAPI,
@ -630,6 +633,9 @@ export class HttpAPIClient implements ElectronAPI {
createTeam: async (_request: TeamCreateRequest): Promise<TeamCreateResponse> => {
throw new Error('Team provisioning is not available in browser mode');
},
launchTeam: async (_request: TeamLaunchRequest): Promise<TeamLaunchResponse> => {
throw new Error('Team launch is not available in browser mode');
},
getProvisioningStatus: async (_runId: string): Promise<TeamProvisioningProgress> => {
throw new Error('Team provisioning is not available in browser mode');
},
@ -671,6 +677,38 @@ export class HttpAPIClient implements ElectronAPI {
aliveList: async (): Promise<string[]> => {
return [];
},
createConfig: async (): Promise<void> => {
throw new Error('Team config creation is not available in browser mode');
},
getMemberLogs: async () => {
console.warn('[HttpAPIClient] getMemberLogs is not available in browser mode');
return [];
},
getMemberStats: async () => {
console.warn('[HttpAPIClient] getMemberStats is not available in browser mode');
return {
linesAdded: 0,
linesRemoved: 0,
filesTouched: [],
toolUsage: {},
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
costUsd: 0,
tasksCompleted: 0,
messageCount: 0,
totalDurationMs: 0,
sessionCount: 0,
computedAt: new Date().toISOString(),
};
},
getAllTasks: async (): Promise<GlobalTask[]> => {
console.warn('[HttpAPIClient] getAllTasks is not available in browser mode');
return [];
},
updateConfig: async () => {
throw new Error('Team config update is not available in browser mode');
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)

View file

@ -6,6 +6,7 @@ import { useTabUI } from '@renderer/hooks/useTabUI';
import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup';
import { useStore } from '@renderer/store';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronRight, Users } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SessionContextPanel } from './SessionContextPanel/index';
@ -57,6 +58,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
syncSearchMatchesWithRendered,
selectSearchMatch,
setTabVisibleAIGroup,
teams,
openTeamTab,
} = useStore(
useShallow((s) => ({
searchQuery: s.searchQuery,
@ -69,6 +72,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered,
selectSearchMatch: s.selectSearchMatch,
setTabVisibleAIGroup: s.setTabVisibleAIGroup,
teams: s.teams,
openTeamTab: s.openTeamTab,
}))
);
@ -105,6 +110,13 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null;
const pendingNavigation = thisTab?.pendingNavigation;
// Look up whether this session belongs to a team
const sessionTeam = useMemo(() => {
if (!sessionDetail?.session?.id) return null;
const sid = sessionDetail.session.id;
return teams.find((t) => t.leadSessionId === sid || t.sessionHistory?.includes(sid)) ?? null;
}, [teams, sessionDetail?.session?.id]);
// Compute all accumulated context injections (phase-aware)
const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
if (!sessionContextStats || !conversation?.items.length) {
@ -748,9 +760,29 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
</button>
</div>
)}
{sessionTeam && (
<div
className="mx-auto max-w-5xl px-6 pt-4"
style={{ marginTop: allContextInjections.length > 0 ? '-1.5rem' : 0 }}
>
<button
onClick={() => openTeamTab(sessionTeam.teamName, sessionTeam.projectPath)}
className="inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-xs transition-colors hover:brightness-110"
style={{
backgroundColor: 'var(--color-surface-raised)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)',
}}
>
<Users className="size-3.5" />
<span>{sessionTeam.displayName}</span>
<ChevronRight className="size-3 opacity-50" />
</button>
</div>
)}
<div
className="mx-auto max-w-5xl px-6 py-8"
style={{ marginTop: allContextInjections.length > 0 ? '-2rem' : 0 }}
style={{ marginTop: allContextInjections.length > 0 && !sessionTeam ? '-2rem' : 0 }}
>
<div className="space-y-8">
{shouldVirtualize ? (

View file

@ -70,7 +70,7 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerChatItemRef(item.group.id)}
className={`rounded-lg transition-all duration-[3000ms] ease-out ${hl.className}`}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
style={hl.style}
>
<UserChatGroup userGroup={item.group} />
@ -88,7 +88,7 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerChatItemRef(item.group.id)}
className={`rounded-lg transition-all duration-[3000ms] ease-out ${hl.className}`}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
style={hl.style}
>
<SystemChatGroup systemGroup={item.group} />
@ -110,7 +110,7 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerAIGroupRef(item.group.id)}
className={`rounded-lg transition-all duration-[3000ms] ease-out ${hl.className}`}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className}`}
style={hl.style}
>
<AIChatGroup

View file

@ -191,9 +191,7 @@ export const FlatInjectionList = ({
}
};
const displayText = row.description
? `${row.label} \u2014 ${row.description}`
: row.label;
const displayText = row.description ? `${row.label} \u2014 ${row.description}` : row.label;
return (
<div key={row.key} className="flex items-center gap-0.5">

View file

@ -152,7 +152,7 @@ export function extractOutputText(content: string | unknown[]): string {
.map((block) =>
typeof block === 'object' && block !== null && 'text' in block
? (block as { text: string }).text
: JSON.stringify(block, null, 2),
: JSON.stringify(block, null, 2)
)
.join('\n');
} else {

View file

@ -0,0 +1,61 @@
/**
* AppLogo - Inline SVG of the app icon (three connected agent nodes).
* Renders at the given size (default 20px) for use in headers/sidebars.
*/
interface AppLogoProps {
size?: number;
className?: string;
}
export const AppLogo = ({ size = 20, className }: AppLogoProps): React.JSX.Element => (
<svg
viewBox="0 0 56 56"
width={size}
height={size}
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<rect width="56" height="56" rx="14" fill="#151620" />
{/* Edges */}
<line
x1="19.5"
y1="19"
x2="36.5"
y2="19"
stroke="#818cf8"
strokeWidth="1.8"
strokeLinecap="round"
opacity="0.4"
/>
<line
x1="36.5"
y1="19"
x2="28"
y2="36.5"
stroke="#a78bfa"
strokeWidth="1.8"
strokeLinecap="round"
opacity="0.4"
/>
<line
x1="28"
y1="36.5"
x2="19.5"
y2="19"
stroke="#c084fc"
strokeWidth="1.8"
strokeLinecap="round"
opacity="0.4"
/>
{/* Nodes */}
<circle cx="19.5" cy="19" r="5" fill="#818cf8" />
<circle cx="36.5" cy="19" r="5" fill="#a78bfa" />
<circle cx="28" cy="36.5" r="5.5" fill="#c084fc" />
{/* Cores */}
<circle cx="19.5" cy="19" r="2" fill="#e0e7ff" />
<circle cx="36.5" cy="19" r="2" fill="#ede9fe" />
<circle cx="28" cy="36.5" r="2.2" fill="#f3e8ff" />
</svg>
);

View file

@ -16,7 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings } from 'lucide-react';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings, Users } from 'lucide-react';
import type { RepositoryGroup } from '@renderer/types/data';
@ -52,7 +52,7 @@ const CommandSearch = ({ value, onChange }: Readonly<CommandSearchProps>): React
}, [openCommandPalette]);
return (
<div className="relative mx-auto w-full max-w-xl">
<div className="relative w-full">
{/* Search container with glow effect on focus */}
<div
className={`relative flex items-center gap-3 rounded-sm border bg-surface-raised px-4 py-3 transition-all duration-200 ${
@ -394,7 +394,12 @@ const ProjectsGrid = ({
export const DashboardView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const openSettingsTab = useStore((s) => s.openSettingsTab);
const { openSettingsTab, openTeamsTab } = useStore(
useShallow((s) => ({
openSettingsTab: s.openSettingsTab,
openTeamsTab: s.openTeamsTab,
}))
);
return (
<div className="relative flex-1 overflow-auto bg-surface">
@ -406,9 +411,19 @@ export const DashboardView = (): React.JSX.Element => {
{/* Content */}
<div className="relative mx-auto max-w-5xl px-8 py-12">
{/* Command Search */}
<div className="mb-12">
<CommandSearch value={searchQuery} onChange={setSearchQuery} />
{/* Team select + Search */}
<div className="mb-12 flex items-center justify-center gap-3">
<button
onClick={openTeamsTab}
className="flex shrink-0 items-center gap-2 rounded-sm border border-border bg-surface-raised px-4 py-3 text-sm text-text-secondary transition-all duration-200 hover:border-zinc-500 hover:text-text"
>
<Users className="size-4" />
Select Team
</button>
<span className="shrink-0 text-xs text-text-muted">or</span>
<div className="flex-1">
<CommandSearch value={searchQuery} onChange={setSearchQuery} />
</div>
</div>
{/* Section header */}

View file

@ -15,7 +15,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow';
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
import { GlobalTaskList } from '../sidebar/GlobalTaskList';
import { SidebarHeader } from './SidebarHeader';
@ -101,9 +101,9 @@ export const Sidebar = (): React.JSX.Element | null => {
{/* Sidebar header with project dropdown */}
<SidebarHeader />
{/* Date-grouped session list */}
{/* Global task list */}
<div className="flex-1 overflow-hidden">
<DateGroupedSessions />
<GlobalTaskList />
</div>
{/* Resize handle */}

View file

@ -20,6 +20,7 @@ import { truncateMiddle } from '@renderer/utils/stringUtils';
import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { AppLogo } from '../common/AppLogo';
import { WorktreeBadge } from '../common/WorktreeBadge';
import type { Worktree, WorktreeSource } from '@renderer/types/data';
@ -338,7 +339,8 @@ export const SidebarHeader = (): React.JSX.Element => {
} as React.CSSProperties
}
>
{/* Project name dropdown button */}
{/* App logo + Project name dropdown button */}
<AppLogo size={22} className="shrink-0" />
<button
onClick={() => setIsProjectDropdownOpen(!isProjectDropdownOpen)}
className="flex min-w-0 items-center gap-2 transition-opacity hover:opacity-80"

View file

@ -8,6 +8,7 @@
import { useEffect, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { AppLogo } from '@renderer/components/common/AppLogo';
import { Minus, Square, X } from 'lucide-react';
const TITLE_BAR_HEIGHT = 32;
@ -50,12 +51,13 @@ export const WindowsTitleBar = (): React.JSX.Element | null => {
return (
<div className="flex shrink-0 select-none items-stretch" style={titleBarStyle}>
{/* Draggable area — app title optional */}
<div className="flex flex-1 items-center pl-4" style={{ minWidth: 0 }}>
<div className="flex flex-1 items-center gap-2 pl-3" style={{ minWidth: 0 }}>
<AppLogo size={18} className="shrink-0" />
<span
className="truncate text-sm font-semibold"
style={{ color: 'var(--color-text-muted)' }}
>
claude-devtools
Claude Agent Teams UI
</span>
</div>

View file

@ -150,7 +150,7 @@ export const AdvancedSection = ({
<div>
<div className="flex items-center gap-3">
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
claude-devtools
Claude Agent Teams UI
</p>
{isElectron && (
<button

View file

@ -2,10 +2,12 @@
* NotificationsSection - Notification settings including triggers and ignored repositories.
*/
import { api } from '@renderer/api';
import {
RepositoryDropdown,
SelectedRepositoryItem,
} from '@renderer/components/common/RepositoryDropdown';
import { ExternalLink } from 'lucide-react';
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
import { NotificationTriggerSettings } from '../NotificationTriggerSettings';
@ -129,6 +131,33 @@ export const NotificationsSection = ({
</div>
</SettingRow>
<SettingsSectionHeader title="Task Completion Notifications" />
<div
className="mb-4 rounded-lg border p-4"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'var(--color-surface-raised)',
}}
>
<p className="mb-3 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
Get native OS notifications when Claude finishes tasks sounds, banners, and Dock/taskbar
badges. Works on macOS, Linux, and Windows.
</p>
<button
onClick={() =>
void api.openExternal('https://github.com/777genius/claude-notifications-go')
}
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:brightness-125"
style={{
backgroundColor: 'var(--color-border-emphasis)',
color: 'var(--color-text)',
}}
>
<ExternalLink className="size-3.5" />
Install claude-notifications-go plugin
</button>
</div>
<SettingsSectionHeader title="Ignored Repositories" />
<p className="mb-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
Notifications from these repositories will be ignored

View file

@ -0,0 +1,224 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useStore } from '@renderer/store';
import { getNonEmptyTaskCategories, groupTasksByDate } from '@renderer/utils/taskGrouping';
import { ListTodo, Search, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { SidebarTaskItem } from './SidebarTaskItem';
import type { GlobalTask } from '@shared/types';
type StatusFilter = 'all' | 'active' | 'done';
const filterButtons: { value: StatusFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'done', label: 'Done' },
];
const dateCategoryLabels: Record<string, string> = {
'Previous 7 Days': 'Last 7 Days',
Older: 'Earlier',
};
function normalizePath(p: string): string {
return p.endsWith('/') ? p.slice(0, -1) : p;
}
function applyFilter(tasks: GlobalTask[], filter: StatusFilter): GlobalTask[] {
if (filter === 'all') return tasks;
if (filter === 'active')
return tasks.filter((t) => t.status === 'pending' || t.status === 'in_progress');
return tasks.filter((t) => t.status === 'completed');
}
function applySearch(tasks: GlobalTask[], query: string): GlobalTask[] {
if (!query.trim()) return tasks;
const q = query.toLowerCase();
return tasks.filter(
(t) =>
t.subject.toLowerCase().includes(q) ||
t.owner?.toLowerCase().includes(q) ||
t.teamDisplayName.toLowerCase().includes(q)
);
}
function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): GlobalTask[] {
if (!projectPath) return tasks;
const normalized = normalizePath(projectPath);
return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized);
}
export const GlobalTaskList = (): React.JSX.Element => {
const {
globalTasks,
globalTasksLoading,
fetchAllTasks,
projects,
activeProjectId,
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
} = useStore(
useShallow((s) => ({
globalTasks: s.globalTasks,
globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks,
projects: s.projects,
activeProjectId: s.activeProjectId,
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
}))
);
const [filter, setFilter] = useState<StatusFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (globalTasks.length === 0 && !globalTasksLoading) {
void fetchAllTasks();
}
}, [globalTasks.length, globalTasksLoading, fetchAllTasks]);
const selectedProjectPath = useMemo(() => {
if (viewMode === 'grouped') {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId);
return worktree?.path ?? null;
}
const project = projects.find((p) => p.id === activeProjectId);
return project?.path ?? null;
}, [
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
projects,
activeProjectId,
]);
const filtered = useMemo(() => {
let result = globalTasks;
result = applyProjectFilter(result, selectedProjectPath);
result = applyFilter(result, filter);
result = applySearch(result, searchQuery);
return result;
}, [globalTasks, selectedProjectPath, filter, searchQuery]);
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
return (
<div className="flex h-full flex-col">
{/* Header + Filter bar */}
<div
className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }}
>
<span className="text-[12px] font-semibold text-text-secondary">Tasks</span>
<div className="flex gap-1">
{filterButtons.map((btn) => (
<button
key={btn.value}
type="button"
className={`rounded px-2 py-0.5 text-[11px] font-medium transition-colors ${
filter === btn.value
? 'bg-surface-raised text-text'
: 'text-text-muted hover:text-text-secondary'
}`}
onClick={() => setFilter(btn.value)}
>
{btn.label}
</button>
))}
</div>
</div>
{/* Search bar */}
<div
className="flex shrink-0 items-center gap-1.5 border-b px-3 py-1"
style={{ borderColor: 'var(--color-border)' }}
>
<Search className="size-3 shrink-0 text-text-muted" />
<input
ref={searchInputRef}
type="text"
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none"
/>
{searchQuery && (
<button
type="button"
className="shrink-0 text-text-muted hover:text-text-secondary"
onClick={() => {
setSearchQuery('');
searchInputRef.current?.focus();
}}
>
<X className="size-3" />
</button>
)}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{globalTasksLoading && globalTasks.length === 0 && (
<div className="space-y-2 p-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-[48px] animate-pulse rounded bg-surface-raised" />
))}
</div>
)}
{!globalTasksLoading && categories.length === 0 && (
<div className="flex flex-col items-center gap-2 px-4 py-8 text-text-muted">
<ListTodo className="size-8 opacity-40" />
<span className="text-[12px]">
{searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'}
</span>
</div>
)}
{categories.map((category) => {
const tasks = grouped[category];
let lastTeam: string | null = null;
return (
<div key={category}>
{/* Date header */}
<div
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{dateCategoryLabels[category] ?? category}
</div>
{tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam;
lastTeam = task.teamName;
return (
<div key={`${task.teamName}-${task.id}`}>
{showTeamHeader && (
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
Team: {task.teamDisplayName}
</div>
)}
<SidebarTaskItem task={task} />
</div>
);
})}
</div>
);
})}
</div>
</div>
);
};

View file

@ -0,0 +1,58 @@
import { useStore } from '@renderer/store';
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
import { CheckCircle2, Circle, Loader2 } from 'lucide-react';
import type { GlobalTask, TeamTaskStatus } from '@shared/types';
import type { LucideIcon } from 'lucide-react';
const statusConfig: Record<TeamTaskStatus, { icon: LucideIcon; color: string; label: string }> = {
pending: { icon: Circle, color: 'text-amber-400', label: 'pending' },
in_progress: { icon: Loader2, color: 'text-blue-400', label: 'in progress' },
completed: { icon: CheckCircle2, color: 'text-emerald-400', label: 'completed' },
deleted: { icon: Circle, color: 'text-zinc-500', label: 'deleted' },
};
function formatTaskDate(dateStr: string | undefined): string | null {
if (!dateStr) return null;
const d = new Date(dateStr);
if (isNaN(d.getTime())) return null;
if (isToday(d)) return format(d, 'HH:mm');
if (isYesterday(d)) return 'Yesterday';
if (isThisYear(d)) return format(d, 'MMM d');
return format(d, 'MMM d, yyyy');
}
interface SidebarTaskItemProps {
task: GlobalTask;
}
export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => {
const openTeamTab = useStore((s) => s.openTeamTab);
const cfg = statusConfig[task.status] ?? statusConfig.pending;
const StatusIcon = cfg.icon;
const dateLabel = formatTaskDate(task.createdAt);
return (
<button
type="button"
className="flex h-[48px] w-full cursor-pointer flex-col justify-center px-3 text-left transition-colors hover:bg-surface-raised"
onClick={() => openTeamTab(task.teamName, undefined, task.id)}
>
<div className="flex w-full items-center gap-1.5 overflow-hidden">
<span className="truncate text-[13px] leading-tight text-text">{task.subject}</span>
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] leading-tight text-text-muted">
<span>{task.owner ?? 'unassigned'}</span>
<span className="opacity-40">·</span>
<span className="truncate">{task.teamDisplayName}</span>
{dateLabel && (
<>
<span className="opacity-40">·</span>
<span className="shrink-0">{dateLabel}</span>
</>
)}
</div>
</button>
);
};

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { ChevronRight } from 'lucide-react';
@ -7,6 +7,7 @@ interface CollapsibleTeamSectionProps {
title: string;
badge?: string | number;
defaultOpen?: boolean;
forceOpen?: boolean;
action?: React.ReactNode;
children: React.ReactNode;
}
@ -15,11 +16,16 @@ export const CollapsibleTeamSection = ({
title,
badge,
defaultOpen = true,
forceOpen,
action,
children,
}: CollapsibleTeamSectionProps): React.JSX.Element => {
const [open, setOpen] = useState(defaultOpen);
useEffect(() => {
if (forceOpen) setOpen(true);
}, [forceOpen]);
return (
<section className="border-b border-[var(--color-border)] py-3 last:border-b-0">
<div className="flex items-center">
@ -34,7 +40,10 @@ export const CollapsibleTeamSection = ({
/>
<span className="text-sm font-medium text-[var(--color-text)]">{title}</span>
{badge != null && (
<Badge variant="secondary" className="px-1.5 py-0.5 text-[10px] font-normal leading-none">
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none"
>
{badge}
</Badge>
)}

View file

@ -1,18 +1,30 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { Plus, Trash2 } from 'lucide-react';
import { MessageSquare, Pencil, Play, Plus, Search, Trash2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { ActivityTimeline } from './activity/ActivityTimeline';
import { MessageComposer } from './activity/MessageComposer';
import { CreateTaskDialog } from './dialogs/CreateTaskDialog';
import { EditTeamDialog } from './dialogs/EditTeamDialog';
import { LaunchTeamDialog } from './dialogs/LaunchTeamDialog';
import { ReviewDialog } from './dialogs/ReviewDialog';
import { SendMessageDialog } from './dialogs/SendMessageDialog';
import { KanbanBoard } from './kanban/KanbanBoard';
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
import { MemberDetailDialog } from './members/MemberDetailDialog';
import { MemberList } from './members/MemberList';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { Session } from '@renderer/types/data';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
interface TeamDetailViewProps {
teamName: string;
@ -22,21 +34,57 @@ interface CreateTaskDialogState {
open: boolean;
defaultSubject: string;
defaultDescription: string;
defaultOwner: string;
}
interface TimeWindow {
start: number;
end: number;
}
function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] {
if (query.startsWith('#')) {
const id = query.slice(1);
return tasks.filter((t) => t.id === id);
}
const lower = query.toLowerCase();
return tasks.filter(
(t) =>
t.id.toLowerCase().includes(lower) ||
t.subject.toLowerCase().includes(lower) ||
(t.owner?.toLowerCase().includes(lower) ?? false)
);
}
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
open: false,
defaultSubject: '',
defaultDescription: '',
defaultOwner: '',
});
const [creatingTask, setCreatingTask] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
// Session loading and filtering state
const [sessions, setSessions] = useState<Session[]>([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [sessionsError, setSessionsError] = useState<string | null>(null);
const [kanbanFilter, setKanbanFilter] = useState<KanbanFilterState>({
sessionId: null,
selectedOwners: new Set(),
});
const {
data,
loading,
error,
projects,
selectTeam,
updateKanban,
updateTaskStatus,
@ -49,11 +97,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError,
lastSendMessageResult,
reviewActionError,
launchTeam,
provisioningError,
kanbanFilterQuery,
clearKanbanFilter,
} = useStore(
useShallow((s) => ({
data: s.selectedTeamData,
loading: s.selectedTeamLoading,
error: s.selectedTeamError,
projects: s.projects,
selectTeam: s.selectTeam,
updateKanban: s.updateKanban,
updateTaskStatus: s.updateTaskStatus,
@ -66,9 +119,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sendMessageError: s.sendMessageError,
lastSendMessageResult: s.lastSendMessageResult,
reviewActionError: s.reviewActionError,
launchTeam: s.launchTeam,
provisioningError: s.provisioningError,
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
}))
);
const [kanbanSearch, setKanbanSearch] = useState('');
useEffect(() => {
if (!teamName) {
return;
@ -76,17 +135,149 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
void selectTeam(teamName);
}, [teamName, selectTeam]);
const openCreateTaskDialog = (subject = '', description = ''): void => {
setCreateTaskDialog({ open: true, defaultSubject: subject, defaultDescription: description });
useEffect(() => {
if (kanbanFilterQuery) {
setKanbanSearch(kanbanFilterQuery);
clearKanbanFilter();
}
}, [kanbanFilterQuery, clearKanbanFilter]);
// Load sessions for the team's project
const projectId = useMemo(() => {
if (!data?.config.projectPath) return null;
return projects.find((p) => p.path === data.config.projectPath)?.id ?? null;
}, [projects, data?.config.projectPath]);
useEffect(() => {
if (!projectId) return;
let cancelled = false;
setSessionsLoading(true);
setSessionsError(null);
void (async () => {
try {
const result = await api.getSessions(projectId);
if (!cancelled) {
setSessions(result);
}
} catch (e) {
if (!cancelled) {
setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions');
}
} finally {
if (!cancelled) {
setSessionsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [projectId]);
// Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessions = useMemo(() => {
const sessionIds = new Set<string>();
if (data?.config.leadSessionId) {
sessionIds.add(data.config.leadSessionId);
}
if (data?.config.sessionHistory) {
for (const id of data.config.sessionHistory) {
sessionIds.add(id);
}
}
// If no session IDs known (backward compat), show all sessions
if (sessionIds.size === 0) return sessions;
return sessions.filter((s) => sessionIds.has(s.id));
}, [sessions, data?.config.leadSessionId, data?.config.sessionHistory]);
// Auto-reset session filter if the selected session is no longer in teamSessions
useEffect(() => {
if (
kanbanFilter.sessionId !== null &&
!teamSessions.some((s) => s.id === kanbanFilter.sessionId)
) {
setKanbanFilter((prev) => ({ ...prev, sessionId: null }));
}
}, [kanbanFilter.sessionId, teamSessions]);
// Compute time-window for session filtering
const timeWindow = useMemo<TimeWindow | null>(() => {
if (kanbanFilter.sessionId === null) return null;
const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt);
const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId);
if (idx === -1) return null;
const start = sorted[idx].createdAt;
const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity;
return { start, end };
}, [kanbanFilter.sessionId, teamSessions]);
// Filter tasks by time-window and owner
const filteredTasks = useMemo(() => {
if (!data) return [];
let result = data.tasks;
// Session time-window filter
if (timeWindow) {
result = result.filter((t) => {
if (!t.createdAt) return true; // legacy tasks always included
const ts = new Date(t.createdAt).getTime();
return ts >= timeWindow.start && ts < timeWindow.end;
});
}
// Owner filter
if (kanbanFilter.selectedOwners.size > 0) {
result = result.filter((t) =>
t.owner
? kanbanFilter.selectedOwners.has(t.owner)
: kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER)
);
}
return result;
}, [data, timeWindow, kanbanFilter.selectedOwners]);
const filteredMessages = useMemo(() => {
if (!data) return [];
if (!timeWindow) return data.messages;
return data.messages.filter((m) => {
const ts = new Date(m.timestamp).getTime();
return ts >= timeWindow.start && ts < timeWindow.end;
});
}, [data, timeWindow]);
const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim();
if (!query) return filteredTasks;
return filterKanbanTasks(filteredTasks, query);
}, [filteredTasks, kanbanSearch]);
const openCreateTaskDialog = (subject = '', description = '', owner = ''): void => {
setCreateTaskDialog({
open: true,
defaultSubject: subject,
defaultDescription: description,
defaultOwner: owner,
});
};
const closeCreateTaskDialog = (): void => {
setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '' });
setCreateTaskDialog({
open: false,
defaultSubject: '',
defaultDescription: '',
defaultOwner: '',
});
};
const handleDeleteTeam = useCallback((): void => {
const confirmed = window.confirm(
`Удалить команду "${teamName}"? Это действие необратимо. Будут удалены все данные команды и задачи.`
`Delete team "${teamName}"? This action is irreversible. All team data and tasks will be deleted.`
);
if (!confirmed) {
return;
@ -105,7 +296,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
subject: string,
description: string,
owner?: string,
blockedBy?: string[]
blockedBy?: string[],
prompt?: string
): void => {
setCreatingTask(true);
void (async () => {
@ -115,7 +307,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
description: description || undefined,
owner,
blockedBy,
prompt,
});
if (prompt && owner && data?.isAlive) {
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
try {
await api.teams.processSend(teamName, msg);
} catch {
// best-effort
}
}
closeCreateTaskDialog();
} catch {
// error shown via store
@ -150,7 +353,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return (
<div className="flex size-full items-center justify-center p-6">
<div className="text-center">
<p className="text-sm font-medium text-red-400">Не удалось загрузить команду</p>
<p className="text-sm font-medium text-red-400">Failed to load team</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{error}</p>
</div>
</div>
@ -160,25 +363,80 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
if (!data) {
return (
<div className="flex size-full items-center justify-center p-6 text-sm text-[var(--color-text-muted)]">
Нет данных по команде
No team data available
</div>
);
}
const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : null;
return (
<div className="size-full overflow-auto p-4">
<div className="mb-3">
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
{data.config.description && (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{data.config.description}</p>
)}
<div
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
style={
headerColorSet
? { borderLeftWidth: '3px', borderLeftColor: headerColorSet.border }
: undefined
}
>
{headerColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: headerColorSet.badge }}
/>
) : null}
<div
className={cn(
'flex items-start justify-between gap-2',
headerColorSet && 'relative z-10'
)}
>
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--color-text)]">{data.config.name}</h2>
{data.config.description && (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">
{data.config.description}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{!data.isAlive ? (
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300"
onClick={() => setLaunchDialogOpen(true)}
>
<Play size={12} />
Launch
</Button>
) : null}
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setEditDialogOpen(true)}
>
<Pencil size={12} />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2 text-xs text-red-400 hover:bg-red-500/10 hover:text-red-300"
onClick={handleDeleteTeam}
>
<Trash2 size={12} />
</Button>
</div>
</div>
</div>
<TeamProvisioningBanner teamName={teamName} />
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
<div className="mb-3 rounded-md border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
Не удалось полностью загрузить kanban. Отображены безопасные данные.
Failed to fully load kanban. Displaying safe data.
</div>
) : null}
{reviewActionError ? (
@ -187,14 +445,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
</div>
) : null}
<CollapsibleTeamSection title="Участники" badge={data.members.length} defaultOpen>
<MemberList members={data.members} />
<CollapsibleTeamSection title="Members" badge={data.members.length} defaultOpen>
<MemberList
members={data.members}
isTeamAlive={data.isAlive}
onMemberClick={setSelectedMember}
/>
</CollapsibleTeamSection>
<CollapsibleTeamSection title="Sessions" defaultOpen={false}>
<TeamSessionsSection
sessions={teamSessions}
sessionsLoading={sessionsLoading}
sessionsError={sessionsError}
leadSessionId={data.config.leadSessionId}
selectedSessionId={kanbanFilter.sessionId}
onSelectSession={(id) => setKanbanFilter((prev) => ({ ...prev, sessionId: id }))}
projectPath={data.config.projectPath}
/>
</CollapsibleTeamSection>
<CollapsibleTeamSection
title="Kanban"
badge={data.tasks.length}
badge={filteredTasks.length}
defaultOpen
forceOpen={kanbanSearch.trim().length > 0}
action={
<Button
variant="ghost"
@ -206,13 +481,40 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}}
>
<Plus size={12} />
Задача
Task
</Button>
}
>
<div className="relative mb-2">
<Search
size={14}
className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks… (#id or text)"
value={kanbanSearch}
onChange={(e) => setKanbanSearch(e.target.value)}
className="h-8 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-8 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
/>
{kanbanSearch && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setKanbanSearch('')}
>
<X size={14} />
</button>
)}
</div>
<KanbanBoard
tasks={data.tasks}
tasks={kanbanDisplayTasks}
kanbanState={data.kanbanState}
filter={kanbanFilter}
sessions={teamSessions}
leadSessionId={data.config.leadSessionId}
members={data.members}
onFilterChange={setKanbanFilter}
onRequestReview={(taskId) => {
void requestReview(teamName, taskId);
}}
@ -239,26 +541,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
/>
</CollapsibleTeamSection>
<CollapsibleTeamSection title="Активность" badge={data.messages.length} defaultOpen>
<div className="flex flex-col gap-3">
<MessageComposer
members={data.members}
sending={sendingMessage}
sendError={sendMessageError}
lastResult={lastSendMessageResult}
onSend={(member, text, summary) => {
void sendTeamMessage(teamName, { member, text, summary });
<CollapsibleTeamSection
title="Messages"
badge={filteredMessages.length}
defaultOpen
action={
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={(e) => {
e.stopPropagation();
setSendDialogRecipient(undefined);
setSendDialogOpen(true);
}}
/>
<div className="rounded-md border border-[var(--color-border)] p-2">
<ActivityTimeline
messages={data.messages}
onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description);
}}
/>
</div>
</div>
>
<MessageSquare size={12} />
Message
</Button>
}
>
<ActivityTimeline
messages={filteredMessages}
members={data.members}
onCreateTaskFromMessage={(subject, description) => {
openCreateTaskDialog(subject, description);
}}
/>
</CollapsibleTeamSection>
<ReviewDialog
@ -283,28 +592,71 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}}
/>
<MemberDetailDialog
open={selectedMember !== null}
member={selectedMember}
teamName={teamName}
tasks={data.tasks}
messages={data.messages}
onClose={() => setSelectedMember(null)}
onSendMessage={() => {
const name = selectedMember?.name ?? '';
setSelectedMember(null);
setSendDialogRecipient(name || undefined);
setSendDialogOpen(true);
}}
onAssignTask={() => {
const name = selectedMember?.name ?? '';
setSelectedMember(null);
openCreateTaskDialog('', '', name);
}}
/>
<CreateTaskDialog
open={createTaskDialog.open}
members={data.members}
tasks={data.tasks}
defaultSubject={createTaskDialog.defaultSubject}
defaultDescription={createTaskDialog.defaultDescription}
defaultOwner={createTaskDialog.defaultOwner}
onClose={closeCreateTaskDialog}
onSubmit={handleCreateTask}
submitting={creatingTask}
/>
<div className="mt-6 border-t border-[var(--color-border)] pt-4">
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={handleDeleteTeam}
>
<Trash2 size={14} className="mr-1.5" />
Удалить команду
</Button>
</div>
<EditTeamDialog
open={editDialogOpen}
teamName={teamName}
currentName={data.config.name}
currentDescription={data.config.description ?? ''}
currentColor={data.config.color ?? ''}
onClose={() => setEditDialogOpen(false)}
onSaved={() => void selectTeam(teamName)}
/>
<LaunchTeamDialog
open={launchDialogOpen}
teamName={teamName}
defaultProjectPath={data.config.projectPath}
provisioningError={provisioningError}
onClose={() => setLaunchDialogOpen(false)}
onLaunch={async (request) => {
await launchTeam(request);
}}
/>
<SendMessageDialog
open={sendDialogOpen}
members={data.members}
defaultRecipient={sendDialogRecipient}
sending={sendingMessage}
sendError={sendMessageError}
lastResult={lastSendMessageResult}
onSend={(member, text, summary) => {
void sendTeamMessage(teamName, { member, text, summary });
}}
onClose={() => setSendDialogOpen(false)}
/>
</div>
);
};

View file

@ -2,9 +2,9 @@ export const TeamEmptyState = (): React.JSX.Element => {
return (
<div className="flex size-full items-center justify-center">
<div className="text-center">
<p className="text-lg font-medium text-[var(--color-text)]">Команды не найдены</p>
<p className="text-lg font-medium text-[var(--color-text)]">No teams found</p>
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
Создайте команду в Claude Code, затем обновите список.
Create a team in Claude Code, then refresh the list.
</p>
</div>
</div>

View file

@ -1,18 +1,95 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { isElectronMode } from '@renderer/api';
import { api, isElectronMode } from '@renderer/api';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store';
import { Trash2 } from 'lucide-react';
import { Copy, FolderOpen, Search, Trash2 } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import type { TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamProvisioningProgress, TeamSummary } from '@shared/types';
function generateUniqueName(sourceName: string, existingNames: string[]): string {
const base = sourceName.replace(/-\d+$/, '');
const existing = new Set(existingNames);
for (let i = 1; ; i++) {
const candidate = `${base}-${i}`;
if (!existing.has(candidate)) {
return candidate;
}
}
}
type TeamStatus = 'running' | 'provisioning' | 'offline';
function getRecentProjects(team: TeamSummary): string[] {
const history = team.projectPathHistory;
if (!history || history.length === 0) {
return team.projectPath ? [team.projectPath] : [];
}
return history.slice(-3).reverse();
}
function folderName(fullPath: string): string {
const parts = fullPath.replace(/\/+$/, '').split('/');
return parts[parts.length - 1] || fullPath;
}
function resolveTeamStatus(
teamName: string,
aliveTeams: string[],
provisioningRuns: Record<string, TeamProvisioningProgress>
): TeamStatus {
if (aliveTeams.includes(teamName)) {
return 'running';
}
const activeStates = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
for (const run of Object.values(provisioningRuns)) {
if (run.teamName === teamName && activeStates.has(run.state)) {
return 'provisioning';
}
}
return 'offline';
}
const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
switch (status) {
case 'running':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" />
Running
</span>
);
case 'provisioning':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-400">
<span className="size-1.5 animate-pulse rounded-full bg-amber-400" />
Launching...
</span>
);
case 'offline':
return (
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/15 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
<span className="size-1.5 rounded-full bg-zinc-500" />
Offline
</span>
);
}
};
export const TeamListView = (): React.JSX.Element => {
const electronMode = isElectronMode();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const { teams, teamsLoading, teamsError, fetchTeams, openTeamTab, deleteTeam } = useStore(
useShallow((s) => ({
teams: s.teams,
@ -23,33 +100,49 @@ export const TeamListView = (): React.JSX.Element => {
deleteTeam: s.deleteTeam,
}))
);
const {
connectionMode,
createTeam,
cancelProvisioning,
provisioningRuns,
activeProvisioningRunId,
provisioningError,
} = useStore(
const { connectionMode, createTeam, provisioningError, provisioningRuns } = useStore(
useShallow((s) => ({
connectionMode: s.connectionMode,
createTeam: s.createTeam,
cancelProvisioning: s.cancelProvisioning,
provisioningRuns: s.provisioningRuns,
activeProvisioningRunId: s.activeProvisioningRunId,
provisioningError: s.provisioningError,
provisioningRuns: s.provisioningRuns,
}))
);
const activeProgress = useMemo(
() => (activeProvisioningRunId ? (provisioningRuns[activeProvisioningRunId] ?? null) : null),
[activeProvisioningRunId, provisioningRuns]
);
const canCreate = electronMode && connectionMode === 'local';
// Fetch alive teams on mount and when teams list changes
useEffect(() => {
if (!electronMode) return;
let cancelled = false;
const fetchAlive = async (): Promise<void> => {
try {
const list = await api.teams.aliveList();
if (!cancelled) setAliveTeams(list);
} catch {
// best-effort
}
};
void fetchAlive();
return () => {
cancelled = true;
};
}, [electronMode, teams]);
const filteredTeams = useMemo<TeamSummary[]>(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return teams;
return teams.filter(
(t) =>
t.teamName.toLowerCase().includes(q) ||
t.displayName.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q)
);
}, [teams, searchQuery]);
const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
const confirmed = window.confirm(`Удалить команду "${teamName}"? Это действие необратимо.`);
const confirmed = window.confirm(`Delete team "${teamName}"? This action is irreversible.`);
if (!confirmed) {
return;
}
@ -58,6 +151,33 @@ export const TeamListView = (): React.JSX.Element => {
[deleteTeam]
);
const handleCopyTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
void (async () => {
try {
const data = await api.teams.getData(teamName);
const existingNames = teams.map((t) => t.teamName);
const uniqueName = generateUniqueName(teamName, existingNames);
const members = (data.config.members ?? []).map((m) => ({
name: m.name,
role: m.role,
}));
setCopyData({
teamName: uniqueName,
description: data.config.description,
color: data.config.color,
members,
});
setShowCreateDialog(true);
} catch {
// silently ignore — team data may be unavailable
}
})();
},
[teams]
);
useEffect(() => {
if (!electronMode) {
return;
@ -70,10 +190,10 @@ export const TeamListView = (): React.JSX.Element => {
<div className="flex size-full items-center justify-center p-6">
<div className="max-w-md text-center">
<p className="text-sm font-medium text-[var(--color-text)]">
Teams доступен только в Electron-режиме
Teams is only available in Electron mode
</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
В browser mode доступ к локальным папкам `~/.claude/teams` недоступен.
In browser mode, access to local `~/.claude/teams` directories is not available.
</p>
</div>
</div>
@ -85,15 +205,15 @@ export const TeamListView = (): React.JSX.Element => {
open={showCreateDialog}
canCreate={canCreate}
provisioningError={provisioningError}
progress={activeProgress}
existingTeamNames={teams.map((t) => t.teamName)}
onClose={() => setShowCreateDialog(false)}
initialData={copyData ?? undefined}
onClose={() => {
setShowCreateDialog(false);
setCopyData(null);
}}
onCreate={async (request) => {
await createTeam(request);
}}
onCancelProvisioning={async (runId) => {
await cancelProvisioning(runId);
}}
onOpenTeam={openTeamTab}
/>
);
@ -118,15 +238,31 @@ export const TeamListView = (): React.JSX.Element => {
void fetchTeams();
}}
>
Обновить
Refresh
</Button>
</div>
</div>
{!canCreate ? (
<p className="mt-2 text-xs text-[var(--color-text-muted)]">
Доступно только в local Electron-режиме.
Only available in local Electron mode.
</p>
) : null}
{teams.length > 0 ? (
<div className="relative mt-3">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
) : null}
</div>
);
@ -135,7 +271,7 @@ export const TeamListView = (): React.JSX.Element => {
<div className="size-full overflow-auto p-4">
{renderHeader()}
<div className="flex size-full items-center justify-center text-sm text-[var(--color-text-muted)]">
Загружаем команды...
Loading teams...
</div>
{createDialogElement}
</div>
@ -148,7 +284,7 @@ export const TeamListView = (): React.JSX.Element => {
{renderHeader()}
<div className="flex size-full items-center justify-center p-6">
<div className="text-center">
<p className="text-sm font-medium text-red-400">Не удалось загрузить команды</p>
<p className="text-sm font-medium text-red-400">Failed to load teams</p>
<p className="mt-2 text-xs text-[var(--color-text-muted)]">{teamsError}</p>
<Button
variant="outline"
@ -158,7 +294,7 @@ export const TeamListView = (): React.JSX.Element => {
void fetchTeams();
}}
>
Повторить
Retry
</Button>
</div>
</div>
@ -181,48 +317,105 @@ export const TeamListView = (): React.JSX.Element => {
<div className="size-full overflow-auto p-4">
{renderHeader()}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => (
<div
key={team.teamName}
role="button"
tabIndex={0}
className="group cursor-pointer rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
onClick={() => openTeamTab(team.teamName)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName);
}
}}
>
<div className="flex items-start justify-between">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
title="Удалить команду"
{filteredTeams.length === 0 && searchQuery.trim() ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot;
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => {
const status = resolveTeamStatus(team.teamName, aliveTeams, provisioningRuns);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className="group relative cursor-pointer overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
onClick={() => openTeamTab(team.teamName)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName);
}
}}
>
<Trash2 size={14} />
</button>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'Без описания'}
</p>
<div className="mt-3 flex items-center gap-2">
<Badge variant="secondary" className="text-[10px] font-normal">
Участников: {team.memberCount}
</Badge>
<Badge variant="secondary" className="text-[10px] font-normal">
Задач: {team.taskCount}
</Badge>
</div>
</div>
))}
</div>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<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)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
title="Copy team"
>
<Copy size={14} />
</button>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
title="Delete team"
>
<Trash2 size={14} />
</button>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
<div className="mt-3 flex items-center gap-2">
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
<Badge variant="secondary" className="text-[10px] font-normal">
Tasks: {team.taskCount}
</Badge>
</div>
{(() => {
const projects = getRecentProjects(team);
if (projects.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<FolderOpen size={10} className="shrink-0" />
<span className="truncate">
{projects.map((p, i) => (
<span key={p} title={p}>
{i === 0 && status === 'running' ? (
<span className="text-emerald-400">{folderName(p)}</span>
) : (
folderName(p)
)}
{i < projects.length - 1 ? ', ' : ''}
</span>
))}
</span>
</div>
);
})()}
</div>
</div>
);
})}
</div>
)}
{createDialogElement}
</div>
);

View file

@ -4,7 +4,7 @@ import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { X } from 'lucide-react';
import { CheckCircle2, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { STEP_LABELS, STEP_ORDER } from './provisioningSteps';
@ -66,10 +66,11 @@ export const TeamProvisioningBanner = ({
return null;
}
if (progress.state === 'ready' || progress.state === 'cancelled') {
if (progress.state === 'cancelled') {
return null;
}
const isReady = progress.state === 'ready';
const isFailed = progress.state === 'failed';
const isDisconnected = progress.state === 'disconnected';
const isActive =
@ -117,6 +118,23 @@ export const TeamProvisioningBanner = ({
);
}
if (isReady) {
return (
<div className="mb-3 flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2">
<CheckCircle2 size={14} className="shrink-0 text-emerald-400" />
<p className="flex-1 text-xs text-emerald-200">Team launched process alive</p>
<Button
variant="outline"
size="sm"
className="h-6 shrink-0 border-emerald-500/40 px-2 text-xs text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200"
onClick={() => setDismissed(true)}
>
<X size={12} />
</Button>
</div>
);
}
if (isActive) {
return (
<div className="mb-3 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
@ -147,7 +165,7 @@ export const TeamProvisioningBanner = ({
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isDone && 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
isCurrent &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
)}

View file

@ -0,0 +1,257 @@
import { useCallback, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { formatDistanceToNowStrict } from 'date-fns';
import {
AlertCircle,
Crown,
ExternalLink,
Filter,
FilterX,
Loader2,
MessageSquare,
Monitor,
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import type { Session } from '@renderer/types/data';
interface TeamSessionsSectionProps {
sessions: Session[];
sessionsLoading: boolean;
sessionsError: string | null;
leadSessionId?: string;
selectedSessionId: string | null;
onSelectSession: (sessionId: string | null) => void;
projectPath?: string;
}
export const TeamSessionsSection = ({
sessions,
sessionsLoading,
sessionsError,
leadSessionId,
selectedSessionId,
onSelectSession,
projectPath,
}: TeamSessionsSectionProps): React.JSX.Element => {
const { openTab, selectSession, projects } = useStore(
useShallow((s) => ({
openTab: s.openTab,
selectSession: s.selectSession,
projects: s.projects,
}))
);
const projectId = useMemo(() => {
if (!projectPath) return null;
return projects.find((p) => p.path === projectPath)?.id ?? null;
}, [projects, projectPath]);
// Sort: lead session first, then by most recent
const sortedSessions = useMemo(() => {
if (!leadSessionId) return sessions;
return [...sessions].sort((a, b) => {
if (a.id === leadSessionId) return -1;
if (b.id === leadSessionId) return 1;
return b.createdAt - a.createdAt;
});
}, [sessions, leadSessionId]);
const handleSessionClick = useCallback(
(session: Session) => {
if (!projectId) return;
openTab(
{
type: 'session',
sessionId: session.id,
projectId,
label: session.firstMessage?.slice(0, 50) ?? 'Session',
},
{ forceNewTab: true }
);
selectSession(session.id);
},
[projectId, openTab, selectSession]
);
if (!projectPath) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
<Monitor size={20} className="mx-auto mb-2 opacity-40" />
No project path linked
<p className="mt-1 text-[10px] opacity-60">Sessions will appear after team provisioning</p>
</div>
);
}
if (!projectId) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
<AlertCircle size={20} className="mx-auto mb-2 opacity-40" />
Project not found
<p className="mt-1 max-w-[260px] truncate text-[10px] opacity-60">{projectPath}</p>
</div>
);
}
if (sessionsLoading) {
return (
<div className="flex items-center justify-center gap-2 py-6 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Loading sessions...
</div>
);
}
if (sessionsError) {
return (
<div className="flex items-center justify-center gap-2 py-6 text-xs text-red-400">
<AlertCircle size={14} />
{sessionsError}
</div>
);
}
if (sortedSessions.length === 0) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
<Monitor size={20} className="mx-auto mb-2 opacity-40" />
No sessions found
</div>
);
}
return (
<div className="space-y-1">
{selectedSessionId !== null && (
<button
type="button"
className="flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-blue-400 transition-colors hover:bg-blue-500/10"
onClick={() => onSelectSession(null)}
>
<FilterX size={12} />
Show for all sessions
</button>
)}
{sortedSessions.map((session) => (
<SessionRow
key={session.id}
session={session}
isLead={session.id === leadSessionId}
isSelected={session.id === selectedSessionId}
onClick={() => handleSessionClick(session)}
onToggleFilter={() =>
onSelectSession(session.id === selectedSessionId ? null : session.id)
}
/>
))}
</div>
);
};
// ---------------------------------------------------------------------------
// Session row
// ---------------------------------------------------------------------------
interface SessionRowProps {
session: Session;
isLead: boolean;
isSelected: boolean;
onClick: () => void;
onToggleFilter: () => void;
}
const SessionRow = ({
session,
isLead,
isSelected,
onClick,
onToggleFilter,
}: SessionRowProps): React.JSX.Element => {
const timeAgo = formatShortTime(new Date(session.createdAt));
const label = session.firstMessage ?? 'Untitled session';
return (
<div
className={`group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)] ${
isLead ? 'border border-blue-500/20 bg-blue-500/5' : ''
} ${isSelected ? 'bg-blue-500/10 ring-1 ring-blue-400/50' : ''}`}
>
{isLead && <Crown size={12} className="shrink-0 text-blue-400" />}
<button type="button" className="min-w-0 flex-1 text-left" onClick={onClick}>
<div className="flex items-center gap-1.5">
{session.isOngoing && (
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-green-400" />
)}
<span className="truncate text-[var(--color-text)]">{label}</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-0.5">
<MessageSquare size={9} />
{session.messageCount}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span className="tabular-nums">{timeAgo}</span>
{isLead && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<span className="text-blue-400">lead</span>
</>
)}
</div>
</button>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
title={isSelected ? 'Remove filter' : 'Filter by this session'}
className={`rounded p-0.5 text-[var(--color-text-muted)] transition-opacity hover:text-blue-400 ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => {
e.stopPropagation();
onToggleFilter();
}}
>
{isSelected ? <FilterX size={12} /> : <Filter size={12} />}
</button>
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text)] group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onClick();
}}
>
<ExternalLink size={12} />
</button>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatShortTime(date: Date): string {
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
return distance
.replace(' seconds', 's')
.replace(' second', 's')
.replace(' minutes', 'm')
.replace(' minute', 'm')
.replace(' hours', 'h')
.replace(' hour', 'h')
.replace(' days', 'd')
.replace(' day', 'd')
.replace(' weeks', 'w')
.replace(' week', 'w')
.replace(' months', 'mo')
.replace(' month', 'mo')
.replace(' years', 'y')
.replace(' year', 'y');
}

View file

@ -1,18 +1,28 @@
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { cn } from '@renderer/lib/utils';
import {
CARD_BG,
CARD_BORDER_STYLE,
CARD_ICON_MUTED,
CARD_TEXT_LIGHT,
} from '@renderer/constants/cssVariables';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import {
getMessageTypeLabel,
getStructuredMessageSummary,
parseStructuredAgentMessage,
} from '@renderer/utils/agentMessageFormatting';
import { CheckCircle2, Circle, ListPlus, LogOut, Power, PowerOff } from 'lucide-react';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { Bot, ListPlus, MessageSquare } from 'lucide-react';
import type { TeamColorSet } from '@renderer/constants/teamColors';
import type { InboxMessage } from '@shared/types';
type StructuredMessage = Record<string, unknown>;
interface ActivityItemProps {
message: InboxMessage;
memberRole?: string;
memberColor?: string;
onCreateTask?: (subject: string, description: string) => void;
}
@ -21,157 +31,85 @@ function getStringField(obj: StructuredMessage, key: string): string | null {
return typeof value === 'string' && value.trim() !== '' ? value : null;
}
function formatIdleReason(reason: string | null): string {
if (!reason) {
return 'idle';
}
return reason;
}
function isCompactStructuredDisplay(parsed: StructuredMessage): boolean {
function getNoiseLabel(parsed: StructuredMessage): string | null {
const type = getStringField(parsed, 'type');
return (
type === 'idle_notification' ||
type === 'shutdown_response' ||
type === 'shutdown_request' ||
type === 'shutdown_approved' ||
type === 'teammate_terminated' ||
type === 'task_completed'
);
}
function agentAvatarUrl(name: string): string {
return `https://robohash.org/${encodeURIComponent(name)}?size=48x48`;
}
const AgentAvatar = ({ name }: { name: string }): React.JSX.Element => (
<img
src={agentAvatarUrl(name)}
alt={name}
className="size-5 shrink-0 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
);
const CompactStatusLine = ({
icon,
text,
className,
}: {
icon: React.ReactNode;
text: string;
className?: string;
}): React.JSX.Element => (
<div className={cn('flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs', className)}>
{icon}
<span>{text}</span>
</div>
);
const StructuredCompactDisplay = ({
parsed,
}: {
parsed: StructuredMessage;
}): React.JSX.Element | null => {
const type = getStringField(parsed, 'type');
const from = getStringField(parsed, 'from');
const agentName = from ?? 'agent';
if (type === 'idle_notification') {
const idleReason = getStringField(parsed, 'idleReason');
return (
<CompactStatusLine
icon={<Circle size={12} className="text-[var(--color-text-muted)]" />}
text={`${agentName} is idle (${formatIdleReason(idleReason)})`}
className="bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]"
/>
);
const reason = getStringField(parsed, 'idleReason');
return reason ? `Idle (${reason})` : 'Idle';
}
if (type === 'shutdown_response') {
const approved = parsed.approve === true;
if (approved) {
return (
<CompactStatusLine
icon={<PowerOff size={12} className="text-red-400" />}
text={`${agentName} has shut down`}
className="bg-red-500/5 text-red-300"
/>
);
}
const reason = getStringField(parsed, 'content') ?? 'declined';
return (
<CompactStatusLine
icon={<Power size={12} className="text-amber-400" />}
text={`${agentName} rejected shutdown: ${reason}`}
className="bg-amber-500/5 text-amber-300"
/>
);
return parsed.approve === true ? 'Shut down' : 'Rejected shutdown';
}
if (type === 'shutdown_request') {
const recipient = getStringField(parsed, 'recipient') ?? agentName;
return (
<CompactStatusLine
icon={<LogOut size={12} className="text-amber-400" />}
text={`Shutdown requested for ${recipient}`}
className="bg-amber-500/5 text-amber-300"
/>
);
return 'Shutdown requested';
}
if (type === 'shutdown_approved' || type === 'teammate_terminated') {
return (
<CompactStatusLine
icon={<PowerOff size={12} className="text-red-400" />}
text={`${agentName} ${type === 'shutdown_approved' ? 'shutdown confirmed' : 'terminated'}`}
className="bg-red-500/5 text-red-300"
/>
);
return type === 'shutdown_approved' ? 'Shutdown confirmed' : 'Terminated';
}
if (type === 'task_completed') {
const rawTaskId = parsed.taskId;
const taskId =
typeof rawTaskId === 'string' || typeof rawTaskId === 'number' ? rawTaskId : null;
const taskLabel = taskId !== null ? `task #${taskId}` : 'a task';
return (
<CompactStatusLine
icon={<CheckCircle2 size={12} className="text-emerald-400" />}
text={`${agentName} completed ${taskLabel}`}
className="bg-emerald-500/5 text-emerald-300"
/>
);
return taskId !== null ? `Completed task #${taskId}` : 'Completed a task';
}
return null;
};
}
const StructuredFallbackDisplay = ({
parsed,
autoSummary,
// ---------------------------------------------------------------------------
// Compact noise row (idle, shutdown, terminated) — minimal dot + name + label
// ---------------------------------------------------------------------------
const NoiseRow = ({
name,
label,
colors,
}: {
parsed: StructuredMessage;
autoSummary: string;
name: string;
label: string;
colors: TeamColorSet;
}): React.JSX.Element => (
<div className="space-y-2">
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
Raw JSON
</summary>
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{JSON.stringify(parsed, null, 2)}
</pre>
</details>
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colors.border }} />
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{name}
</span>
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{label}
</span>
</div>
);
export const ActivityItem = ({ message, onCreateTask }: ActivityItemProps): React.JSX.Element => {
// ---------------------------------------------------------------------------
// Full message card — left colored border, name badge, expanded content
// ---------------------------------------------------------------------------
export const ActivityItem = ({
message,
memberRole,
memberColor,
onCreateTask,
}: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const formattedRole = formatAgentRole(memberRole);
const timestamp = Number.isNaN(Date.parse(message.timestamp))
? message.timestamp
: new Date(message.timestamp).toLocaleString();
const structured = parseStructuredAgentMessage(message.text);
const noiseLabel = structured ? getNoiseLabel(structured) : null;
// Noise messages: minimal inline row
if (noiseLabel) {
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
}
const messageType =
structured && typeof structured.type === 'string' ? getMessageTypeLabel(structured.type) : null;
const autoSummary = structured ? getStructuredMessageSummary(structured) : null;
@ -183,81 +121,109 @@ export const ActivityItem = ({ message, onCreateTask }: ActivityItemProps): Reac
onCreateTask?.(subject, description);
};
const isCompact = structured !== null && isCompactStructuredDisplay(structured);
if (isCompact && structured) {
return (
<article className="group rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<AgentAvatar name={message.from} />
<StructuredCompactDisplay parsed={structured} />
</div>
<div className="flex shrink-0 items-center gap-1.5">
{onCreateTask && (
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)] group-hover:opacity-100"
title="Create task from message"
onClick={handleCreateTask}
>
<ListPlus size={14} />
</button>
)}
<p className="text-[10px] text-[var(--color-text-muted)]">{timestamp}</p>
</div>
</div>
</article>
);
}
const summaryText = message.summary || autoSummary || '';
return (
<article className="group rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<AgentAvatar name={message.from} />
<p className="truncate text-xs font-medium text-[var(--color-text)]">
{message.from}
{message.to && message.to !== message.from ? (
<span className="font-normal text-[var(--color-text-muted)]">
{' → '}
{message.to}
</span>
) : null}
</p>
{messageType ? (
<span className="rounded border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
{messageType}
</span>
) : null}
</div>
<div className="flex items-center gap-1.5">
<article
className="group overflow-hidden rounded-md"
style={{
backgroundColor: CARD_BG,
border: CARD_BORDER_STYLE,
borderLeft: `3px solid ${colors.border}`,
}}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
{message.source === 'lead_session' ? (
<Bot className="size-3.5 shrink-0" style={{ color: colors.border }} />
) : (
<MessageSquare className="size-3.5 shrink-0" style={{ color: colors.border }} />
)}
{/* Name badge */}
<span
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
style={{
backgroundColor: colors.badge,
color: colors.text,
border: `1px solid ${colors.border}40`,
}}
>
{message.from}
</span>
{/* Role */}
{formattedRole ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formattedRole}
</span>
) : null}
{/* Message type label */}
{messageType ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
{messageType}
</span>
) : null}
{/* Lead session marker */}
{message.source === 'lead_session' ? (
<span className="text-[10px] uppercase tracking-wide" style={{ color: CARD_ICON_MUTED }}>
session
</span>
) : null}
{/* Recipient */}
{message.to && message.to !== message.from ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
&rarr; {message.to}
</span>
) : null}
{/* Summary */}
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText}
</span>
{/* Timestamp + create task */}
<div className="flex shrink-0 items-center gap-1.5">
{onCreateTask && (
<button
type="button"
className="rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)] group-hover:opacity-100"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
title="Create task from message"
onClick={handleCreateTask}
>
<ListPlus size={14} />
</button>
)}
<p className="text-[10px] text-[var(--color-text-muted)]">{timestamp}</p>
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp}
</span>
</div>
</div>
{message.summary ? (
<p className="mb-1 text-xs font-medium text-[var(--color-text)]">{message.summary}</p>
) : autoSummary && autoSummary !== messageType ? (
<p className="mb-2 text-xs font-medium text-[var(--color-text)]">{autoSummary}</p>
) : null}
{structured ? (
<StructuredFallbackDisplay
parsed={structured}
autoSummary={autoSummary ?? 'Structured message'}
/>
) : (
<MarkdownViewer content={message.text} maxHeight="max-h-56" copyable className="mt-2" />
)}
{/* Content — always expanded */}
<div className="px-3 pb-3">
{structured ? (
<div className="space-y-2">
{autoSummary && autoSummary !== messageType ? (
<p className="text-xs text-[var(--color-text-secondary)]">{autoSummary}</p>
) : null}
<details className="rounded border border-[var(--color-border)] bg-[var(--color-surface)]">
<summary className="cursor-pointer px-2 py-1 text-[11px] text-[var(--color-text-muted)]">
Raw JSON
</summary>
<pre className="overflow-auto px-2 pb-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{JSON.stringify(structured, null, 2)}
</pre>
</details>
</div>
) : (
<MarkdownViewer content={message.text} maxHeight="max-h-56" copyable />
)}
</div>
</article>
);
};

View file

@ -1,34 +1,51 @@
import { ActivityItem } from './ActivityItem';
import type { InboxMessage } from '@shared/types';
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
interface ActivityTimelineProps {
messages: InboxMessage[];
members?: ResolvedTeamMember[];
onCreateTaskFromMessage?: (subject: string, description: string) => void;
}
export const ActivityTimeline = ({
messages,
members,
onCreateTaskFromMessage,
}: ActivityTimelineProps): React.JSX.Element => {
const memberInfo = new Map<string, { role?: string; color?: string }>();
if (members) {
for (const m of members) {
memberInfo.set(m.name, {
role: m.role ?? (m.agentType !== 'general-purpose' ? m.agentType : undefined),
color: m.color,
});
}
}
if (messages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
<p>Нет сообщений</p>
<p className="mt-1 text-[11px]">Отправьте сообщение участнику, чтобы увидеть активность.</p>
<p>No messages</p>
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
</div>
);
}
return (
<div className="space-y-2">
{messages.slice(0, 200).map((message) => (
<ActivityItem
key={`${message.messageId ?? 'no-id'}-${message.timestamp}-${message.from}`}
message={message}
onCreateTask={onCreateTaskFromMessage}
/>
))}
<div className="space-y-1">
{messages.slice(0, 200).map((message, index) => {
const info = memberInfo.get(message.from);
return (
<ActivityItem
key={`${message.messageId ?? index}-${message.timestamp}-${message.from}`}
message={message}
memberRole={info?.role}
memberColor={info?.color}
onCreateTask={onCreateTaskFromMessage}
/>
);
})}
</div>
);
};

View file

@ -1,137 +0,0 @@
import { useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { ChevronRight, MessageSquare } from 'lucide-react';
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
interface MessageComposerProps {
members: ResolvedTeamMember[];
sending: boolean;
sendError: string | null;
lastResult: SendMessageResult | null;
onSend: (member: string, text: string, summary?: string) => void;
}
const NO_MEMBER = '__none__';
export const MessageComposer = ({
members,
sending,
sendError,
lastResult,
onSend,
}: MessageComposerProps): React.JSX.Element => {
const [open, setOpen] = useState(false);
const [member, setMember] = useState('');
const [text, setText] = useState('');
const [summary, setSummary] = useState('');
const canSend = member.trim().length > 0 && text.trim().length > 0 && !sending;
return (
<div className="rounded-md border border-[var(--color-border-emphasis)] bg-[var(--color-surface-sidebar)]">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left"
onClick={() => setOpen((prev) => !prev)}
>
<MessageSquare size={14} className="shrink-0 text-[var(--color-text-muted)]" />
<span className="flex-1 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
Send Message
</span>
{lastResult ? (
<span className="text-[10px] text-green-300">
Sent ({lastResult.messageId.slice(0, 8)}...)
</span>
) : null}
<ChevronRight
size={14}
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${open ? 'rotate-90' : ''}`}
/>
</button>
{open ? (
<div className="border-t border-[var(--color-border)] px-3 pb-3 pt-2">
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="mc-recipient" className="text-xs text-[var(--color-text-muted)]">
Recipient
</Label>
<Select
value={member || NO_MEMBER}
onValueChange={(value) => setMember(value === NO_MEMBER ? '' : value)}
>
<SelectTrigger id="mc-recipient" className="h-8 text-xs" aria-label="Recipient">
<SelectValue placeholder="Select member..." />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
{members.map((item) => (
<SelectItem key={item.name} value={item.name}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="mc-summary" className="text-xs text-[var(--color-text-muted)]">
Summary (optional)
</Label>
<Input
id="mc-summary"
className="h-8 text-xs"
value={summary}
aria-label="Summary"
onChange={(event) => setSummary(event.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="mc-message" className="text-xs text-[var(--color-text-muted)]">
Message
</Label>
<Textarea
id="mc-message"
className="min-h-[80px] text-xs"
value={text}
aria-label="Message text"
onChange={(event) => setText(event.target.value)}
/>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
disabled={!canSend}
aria-label="Send message"
onClick={() => {
onSend(member.trim(), text, summary.trim() || undefined);
setText('');
setSummary('');
}}
>
{sending ? 'Sending...' : 'Send'}
</Button>
</div>
{sendError ? <p className="mt-2 text-[10px] text-red-300">{sendError}</p> : null}
</div>
) : null}
</div>
);
};

View file

@ -30,8 +30,15 @@ interface CreateTaskDialogProps {
tasks: TeamTask[];
defaultSubject?: string;
defaultDescription?: string;
defaultOwner?: string;
onClose: () => void;
onSubmit: (subject: string, description: string, owner?: string, blockedBy?: string[]) => void;
onSubmit: (
subject: string,
description: string,
owner?: string,
blockedBy?: string[],
prompt?: string
) => void;
submitting?: boolean;
}
@ -41,14 +48,28 @@ export const CreateTaskDialog = ({
tasks,
defaultSubject = '',
defaultDescription = '',
defaultOwner = '',
onClose,
onSubmit,
submitting = false,
}: CreateTaskDialogProps): React.JSX.Element => {
const [subject, setSubject] = useState(defaultSubject);
const [description, setDescription] = useState(defaultDescription);
const [owner, setOwner] = useState<string>('');
const [owner, setOwner] = useState<string>(defaultOwner);
const [blockedBy, setBlockedBy] = useState<string[]>([]);
const [prompt, setPrompt] = useState('');
const [prevOpen, setPrevOpen] = useState(false);
if (open && !prevOpen) {
setSubject(defaultSubject);
setDescription(defaultDescription);
setOwner(defaultOwner);
setBlockedBy([]);
setPrompt('');
}
if (open !== prevOpen) {
setPrevOpen(open);
}
const canSubmit = subject.trim().length > 0 && !submitting;
@ -67,12 +88,9 @@ export const CreateTaskDialog = ({
subject.trim(),
description.trim(),
owner || undefined,
blockedBy.length > 0 ? blockedBy : undefined
blockedBy.length > 0 ? blockedBy : undefined,
prompt.trim() || undefined
);
setSubject('');
setDescription('');
setOwner('');
setBlockedBy([]);
};
const handleOpenChange = (nextOpen: boolean): void => {
@ -85,18 +103,19 @@ export const CreateTaskDialog = ({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Создать задачу</DialogTitle>
<DialogTitle>Create Task</DialogTitle>
<DialogDescription>
Задача будет создана в директории tasks/ команды и появится на Kanban-доске.
The task will be created in the team&apos;s tasks/ directory and appear on the Kanban
board.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="task-subject">Название</Label>
<Label htmlFor="task-subject">Subject</Label>
<Input
id="task-subject"
placeholder="Что нужно сделать?"
placeholder="What needs to be done?"
value={subject}
autoFocus
onChange={(e) => setSubject(e.target.value)}
@ -107,10 +126,10 @@ export const CreateTaskDialog = ({
</div>
<div className="grid gap-2">
<Label htmlFor="task-description">Описание (опционально)</Label>
<Label htmlFor="task-description">Description (optional)</Label>
<Textarea
id="task-description"
placeholder="Детали задачи..."
placeholder="Task details..."
value={description}
rows={3}
onChange={(e) => setDescription(e.target.value)}
@ -118,12 +137,27 @@ export const CreateTaskDialog = ({
</div>
<div className="grid gap-2">
<Label>Исполнитель (опционально)</Label>
<Select value={owner} onValueChange={setOwner}>
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label>
<Textarea
id="task-prompt"
placeholder="Custom instructions for the team member..."
value={prompt}
rows={3}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label>Assignee (optional)</Label>
<Select
value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="Не назначен" />
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unassigned__">Unassigned</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.agentType);
return (
@ -139,7 +173,7 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label>Заблокирована задачами (опционально)</Label>
<Label>Blocked by tasks (optional)</Label>
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
{availableTasks.map((t) => {
const isSelected = blockedBy.includes(t.id);
@ -176,7 +210,7 @@ export const CreateTaskDialog = ({
</div>
{blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300">
Задача будет заблокирована: {blockedBy.map((id) => `#${id}`).join(', ')}
Task will be blocked by: {blockedBy.map((id) => `#${id}`).join(', ')}
</p>
) : null}
</div>
@ -185,10 +219,10 @@ export const CreateTaskDialog = ({
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Отмена
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? 'Создание...' : 'Создать'}
{submitting ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>

View file

@ -1,9 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { highlightLine } from '@renderer/components/chat/viewers/syntaxHighlighter';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Combobox } from '@renderer/components/ui/combobox';
import {
Dialog,
@ -23,29 +22,44 @@ import {
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { cn } from '@renderer/lib/utils';
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
import { STEP_LABELS, STEP_ORDER } from '../provisioningSteps';
const TEAM_COLOR_NAMES = [
'blue',
'green',
'red',
'yellow',
'purple',
'cyan',
'orange',
'pink',
] as const;
import type { ProvisioningStep } from '../provisioningSteps';
import type {
Project,
TeamCreateRequest,
TeamProvisioningMemberInput,
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
} from '@shared/types';
export interface TeamCopyData {
teamName: string;
description?: string;
color?: string;
members: TeamProvisioningMemberInput[];
}
interface CreateTeamDialogProps {
open: boolean;
canCreate: boolean;
provisioningError: string | null;
progress: TeamProvisioningProgress | null;
existingTeamNames: string[];
initialData?: TeamCopyData;
onClose: () => void;
onCreate: (request: TeamCreateRequest) => Promise<void>;
onCancelProvisioning: (runId: string) => Promise<void>;
onOpenTeam: (teamName: string) => void;
onOpenTeam: (teamName: string, projectPath?: string) => void;
}
interface ValidationResult {
@ -75,7 +89,7 @@ interface MemberDraft {
const DEV_DEFAULT_MEMBERS: Pick<MemberDraft, 'name' | 'roleSelection'>[] = [
{ name: 'alice', roleSelection: 'reviewer' },
{ name: 'bob', roleSelection: 'developer' },
{ name: 'carol', roleSelection: 'lead' },
{ name: 'carol', roleSelection: 'developer' },
];
function newDraftId(): string {
@ -148,46 +162,11 @@ function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
.filter((member): member is NonNullable<typeof member> => member !== null);
}
function renderCliLogLine(line: string, index: number): React.JSX.Element {
const trimmed = line.trim();
if (!trimmed) {
return (
<div key={index} className="h-2">
{'\n'}
</div>
);
}
let jsonLines: string[] | null = null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (typeof parsed === 'object' && parsed !== null) {
jsonLines = JSON.stringify(parsed, null, 2).split('\n');
}
} catch {
// Not JSON, render as plain text
}
if (jsonLines) {
return (
<div key={index}>
{jsonLines.map((jsonLine, lineIdx) => (
<div key={lineIdx} className="whitespace-pre font-mono">
{highlightLine(jsonLine, 'json')}
</div>
))}
</div>
);
}
return (
<div key={index} className="whitespace-pre-wrap break-all font-mono [overflow-wrap:anywhere]">
{trimmed}
</div>
);
}
function validateRequest(request: TeamCreateRequest): ValidationResult {
function validateRequest(
request: TeamCreateRequest,
options?: { requireCwd?: boolean }
): ValidationResult {
const requireCwd = options?.requireCwd ?? true;
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
return {
valid: false,
@ -196,7 +175,7 @@ function validateRequest(request: TeamCreateRequest): ValidationResult {
},
};
}
if (!request.cwd.trim()) {
if (requireCwd && !request.cwd.trim()) {
return {
valid: false,
errors: {
@ -220,6 +199,15 @@ function validateRequest(request: TeamCreateRequest): ValidationResult {
},
};
}
const memberNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
if (request.members.some((member) => !memberNamePattern.test(member.name.trim()))) {
return {
valid: false,
errors: {
members: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars',
},
};
}
const uniqueNames = new Set(request.members.map((member) => member.name.trim().toLowerCase()));
if (uniqueNames.size !== request.members.length) {
return {
@ -236,17 +224,17 @@ export const CreateTeamDialog = ({
open,
canCreate,
provisioningError,
progress,
existingTeamNames,
initialData,
onClose,
onCreate,
onCancelProvisioning,
onOpenTeam,
}: CreateTeamDialogProps): React.JSX.Element => {
const isDev = process.env.NODE_ENV !== 'production';
const [teamName, setTeamName] = useState('');
const [description, setDescription] = useState('');
const [prompt, setPrompt] = useState('');
const [members, setMembers] = useState<MemberDraft[]>([]);
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPath] = useState('');
@ -264,20 +252,29 @@ export const CreateTeamDialog = ({
cwd?: string;
}>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const autoOpenedRef = useRef(false);
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const resetFormState = (): void => {
setTeamName('');
setDescription('');
setPrompt('');
setMembers([]);
setTeamColor('');
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
setLocalError(null);
setFieldErrors({});
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
autoOpenedRef.current = false;
setLaunchTeam(true);
};
useEffect(() => {
if (!open || !canCreate) {
if (!open || !canCreate || !launchTeam) {
return;
}
@ -319,7 +316,7 @@ export const CreateTeamDialog = ({
return () => {
cancelled = true;
};
}, [open, canCreate]);
}, [open, canCreate, launchTeam]);
useEffect(() => {
if (!open) {
@ -356,7 +353,30 @@ export const CreateTeamDialog = ({
}, [open]);
useEffect(() => {
if (!open || members.length > 0) {
if (!open) {
return;
}
if (initialData) {
setTeamName(initialData.teamName);
setDescription(initialData.description ?? '');
setTeamColor(initialData.color ?? '');
setMembers(
initialData.members.map((m) => {
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = m.role != null && presetRoles.includes(m.role);
const isCustom = m.role != null && m.role.length > 0 && !isPreset;
return createMemberDraft({
name: m.name,
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
customRole: isCustom ? m.role : '',
});
})
);
return;
}
if (members.length > 0) {
return;
}
@ -373,10 +393,11 @@ export const CreateTeamDialog = ({
}
setMembers([createMemberDraft()]);
}, [isDev, members.length, open]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
}, [open]);
useEffect(() => {
if (!open || !isDev) {
if (!open || !isDev || initialData) {
return;
}
if (teamName.trim().length === 0) {
@ -385,7 +406,7 @@ export const CreateTeamDialog = ({
if (description.trim().length === 0) {
setDescription(DEV_DEFAULT_TEAM.description);
}
}, [open, isDev, teamName, description]);
}, [open, isDev, teamName, description, initialData]);
useEffect(() => {
if (cwdMode !== 'project') {
@ -403,71 +424,17 @@ export const CreateTeamDialog = ({
() => ({
teamName: teamName.trim(),
description: description.trim() || undefined,
color: teamColor || undefined,
members: buildMembers(members),
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
}),
[teamName, description, members, effectiveCwd]
[teamName, description, teamColor, members, effectiveCwd, prompt]
);
const activeError = localError ?? provisioningError;
const isReady = progress?.state === 'ready' || progress?.state === 'disconnected';
const isFailed = progress?.state === 'failed';
// Navigate as soon as team actually appears in the teams list (FileWatcher detected config.json)
const provisioningTeamName = progress?.teamName;
const teamExistsInList =
provisioningTeamName != null && existingTeamNames.includes(provisioningTeamName);
useEffect(() => {
if (!teamExistsInList || !provisioningTeamName) {
return;
}
if (autoOpenedRef.current) {
return;
}
autoOpenedRef.current = true;
onOpenTeam(provisioningTeamName);
onClose();
}, [teamExistsInList, provisioningTeamName, onOpenTeam, onClose]);
const canCancel =
progress?.state === 'spawning' ||
progress?.state === 'monitoring' ||
progress?.state === 'verifying';
const canOpenExistingTeam =
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
const progressStepIndex = progress ? STEP_ORDER.indexOf(progress.state as ProvisioningStep) : -1;
const failedStepIndex =
progress?.state === 'failed'
? Math.max(
0,
STEP_ORDER.findIndex((step) => progress.message.toLowerCase().includes(step))
)
: -1;
const cliLogsText = (() => {
const tail = progress?.cliLogsTail?.trim();
if (tail) {
return tail;
}
if (!progress) {
return '';
}
if (progress.state === 'ready' || progress.state === 'disconnected') {
const timedOut =
progress.warnings?.some((w) => w.toLowerCase().includes('timed out')) ?? false;
return timedOut
? 'No CLI output captured before timeout. The team appears ready based on created files.'
: 'No CLI output captured.';
}
if (progress.state === 'failed') {
return 'No CLI output captured.';
}
if (progress.state === 'cancelled') {
return 'Cancelled.';
}
return 'Waiting for CLI output...';
})();
const updateMemberName = (memberId: string, name: string): void => {
setMembers((prev) =>
@ -503,7 +470,12 @@ export const CreateTeamDialog = ({
};
const handleSubmit = (): void => {
const validation = validateRequest(request);
if (existingTeamNames.includes(request.teamName)) {
setFieldErrors({ teamName: 'Team name already exists' });
setLocalError('Check form fields');
return;
}
const validation = validateRequest(request, { requireCwd: launchTeam });
if (!validation.valid) {
setFieldErrors(validation.errors ?? {});
setLocalError('Check form fields');
@ -512,9 +484,37 @@ export const CreateTeamDialog = ({
setFieldErrors({});
setLocalError(null);
setIsSubmitting(true);
if (!launchTeam) {
void (async () => {
try {
await api.teams.createConfig({
teamName: request.teamName,
displayName: request.displayName,
description: request.description,
color: request.color,
members: request.members,
});
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();
onClose();
} catch (error) {
setLocalError(error instanceof Error ? error.message : 'Failed to create team config');
} finally {
setIsSubmitting(false);
}
})();
return;
}
void (async () => {
try {
await onCreate(request);
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();
onClose();
} catch {
// error is shown via provisioningError prop
} finally {
setIsSubmitting(false);
}
@ -533,13 +533,15 @@ export const CreateTeamDialog = ({
>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm">Create Team</DialogTitle>
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
<DialogDescription className="text-xs">
Team provisioning via local Claude CLI.
{initialData
? 'Create a new team based on an existing one.'
: 'Team provisioning via local Claude CLI.'}
</DialogDescription>
</DialogHeader>
{canCreate && prepareState === 'failed' ? (
{canCreate && launchTeam && prepareState === 'failed' ? (
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
{prepareWarnings.length > 0 ? (
@ -572,7 +574,9 @@ export const CreateTeamDialog = ({
onChange={(event) => setTeamName(event.target.value)}
placeholder="team-alpha"
/>
{fieldErrors.teamName ? (
{existingTeamNames.includes(teamName.trim()) ? (
<p className="text-[11px] text-red-300">Team name already exists</p>
) : fieldErrors.teamName ? (
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
) : null}
</div>
@ -591,6 +595,37 @@ export const CreateTeamDialog = ({
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">color (optional)</Label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = teamColor === colorName;
return (
<button
key={colorName}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)}
style={{
backgroundColor: colorSet.badge,
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setTeamColor(isSelected ? '' : colorName)}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
</div>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">members</Label>
<div className="space-y-2">
@ -662,174 +697,145 @@ export const CreateTeamDialog = ({
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant={cwdMode === 'project' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('project')}
>
From project list
</Button>
<Button
type="button"
variant={cwdMode === 'custom' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('custom')}
>
Custom path
</Button>
</div>
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={setSelectedProjectPath}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? (
<p className="text-[11px] text-red-300">{projectsError}</p>
) : null}
{!projectsLoading && projects.length === 0 ? (
<p className="text-[11px] text-amber-300">
No projects found, switch to custom path.
</p>
) : null}
</div>
) : (
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
className="h-8 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => setCustomCwd(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
setCustomCwd(paths[0]);
}
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
)}
</div>
{fieldErrors.cwd ? <p className="text-[11px] text-red-300">{fieldErrors.cwd}</p> : null}
<div className="flex items-center gap-2 md:col-span-2">
<Checkbox
id="launch-team"
checked={launchTeam}
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
/>
<Label
htmlFor="launch-team"
className="cursor-pointer text-xs text-[var(--color-text)]"
>
Launch team
</Label>
</div>
</div>
{progress ? (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3">
<p className="text-xs font-medium text-[var(--color-text)]">Provisioning Progress</p>
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{progress.message}</p>
<div className="mt-2 flex items-center gap-1 overflow-x-auto pb-1">
{STEP_ORDER.map((step, index) => {
const isFailedStep = progress.state === 'failed' && index === failedStepIndex;
const isDone =
progress.state === 'ready' ||
(progressStepIndex >= 0 && index < progressStepIndex) ||
(progress.state === 'failed' && index < failedStepIndex);
const isActive =
(progressStepIndex >= 0 && index === progressStepIndex) || isFailedStep;
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt for team lead (optional)
</Label>
<Textarea
id="team-prompt"
className="min-h-[40px] resize-none text-xs"
rows={3}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Instructions for the team lead during provisioning..."
/>
</div>
) : null}
return (
<div key={step} className="flex items-center gap-1">
<Badge
variant="secondary"
className={cn(
'whitespace-nowrap px-2 py-0.5 text-[11px] font-normal',
isFailedStep && 'border-red-400/60 bg-red-500/10 text-red-300',
!isFailedStep &&
isDone &&
'border-emerald-400/60 bg-emerald-500/10 text-emerald-200',
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant={cwdMode === 'project' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('project')}
>
From project list
</Button>
<Button
type="button"
variant={cwdMode === 'custom' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('custom')}
>
Custom path
</Button>
</div>
!isFailedStep &&
!isDone &&
isActive &&
'border-[var(--color-accent)]/70 bg-[var(--color-accent)]/15 text-[var(--color-text)]'
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={setSelectedProjectPath}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
>
<span className="mr-1 inline-flex size-4 items-center justify-center rounded-full border border-current text-[10px]">
{index + 1}
</span>
{STEP_LABELS[step]}
</Badge>
{index < STEP_ORDER.length - 1 ? (
<span className="text-[var(--color-text-muted)]">&rarr;</span>
/>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? (
<p className="text-[11px] text-red-300">{projectsError}</p>
) : null}
{!projectsLoading && projects.length === 0 ? (
<p className="text-[11px] text-amber-300">
No projects found, switch to custom path.
</p>
) : null}
</div>
);
})}
</div>
{progress.warnings?.length ? (
<div className="mt-2 space-y-1">
{progress.warnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
</div>
) : null}
<div className="mt-2">
<p className="mb-1 text-[11px] font-medium text-[var(--color-text)]">
Claude CLI logs
</p>
<div className="max-h-44 min-w-0 overflow-auto rounded border border-[var(--color-border)] bg-black/25 p-2 text-[11px] leading-relaxed text-[var(--color-text-muted)]">
{cliLogsText.split('\n').map((line, index) => renderCliLogLine(line, index))}
) : (
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
className="h-8 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => setCustomCwd(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
setCustomCwd(paths[0]);
}
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
)}
</div>
{fieldErrors.cwd ? (
<p className="text-[11px] text-red-300">{fieldErrors.cwd}</p>
) : null}
</div>
</div>
) : null}
) : null}
</div>
{activeError ? (
<p className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
@ -837,7 +843,7 @@ export const CreateTeamDialog = ({
</p>
) : null}
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
@ -851,7 +857,7 @@ export const CreateTeamDialog = ({
</div>
) : null}
{canCreate && prepareState === 'ready' ? (
{canCreate && launchTeam && prepareState === 'ready' ? (
<div className="flex items-center gap-2 text-xs text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>CLI environment ready</span>
@ -859,30 +865,6 @@ export const CreateTeamDialog = ({
) : null}
<DialogFooter className="gap-2 sm:gap-0">
{canCancel ? (
<Button
variant="outline"
size="sm"
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
onClick={() => {
void onCancelProvisioning(progress.runId);
}}
>
Cancel
</Button>
) : null}
{isReady ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onOpenTeam(progress.teamName);
onClose();
}}
>
Open Team
</Button>
) : null}
{canOpenExistingTeam ? (
<Button
variant="outline"
@ -900,16 +882,14 @@ export const CreateTeamDialog = ({
</Button>
<Button
size="sm"
disabled={!canCreate || isSubmitting || prepareState !== 'ready' || !!progress}
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
onClick={handleSubmit}
>
{isSubmitting || progress ? (
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Creating...
</>
) : isFailed ? (
'Retry'
) : (
'Create'
)}

View file

@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { cn } from '@renderer/lib/utils';
import { Loader2 } from 'lucide-react';
const TEAM_COLOR_NAMES = [
'blue',
'green',
'red',
'yellow',
'purple',
'cyan',
'orange',
'pink',
] as const;
interface EditTeamDialogProps {
open: boolean;
teamName: string;
currentName: string;
currentDescription: string;
currentColor: string;
onClose: () => void;
onSaved: () => void;
}
export const EditTeamDialog = ({
open,
teamName,
currentName,
currentDescription,
currentColor,
onClose,
onSaved,
}: EditTeamDialogProps): React.JSX.Element => {
const [name, setName] = useState(currentName);
const [description, setDescription] = useState(currentDescription);
const [color, setColor] = useState(currentColor);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setName(currentName);
setDescription(currentDescription);
setColor(currentColor);
setError(null);
}
}, [open, currentName, currentDescription, currentColor]);
const handleSave = (): void => {
if (!name.trim()) {
setError('Team name cannot be empty');
return;
}
setSaving(true);
setError(null);
void (async () => {
try {
await api.teams.updateConfig(teamName, {
name: name.trim(),
description: description.trim(),
color,
});
onSaved();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to save');
} finally {
setSaving(false);
}
})();
};
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Team</DialogTitle>
<DialogDescription>Change team name, description and color</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<label
htmlFor="edit-team-name"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
Name
</label>
<input
id="edit-team-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !saving && name.trim()) handleSave();
}}
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
placeholder="Team name"
/>
</div>
<div>
<label
htmlFor="edit-team-description"
className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"
>
Description
</label>
<textarea
id="edit-team-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full resize-none rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none focus:border-[var(--color-border-emphasis)]"
placeholder="Team description (optional)"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]">
Color (optional)
</label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = color === colorName;
return (
<button
key={colorName}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)}
style={{
backgroundColor: colorSet.badge,
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setColor(isSelected ? '' : colorName)}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
</div>
</div>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !name.trim()}>
{saving && <Loader2 size={14} className="mr-1.5 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,423 @@
import React, { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { Combobox } from '@renderer/components/ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { Textarea } from '@renderer/components/ui/textarea';
import { cn } from '@renderer/lib/utils';
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
import type { Project, TeamLaunchRequest, TeamProvisioningPrepareResult } from '@shared/types';
interface LaunchTeamDialogProps {
open: boolean;
teamName: string;
defaultProjectPath?: string;
provisioningError: string | null;
onClose: () => void;
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function renderHighlightedText(text: string, query: string): React.JSX.Element {
if (!query.trim()) {
return <span>{text}</span>;
}
const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig');
const parts = text.split(pattern);
return (
<span>
{parts.map((part, index) => {
const isMatch = part.toLowerCase() === query.toLowerCase();
if (!isMatch) {
return <span key={`${part}-${index}`}>{part}</span>;
}
return (
<mark
key={`${part}-${index}`}
// eslint-disable-next-line tailwindcss/no-custom-classname -- Tailwind arbitrary value with CSS variable
className="bg-[var(--color-accent)]/25 rounded px-0.5 text-[var(--color-text)]"
>
{part}
</mark>
);
})}
</span>
);
}
export const LaunchTeamDialog = ({
open,
teamName,
defaultProjectPath,
provisioningError,
onClose,
onLaunch,
}: LaunchTeamDialogProps): React.JSX.Element => {
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
const [selectedProjectPath, setSelectedProjectPath] = useState('');
const [customCwd, setCustomCwd] = useState('');
const [prompt, setPrompt] = useState('');
const [projects, setProjects] = useState<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const resetFormState = (): void => {
setLocalError(null);
setIsSubmitting(false);
setPrepareState('idle');
setPrepareMessage(null);
setPrepareWarnings([]);
setPrompt('');
setCwdMode('project');
setSelectedProjectPath('');
setCustomCwd('');
};
// Warm up CLI on open
useEffect(() => {
if (!open) {
return;
}
if (typeof api.teams.prepareProvisioning !== 'function') {
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareMessage(
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
);
return;
}
let cancelled = false;
setPrepareState('loading');
setPrepareMessage('Warming up CLI environment...');
setPrepareWarnings([]);
void (async () => {
try {
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
if (cancelled) {
return;
}
setPrepareState(prepResult.ready ? 'ready' : 'failed');
setPrepareMessage(prepResult.message);
setPrepareWarnings(prepResult.warnings ?? []);
} catch (error) {
if (cancelled) {
return;
}
setPrepareState('failed');
setPrepareWarnings([]);
setPrepareMessage(
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
);
}
})();
return () => {
cancelled = true;
};
}, [open]);
// Fetch projects on open
useEffect(() => {
if (!open) {
return;
}
setProjectsLoading(true);
setProjectsError(null);
let cancelled = false;
void (async () => {
try {
const nextProjects = await api.getProjects();
if (cancelled) {
return;
}
setProjects(nextProjects);
} catch (error) {
if (cancelled) {
return;
}
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
setProjects([]);
} finally {
if (!cancelled) {
setProjectsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [open]);
// Pre-select defaultProjectPath when projects loaded
useEffect(() => {
if (cwdMode !== 'project') {
return;
}
if (selectedProjectPath || projects.length === 0) {
return;
}
if (defaultProjectPath) {
const match = projects.find((p) => p.path === defaultProjectPath);
if (match) {
setSelectedProjectPath(match.path);
return;
}
}
setSelectedProjectPath(projects[0].path);
}, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const activeError = localError ?? provisioningError;
const handleSubmit = (): void => {
if (!effectiveCwd) {
setLocalError('Select working directory (cwd)');
return;
}
setLocalError(null);
setIsSubmitting(true);
void (async () => {
try {
await onLaunch({
teamName,
cwd: effectiveCwd,
prompt: prompt.trim() || undefined,
});
resetFormState();
onClose();
} catch {
// error is shown via provisioningError prop
} finally {
setIsSubmitting(false);
}
})();
};
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
resetFormState();
onClose();
}
}}
>
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm">Launch Team</DialogTitle>
<DialogDescription className="text-xs">
Start team <span className="font-mono font-medium">{teamName}</span> via local Claude
CLI.
</DialogDescription>
</DialogHeader>
{prepareState === 'failed' ? (
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
{prepareWarnings.length > 0 ? (
<div className="mt-1 space-y-1">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
{warning}
</p>
))}
</div>
) : null}
</div>
) : null}
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant={cwdMode === 'project' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('project')}
>
From project list
</Button>
<Button
type="button"
variant={cwdMode === 'custom' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('custom')}
>
Custom path
</Button>
</div>
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={setSelectedProjectPath}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? (
<p className="text-[11px] text-red-300">{projectsError}</p>
) : null}
{!projectsLoading && projects.length === 0 ? (
<p className="text-[11px] text-amber-300">
No projects found, switch to custom path.
</p>
) : null}
</div>
) : (
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
className="h-8 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => setCustomCwd(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
setCustomCwd(paths[0]);
}
})();
}}
>
Browse
</Button>
</div>
</div>
)}
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="launch-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt (optional)
</Label>
<Textarea
id="launch-prompt"
className="min-h-[100px] resize-y text-xs"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="Instructions for team lead..."
/>
</div>
</div>
{activeError ? (
<p className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
{activeError}
</p>
) : null}
{prepareState === 'idle' || prepareState === 'loading' ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
</div>
) : null}
{prepareState === 'ready' ? (
<div className="flex items-center gap-2 text-xs text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>CLI environment ready</span>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
disabled={isSubmitting || prepareState !== 'ready'}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
Launching...
</>
) : (
'Launch'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,157 @@
import { useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Textarea } from '@renderer/components/ui/textarea';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
interface SendMessageDialogProps {
open: boolean;
members: ResolvedTeamMember[];
defaultRecipient?: string;
sending: boolean;
sendError: string | null;
lastResult: SendMessageResult | null;
onSend: (member: string, text: string, summary?: string) => void;
onClose: () => void;
}
const NO_MEMBER = '__none__';
export const SendMessageDialog = ({
open,
members,
defaultRecipient,
sending,
sendError,
lastResult,
onSend,
onClose,
}: SendMessageDialogProps): React.JSX.Element => {
const [member, setMember] = useState('');
const [text, setText] = useState('');
const [summary, setSummary] = useState('');
const [prevOpen, setPrevOpen] = useState(false);
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null);
// Reset form when dialog opens
if (open && !prevOpen) {
setMember(defaultRecipient ?? '');
setText('');
setSummary('');
setPrevResult(lastResult);
}
if (open !== prevOpen) {
setPrevOpen(open);
}
// Auto-close on successful send (lastResult changed while dialog is open)
useEffect(() => {
if (open && lastResult && lastResult !== prevResult) {
setMember('');
setText('');
setSummary('');
onClose();
}
}, [open, lastResult, prevResult, onClose]);
const canSend = member.trim().length > 0 && text.trim().length > 0 && !sending;
const handleSubmit = (): void => {
if (!canSend) return;
onSend(member.trim(), text.trim(), summary.trim() || undefined);
};
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
onClose();
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Send Message</DialogTitle>
<DialogDescription>Send a direct message to a team member.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="smd-recipient">Recipient</Label>
<Select
value={member || NO_MEMBER}
onValueChange={(v) => setMember(v === NO_MEMBER ? '' : v)}
>
<SelectTrigger id="smd-recipient">
<SelectValue placeholder="Select member..." />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_MEMBER}>Select member...</SelectItem>
{members.map((m) => {
const role = formatAgentRole(m.agentType);
return (
<SelectItem key={m.name} value={m.name}>
{m.name}
{role ? ` (${role})` : ''}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="smd-summary">Summary (optional)</Label>
<Input
id="smd-summary"
placeholder="Brief description shown as preview..."
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="smd-message">Message</Label>
<Textarea
id="smd-message"
placeholder="Write your message..."
value={text}
rows={4}
onChange={(e) => setText(e.target.value)}
/>
</div>
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={sending}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSend}>
{sending ? 'Sending...' : 'Send'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -1,11 +1,25 @@
import { useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { cn } from '@renderer/lib/utils';
import { Columns3, LayoutGrid } from 'lucide-react';
import { KanbanColumn } from './KanbanColumn';
import { KanbanFilterPopover } from './KanbanFilterPopover';
import { KanbanTaskCard } from './KanbanTaskCard';
import type { KanbanColumnId, KanbanState, TeamTask } from '@shared/types';
import type { KanbanFilterState } from './KanbanFilterPopover';
import type { Session } from '@renderer/types/data';
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
interface KanbanBoardProps {
tasks: TeamTask[];
kanbanState: KanbanState;
filter: KanbanFilterState;
sessions: Session[];
leadSessionId?: string;
members: ResolvedTeamMember[];
onFilterChange: (filter: KanbanFilterState) => void;
onRequestReview: (taskId: string) => void;
onApprove: (taskId: string) => void;
onRequestChanges: (taskId: string) => void;
@ -14,6 +28,8 @@ interface KanbanBoardProps {
onScrollToTask?: (taskId: string) => void;
}
type KanbanViewMode = 'grid' | 'columns';
const COLUMNS: { id: KanbanColumnId; title: string }[] = [
{ id: 'todo', title: 'TODO' },
{ id: 'in_progress', title: 'IN PROGRESS' },
@ -43,6 +59,11 @@ function getTaskColumn(task: TeamTask, kanbanState: KanbanState): KanbanColumnId
export const KanbanBoard = ({
tasks,
kanbanState,
filter,
sessions,
leadSessionId,
members,
onFilterChange,
onRequestReview,
onApprove,
onRequestChanges,
@ -50,50 +71,122 @@ export const KanbanBoard = ({
onCompleteTask,
onScrollToTask,
}: KanbanBoardProps): React.JSX.Element => {
const taskMap = new Map(tasks.map((t) => [t.id, t]));
const grouped = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
);
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
for (const task of tasks) {
const column = getTaskColumn(task, kanbanState);
if (!column) {
continue;
const taskMap = useMemo(() => new Map(tasks.map((t) => [t.id, t])), [tasks]);
const grouped = useMemo(() => {
const result = new Map<KanbanColumnId, TeamTask[]>(
COLUMNS.map(({ id }) => [id, [] as TeamTask[]])
);
for (const task of tasks) {
const column = getTaskColumn(task, kanbanState);
if (!column) {
continue;
}
result.get(column)?.push(task);
}
grouped.get(column)?.push(task);
}
return result;
}, [tasks, kanbanState]);
const renderCards = (columnId: KanbanColumnId, columnTasks: TeamTask[]): React.JSX.Element => {
if (columnTasks.length === 0) {
return (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
);
}
return (
<>
{columnTasks.map((task) => (
<KanbanTaskCard
key={task.id}
task={task}
columnId={columnId}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
taskMap={taskMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onCompleteTask={onCompleteTask}
onScrollToTask={onScrollToTask}
/>
))}
</>
);
};
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? [];
return (
<KanbanColumn key={column.id} title={column.title} count={columnTasks.length}>
{columnTasks.length === 0 ? (
<div className="rounded-md border border-dashed border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
No tasks
</div>
) : (
columnTasks.map((task) => (
<KanbanTaskCard
key={task.id}
task={task}
columnId={column.id}
kanbanTaskState={kanbanState.tasks[task.id]}
hasReviewers={kanbanState.reviewers.length > 0}
taskMap={taskMap}
onRequestReview={onRequestReview}
onApprove={onApprove}
onRequestChanges={onRequestChanges}
onMoveBackToDone={onMoveBackToDone}
onCompleteTask={onCompleteTask}
onScrollToTask={onScrollToTask}
/>
))
<div>
<div className="mb-2 flex items-center justify-end gap-2">
<KanbanFilterPopover
filter={filter}
sessions={sessions}
leadSessionId={leadSessionId}
members={members}
onFilterChange={onFilterChange}
/>
<div className="inline-flex rounded-md border border-[var(--color-border)]">
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-r-none px-2',
viewMode === 'grid'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
</KanbanColumn>
);
})}
onClick={() => setViewMode('grid')}
aria-label="Grid view"
title="Grid"
>
<LayoutGrid size={14} />
</Button>
<Button
variant="ghost"
size="sm"
className={cn(
'h-7 rounded-l-none border-l border-[var(--color-border)] px-2',
viewMode === 'columns'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
onClick={() => setViewMode('columns')}
aria-label="Columns view"
title="Columns"
>
<Columns3 size={14} />
</Button>
</div>
</div>
{viewMode === 'grid' ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
{COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? [];
return (
<KanbanColumn key={column.id} title={column.title} count={columnTasks.length}>
{renderCards(column.id, columnTasks)}
</KanbanColumn>
);
})}
</div>
) : (
<div className="flex gap-3 overflow-x-auto pb-2">
{COLUMNS.map((column) => {
const columnTasks = grouped.get(column.id) ?? [];
return (
<div key={column.id} className="w-64 shrink-0">
<KanbanColumn title={column.title} count={columnTasks.length}>
{renderCards(column.id, columnTasks)}
</KanbanColumn>
</div>
);
})}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,160 @@
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Crown, Filter } from 'lucide-react';
import type { Session } from '@renderer/types/data';
import type { ResolvedTeamMember } from '@shared/types';
export const UNASSIGNED_OWNER = '__unassigned__';
export interface KanbanFilterState {
sessionId: string | null;
selectedOwners: Set<string>;
}
interface KanbanFilterPopoverProps {
filter: KanbanFilterState;
sessions: Session[];
leadSessionId?: string;
members: ResolvedTeamMember[];
onFilterChange: (filter: KanbanFilterState) => void;
}
export const KanbanFilterPopover = ({
filter,
sessions,
leadSessionId,
members,
onFilterChange,
}: KanbanFilterPopoverProps): React.JSX.Element => {
const activeCount = useMemo(() => {
let count = 0;
if (filter.sessionId !== null) count += 1;
if (filter.selectedOwners.size > 0) count += 1;
return count;
}, [filter.sessionId, filter.selectedOwners]);
const handleSessionSelect = (sessionId: string | null): void => {
onFilterChange({ ...filter, sessionId });
};
const handleOwnerToggle = (ownerKey: string): void => {
const next = new Set(filter.selectedOwners);
if (next.has(ownerKey)) {
next.delete(ownerKey);
} else {
next.add(ownerKey);
}
onFilterChange({ ...filter, selectedOwners: next });
};
const handleClearAll = (): void => {
onFilterChange({ sessionId: null, selectedOwners: new Set() });
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-7 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter tasks"
title="Filter"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 p-0">
{/* Session section */}
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Session
</p>
<div className="max-h-40 space-y-0.5 overflow-y-auto">
<button
type="button"
className={`w-full rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
filter.sessionId === null
? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => handleSessionSelect(null)}
>
All sessions
</button>
{sessions.map((session) => {
const isLead = session.id === leadSessionId;
const isSelected = filter.sessionId === session.id;
const label = session.firstMessage?.slice(0, 50) ?? session.id.slice(0, 8);
return (
<button
key={session.id}
type="button"
className={`flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => handleSessionSelect(isSelected ? null : session.id)}
>
{isLead && <Crown size={11} className="shrink-0 text-blue-400" />}
<span className="truncate">{label}</span>
</button>
);
})}
</div>
</div>
{/* Teammate section */}
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Teammate
</p>
<div className="space-y-1.5">
{members.map((member) => (
<label
key={member.name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox
checked={filter.selectedOwners.has(member.name)}
onCheckedChange={() => handleOwnerToggle(member.name)}
/>
{member.name}
</label>
))}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs italic text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={filter.selectedOwners.has(UNASSIGNED_OWNER)}
onCheckedChange={() => handleOwnerToggle(UNASSIGNED_OWNER)}
/>
(unassigned)
</label>
</div>
</div>
{/* Footer */}
<div className="flex justify-end p-2">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={activeCount === 0}
onClick={handleClearAll}
>
Clear all
</Button>
</div>
</PopoverContent>
</Popover>
);
};

View file

@ -1,38 +1,40 @@
import { Badge } from '@renderer/components/ui/badge';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberCardProps {
member: ResolvedTeamMember;
isTeamAlive?: boolean;
onClick?: () => void;
}
const statusDotColor: Record<string, string> = {
active: 'bg-emerald-400',
idle: 'bg-emerald-400/50',
terminated: 'bg-zinc-500',
unknown: 'bg-zinc-600',
};
export const MemberCard = ({ member }: MemberCardProps): React.JSX.Element => {
const dotClass =
member.status === 'terminated'
? statusDotColor.terminated
: member.currentTaskId
? statusDotColor.active
: statusDotColor.idle;
const avatarUrl = `https://robohash.org/${encodeURIComponent(member.name)}?size=64x64`;
const presenceLabel =
member.status === 'terminated' ? 'terminated' : member.currentTaskId ? 'working' : 'idle';
export const MemberCard = ({
member,
isTeamAlive,
onClick,
}: MemberCardProps): React.JSX.Element => {
const dotClass = getMemberDotClass(member, isTeamAlive);
const presenceLabel = getPresenceLabel(member, isTeamAlive);
return (
<div
className="group flex items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]"
title={member.currentTaskId ? `Текущая задача: ${member.currentTaskId}` : undefined}
className="group flex cursor-pointer items-center gap-2.5 rounded px-2 py-1.5 hover:bg-[var(--color-surface-raised)]"
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.();
}
}}
>
<div className="relative shrink-0">
<img
src={avatarUrl}
src={agentAvatarUrl(member.name)}
alt={member.name}
className="size-7 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
@ -45,15 +47,18 @@ export const MemberCard = ({ member }: MemberCardProps): React.JSX.Element => {
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
{member.name}
</span>
{formatAgentRole(member.agentType) && (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{formatAgentRole(member.agentType)}
</span>
)}
{(() => {
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
return roleLabel ? (
<span className="hidden shrink-0 text-xs text-[var(--color-text-muted)] sm:inline">
{roleLabel}
</span>
) : null;
})()}
<Badge
variant="secondary"
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
title={member.currentTaskId ? `Текущая задача: ${member.currentTaskId}` : undefined}
title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined}
>
{presenceLabel}
</Badge>

View file

@ -0,0 +1,129 @@
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs';
import { BarChart3, FileText, ListPlus, MessageSquare } from 'lucide-react';
import { MemberDetailHeader } from './MemberDetailHeader';
import { MemberDetailStats } from './MemberDetailStats';
import { MemberLogsTab } from './MemberLogsTab';
import { MemberMessagesTab } from './MemberMessagesTab';
import { MemberStatsTab } from './MemberStatsTab';
import { MemberTasksTab } from './MemberTasksTab';
import type { InboxMessage, ResolvedTeamMember, TeamTask } from '@shared/types';
interface MemberDetailDialogProps {
open: boolean;
member: ResolvedTeamMember | null;
teamName: string;
tasks: TeamTask[];
messages: InboxMessage[];
onClose: () => void;
onSendMessage: () => void;
onAssignTask: () => void;
}
export const MemberDetailDialog = ({
open,
member,
teamName,
tasks,
messages,
onClose,
onSendMessage,
onAssignTask,
}: MemberDetailDialogProps): React.JSX.Element | null => {
const memberTasks = useMemo(
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
[tasks, member]
);
const memberMessages = useMemo(
() => (member ? messages.filter((m) => m.from === member.name || m.to === member.name) : []),
[messages, member]
);
const inProgressTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'in_progress').length,
[memberTasks]
);
const completedTasks = useMemo(
() => memberTasks.filter((t) => t.status === 'completed').length,
[memberTasks]
);
if (!member) return null;
return (
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
<DialogContent className="sm:max-w-screen-sm">
<DialogHeader>
<MemberDetailHeader member={member} />
</DialogHeader>
<MemberDetailStats
totalTasks={memberTasks.length}
inProgressTasks={inProgressTasks}
completedTasks={completedTasks}
messageCount={memberMessages.length}
lastActiveAt={member.lastActiveAt}
/>
<Tabs defaultValue="tasks">
<TabsList className="w-full">
<TabsTrigger value="tasks" className="flex-1 gap-1.5">
Tasks
{memberTasks.length > 0 && (
<span className="rounded-full bg-[var(--color-surface)] px-1.5 text-[10px]">
{memberTasks.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="messages" className="flex-1 gap-1.5">
Messages
{memberMessages.length > 0 && (
<span className="rounded-full bg-[var(--color-surface)] px-1.5 text-[10px]">
{memberMessages.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="stats" className="flex-1 gap-1.5">
<BarChart3 size={12} />
Stats
</TabsTrigger>
<TabsTrigger value="logs" className="flex-1 gap-1.5">
<FileText size={12} />
Logs
</TabsTrigger>
</TabsList>
<TabsContent value="tasks">
<MemberTasksTab tasks={memberTasks} />
</TabsContent>
<TabsContent value="messages">
<MemberMessagesTab messages={memberMessages} />
</TabsContent>
<TabsContent value="stats">
<MemberStatsTab teamName={teamName} memberName={member.name} />
</TabsContent>
<TabsContent value="logs">
<MemberLogsTab teamName={teamName} memberName={member.name} />
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
<MessageSquare size={14} />
Send Message
</Button>
<Button variant="outline" size="sm" className="gap-1.5" onClick={onAssignTask}>
<ListPlus size={14} />
Assign Task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,45 @@
import { Badge } from '@renderer/components/ui/badge';
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
import type { ResolvedTeamMember } from '@shared/types';
interface MemberDetailHeaderProps {
member: ResolvedTeamMember;
}
export const MemberDetailHeader = ({ member }: MemberDetailHeaderProps): React.JSX.Element => {
const role = member.role || formatAgentRole(member.agentType);
const presenceLabel = getPresenceLabel(member);
const dotClass = getMemberDotClass(member);
return (
<div className="flex items-center gap-3">
<div className="relative shrink-0">
<img
src={agentAvatarUrl(member.name, 96)}
alt={member.name}
className="size-12 rounded-full bg-[var(--color-surface-raised)]"
loading="lazy"
/>
<span
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
aria-label={member.status}
/>
</div>
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">{member.name}</DialogTitle>
<DialogDescription className="mt-1 flex items-center gap-2">
{role && <span>{role}</span>}
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
{presenceLabel}
</Badge>
</DialogDescription>
</div>
</div>
);
};

View file

@ -0,0 +1,50 @@
import { formatDistanceToNow } from 'date-fns';
interface MemberDetailStatsProps {
totalTasks: number;
inProgressTasks: number;
completedTasks: number;
messageCount: number;
lastActiveAt: string | null;
}
const StatBlock = ({
label,
value,
sub,
}: {
label: string;
value: string | number;
sub?: string;
}): React.JSX.Element => (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
<p className="text-lg font-semibold text-[var(--color-text)]">{value}</p>
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p>
{sub && <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">{sub}</p>}
</div>
);
export const MemberDetailStats = ({
totalTasks,
inProgressTasks,
completedTasks,
messageCount,
lastActiveAt,
}: MemberDetailStatsProps): React.JSX.Element => {
const lastActive = lastActiveAt
? formatDistanceToNow(new Date(lastActiveAt), { addSuffix: true })
: '—';
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<StatBlock
label="Tasks"
value={totalTasks}
sub={inProgressTasks > 0 ? `in progress: ${inProgressTasks}` : undefined}
/>
<StatBlock label="Completed" value={completedTasks} />
<StatBlock label="Messages" value={messageCount} />
<StatBlock label="Activity" value={lastActive} />
</div>
);
};

View file

@ -0,0 +1,185 @@
import { useMemo, useState } from 'react';
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
import { LastOutputDisplay } from '@renderer/components/chat/LastOutputDisplay';
import { SystemChatGroup } from '@renderer/components/chat/SystemChatGroup';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import { format } from 'date-fns';
import { Bot, ChevronDown } from 'lucide-react';
import type { EnhancedChunk } from '@renderer/types/data';
import type { AIGroup, UserGroup } from '@renderer/types/groups';
interface MemberExecutionLogProps {
chunks: EnhancedChunk[];
memberName?: string;
}
type ExpandedItemIdsByGroup = Map<string, Set<string>>;
export const MemberExecutionLog = ({
chunks,
memberName,
}: MemberExecutionLogProps): React.JSX.Element => {
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
// Store collapsed groups instead of expanded: by default, everything is expanded.
// This avoids resetting state in an effect when conversation changes.
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
const [expandedItemIdsByGroup, setExpandedItemIdsByGroup] = useState<ExpandedItemIdsByGroup>(
new Map()
);
if (!conversation.items.length) {
return (
<div className="py-6 text-center text-xs text-[var(--color-text-muted)]">
Nothing to display
</div>
);
}
return (
<div className="space-y-6">
{conversation.items.map((item) => {
if (item.type === 'system') {
return <SystemChatGroup key={item.group.id} systemGroup={item.group} />;
}
if (item.type === 'user') {
return <UserLogItem key={item.group.id} group={item.group} />;
}
if (item.type === 'ai') {
return (
<AIExecutionGroup
key={item.group.id}
group={item.group}
memberName={memberName}
expanded={!collapsedGroupIds.has(item.group.id)}
expandedItemIds={expandedItemIdsByGroup.get(item.group.id) ?? new Set()}
onToggleExpanded={() => {
setCollapsedGroupIds((prev) => {
const next = new Set(prev);
if (next.has(item.group.id)) next.delete(item.group.id);
else next.add(item.group.id);
return next;
});
}}
onToggleItem={(itemId) => {
setExpandedItemIdsByGroup((prev) => {
const next = new Map(prev);
const current = new Set(next.get(item.group.id) ?? []);
if (current.has(itemId)) current.delete(itemId);
else current.add(itemId);
next.set(item.group.id, current);
return next;
});
}}
/>
);
}
if (item.type === 'compact') {
// Compact boundaries are useful in full session view but noisy here
return null;
}
return null;
})}
</div>
);
};
const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
const text = group.content.rawText ?? group.content.text ?? '';
if (!text.trim()) {
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--color-border)] bg-[var(--chat-user-bg)] px-4 py-3">
<div className="text-[10px] text-[var(--color-text-muted)]">
{format(group.timestamp, 'h:mm:ss a')}
</div>
<div className="mt-1 text-xs text-[var(--color-text-muted)]">(empty)</div>
</div>
</div>
);
}
return (
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--chat-user-border)] bg-[var(--chat-user-bg)] px-4 py-3">
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
{format(group.timestamp, 'h:mm:ss a')}
</div>
<div className="mt-2 text-sm text-[var(--chat-user-text)]">
<MarkdownViewer content={text} copyable />
</div>
</div>
</div>
);
};
interface AIExecutionGroupProps {
group: AIGroup;
memberName?: string;
expanded: boolean;
expandedItemIds: Set<string>;
onToggleExpanded: () => void;
onToggleItem: (itemId: string) => void;
}
const AIExecutionGroup = ({
group,
memberName,
expanded,
expandedItemIds,
onToggleExpanded,
onToggleItem,
}: AIExecutionGroupProps): React.JSX.Element => {
const enhanced = useMemo(() => {
if (!memberName) {
return enhanceAIGroup(group);
}
const normalized = memberName.trim().toLowerCase();
const filteredProcesses = group.processes.filter(
(p) => p.team?.memberName?.toLowerCase() === normalized
);
return enhanceAIGroup({ ...group, processes: filteredProcesses });
}, [group, memberName]);
const hasToggleContent = enhanced.displayItems.length > 0;
return (
<div className="space-y-3 border-l-2 pl-3" style={{ borderColor: 'var(--chat-ai-border)' }}>
{hasToggleContent ? (
<button
type="button"
className="flex w-full items-center gap-2 text-left"
onClick={onToggleExpanded}
aria-expanded={expanded}
>
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
Claude
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
{enhanced.itemsSummary}
</span>
<ChevronDown
className={`size-3.5 shrink-0 text-[var(--color-text-muted)] transition-transform ${expanded ? 'rotate-180' : ''}`}
/>
</button>
) : null}
{hasToggleContent && expanded ? (
<div className="py-1 pl-2">
<DisplayItemList
items={enhanced.displayItems}
onItemClick={onToggleItem}
expandedItemIds={expandedItemIds}
aiGroupId={group.id}
/>
</div>
) : null}
<LastOutputDisplay lastOutput={enhanced.lastOutput} aiGroupId={group.id} />
</div>
);
};

View file

@ -4,13 +4,19 @@ import type { ResolvedTeamMember } from '@shared/types';
interface MemberListProps {
members: ResolvedTeamMember[];
isTeamAlive?: boolean;
onMemberClick?: (member: ResolvedTeamMember) => void;
}
export const MemberList = ({ members }: MemberListProps): React.JSX.Element => {
export const MemberList = ({
members,
isTeamAlive,
onMemberClick,
}: MemberListProps): React.JSX.Element => {
if (members.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Участники не найдены
No members found
</div>
);
}
@ -18,7 +24,12 @@ export const MemberList = ({ members }: MemberListProps): React.JSX.Element => {
return (
<div className="flex flex-col gap-0.5">
{members.map((member) => (
<MemberCard key={member.name} member={member} />
<MemberCard
key={member.name}
member={member}
isTeamAlive={isTeamAlive}
onClick={() => onMemberClick?.(member)}
/>
))}
</div>
);

View file

@ -0,0 +1,243 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { formatDuration } from '@renderer/utils/formatters';
import {
AlertCircle,
ChevronDown,
ChevronRight,
Clock,
FileText,
Loader2,
MessageSquare,
} from 'lucide-react';
import type { EnhancedChunk } from '@renderer/types/data';
import type { MemberLogSummary } from '@shared/types';
interface MemberLogsTabProps {
teamName: string;
memberName: string;
}
export const MemberLogsTab = ({ teamName, memberName }: MemberLogsTabProps): React.JSX.Element => {
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailChunks, setDetailChunks] = useState<EnhancedChunk[] | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const result = await api.teams.getMemberLogs(teamName, memberName);
if (!cancelled) {
setLogs(result);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unknown error');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [teamName, memberName]);
const handleExpand = useCallback(
async (log: MemberLogSummary) => {
const rowId =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`;
if (expandedId === rowId) {
setExpandedId(null);
setDetailChunks(null);
return;
}
setExpandedId(rowId);
setDetailChunks(null);
setDetailLoading(true);
try {
if (log.kind === 'subagent') {
const d = await api.getSubagentDetail(log.projectId, log.sessionId, log.subagentId);
setDetailChunks(d?.chunks ?? null);
} else {
const d = await api.getSessionDetail(log.projectId, log.sessionId);
setDetailChunks((d?.chunks ?? null) as unknown as EnhancedChunk[] | null);
}
} catch {
setDetailChunks(null);
} finally {
setDetailLoading(false);
}
},
[expandedId]
);
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Searching logs...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
);
}
if (logs.length === 0) {
return (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<FileText size={20} className="mx-auto mb-2 opacity-40" />
No logs found
<p className="mt-1 text-[10px] opacity-60">
This member has no recorded session activity yet
</p>
</div>
);
}
return (
<div className="max-h-[400px] space-y-1.5 overflow-y-auto pr-1">
{logs.map((log) => (
<LogCard
key={
log.kind === 'subagent' ? `${log.sessionId}-${log.subagentId}` : `lead-${log.sessionId}`
}
log={log}
expanded={
expandedId ===
(log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`)
}
detailChunks={
expandedId ===
(log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`)
? detailChunks
: null
}
detailLoading={
expandedId ===
(log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
: `lead:${log.sessionId}`) && detailLoading
}
onToggle={() => void handleExpand(log)}
/>
))}
</div>
);
};
interface LogCardProps {
log: MemberLogSummary;
expanded: boolean;
detailChunks: EnhancedChunk[] | null;
detailLoading: boolean;
onToggle: () => void;
}
const LogCard = ({
log,
expanded,
detailChunks,
detailLoading,
onToggle,
}: LogCardProps): React.JSX.Element => {
const timeAgo = formatRelativeTime(log.startTime);
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)]"
onClick={onToggle}
>
{expanded ? (
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
) : (
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--color-text)]">{log.description}</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<Clock size={10} />
{timeAgo}
</span>
{log.durationMs > 0 && <span>{formatDuration(log.durationMs)}</span>}
<span className="flex items-center gap-1">
<MessageSquare size={10} />
{log.messageCount}
</span>
{log.isOngoing && (
<span className="rounded-full bg-green-500/20 px-1.5 text-green-400">active</span>
)}
</div>
</div>
</button>
{expanded && (
<div className="border-t border-[var(--color-border)] px-3 py-2">
{detailLoading && (
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading details...
</div>
)}
{!detailLoading && !detailChunks && (
<div className="py-4 text-xs text-[var(--color-text-muted)]">
Failed to load details
</div>
)}
{!detailLoading && detailChunks && (
<div className="max-h-[360px] overflow-y-auto pr-1">
<MemberExecutionLog
chunks={detailChunks}
memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined}
/>
</div>
)}
</div>
)}
</div>
);
};
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const now = Date.now();
const diffMs = now - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}

View file

@ -0,0 +1,33 @@
import { ActivityItem } from '../activity/ActivityItem';
import type { InboxMessage } from '@shared/types';
interface MemberMessagesTabProps {
messages: InboxMessage[];
onCreateTask?: (subject: string, description: string) => void;
}
const MAX_MESSAGES = 100;
export const MemberMessagesTab = ({
messages,
onCreateTask,
}: MemberMessagesTabProps): React.JSX.Element => {
const displayMessages = messages.slice(0, MAX_MESSAGES);
if (displayMessages.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-text-muted)]">
No messages with this member
</div>
);
}
return (
<div className="max-h-[320px] space-y-2 overflow-y-auto">
{displayMessages.map((msg, idx) => (
<ActivityItem key={msg.messageId ?? idx} message={msg} onCreateTask={onCreateTask} />
))}
</div>
);
};

View file

@ -0,0 +1,222 @@
import { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
import { AlertCircle, BarChart3, ChevronDown, ChevronRight, FileCode, Loader2 } from 'lucide-react';
import type { MemberFullStats } from '@shared/types';
interface MemberStatsTabProps {
teamName: string;
memberName: string;
}
export const MemberStatsTab = ({
teamName,
memberName,
}: MemberStatsTabProps): React.JSX.Element => {
const [stats, setStats] = useState<MemberFullStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const result = await api.teams.getMemberStats(teamName, memberName);
if (!cancelled) {
setStats(result);
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : 'Unknown error');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [teamName, memberName]);
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Computing stats...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
);
}
if (!stats) {
return (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<BarChart3 size={20} className="mx-auto mb-2 opacity-40" />
No stats available
</div>
);
}
const totalTokens = stats.inputTokens + stats.outputTokens;
const totalToolCalls = Object.values(stats.toolUsage).reduce((sum, c) => sum + c, 0);
return (
<div className="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<SummaryCards stats={stats} totalTokens={totalTokens} totalToolCalls={totalToolCalls} />
<ToolUsageBars toolUsage={stats.toolUsage} />
<FilesTouchedSection files={stats.filesTouched} />
<StatsFooter stats={stats} />
</div>
);
};
const StatCard = ({
label,
value,
sub,
}: {
label: string;
value: string | number;
sub?: string;
}): React.JSX.Element => (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-3 py-2">
<p className="text-lg font-semibold text-[var(--color-text)]">{value}</p>
<p className="text-[11px] text-[var(--color-text-muted)]">{label}</p>
{sub && <p className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">{sub}</p>}
</div>
);
const SummaryCards = ({
stats,
totalTokens,
totalToolCalls,
}: {
stats: MemberFullStats;
totalTokens: number;
totalToolCalls: number;
}): React.JSX.Element => (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<StatCard
label="Lines"
value={`+${stats.linesAdded}`}
sub={stats.linesRemoved > 0 ? `-${stats.linesRemoved}` : undefined}
/>
<StatCard label="Files" value={stats.filesTouched.length} />
<StatCard label="Tool Calls" value={totalToolCalls} />
<StatCard label="Tokens" value={formatTokensCompact(totalTokens)} />
</div>
);
const ToolUsageBars = ({
toolUsage,
}: {
toolUsage: Record<string, number>;
}): React.JSX.Element | null => {
const entries = Object.entries(toolUsage).sort(([, a], [, b]) => b - a);
if (entries.length === 0) return null;
const maxCount = entries[0][1];
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<p className="mb-2 text-[11px] font-medium text-[var(--color-text-secondary)]">Tool Usage</p>
<div className="space-y-1.5">
{entries.map(([name, count]) => (
<div key={name} className="flex items-center gap-2 text-[11px]">
<span className="w-16 shrink-0 truncate text-right text-[var(--color-text-muted)]">
{name}
</span>
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-[var(--color-surface-raised)]">
<div
className="h-full rounded-sm bg-blue-500/40"
style={{ width: `${(count / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 shrink-0 text-right tabular-nums text-[var(--color-text-muted)]">
{count}
</span>
</div>
))}
</div>
</div>
);
};
const FilesTouchedSection = ({ files }: { files: string[] }): React.JSX.Element | null => {
const [expanded, setExpanded] = useState(false);
if (files.length === 0) return null;
const visibleFiles = expanded ? files : files.slice(0, 5);
const hiddenCount = files.length - 5;
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<p className="mb-2 text-[11px] font-medium text-[var(--color-text-secondary)]">
Files Touched ({files.length})
</p>
<div className="space-y-0.5">
{visibleFiles.map((filePath) => {
const basename = filePath.split('/').pop() ?? filePath;
return (
<div
key={filePath}
className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-muted)]"
title={filePath}
>
<FileCode size={10} className="shrink-0 opacity-50" />
<span className="truncate">{basename}</span>
</div>
);
})}
</div>
{hiddenCount > 0 && (
<button
className="mt-1.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <ChevronDown size={10} /> : <ChevronRight size={10} />}
{expanded ? 'Show less' : `+${hiddenCount} more`}
</button>
)}
</div>
);
};
const StatsFooter = ({ stats }: { stats: MemberFullStats }): React.JSX.Element => {
const computedAgo = formatRelativeTime(stats.computedAt);
return (
<div className="text-center text-[10px] text-[var(--color-text-muted)]">
{stats.sessionCount} session{stats.sessionCount !== 1 ? 's' : ''} · computed {computedAgo}
</div>
);
};
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const now = Date.now();
const diffMs = now - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return date.toLocaleDateString();
}

View file

@ -0,0 +1,62 @@
import { useMemo } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
import type { TeamTask } from '@shared/types';
interface MemberTasksTabProps {
tasks: TeamTask[];
}
const STATUS_ORDER: Record<string, number> = {
in_progress: 0,
pending: 1,
completed: 2,
};
export const MemberTasksTab = ({ tasks }: MemberTasksTabProps): React.JSX.Element => {
const visibleTasks = useMemo(
() =>
tasks
.filter((t) => t.status !== 'deleted')
.sort((a, b) => (STATUS_ORDER[a.status] ?? 3) - (STATUS_ORDER[b.status] ?? 3)),
[tasks]
);
if (visibleTasks.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-text-muted)]">
No tasks assigned to this member
</div>
);
}
return (
<div className="max-h-[320px] overflow-y-auto">
<div className="flex flex-col gap-1">
{visibleTasks.map((task) => {
const style = TASK_STATUS_STYLES[task.status];
return (
<div
key={task.id}
className="flex items-center gap-2 rounded-md px-2.5 py-2 hover:bg-[var(--color-surface-raised)]"
>
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px] font-normal">
#{task.id}
</Badge>
<span className="min-w-0 flex-1 truncate text-sm text-[var(--color-text)]">
{task.subject}
</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${style.bg} ${style.text}`}
>
{TASK_STATUS_LABELS[task.status]}
</span>
</div>
);
})}
</div>
</div>
);
};

View file

@ -25,33 +25,39 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
});
}, [tasks, ownerFilter, statusFilter]);
const showStatusFilter = useMemo(() => {
const counts = new Map<string, number>();
for (const task of tasks) {
counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
}
return Array.from(counts.values()).some((count) => count > 10);
}, [tasks]);
if (tasks.length === 0) {
return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
Нет задач в этой команде
No tasks in this team
</div>
);
}
const showFilters = tasks.length > 200;
return (
<div className="overflow-hidden rounded-md border border-[var(--color-border)]">
{showFilters ? (
<div className="flex flex-wrap gap-2 border-b border-[var(--color-border)] p-2">
<select
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text)]"
value={ownerFilter}
aria-label="Filter tasks by owner"
onChange={(event) => setOwnerFilter(event.target.value)}
>
<option value="all">All owners</option>
{ownerOptions.map((owner) => (
<option key={owner} value={owner}>
{owner}
</option>
))}
</select>
<div className="flex flex-wrap gap-2 border-b border-[var(--color-border)] p-2">
<select
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text)]"
value={ownerFilter}
aria-label="Filter tasks by owner"
onChange={(event) => setOwnerFilter(event.target.value)}
>
<option value="all">All owners</option>
{ownerOptions.map((owner) => (
<option key={owner} value={owner}>
{owner}
</option>
))}
</select>
{showStatusFilter ? (
<select
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text)]"
value={statusFilter}
@ -64,11 +70,13 @@ export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
<option value="completed">completed</option>
<option value="deleted">deleted</option>
</select>
) : null}
{ownerFilter !== 'all' || statusFilter !== 'all' ? (
<p className="self-center text-[11px] text-[var(--color-text-muted)]">
Showing {filteredTasks.length} of {tasks.length}
</p>
</div>
) : null}
) : null}
</div>
<table className="min-w-full table-fixed">
<thead className="bg-[var(--color-surface-raised)]">
<tr>

View file

@ -0,0 +1,29 @@
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { cn } from '@renderer/lib/utils';
import { Check } from 'lucide-react';
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer size-4 shrink-0 rounded border border-[var(--color-border-emphasis)] bg-transparent',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-accent)]',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:border-[var(--color-accent)] data-[state=checked]:bg-[var(--color-accent)] data-[state=checked]:text-white',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<Check className="size-3" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View file

@ -65,6 +65,8 @@ export const Combobox = ({
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
sideOffset={4}
collisionPadding={8}
avoidCollisions
>
<CommandPrimitive
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
@ -78,7 +80,10 @@ export const Combobox = ({
className="flex h-8 w-full border-0 bg-transparent py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
/>
</div>
<CommandPrimitive.List id={listboxId} className="max-h-52 overflow-auto p-1">
<CommandPrimitive.List
id={listboxId}
className="max-h-72 overflow-y-auto overscroll-contain p-1"
>
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
{emptyMessage}
</CommandPrimitive.Empty>

View file

@ -0,0 +1,52 @@
/* eslint-disable react/jsx-props-no-spreading -- Standard shadcn pattern: forward remaining props to underlying elements */
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@renderer/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-[var(--color-surface-raised)] p-1 text-[var(--color-text-muted)]',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-[var(--color-surface)] data-[state=active]:text-[var(--color-text)] data-[state=active]:shadow-sm',
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn('mt-2 focus-visible:outline-none', className)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsContent, TabsList, TabsTrigger };
/* eslint-enable react/jsx-props-no-spreading -- Re-enable after shadcn component */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -4,22 +4,22 @@
/* Theme CSS Custom Properties */
:root {
/* Dark theme (default) - Soft Charcoal palette */
--color-surface: #141416; /* Main Chat Area Background (Soft Matte Charcoal) */
--color-surface-raised: #27272a; /* Active/Selected items (Zinc-800) */
--color-surface-overlay: #27272a; /* Overlay surfaces */
--color-surface-sidebar: #0f0f11; /* Sidebar Background (slightly darker) */
--color-border: rgba(255, 255, 255, 0.05); /* Borders/Dividers (5% white) */
--color-border-subtle: rgba(255, 255, 255, 0.05); /* Subtle borders (5% white) */
--color-border-emphasis: rgba(255, 255, 255, 0.1); /* Emphasis borders (10% white) */
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
--color-text-muted: #71717a;
/* Dark theme (default) - Midnight Slate palette (cool indigo undertone) */
--color-surface: #12131a; /* Main background — deep midnight */
--color-surface-raised: #1c1d26; /* Active/Selected items — cool slate */
--color-surface-overlay: #1c1d26; /* Overlay surfaces */
--color-surface-sidebar: #0c0d13; /* Sidebar — deepest navy-black */
--color-border: rgba(148, 163, 184, 0.07); /* Borders — slate-tinted */
--color-border-subtle: rgba(148, 163, 184, 0.05); /* Subtle borders */
--color-border-emphasis: rgba(148, 163, 184, 0.12); /* Emphasis borders */
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
/* Scrollbar colors */
--scrollbar-thumb: rgba(255, 255, 255, 0.15);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.25);
--scrollbar-thumb-active: rgba(255, 255, 255, 0.35);
--scrollbar-thumb: rgba(148, 163, 184, 0.15);
--scrollbar-thumb-hover: rgba(148, 163, 184, 0.25);
--scrollbar-thumb-active: rgba(148, 163, 184, 0.35);
/* Search highlights */
--highlight-bg: rgba(202, 138, 4, 0.7);
@ -28,50 +28,50 @@
--highlight-text-inactive: #fef08a;
--highlight-ring: #facc15;
/* User chat bubble - Soft Charcoal theme (softer text for visual hierarchy) */
--chat-user-bg: #27272a;
--chat-user-text: #a1a1aa;
--chat-user-border: rgba(255, 255, 255, 0.08);
--chat-user-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.03);
/* User chat bubble — cool slate */
--chat-user-bg: #1c1d26;
--chat-user-text: #94a3b8;
--chat-user-border: rgba(148, 163, 184, 0.1);
--chat-user-shadow: 0 1px 0 0 rgba(99, 102, 241, 0.04);
/* User bubble inline tags - Linear-style neutral */
--chat-user-tag-bg: rgba(255, 255, 255, 0.08);
--chat-user-tag-text: #e4e4e7;
--chat-user-tag-border: rgba(255, 255, 255, 0.12);
/* User bubble inline tags */
--chat-user-tag-bg: rgba(148, 163, 184, 0.08);
--chat-user-tag-text: #e2e8f0;
--chat-user-tag-border: rgba(148, 163, 184, 0.12);
/* Tool items */
--tool-item-name: #e4e4e7;
--tool-item-summary: #a1a1aa;
--tool-item-muted: #71717a;
--tool-item-hover-bg: rgba(39, 39, 42, 0.5);
--tool-item-name: #e2e8f0;
--tool-item-summary: #94a3b8;
--tool-item-muted: #64748b;
--tool-item-hover-bg: rgba(28, 29, 38, 0.6);
/* System chat bubble */
--chat-system-bg: rgba(39, 39, 42, 0.5);
--chat-system-text: #d4d4d8;
--chat-system-bg: rgba(28, 29, 38, 0.6);
--chat-system-text: #cbd5e1;
/* AI message styling */
--chat-ai-border: rgba(255, 255, 255, 0.05);
--chat-ai-icon: #71717a;
--chat-ai-border: rgba(148, 163, 184, 0.06);
--chat-ai-icon: #64748b;
/* Code blocks - Soft Charcoal theme */
--code-bg: #1c1c1e;
--code-header-bg: #1c1c1e;
--code-border: rgba(255, 255, 255, 0.1);
--code-line-number: #52525b;
--code-filename: #60a5fa;
/* Code blocks — midnight slate */
--code-bg: #171822;
--code-header-bg: #171822;
--code-border: rgba(148, 163, 184, 0.1);
--code-line-number: #475569;
--code-filename: #818cf8;
/* Syntax highlighting - Dark theme */
/* Syntax highlighting — cooler accents */
--syntax-string: #4ade80;
--syntax-comment: #71717a;
--syntax-comment: #64748b;
--syntax-number: #fb923c;
--syntax-keyword: #c084fc;
--syntax-type: #facc15;
--syntax-operator: #a1a1aa;
--syntax-function: #60a5fa;
--syntax-type: #fbbf24;
--syntax-operator: #94a3b8;
--syntax-function: #818cf8;
/* Inline code - Linear-style neutral */
--inline-code-bg: rgba(255, 255, 255, 0.08);
--inline-code-text: #e4e4e7;
/* Inline code */
--inline-code-bg: rgba(148, 163, 184, 0.08);
--inline-code-text: #e2e8f0;
/* Diff viewer */
--diff-added-bg: rgba(34, 197, 94, 0.15);
@ -81,21 +81,21 @@
--diff-removed-text: #f87171;
--diff-removed-border: #ef4444;
/* Markdown prose - Brighter body text for AI responses (visual hierarchy) */
--prose-heading: #ffffff;
--prose-body: #f4f4f5;
--prose-muted: #a1a1aa;
--prose-link: #60a5fa;
--prose-code-bg: rgba(255, 255, 255, 0.08);
--prose-code-text: #e4e4e7;
--prose-pre-bg: #1c1c1e;
--prose-pre-border: rgba(255, 255, 255, 0.1);
--prose-blockquote-border: rgba(255, 255, 255, 0.1);
--prose-table-border: rgba(255, 255, 255, 0.05);
--prose-table-header-bg: #27272a;
/* Markdown prose */
--prose-heading: #f8fafc;
--prose-body: #f1f5f9;
--prose-muted: #94a3b8;
--prose-link: #818cf8;
--prose-code-bg: rgba(148, 163, 184, 0.08);
--prose-code-text: #e2e8f0;
--prose-pre-bg: #171822;
--prose-pre-border: rgba(148, 163, 184, 0.1);
--prose-blockquote-border: rgba(148, 163, 184, 0.1);
--prose-table-border: rgba(148, 163, 184, 0.06);
--prose-table-header-bg: #1c1d26;
/* Thinking blocks */
--thinking-bg: rgba(88, 28, 135, 0.2);
/* Thinking blocks — slightly more vibrant purple */
--thinking-bg: rgba(88, 28, 135, 0.22);
--thinking-border: rgba(107, 33, 168, 0.4);
--thinking-text: #d8b4fe;
--thinking-text-muted: #e9d5ff;
@ -118,10 +118,10 @@
--tool-result-error-text: #fca5a5;
/* Output blocks */
--output-bg: rgba(31, 41, 55, 0.3);
--output-border: #374151;
--output-text: #e5e7eb;
--output-content-border: rgba(55, 65, 81, 0.5);
--output-bg: rgba(23, 24, 34, 0.5);
--output-border: #2d3044;
--output-text: #e2e8f0;
--output-content-border: rgba(45, 48, 68, 0.5);
/* Badges */
--badge-error-bg: #dc2626;
@ -130,21 +130,21 @@
--badge-warning-text: #ffffff;
--badge-success-bg: #16a34a;
--badge-success-text: #ffffff;
--badge-info-bg: #2563eb;
--badge-info-bg: #4f46e5;
--badge-info-text: #ffffff;
--badge-neutral-bg: #3f3f46;
--badge-neutral-text: #e4e4e7;
--badge-neutral-bg: #2a2c38;
--badge-neutral-text: #e2e8f0;
/* Language/tag badges */
--tag-bg: #27272a;
--tag-text: #a1a1aa;
--tag-border: rgba(255, 255, 255, 0.05);
--tag-bg: #1c1d26;
--tag-text: #94a3b8;
--tag-border: rgba(148, 163, 184, 0.06);
/* Highlighted text (skills, paths) - Linear-style neutral (same as inline code) */
--skill-highlight-bg: rgba(255, 255, 255, 0.08);
--skill-highlight-text: #e4e4e7;
--path-highlight-bg: rgba(255, 255, 255, 0.08);
--path-highlight-text: #e4e4e7;
/* Highlighted text (skills, paths) */
--skill-highlight-bg: rgba(148, 163, 184, 0.08);
--skill-highlight-text: #e2e8f0;
--path-highlight-bg: rgba(148, 163, 184, 0.08);
--path-highlight-text: #e2e8f0;
/* Interruption badge */
--interruption-bg: rgba(127, 29, 29, 0.3);
@ -167,30 +167,30 @@
--error-highlight-bg: rgba(239, 68, 68, 0.2);
/* Keyboard hints */
--kbd-bg: #27272a;
--kbd-border: rgba(255, 255, 255, 0.1);
--kbd-text: #d4d4d8;
--kbd-bg: #1c1d26;
--kbd-border: rgba(148, 163, 184, 0.1);
--kbd-text: #cbd5e1;
/* Subagent/Card styling */
--card-bg: #121212;
--card-border: #27272a;
--card-header-bg: #18181b;
--card-header-hover: #1f1f23;
--card-icon-muted: #52525b;
--card-text-light: #d4d4d8;
--card-text-lighter: #e4e4e7;
--card-separator: #3f3f46;
--card-bg: #0f1018;
--card-border: #1c1d26;
--card-header-bg: #151620;
--card-header-hover: #1a1b26;
--card-icon-muted: #475569;
--card-text-light: #cbd5e1;
--card-text-lighter: #e2e8f0;
--card-separator: #2a2c38;
/* Sticky Context button - transparent glass */
--context-btn-bg: rgba(255, 255, 255, 0.08);
--context-btn-bg-hover: rgba(255, 255, 255, 0.14);
--context-btn-active-bg: rgba(99, 102, 241, 0.45);
/* Sticky Context button — indigo glass */
--context-btn-bg: rgba(148, 163, 184, 0.08);
--context-btn-bg-hover: rgba(148, 163, 184, 0.14);
--context-btn-active-bg: rgba(99, 102, 241, 0.5);
--context-btn-active-text: #e0e7ff;
/* Skeleton — tinted deep charcoal (2% cool shift) */
--skeleton-base: #24262c;
--skeleton-base-light: #2c2e35;
--skeleton-base-dim: rgba(36, 38, 44, 0.6);
/* Skeleton — cool midnight */
--skeleton-base: #1a1c28;
--skeleton-base-light: #23252f;
--skeleton-base-dim: rgba(26, 28, 40, 0.6);
}
/* Light theme overrides - Warm neutral palette for eye comfort */

View file

@ -4,14 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/png" href="./favicon.png" />
<title>claude-devtools</title>
<title>Claude Agent Teams UI</title>
<style>
/* Splash: spotlight gradient + noise overlay */
#splash {
position: fixed; inset: 0; z-index: 9999;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: radial-gradient(ellipse 60% 50% at 50% 45%, #1c1c20 0%, #0e0e10 100%);
background: radial-gradient(ellipse 60% 50% at 50% 45%, #181924 0%, #0c0d13 100%);
transition: opacity 0.3s ease-out;
}
#splash-noise {
@ -25,15 +25,16 @@
color: #a1a1aa;
}
/* Logo quadrant breathing — clockwise: TL → TR → BR → BL */
@keyframes splash-q {
0%, 100% { opacity: 0.12; }
10%, 25% { opacity: 0.95; }
40% { opacity: 0.12; }
/* Logo node breathing — cycles through 3 agent nodes */
@keyframes splash-node {
0%, 100% { opacity: 0.18; }
12%, 28% { opacity: 1; }
45% { opacity: 0.18; }
}
.splash-q {
animation: splash-q 3.2s cubic-bezier(0.4, 0, 0.2, 1) infinite both;
.splash-node {
animation: splash-node 3s cubic-bezier(0.4, 0, 0.2, 1) infinite both;
}
.splash-edge { transition: opacity 0.3s; }
/* Light theme splash overrides */
:root.light #splash {
@ -42,7 +43,9 @@
:root.light #splash-text { color: #52525b; }
:root.light #splash-noise { opacity: 0.02; }
:root.light .splash-logo-bg { fill: #e4e4e7; }
:root.light .splash-logo-shape { fill: #52525b; }
:root.light .splash-node-fill { fill: #52525b; }
:root.light .splash-core-fill { fill: #fafafa; }
:root.light .splash-edge { stroke: #71717a; }
</style>
<script>
// Flash prevention: Apply cached theme before React loads
@ -65,19 +68,23 @@
</filter>
<rect width="100%" height="100%" filter="url(#noiseFilter)"/>
</svg>
<!-- Logo with animated quadrants -->
<!-- Logo with animated agent nodes -->
<svg id="splash-logo" viewBox="0 0 56 56" width="56" height="56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect class="splash-logo-bg" width="56" height="56" rx="14" fill="#1c1c20"/>
<!-- TL: small circle -->
<circle class="splash-q splash-logo-shape" style="animation-delay:0s" cx="19" cy="19" r="5" fill="#d4d4d8"/>
<!-- TR: right-facing semicircle -->
<path class="splash-q splash-logo-shape" style="animation-delay:0.8s" d="M34,12 A7,7 0 0,1 34,26 Z" fill="#d4d4d8"/>
<!-- BR: large circle -->
<circle class="splash-q splash-logo-shape" style="animation-delay:1.6s" cx="37" cy="37" r="6.5" fill="#d4d4d8"/>
<!-- BL: down-facing semicircle -->
<path class="splash-q splash-logo-shape" style="animation-delay:2.4s" d="M12,34 A7,7 0 0,0 26,34 Z" fill="#d4d4d8"/>
<rect class="splash-logo-bg" width="56" height="56" rx="14" fill="#151620"/>
<!-- Edges connecting nodes -->
<line class="splash-edge" x1="19" y1="19" x2="37" y2="19" stroke="#818cf8" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<line class="splash-edge" x1="37" y1="19" x2="28" y2="37" stroke="#a78bfa" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<line class="splash-edge" x1="28" y1="37" x2="19" y2="19" stroke="#c084fc" stroke-width="2" stroke-linecap="round" opacity="0.35"/>
<!-- Agent nodes -->
<circle class="splash-node splash-node-fill" style="animation-delay:0s" cx="19" cy="19" r="5.5" fill="#818cf8"/>
<circle class="splash-node splash-node-fill" style="animation-delay:1s" cx="37" cy="19" r="5.5" fill="#a78bfa"/>
<circle class="splash-node splash-node-fill" style="animation-delay:2s" cx="28" cy="37" r="6" fill="#c084fc"/>
<!-- Core highlights -->
<circle class="splash-node splash-core-fill" style="animation-delay:0s" cx="19" cy="19" r="2" fill="#e0e7ff"/>
<circle class="splash-node splash-core-fill" style="animation-delay:1s" cx="37" cy="19" r="2" fill="#ede9fe"/>
<circle class="splash-node splash-core-fill" style="animation-delay:2s" cx="28" cy="37" r="2.2" fill="#f3e8ff"/>
</svg>
<div id="splash-text">claude-devtools</div>
<div id="splash-text">Claude Agent Teams UI</div>
</div>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>

View file

@ -67,15 +67,18 @@ export function initializeNotificationListeners(): () => void {
cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress();
});
void useStore.getState().fetchTeams();
const pendingSessionRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
const pendingProjectRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let teamListRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let globalTasksRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const SESSION_REFRESH_DEBOUNCE_MS = 150;
const PROJECT_REFRESH_DEBOUNCE_MS = 300;
const TEAM_REFRESH_THROTTLE_MS = 800;
const TEAM_LIST_REFRESH_THROTTLE_MS = 2000;
const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500;
const getBaseProjectId = (projectId: string | null | undefined): string | null => {
if (!projectId) return null;
const separatorIndex = projectId.indexOf('::');
@ -299,6 +302,14 @@ export function initializeNotificationListeners(): () => void {
}, TEAM_LIST_REFRESH_THROTTLE_MS);
}
// Throttled refresh of global tasks list for sidebar.
if (!globalTasksRefreshTimer) {
globalTasksRefreshTimer = setTimeout(() => {
globalTasksRefreshTimer = null;
void useStore.getState().fetchAllTasks();
}, GLOBAL_TASKS_REFRESH_THROTTLE_MS);
}
if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) {
return;
}
@ -327,6 +338,10 @@ export function initializeNotificationListeners(): () => void {
clearTimeout(teamListRefreshTimer);
teamListRefreshTimer = null;
}
if (globalTasksRefreshTimer) {
clearTimeout(globalTasksRefreshTimer);
globalTasksRefreshTimer = null;
}
});
}
}

View file

@ -355,6 +355,46 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
return;
}
}
// For team tabs, re-select the team so global selectedTeamData matches this tab.
// Without this, switching between team A and team B tabs leaves stale data
// because each TeamDetailView is kept mounted (CSS display-toggle) and its
// useEffect(teamName) only fires once on mount.
if (tab.type === 'team' && tab.teamName) {
if (state.selectedTeamName !== tab.teamName) {
// Different team -- full reload (also auto-selects project via selectTeam)
void state.selectTeam(tab.teamName);
} else {
// Same team already loaded -- just sync sidebar project if team has a projectPath.
// This covers the case where the user switched to a session tab (changing the
// sidebar project) and then switches back to the team tab.
const teamData = state.selectedTeamData;
const projectPath = teamData?.config.projectPath;
if (projectPath) {
const normalizedTeamPath = projectPath.endsWith('/')
? projectPath.slice(0, -1)
: projectPath;
const normalizePath = (p: string): string => (p.endsWith('/') ? p.slice(0, -1) : p);
const matchingProject = state.projects.find(
(p) => normalizePath(p.path) === normalizedTeamPath
);
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
state.selectProject(matchingProject.id);
} else if (!matchingProject) {
for (const repo of state.repositoryGroups) {
const matchingWorktree = repo.worktrees.find(
(wt) => normalizePath(wt.path) === normalizedTeamPath
);
if (matchingWorktree && state.selectedWorktreeId !== matchingWorktree.id) {
state.selectRepository(repo.id);
state.selectWorktree(matchingWorktree.id);
break;
}
}
}
}
}
}
},
// Open a new dashboard tab in the focused pane

View file

@ -4,10 +4,12 @@ import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
import type { AppState } from '../types';
import type {
CreateTaskRequest,
GlobalTask,
SendMessageRequest,
SendMessageResult,
TeamCreateRequest,
TeamData,
TeamLaunchRequest,
TeamProvisioningProgress,
TeamSummary,
TeamTask,
@ -38,6 +40,9 @@ export interface TeamSlice {
teams: TeamSummary[];
teamsLoading: boolean;
teamsError: string | null;
globalTasks: GlobalTask[];
globalTasksLoading: boolean;
globalTasksError: string | null;
selectedTeamName: string | null;
selectedTeamData: TeamData | null;
selectedTeamLoading: boolean;
@ -49,10 +54,13 @@ export interface TeamSlice {
provisioningRuns: Record<string, TeamProvisioningProgress>;
activeProvisioningRunId: string | null;
provisioningError: string | null;
kanbanFilterQuery: string | null;
provisioningProgressUnsubscribe: (() => void) | null;
fetchTeams: () => Promise<void>;
fetchAllTasks: () => Promise<void>;
openTeamsTab: () => void;
openTeamTab: (teamName: string) => void;
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => void;
clearKanbanFilter: () => void;
selectTeam: (teamName: string) => Promise<void>;
refreshTeamData: (teamName: string) => Promise<void>;
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<void>;
@ -62,6 +70,7 @@ export interface TeamSlice {
updateTaskStatus: (teamName: string, taskId: string, status: TeamTaskStatus) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
cancelProvisioning: (runId: string) => Promise<void>;
getProvisioningStatus: (runId: string) => Promise<void>;
onProvisioningProgress: (progress: TeamProvisioningProgress) => void;
@ -73,6 +82,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teams: [],
teamsLoading: false,
teamsError: null,
globalTasks: [],
globalTasksLoading: false,
globalTasksError: null,
selectedTeamName: null,
selectedTeamData: null,
selectedTeamLoading: false,
@ -84,6 +96,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
provisioningRuns: {},
activeProvisioningRunId: null,
provisioningError: null,
kanbanFilterQuery: null,
provisioningProgressUnsubscribe: null,
fetchTeams: async () => {
@ -104,6 +117,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
},
fetchAllTasks: async () => {
set({ globalTasksLoading: true, globalTasksError: null });
try {
const tasks = await unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks());
set({ globalTasks: tasks, globalTasksLoading: false, globalTasksError: null });
} catch (error) {
set({
globalTasksLoading: false,
globalTasksError:
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to fetch tasks',
});
}
},
openTeamsTab: () => {
const state = get();
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
@ -119,24 +150,43 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
});
},
openTeamTab: (teamName: string) => {
openTeamTab: (teamName: string, projectPath?: string, taskId?: string) => {
if (!teamName.trim()) {
return;
}
// If projectPath is provided, immediately select the matching project in the sidebar.
// This avoids a race condition where config.json hasn't been updated with projectPath yet.
if (projectPath) {
const state = get();
const normalizePath = (p: string): string => (p.endsWith('/') ? p.slice(0, -1) : p);
const normalizedPath = normalizePath(projectPath);
const matchingProject = state.projects.find((p) => normalizePath(p.path) === normalizedPath);
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
state.selectProject(matchingProject.id);
}
}
const state = get();
const allTabs = state.getAllPaneTabs();
const existing = allTabs.find((tab) => tab.type === 'team' && tab.teamName === teamName);
if (existing) {
state.setActiveTab(existing.id);
return;
} else {
state.openTab({
type: 'team',
label: teamName,
teamName,
});
}
state.openTab({
type: 'team',
label: teamName,
teamName,
});
if (taskId) {
set({ kanbanFilterQuery: `#${taskId}` });
}
},
clearKanbanFilter: () => {
set({ kanbanFilterQuery: null });
},
selectTeam: async (teamName: string) => {
@ -149,12 +199,51 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
try {
const data = await unwrapIpc('team:getData', () => api.teams.getData(teamName));
// Stale check: user may have switched to another team during the async call
if (get().selectedTeamName !== teamName) {
return;
}
set({
selectedTeamName: teamName,
selectedTeamData: data,
selectedTeamLoading: false,
selectedTeamError: null,
});
// Auto-select the project associated with this team's cwd/projectPath.
// Must search both flat projects and grouped repositoryGroups/worktrees
// because the default viewMode is 'grouped' and flat projects may be empty.
const projectPath = data.config.projectPath;
if (projectPath) {
const state = get();
const normalizedTeamPath = projectPath.endsWith('/')
? projectPath.slice(0, -1)
: projectPath;
const normalizePath = (p: string): string => (p.endsWith('/') ? p.slice(0, -1) : p);
// 1. Try flat projects list
const matchingProject = state.projects.find(
(p) => normalizePath(p.path) === normalizedTeamPath
);
if (matchingProject && state.selectedProjectId !== matchingProject.id) {
state.selectProject(matchingProject.id);
} else if (!matchingProject) {
// 2. Try grouped view: search worktrees across all repository groups
for (const repo of state.repositoryGroups) {
const matchingWorktree = repo.worktrees.find(
(wt) => normalizePath(wt.path) === normalizedTeamPath
);
if (matchingWorktree) {
if (state.selectedWorktreeId !== matchingWorktree.id) {
state.selectRepository(repo.id);
state.selectWorktree(matchingWorktree.id);
}
break;
}
}
}
}
} catch (error) {
set({
selectedTeamLoading: false,
@ -298,6 +387,29 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
}
},
launchTeam: async (request: TeamLaunchRequest) => {
set({ provisioningError: null });
try {
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
set({
activeProvisioningRunId: response.runId,
provisioningError: null,
});
await get().getProvisioningStatus(response.runId);
return response.runId;
} catch (error) {
set({
provisioningError:
error instanceof IpcError
? error.message
: error instanceof Error
? error.message
: 'Failed to launch team',
});
throw error;
}
},
getProvisioningStatus: async (runId: string) => {
const progress = await unwrapIpc('team:provisioningStatus', () =>
api.teams.getProvisioningStatus(runId)

View file

@ -1,5 +1,5 @@
/**
* Notification and configuration types for claude-devtools.
* Notification and configuration types for Claude Agent Teams UI.
*
* Re-exports types from shared for backwards compatibility.
* The canonical definitions are in @shared/types/notifications.

Some files were not shown because too many files have changed in this diff Show more