feat: use agent teams
|
|
@ -1,4 +1,4 @@
|
|||
# claude-devtools
|
||||
# Claude Agent Teams UI
|
||||
|
||||
Electron app that visualizes Claude Code session execution
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Contributing
|
||||
|
||||
Thanks for contributing to claude-devtools.
|
||||
Thanks for contributing to Claude Agent Teams UI.
|
||||
|
||||
## Prerequisites
|
||||
- Node.js 20+
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
LICENSE
|
|
@ -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
|
||||
|
|
|
|||
92
README.md
|
|
@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases"><img src="https://img.shields.io/github/downloads/777genius/claude_agent_teams_ui/total?style=flat-square&color=green" alt="Downloads" /></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows%20%7C%20Docker-lightgrey?style=flat-square" alt="Platform" />
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<a href="#docker--standalone-deployment">
|
||||
<img src="https://img.shields.io/badge/Docker-Deploy-2496ED?logo=docker&logoColor=white&style=flat" alt="Deploy with Docker" height="30" />
|
||||
</a>
|
||||
<a 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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ claude # Новая независимая сессия
|
|||
|
||||
---
|
||||
|
||||
## ЧАСТЬ 2: Существующая инфраструктура в claude-devtools
|
||||
## ЧАСТЬ 2: Существующая инфраструктура в Claude Agent Teams UI
|
||||
|
||||
### Уже реализовано (можно переиспользовать)
|
||||
|
||||
|
|
|
|||
15
package.json
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
public/icon.png
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 471 KiB After Width: | Height: | Size: 83 KiB |
50
resources/icon.svg
Normal 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 |
|
Before Width: | Height: | Size: 662 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 710 B After Width: | Height: | Size: 505 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 812 B |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 216 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 353 KiB After Width: | Height: | Size: 115 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
236
src/main/services/team/MemberStatsComputer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
542
src/main/services/team/TeamMemberLogsFinder.ts
Normal 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, '\\$&');
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
94
src/main/services/team/TeamMembersMetaStore.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
61
src/renderer/components/common/AppLogo.tsx
Normal 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>
|
||||
);
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
224
src/renderer/components/sidebar/GlobalTaskList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
58
src/renderer/components/sidebar/SidebarTaskItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "{searchQuery.trim()}"
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)]'
|
||||
)}
|
||||
|
|
|
|||
257
src/renderer/components/team/TeamSessionsSection.tsx
Normal 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');
|
||||
}
|
||||
|
|
@ -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 }}>
|
||||
→ {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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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)]">→</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'
|
||||
)}
|
||||
|
|
|
|||
177
src/renderer/components/team/dialogs/EditTeamDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
423
src/renderer/components/team/dialogs/LaunchTeamDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
157
src/renderer/components/team/dialogs/SendMessageDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
160
src/renderer/components/team/kanban/KanbanFilterPopover.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
129
src/renderer/components/team/members/MemberDetailDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
45
src/renderer/components/team/members/MemberDetailHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
src/renderer/components/team/members/MemberDetailStats.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
185
src/renderer/components/team/members/MemberExecutionLog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
243
src/renderer/components/team/members/MemberLogsTab.tsx
Normal 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();
|
||||
}
|
||||
33
src/renderer/components/team/members/MemberMessagesTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
222
src/renderer/components/team/members/MemberStatsTab.tsx
Normal 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();
|
||||
}
|
||||
62
src/renderer/components/team/members/MemberTasksTab.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
29
src/renderer/components/ui/checkbox.tsx
Normal 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 };
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
52
src/renderer/components/ui/tabs.tsx
Normal 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 */
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||