diff --git a/CLAUDE.md b/CLAUDE.md index 15fe5352..d9a120ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# claude-devtools +# Claude Agent Teams UI Electron app that visualizes Claude Code session execution diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b722a47..b842ec16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for contributing to claude-devtools. +Thanks for contributing to Claude Agent Teams UI. ## Prerequisites - Node.js 20+ diff --git a/Dockerfile b/Dockerfile index 540aaafe..c1cd5213 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/LICENSE b/LICENSE index 5f2f78fc..3ad03bcb 100644 --- a/LICENSE +++ b/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 diff --git a/README.md b/README.md index 12542ea6..c27050cd 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,8 @@

- claude-devtools + Claude Agent Teams UI

-

claude-devtools

- -

- - claude-devtools - See everything Claude Code hides from your terminal | Product Hunt - -

+

Claude Agent Teams UI

Terminal tells you nothing. This shows you everything. @@ -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.

- -

- Website  - Latest Release  - CI Status  - Downloads  + Latest Release  + CI Status  + Downloads  Platform


- - Website -    - + Download for macOS    - + Download for Linux    - + Download for Windows    Deploy with Docker -    - - Install with Homebrew

@@ -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 context -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. diff --git a/SECURITY.md b/SECURITY.md index a886bfa9..3ea3b06c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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`. diff --git a/docker-compose.yml b/docker-compose.yml index 846f3710..28891ad7 100644 --- a/docker-compose.yml +++ b/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" diff --git a/docs/team-management/README.md b/docs/team-management/README.md index 9ddc6371..8778577a 100644 --- a/docs/team-management/README.md +++ b/docs/team-management/README.md @@ -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)`. diff --git a/docs/team-management/research-worktrees.md b/docs/team-management/research-worktrees.md index c0ae3d53..438c8809 100644 --- a/docs/team-management/research-worktrees.md +++ b/docs/team-management/research-worktrees.md @@ -33,7 +33,7 @@ claude # Новая независимая сессия --- -## ЧАСТЬ 2: Существующая инфраструктура в claude-devtools +## ЧАСТЬ 2: Существующая инфраструктура в Claude Agent Teams UI ### Уже реализовано (можно переиспользовать) diff --git a/package.json b/package.json index 0071986f..99fbf21e 100644 --- a/package.json +++ b/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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86b0f669..a40dc5ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) @@ -1021,6 +1027,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: @@ -1222,6 +1241,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: @@ -1253,6 +1285,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: @@ -5830,6 +5875,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 @@ -6023,6 +6084,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 @@ -6066,6 +6144,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 diff --git a/public/icon.png b/public/icon.png index a61d654b..507ff3f6 100644 Binary files a/public/icon.png and b/public/icon.png differ diff --git a/resources/icon.png b/resources/icon.png index 6ad9c209..507ff3f6 100644 Binary files a/resources/icon.png and b/resources/icon.png differ diff --git a/resources/icon.svg b/resources/icon.svg new file mode 100644 index 00000000..29595c33 --- /dev/null +++ b/resources/icon.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/icons/mac/icon.icns b/resources/icons/mac/icon.icns index 3961a404..49f071b5 100644 Binary files a/resources/icons/mac/icon.icns and b/resources/icons/mac/icon.icns differ diff --git a/resources/icons/png/1024x1024.png b/resources/icons/png/1024x1024.png index 6e450783..507ff3f6 100644 Binary files a/resources/icons/png/1024x1024.png and b/resources/icons/png/1024x1024.png differ diff --git a/resources/icons/png/128x128.png b/resources/icons/png/128x128.png index 8fc2bf50..022c44b4 100644 Binary files a/resources/icons/png/128x128.png and b/resources/icons/png/128x128.png differ diff --git a/resources/icons/png/16x16.png b/resources/icons/png/16x16.png index b73af219..0c16eec9 100644 Binary files a/resources/icons/png/16x16.png and b/resources/icons/png/16x16.png differ diff --git a/resources/icons/png/24x24.png b/resources/icons/png/24x24.png index 13bec5a3..b9bf186d 100644 Binary files a/resources/icons/png/24x24.png and b/resources/icons/png/24x24.png differ diff --git a/resources/icons/png/256x256.png b/resources/icons/png/256x256.png index f8965103..2eb4990e 100644 Binary files a/resources/icons/png/256x256.png and b/resources/icons/png/256x256.png differ diff --git a/resources/icons/png/32x32.png b/resources/icons/png/32x32.png index 97afb8fc..afa7ae8c 100644 Binary files a/resources/icons/png/32x32.png and b/resources/icons/png/32x32.png differ diff --git a/resources/icons/png/48x48.png b/resources/icons/png/48x48.png index 50c3eab8..26c4e70d 100644 Binary files a/resources/icons/png/48x48.png and b/resources/icons/png/48x48.png differ diff --git a/resources/icons/png/512x512.png b/resources/icons/png/512x512.png index a61d654b..82ad1ced 100644 Binary files a/resources/icons/png/512x512.png and b/resources/icons/png/512x512.png differ diff --git a/resources/icons/png/64x64.png b/resources/icons/png/64x64.png index b6d20047..014623a6 100644 Binary files a/resources/icons/png/64x64.png and b/resources/icons/png/64x64.png differ diff --git a/resources/icons/win/icon.ico b/resources/icons/win/icon.ico index 574d3059..0f2ba7ac 100644 Binary files a/resources/icons/win/icon.ico and b/resources/icons/win/icon.ico differ diff --git a/src/main/index.ts b/src/main/index.ts index d911f96c..27d94fed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index f987b892..f76fd536 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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, }); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 183f0327..a1b81113 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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> { + 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> { + if (!request || typeof request !== 'object') { + return { success: false, error: 'Invalid team launch request' }; + } + + const payload = request as Partial; + 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> { + if (!request || typeof request !== 'object') { + return { success: false, error: 'Invalid create config request' }; + } + + const payload = request as Partial; + 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(); + 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> { + 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> { + 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> { return wrapTeamHandler('aliveList', async () => getTeamProvisioningService().getAliveTeams()); } + +async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise> { + return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks()); +} diff --git a/src/main/services/analysis/ChunkBuilder.ts b/src/main/services/analysis/ChunkBuilder.ts index 528c3edd..bf02f3aa 100644 --- a/src/main/services/analysis/ChunkBuilder.ts +++ b/src/main/services/analysis/ChunkBuilder.ts @@ -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 ); diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts index 8fde9184..2b7f7480 100644 --- a/src/main/services/analysis/SubagentDetailBuilder.ts +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -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 { 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` ); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 3fb087ba..46921bf4 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -46,8 +46,12 @@ async function resolveFromPathEnv(binaryName: string): Promise { return null; } +let cachedPath: string | null | undefined; + export class ClaudeBinaryResolver { static async resolve(): Promise { + 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; } } diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts new file mode 100644 index 00000000..d6514977 --- /dev/null +++ b/src/main/services/team/MemberStatsComputer.ts @@ -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(); + + constructor(private readonly logsFinder: TeamMemberLogsFinder) {} + + async getStats(teamName: string, memberName: string): Promise { + 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(); + const toolUsage: Record = {}; + 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; + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + messageCount: number; + durationMs: number; + }> { + let linesAdded = 0; + let linesRemoved = 0; + const filesTouchedSet = new Set(); + const toolUsage: Record = {}; + 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; + + 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).type === 'tool_use' + ) { + const toolBlock = block as Record; + 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 | 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 | null { + if (typeof msg.role === 'string') return msg.role; + if (msg.message && typeof msg.message === 'object') { + const inner = msg.message as Record; + if (typeof inner.role === 'string') return inner.role; + } + return null; + } + + private extractContent(msg: Record): unknown[] | null { + const content = msg.content ?? (msg.message as Record | undefined)?.content; + if (Array.isArray(content)) return content as unknown[]; + return null; + } + + private extractUsage( + msg: Record + ): { inputTokens: number; outputTokens: number; cacheReadTokens: number } | null { + const usage = (msg.usage ?? (msg.message as Record | undefined)?.usage) as + | Record + | 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, + }; + } +} diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index e47ca601..4ae804be 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -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: /teams//tools/teamctl.js + // Expected: /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//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 { - const toolsDir = path.join(getTeamsBasePath(), teamName, 'tools'); + async ensureInstalled(): Promise { + 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; } diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 24f4c17b..43c06c94 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -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 { 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(); + 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 { + 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; + } } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0c3f1e8b..0e60c01d 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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 { return this.configReader.listTeams(); } + async getAllTasks(): Promise { + const [rawTasks, teams] = await Promise.all([ + this.taskReader.getAllTasks(), + this.configReader.listTeams(), + ]); + + const teamInfoMap = new Map(); + 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 { + return this.configReader.updateConfig(teamName, updates); + } + async deleteTeam(teamName: string): Promise { 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 { + 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 { + 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; + try { + msg = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + if (msg.type !== 'assistant') continue; + + const message = (msg.message ?? msg) as Record; + 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[]) { + 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 { if (patch.op !== 'request_changes') { await this.kanbanManager.updateTask(teamName, taskId, patch); diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts new file mode 100644 index 00000000..daeaa7bf --- /dev/null +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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 { + 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 { + 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>>; + sessionIds: string[]; + knownMembers: Set; + 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( + (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 { + 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 + ): Promise { + 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; + + 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).routing as + | Record + | 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, + knownMembers: Set + ): { 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 | null { + if (typeof msg.content === 'string') { + return msg.content; + } + if (Array.isArray(msg.content)) { + const textParts = (msg.content as Record[]) + .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); + } + return null; + } + + private extractRole(msg: Record): string | null { + if (typeof msg.role === 'string') { + return msg.role; + } + if (msg.message && typeof msg.message === 'object') { + const inner = msg.message as Record; + if (typeof inner.role === 'string') { + return inner.role; + } + } + return null; + } + + private async parseLeadSessionSummary( + jsonlPath: string, + projectId: string, + sessionId: string, + memberName: string + ): Promise { + 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; + 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, '\\$&'); +} diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 15e422dd..b692f2ab 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -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(); + 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(); + 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, }); } diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts new file mode 100644 index 00000000..89ff6de8 --- /dev/null +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -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 { + 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; + if (!Array.isArray(file.members)) { + return []; + } + + const deduped = new Map(); + 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 { + const deduped = new Map(); + 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)); + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1797d82b..e45a30e5 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -13,12 +13,17 @@ import * as os from 'os'; import * as path from 'path'; import { promisify } from 'util'; +import { atomicWriteAsync } from './atomicWrite'; import { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; import { TeamConfigReader } from './TeamConfigReader'; +import { TeamInboxReader } from './TeamInboxReader'; +import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { TeamCreateRequest, TeamCreateResponse, + TeamLaunchRequest, + TeamLaunchResponse, TeamProvisioningPrepareResult, TeamProvisioningProgress, TeamProvisioningState, @@ -97,6 +102,7 @@ interface ProvisioningRun { fsPhase: 'waiting_config' | 'waiting_members' | 'waiting_tasks' | 'all_files_found'; waitingTasksSince: number | null; provisioningComplete: boolean; + isLaunch: boolean; } type ProvisioningAuthSource = @@ -229,15 +235,32 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string { .join('\n'); } +function buildTaskStatusProtocol(teamName: string): string { + return `MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: +1. Use this command to mark task started: + node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task start +2. Use this command to mark task completed BEFORE sending your final reply: + node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" task complete +3. If you are asked to review and task is accepted, move it to APPROVED (not DONE): + node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review approve +4. If review fails and changes are needed: + node \\"$HOME/.claude/tools/teamctl.js\\" --team \\"${teamName}\\" review request-changes --comment \\"\\" +5. NEVER skip status updates. A task is NOT done until completed status is written. +Failure to follow this protocol means the task board will show incorrect status.`; +} + function buildProvisioningPrompt(request: TeamCreateRequest): string { const displayName = request.displayName?.trim() || request.teamName; const description = request.description?.trim() || 'No description'; const members = buildMembersPrompt(request.members); + const taskProtocol = buildTaskStatusProtocol(request.teamName); + const userPromptBlock = request.prompt?.trim() + ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` + : ''; return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. -Output must be in English. -Goal: Provision a Claude Code agent team with live teammates and an initial task. +Goal: Provision a Claude Code agent team with live teammates. Constraints: - Do NOT call TeamDelete under any circumstances. @@ -255,36 +278,59 @@ Steps (execute in this exact order): - team_name: "${request.teamName}" - name: the member's name - subagent_type: "general-purpose" - - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Wait for messages from the team lead and respond accordingly. + - prompt: "You are {name}, a {role} on team \\"${displayName}\\" (${request.teamName}). Send ONE message to the team lead: 'Hello! My name is {name} ({role}). I'm ready.' Then wait for task assignments. -MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: -1. Use this command to mark task started: - node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" task start -2. Use this command to mark task completed BEFORE sending your final reply: - node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" task complete -3. If you are asked to review and task is accepted, move it to APPROVED (not DONE): - node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" review approve -4. If review fails and changes are needed: - node \\"$HOME/.claude/teams/${request.teamName}/tools/teamctl.js\\" review request-changes --comment \\"\\" -5. NEVER skip status updates. A task is NOT done until completed status is written. -Failure to follow this protocol means the task board will show incorrect status." - -3) TaskCreate — create 1 initial task: - - subject: "Bootstrap check" - - description: "Confirm team provisioning succeeded. Each member replied OK." - -4) SendMessage to each teammate: - - type: "message" - - summary: "Bootstrap" - - content: "Team \\"${displayName}\\" is ready. Your role: {role}. Reply with 'OK' when you are available." - -5) Wait for all teammates to reply OK, then output a short summary. +${taskProtocol}" +3) After spawning all members, output a short summary. +${userPromptBlock} Members: ${members} `; } +function buildLaunchPrompt( + request: TeamLaunchRequest, + members: TeamCreateRequest['members'] +): string { + const membersBlock = buildMembersPrompt(members); + const userPromptBlock = request.prompt?.trim() + ? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n` + : ''; + const taskProtocol = buildTaskStatusProtocol(request.teamName); + + return `You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn. + +Goal: Reconnect with existing team "${request.teamName}". + +Constraints: +- Do NOT call TeamDelete under any circumstances. +- Do NOT use TodoWrite — use TaskCreate for tasks. +- Do NOT send shutdown_request messages (SendMessage type: "shutdown_request" is FORBIDDEN). +- Do NOT shut down, terminate, or clean up the team or its members. +- Keep assistant text minimal. + +Steps (execute in this exact order): + +1) Read team config at ~/.claude/teams/${request.teamName}/config.json — understand current team state. + +2) Read the task list via TaskList — understand pending work. + +3) Spawn each existing member as a live teammate using the Task tool: + - team_name: "${request.teamName}" + - name: the member's name + - subagent_type: "general-purpose" + - prompt: "You are {name}, a {role} on team \\"${request.teamName}\\". The team has been reconnected. Send ONE message to the team lead: 'Hello! My name is {name} ({role}). I'm ready.' Then check TaskList for pending work and resume. + +${taskProtocol}" + +4) After spawning all members, output a short summary. +${userPromptBlock} +Members: +${membersBlock} +`; +} + function updateProgress( run: ProvisioningRun, state: Exclude, @@ -357,13 +403,52 @@ function buildCliExitError(code: number | null, stdoutText: string, stderrText: return `Claude CLI exited with code ${code ?? 'unknown'}`; } +interface CachedProbeResult { + claudePath: string; + env: NodeJS.ProcessEnv; + authSource: ProvisioningAuthSource; + warning?: string; +} + +let cachedProbeResult: CachedProbeResult | null = null; + export class TeamProvisioningService { private readonly runs = new Map(); private readonly activeByTeam = new Map(); - constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + constructor( + private readonly configReader: TeamConfigReader = new TeamConfigReader(), + private readonly inboxReader: TeamInboxReader = new TeamInboxReader(), + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore() + ) {} + + async warmup(): Promise { + try { + const claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) return; + const { env, authSource } = await this.buildProvisioningEnv(); + const cwd = process.cwd(); + const probe = await this.probeClaudeRuntime(claudePath, cwd, env); + cachedProbeResult = { claudePath, env, authSource, warning: probe.warning }; + logger.info('CLI warmup completed'); + } catch (error) { + logger.warn(`CLI warmup failed: ${error instanceof Error ? error.message : String(error)}`); + } + } async prepareForProvisioning(cwd?: string): Promise { + if (cachedProbeResult) { + const { warning, authSource } = cachedProbeResult; + const warnings: string[] = []; + if (warning) warnings.push(warning); + const isAuthFailure = warning ? this.isAuthFailureWarning(warning) : false; + return { + ready: !warning || authSource !== 'none' || !isAuthFailure, + message: 'CLI is warmed up and ready to launch', + warnings: warnings.length > 0 ? warnings : undefined, + }; + } + const claudePath = await ClaudeBinaryResolver.resolve(); if (!claudePath) { throw new Error('Claude CLI not found; install it or provide a valid path'); @@ -408,15 +493,16 @@ export class TeamProvisioningService { const probe = await this.probeClaudeRuntime(claudePath, targetCwd, executionEnv); if (probe.warning) { - if (authSource === 'none') { - // Preflight also failed — auth is truly missing + const isAuthFailure = this.isAuthFailureWarning(probe.warning); + if (authSource === 'none' && isAuthFailure) { + // No auth source + preflight indicates auth failure — block to avoid a confusing hang later. return { ready: false, message: probe.warning, warnings: warnings.length > 0 ? warnings : undefined, }; } - // We had an auth source but preflight still complained — warn but allow + // Preflight warnings (including timeouts) should not block provisioning. warnings.push(probe.warning); } @@ -427,6 +513,17 @@ export class TeamProvisioningService { }; } + private isAuthFailureWarning(text: string): boolean { + const lower = text.toLowerCase(); + return ( + lower.includes('not authenticated') || + lower.includes('not logged in') || + lower.includes('please run /login') || + lower.includes('missing api key') || + lower.includes('invalid api key') + ); + } + async createTeam( request: TeamCreateRequest, onProgress: (progress: TeamProvisioningProgress) => void @@ -472,6 +569,7 @@ export class TeamProvisioningService { lastLogProgressAt: 0, waitingTasksSince: null, provisioningComplete: false, + isLaunch: false, fsPhase: 'waiting_config', progress: { runId, @@ -632,6 +730,235 @@ export class TeamProvisioningService { return { runId }; } + async launchTeam( + request: TeamLaunchRequest, + onProgress: (progress: TeamProvisioningProgress) => void + ): Promise { + if (this.activeByTeam.has(request.teamName)) { + throw new Error('Team is already running'); + } + + // Verify config.json exists — team must already be provisioned + const configPath = path.join(getTeamsBasePath(), request.teamName, 'config.json'); + let configRaw: string; + try { + configRaw = await fs.promises.readFile(configPath, 'utf8'); + } catch { + throw new Error(`Team "${request.teamName}" not found — config.json does not exist`); + } + + const { + members: expectedMemberSpecs, + source, + warning, + } = await this.resolveLaunchExpectedMembers(request.teamName, configRaw); + const expectedMembers = expectedMemberSpecs.map((m) => m.name); + + // IMPORTANT: The CLI auto-suffixes teammate names when they already exist in config.json. + // Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names. + await this.normalizeTeamConfigForLaunch(request.teamName, configRaw); + + await ensureCwdExists(request.cwd); + + const claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); + } + + const teamsBasePathsToProbe = getTeamsBasePathsToProbe(); + const runId = randomUUID(); + const startedAt = nowIso(); + + // Build a synthetic TeamCreateRequest for reuse by shared infrastructure + const syntheticRequest: TeamCreateRequest = { + teamName: request.teamName, + members: expectedMemberSpecs, + cwd: request.cwd, + }; + + const run: ProvisioningRun = { + runId, + teamName: request.teamName, + startedAt, + stdoutBuffer: '', + stderrBuffer: '', + processKilled: false, + finalizingByTimeout: false, + cancelRequested: false, + teamsBasePathsToProbe, + child: null, + timeoutHandle: null, + fsMonitorHandle: null, + onProgress, + expectedMembers, + request: syntheticRequest, + lastLogProgressAt: 0, + waitingTasksSince: null, + provisioningComplete: false, + isLaunch: true, + fsPhase: 'waiting_members', + progress: { + runId, + teamName: request.teamName, + state: 'validating', + message: + source === 'members-meta' + ? 'Validating team launch request (members from members.meta.json)' + : source === 'inboxes' + ? 'Validating team launch request (members from inboxes)' + : 'Validating team launch request (fallback members from config.json)', + startedAt, + updatedAt: startedAt, + warnings: warning ? [warning] : undefined, + cliLogsTail: undefined, + }, + }; + + this.runs.set(runId, run); + this.activeByTeam.set(request.teamName, runId); + run.onProgress(run.progress); + + const prompt = buildLaunchPrompt(request, expectedMemberSpecs); + let child: ReturnType; + const { env: shellEnv, authSource } = await this.buildProvisioningEnv(); + if (authSource === 'none') { + logger.warn( + 'No explicit auth env var found for `-p` mode (launch). ' + + 'Attempting spawn anyway — CLI may authenticate via apiKeyHelper, SSO, or other mechanism.' + ); + } + try { + child = spawn( + claudePath, + [ + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose', + '--setting-sources', + 'user,project,local', + '--disallowedTools', + 'TeamDelete,TodoWrite', + ], + { + cwd: request.cwd, + env: { + ...shellEnv, + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); + } catch (error) { + this.runs.delete(runId); + this.activeByTeam.delete(request.teamName); + throw error; + } + + updateProgress(run, 'spawning', 'Starting Claude CLI process for team launch', { + pid: child.pid ?? undefined, + }); + run.onProgress(run.progress); + run.child = child; + + // Send launch prompt + if (child.stdin) { + const message = JSON.stringify({ + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: prompt }], + }, + }); + child.stdin.write(message + '\n'); + } + + if (child.stdout) { + let stdoutLineBuf = ''; + child.stdout.on('data', (chunk: Buffer) => { + const text = chunk.toString('utf8'); + run.stdoutBuffer += text; + if (run.stdoutBuffer.length > STDOUT_RING_LIMIT) { + run.stdoutBuffer = run.stdoutBuffer.slice(run.stdoutBuffer.length - STDOUT_RING_LIMIT); + } + + stdoutLineBuf += text; + const lines = stdoutLineBuf.split('\n'); + stdoutLineBuf = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const msg = JSON.parse(trimmed) as Record; + this.handleStreamJsonMessage(run, msg); + } catch { + // Not valid JSON + } + } + + const currentTs = Date.now(); + if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { + run.lastLogProgressAt = currentTs; + emitLogsProgress(run); + } + }); + } + + if (child.stderr) { + child.stderr.on('data', (chunk: Buffer) => { + run.stderrBuffer += chunk.toString('utf8'); + if (run.stderrBuffer.length > STDERR_RING_LIMIT) { + run.stderrBuffer = run.stderrBuffer.slice(run.stderrBuffer.length - STDERR_RING_LIMIT); + } + const currentTs = Date.now(); + if (currentTs - run.lastLogProgressAt >= LOG_PROGRESS_THROTTLE_MS) { + run.lastLogProgressAt = currentTs; + emitLogsProgress(run); + } + }); + } + + // Filesystem monitor — config already exists, start from 'waiting_members' + this.startFilesystemMonitor(run, syntheticRequest); + + run.timeoutHandle = setTimeout(() => { + if (!run.processKilled && !run.provisioningComplete) { + run.processKilled = true; + run.finalizingByTimeout = true; + void (async () => { + const readyOnTimeout = await this.tryCompleteAfterTimeout(run); + run.child?.stdin?.end(); + run.child?.kill(); + if (readyOnTimeout) { + return; + } + + const progress = updateProgress(run, 'failed', 'Timed out waiting for CLI (launch)', { + error: 'Timed out waiting for CLI during team launch.', + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + })(); + } + }, RUN_TIMEOUT_MS); + + child.once('error', (error) => { + const progress = updateProgress(run, 'failed', 'Failed to start Claude CLI (launch)', { + error: error.message, + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + this.cleanupRun(run); + }); + + child.once('exit', (code) => { + void this.handleProcessExit(run, code); + }); + + return { runId }; + } + async getProvisioningStatus(runId: string): Promise { const run = this.runs.get(runId); if (!run) { @@ -763,6 +1090,18 @@ export class TeamProvisioningService { } this.stopFilesystemMonitor(run); + if (run.isLaunch) { + await this.ensureProjectPathInConfig(run.teamName, run.request.cwd); + await this.appendSessionToHistory(run.teamName); + const readyMessage = 'Team launched — process alive and ready'; + const progress = updateProgress(run, 'ready', readyMessage, { + cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), + }); + run.onProgress(progress); + logger.info(`[${run.teamName}] Launch complete. Process alive for subsequent tasks.`); + return; + } + // Quick verification: config should exist by now const configProbe = await this.waitForValidConfig(run, 5000); if (!configProbe.ok) { @@ -788,8 +1127,10 @@ export class TeamProvisioningService { return; } - // Patch config with expected members (fallback if CLI didn't register all) - await this.patchConfigWithExpectedMembers(run.teamName, run.request); + // Persist teammates metadata separately from config.json. + await this.persistMembersMeta(run.teamName, run.request); + await this.ensureProjectPathInConfig(run.teamName, run.request.cwd); + await this.appendSessionToHistory(run.teamName); const progress = updateProgress(run, 'ready', 'Team provisioned — process alive and ready', { cliLogsTail: extractLogsTail(run.stdoutBuffer, run.stderrBuffer), @@ -901,15 +1242,12 @@ export class TeamProvisioningService { if (taskFound || taskFallbackExpired) { run.fsPhase = 'all_files_found'; - const message = taskFound - ? `Team provisioned: ${taskCount} task(s). Teammates working...` - : 'Team provisioned (no task file yet). Teammates working...'; - const progress = updateProgress(run, 'monitoring', message); - run.onProgress(progress); - // No early-kill — let the process run naturally. - // The lead will finish when teammates complete their work, - // then system-reminder fires, --disallowedTools blocks TeamDelete, - // and the process exits on its own. + // Mark provisioning complete early — files are on disk, + // no need to wait for stream-json result.success. + // The process stays alive for subsequent tasks. + if (!run.provisioningComplete) { + void this.handleProvisioningTurnComplete(run); + } } } } catch (error) { @@ -1006,7 +1344,9 @@ export class TeamProvisioningService { if (missingInboxes.length > 0) { warnings.push('Some inboxes not created yet'); } - await this.patchConfigWithExpectedMembers(run.teamName, run.request); + if (!run.isLaunch) { + await this.persistMembersMeta(run.teamName, run.request); + } // Mark as disconnected since the process is dead const progress = updateProgress( run, @@ -1165,7 +1505,9 @@ export class TeamProvisioningService { warnings.push('Some inboxes not created yet'); } - await this.patchConfigWithExpectedMembers(run.teamName, run.request); + if (!run.isLaunch) { + await this.persistMembersMeta(run.teamName, run.request); + } // Process was killed by timeout — mark as disconnected, not ready const progress = updateProgress(run, 'disconnected', 'Team provisioned but process timed out', { warnings, @@ -1327,58 +1669,395 @@ export class TeamProvisioningService { } /** - * After the CLI creates config.json (with only team-lead), patch in the - * expected members from the provisioning request. The simplified prompt - * sends bootstrap messages to inboxes but does not spawn actual teammate - * processes, so the members array would otherwise only contain the lead. + * Append current leadSessionId to sessionHistory array in config.json. + * Called after launch/create to track which sessions belong to this team. */ - private async patchConfigWithExpectedMembers( - teamName: string, - request: TeamCreateRequest - ): Promise { + private async appendSessionToHistory(teamName: string): Promise { const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); try { const raw = await fs.promises.readFile(configPath, 'utf8'); const config = JSON.parse(raw) as Record; - const existingMembers = Array.isArray(config.members) - ? (config.members as Record[]) - : []; - - const existingNames = new Set( - existingMembers.filter((m) => typeof m.name === 'string').map((m) => m.name as string) - ); - - const memberColors = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red']; - let colorIdx = 0; - - for (const member of request.members) { - if (existingNames.has(member.name)) { - continue; - } - - existingMembers.push({ - agentId: `${member.name}@${teamName}`, - name: member.name, - agentType: 'general-purpose', - role: member.role?.trim() || undefined, - color: memberColors[colorIdx % memberColors.length], - joinedAt: Date.now(), - tmuxPaneId: '', - cwd: request.cwd, - subscriptions: [], - }); - colorIdx++; + const leadSessionId = config.leadSessionId; + if (typeof leadSessionId !== 'string' || leadSessionId.trim().length === 0) { + return; } - - config.members = existingMembers; + const history = Array.isArray(config.sessionHistory) + ? (config.sessionHistory as string[]) + : []; + if (history.includes(leadSessionId)) { + return; + } + history.push(leadSessionId); + config.sessionHistory = history; await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); + logger.info(`[${teamName}] Appended session ${leadSessionId} to sessionHistory`); + } catch (error) { + logger.warn( + `[${teamName}] Failed to append session to history: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + private async ensureProjectPathInConfig(teamName: string, projectPath: string): Promise { + if (!projectPath.trim()) { + return; + } + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + try { + const raw = await fs.promises.readFile(configPath, 'utf8'); + const config = JSON.parse(raw) as Record; + + // Always update projectPath to current cwd + config.projectPath = projectPath; + + // Maintain ordered history (no duplicates, most recent last) + const history = Array.isArray(config.projectPathHistory) + ? (config.projectPathHistory as string[]).filter( + (p) => typeof p === 'string' && p !== projectPath + ) + : []; + history.push(projectPath); + config.projectPathHistory = history; + + await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); + } catch (error) { + logger.warn( + `[${teamName}] Failed to ensure projectPath in config.json: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + private async normalizeTeamConfigForLaunch(teamName: string, configRaw: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const backupPath = `${configPath}.prelaunch.bak`; + + let parsed: unknown; + try { + parsed = JSON.parse(configRaw) as unknown; + } catch { + return; + } + if (!parsed || typeof parsed !== 'object') { + return; + } + + const config = parsed as Record; + const members = Array.isArray(config.members) + ? (config.members as Record[]) + : []; + if (members.length === 0) { + return; + } + + // Keep only the lead entry. + const leadMembers = members.filter((member) => { + const agentType = member.agentType; + if (typeof agentType === 'string' && agentType === 'team-lead') { + return true; + } + const leadAgentId = config.leadAgentId; + return ( + typeof leadAgentId === 'string' && + typeof member.agentId === 'string' && + member.agentId === leadAgentId + ); + }); + + // If already lead-only, no-op. + if (leadMembers.length === members.length) { + return; + } + + // Try to determine base teammate names for inbox cleanup (prefer meta). + const baseNames = new Set(); + try { + const metaMembers = await this.membersMetaStore.getMembers(teamName); + for (const member of metaMembers) { + const name = member.name.trim(); + if (name.length > 0) baseNames.add(name); + } + } catch { + // ignore + } + if (baseNames.size === 0) { + for (const member of members) { + const name = typeof member.name === 'string' ? member.name.trim() : ''; + const agentType = typeof member.agentType === 'string' ? member.agentType : ''; + if (name && agentType && agentType !== 'team-lead' && !/-\d+$/.test(name)) { + baseNames.add(name); + } + } + } + + // Backup current config on disk for crash recovery / debugging. + try { + await atomicWriteAsync(backupPath, configRaw); + } catch (error) { + logger.warn( + `[${teamName}] Failed to write config prelaunch backup: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + // Write normalized config atomically. + config.members = leadMembers; + try { + await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); logger.info( - `Patched config.json for ${teamName}: added ${request.members.length - (existingNames.size - 1)} members` + `[${teamName}] Normalized config.json for launch: kept ${leadMembers.length} lead member(s)` ); } catch (error) { logger.warn( - `Failed to patch config.json with members: ${error instanceof Error ? error.message : String(error)}` + `[${teamName}] Failed to normalize config.json for launch: ${ + error instanceof Error ? error.message : String(error) + }` ); + return; + } + + // Best-effort: merge and remove suffixed inboxes like alice-2.json to avoid UI duplicates. + await this.mergeAndRemoveDuplicateInboxes(teamName, baseNames); + } + + private async mergeAndRemoveDuplicateInboxes( + teamName: string, + baseNames: Set + ): Promise { + if (baseNames.size === 0) return; + + const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); + let entries: string[]; + try { + entries = await fs.promises.readdir(inboxDir); + } catch { + return; + } + + const existing = new Set(entries.filter((e) => e.endsWith('.json') && !e.startsWith('.'))); + + for (const baseName of baseNames) { + const canonicalFile = `${baseName}.json`; + if (!existing.has(canonicalFile)) { + continue; + } + + const duplicates = Array.from(existing) + .filter((file) => file.startsWith(`${baseName}-`) && file.endsWith('.json')) + .filter((file) => /-\d+\.json$/.test(file)); + + if (duplicates.length === 0) { + continue; + } + + const canonicalPath = path.join(inboxDir, canonicalFile); + let canonicalRaw: string; + try { + canonicalRaw = await fs.promises.readFile(canonicalPath, 'utf8'); + } catch { + // If cannot read, skip cleanup for this base. + continue; + } + + let canonicalParsed: unknown; + try { + canonicalParsed = JSON.parse(canonicalRaw) as unknown; + } catch { + canonicalParsed = []; + } + const canonicalList = Array.isArray(canonicalParsed) ? (canonicalParsed as unknown[]) : []; + + const merged = [...canonicalList]; + for (const dupFile of duplicates) { + const dupPath = path.join(inboxDir, dupFile); + let dupRaw: string; + try { + dupRaw = await fs.promises.readFile(dupPath, 'utf8'); + } catch { + continue; + } + + let dupParsed: unknown; + try { + dupParsed = JSON.parse(dupRaw) as unknown; + } catch { + dupParsed = []; + } + if (Array.isArray(dupParsed)) { + const dupList = dupParsed as unknown[]; + merged.push(...dupList); + } + } + + // Dedup by messageId when available, then sort by timestamp desc. + const dedupById = new Map(); + const noId: unknown[] = []; + for (const item of merged) { + if (!item || typeof item !== 'object') { + continue; + } + const msg = item as { messageId?: unknown }; + if (typeof msg.messageId === 'string' && msg.messageId.trim().length > 0) { + dedupById.set(msg.messageId, item); + } else { + noId.push(item); + } + } + const mergedDeduped = [...Array.from(dedupById.values()), ...noId]; + mergedDeduped.sort((a, b) => { + const at = + a && typeof a === 'object' + ? Date.parse((a as { timestamp?: string }).timestamp ?? '') + : 0; + const bt = + b && typeof b === 'object' + ? Date.parse((b as { timestamp?: string }).timestamp ?? '') + : 0; + if (Number.isNaN(at) || Number.isNaN(bt)) return 0; + return bt - at; + }); + + try { + await atomicWriteAsync(canonicalPath, JSON.stringify(mergedDeduped, null, 2)); + } catch { + continue; + } + + for (const dupFile of duplicates) { + try { + await fs.promises.unlink(path.join(inboxDir, dupFile)); + existing.delete(dupFile); + } catch { + // Best-effort cleanup. + } + } + } + } + + private async persistMembersMeta(teamName: string, request: TeamCreateRequest): Promise { + const teammateMembers = request.members.filter((member) => member.name.trim().length > 0); + if (teammateMembers.length === 0) { + return; + } + + const memberColors = ['blue', 'green', 'yellow', 'cyan', 'magenta', 'red'] as const; + const joinedAt = Date.now(); + + try { + await this.membersMetaStore.writeMembers( + teamName, + teammateMembers.map((member, index) => ({ + name: member.name, + role: member.role?.trim() || undefined, + agentType: 'general-purpose', + color: memberColors[index % memberColors.length], + joinedAt, + })) + ); + } catch (error) { + logger.warn( + `[${teamName}] Failed to persist members.meta.json: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + private async resolveLaunchExpectedMembers( + teamName: string, + configRaw: string + ): Promise<{ + members: TeamCreateRequest['members']; + source: 'members-meta' | 'inboxes' | 'config-fallback'; + warning?: string; + }> { + try { + const metaMembers = await this.membersMetaStore.getMembers(teamName); + const byName = new Map(); + for (const member of metaMembers) { + const name = member.name?.trim(); + if (!name) continue; + const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined; + const prev = byName.get(name); + if (!prev) { + byName.set(name, { name, role }); + } else if (!prev.role && role) { + byName.set(name, { ...prev, role }); + } + } + const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); + if (members.length > 0) { + return { members, source: 'members-meta' }; + } + } catch (error) { + logger.warn( + `[${teamName}] Failed to read members.meta.json: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + try { + const inboxNames = Array.from( + new Set( + (await this.inboxReader.listInboxNames(teamName)) + .map((name) => name.trim()) + .filter((name) => name.length > 0) + ) + ); + if (inboxNames.length > 0) { + const members = inboxNames.map((name) => ({ name })); + return { members, source: 'inboxes' }; + } + } catch (error) { + logger.warn( + `[${teamName}] Failed to read inbox member names: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const configMembers = this.extractTeammateSpecsFromConfig(teamName, configRaw); + if (configMembers.length > 0) { + return { + members: configMembers, + source: 'config-fallback', + warning: + 'members.meta.json and inboxes are empty; launch fell back to config.json members. ' + + 'Run a fresh team bootstrap to persist stable member metadata.', + }; + } + + return { + members: [], + source: 'config-fallback', + warning: + 'No teammate roster found in members.meta.json, inboxes, or config.json. Launch will continue without explicit teammate names.', + }; + } + + private extractTeammateSpecsFromConfig( + teamName: string, + configRaw: string + ): TeamCreateRequest['members'] { + try { + const parsed = JSON.parse(configRaw) as { members?: { name?: string; agentType?: string }[] }; + if (!Array.isArray(parsed.members)) { + return []; + } + const byName = new Map(); + for (const member of parsed.members) { + if (!member || member.agentType === 'team-lead') continue; + const name = typeof member.name === 'string' ? member.name.trim() : ''; + if (!name) continue; + byName.set(name, { name }); + } + return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); + } catch { + logger.warn(`[${teamName}] Failed to parse config.json for launch fallback members`); + return []; } } @@ -1410,13 +2089,23 @@ export class TeamProvisioningService { } // Stage 2: verify `-p` mode auth actually works - const pingProbe = await this.spawnProbe( - claudePath, - ['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'], - cwd, - env, - PREFLIGHT_TIMEOUT_MS - ); + let pingProbe: { exitCode: number | null; stdout: string; stderr: string } | null = null; + try { + pingProbe = await this.spawnProbe( + claudePath, + ['-p', 'Reply with the single word PONG and nothing else', '--output-format', 'text'], + cwd, + env, + PREFLIGHT_TIMEOUT_MS + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + warning: + 'Preflight check for `claude -p` did not complete. ' + + `Proceeding anyway. Details: ${message}`, + }; + } const combinedOutput = buildCombinedLogs(pingProbe.stdout, pingProbe.stderr); const lowerOutput = combinedOutput.toLowerCase(); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 617c81c3..2c6ac2ac 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -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; + } } diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 86f7b43c..457db1bc 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -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)); diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index 1a3f6b75..b3f642e0 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -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'; diff --git a/src/main/standalone.ts b/src/main/standalone.ts index e417470d..66b05048 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -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 diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index dd1b94ad..4828c471 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -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) diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index a14b87fd..9b1abde8 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -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 diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index b7e2d78a..7a8c537b 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -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 diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 2fab97e5..07478cca 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -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'); +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 73db8fd2..01f44e23 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 257c2a75..a7919f06 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, @@ -496,6 +510,9 @@ const electronAPI: ElectronAPI = { createTeam: async (request: TeamCreateRequest) => { return invokeIpcWithResult(TEAM_CREATE, request); }, + launchTeam: async (request: TeamLaunchRequest) => { + return invokeIpcWithResult(TEAM_LAUNCH, request); + }, getProvisioningStatus: async (runId: string) => { return invokeIpcWithResult(TEAM_PROVISIONING_STATUS, runId); }, @@ -526,6 +543,21 @@ const electronAPI: ElectronAPI = { aliveList: async () => { return invokeIpcWithResult(TEAM_ALIVE_LIST); }, + createConfig: async (request: TeamCreateConfigRequest) => { + return invokeIpcWithResult(TEAM_CREATE_CONFIG, request); + }, + getMemberLogs: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_GET_MEMBER_LOGS, teamName, memberName); + }, + getMemberStats: async (teamName: string, memberName: string) => { + return invokeIpcWithResult(TEAM_GET_MEMBER_STATS, teamName, memberName); + }, + getAllTasks: async () => { + return invokeIpcWithResult(TEAM_GET_ALL_TASKS); + }, + updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => { + return invokeIpcWithResult(TEAM_UPDATE_CONFIG, teamName, updates); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { ipcRenderer.on( TEAM_CHANGE, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 95449318..d0945fd0 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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, @@ -616,6 +619,9 @@ export class HttpAPIClient implements ElectronAPI { createTeam: async (_request: TeamCreateRequest): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, + launchTeam: async (_request: TeamLaunchRequest): Promise => { + throw new Error('Team launch is not available in browser mode'); + }, getProvisioningStatus: async (_runId: string): Promise => { throw new Error('Team provisioning is not available in browser mode'); }, @@ -657,6 +663,38 @@ export class HttpAPIClient implements ElectronAPI { aliveList: async (): Promise => { return []; }, + createConfig: async (): Promise => { + 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 => { + 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) diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index a6a4acbe..d1a2fd0d 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -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 => { )} + {sessionTeam && ( +
0 ? '-1.5rem' : 0 }} + > + +
+ )}
0 ? '-2rem' : 0 }} + style={{ marginTop: allContextInjections.length > 0 && !sessionTeam ? '-2rem' : 0 }} >
{shouldVirtualize ? ( diff --git a/src/renderer/components/chat/ChatHistoryItem.tsx b/src/renderer/components/chat/ChatHistoryItem.tsx index b3b589dd..8e049138 100644 --- a/src/renderer/components/chat/ChatHistoryItem.tsx +++ b/src/renderer/components/chat/ChatHistoryItem.tsx @@ -70,7 +70,7 @@ const ChatHistoryItemInner = ({ return (
@@ -88,7 +88,7 @@ const ChatHistoryItemInner = ({ return (
@@ -110,7 +110,7 @@ const ChatHistoryItemInner = ({ return (
diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index 4b64eed4..6463f947 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -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 { diff --git a/src/renderer/components/common/AppLogo.tsx b/src/renderer/components/common/AppLogo.tsx new file mode 100644 index 00000000..23214667 --- /dev/null +++ b/src/renderer/components/common/AppLogo.tsx @@ -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 => ( + + + {/* Edges */} + + + + {/* Nodes */} + + + + {/* Cores */} + + + + +); diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index a85afb05..f65d881f 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -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): React }, [openCommandPalette]); return ( -
+
{/* Search container with glow effect on focus */}
{ const [searchQuery, setSearchQuery] = useState(''); - const openSettingsTab = useStore((s) => s.openSettingsTab); + const { openSettingsTab, openTeamsTab } = useStore( + useShallow((s) => ({ + openSettingsTab: s.openSettingsTab, + openTeamsTab: s.openTeamsTab, + })) + ); return (
@@ -406,9 +411,19 @@ export const DashboardView = (): React.JSX.Element => { {/* Content */}
- {/* Command Search */} -
- + {/* Team select + Search */} +
+ + or +
+ +
{/* Section header */} diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index 6f8f5706..bd1d6a0c 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -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 */} - {/* Date-grouped session list */} + {/* Global task list */}
- +
{/* Resize handle */} diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx index d3cbd291..1accc1f6 100644 --- a/src/renderer/components/layout/SidebarHeader.tsx +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -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 */} + +
+

Notifications from these repositories will be ignored diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx new file mode 100644 index 00000000..f5e91817 --- /dev/null +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -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 = { + '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('all'); + const [searchQuery, setSearchQuery] = useState(''); + const searchInputRef = useRef(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 ( +

+ {/* Header + Filter bar */} +
+ Tasks +
+ {filterButtons.map((btn) => ( + + ))} +
+
+ + {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + {/* Content */} +
+ {globalTasksLoading && globalTasks.length === 0 && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {!globalTasksLoading && categories.length === 0 && ( +
+ + + {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} + +
+ )} + + {categories.map((category) => { + const tasks = grouped[category]; + let lastTeam: string | null = null; + + return ( +
+ {/* Date header */} +
+ {dateCategoryLabels[category] ?? category} +
+ + {tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + +
+ ); + })} +
+ ); + })} +
+
+ ); +}; diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx new file mode 100644 index 00000000..6d9d498f --- /dev/null +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -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 = { + 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 ( + + ); +}; diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 1bafa55b..386af8d9 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -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 (
@@ -34,7 +40,10 @@ export const CollapsibleTeamSection = ({ /> {title} {badge != null && ( - + {badge} )} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index f09971e9..141f89a1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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(null); + const [selectedMember, setSelectedMember] = useState(null); const [createTaskDialog, setCreateTaskDialog] = useState({ 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(undefined); + + // Session loading and filtering state + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionsError, setSessionsError] = useState(null); + const [kanbanFilter, setKanbanFilter] = useState({ + 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(); + 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(() => { + 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 (
-

Не удалось загрузить команду

+

Failed to load team

{error}

@@ -160,25 +363,80 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele if (!data) { return (
- Нет данных по команде + No team data available
); } + const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : null; + return (
-
-

{data.config.name}

- {data.config.description && ( -

{data.config.description}

- )} +
+ {headerColorSet ? ( +
+ ) : null} +
+
+

{data.config.name}

+ {data.config.description && ( +

+ {data.config.description} +

+ )} +
+
+ {!data.isAlive ? ( + + ) : null} + + +
+
{data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? (
- Не удалось полностью загрузить kanban. Отображены безопасные данные. + Failed to fully load kanban. Displaying safe data.
) : null} {reviewActionError ? ( @@ -187,14 +445,31 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
) : null} - - + + + + + + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> 0} action={ } > +
+ + 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 && ( + + )} +
{ void requestReview(teamName, taskId); }} @@ -239,26 +541,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele />
- -
- { - void sendTeamMessage(teamName, { member, text, summary }); + { + e.stopPropagation(); + setSendDialogRecipient(undefined); + setSendDialogOpen(true); }} - /> -
- { - openCreateTaskDialog(subject, description); - }} - /> -
-
+ > + + Message + + } + > + { + openCreateTaskDialog(subject, description); + }} + />
+ setSelectedMember(null)} + onSendMessage={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + setSendDialogRecipient(name || undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + setSelectedMember(null); + openCreateTaskDialog('', '', name); + }} + /> + -
- -
+ setEditDialogOpen(false)} + onSaved={() => void selectTeam(teamName)} + /> + + setLaunchDialogOpen(false)} + onLaunch={async (request) => { + await launchTeam(request); + }} + /> + + { + void sendTeamMessage(teamName, { member, text, summary }); + }} + onClose={() => setSendDialogOpen(false)} + />
); }; diff --git a/src/renderer/components/team/TeamEmptyState.tsx b/src/renderer/components/team/TeamEmptyState.tsx index f3042a9e..ed7fe81f 100644 --- a/src/renderer/components/team/TeamEmptyState.tsx +++ b/src/renderer/components/team/TeamEmptyState.tsx @@ -2,9 +2,9 @@ export const TeamEmptyState = (): React.JSX.Element => { return (
-

Команды не найдены

+

No teams found

- Создайте команду в Claude Code, затем обновите список. + Create a team in Claude Code, then refresh the list.

diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 87d2b255..5c151994 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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 +): 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 ( + + + Running + + ); + case 'provisioning': + return ( + + + Launching... + + ); + case 'offline': + return ( + + + Offline + + ); + } +}; + export const TeamListView = (): React.JSX.Element => { const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); + const [copyData, setCopyData] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [aliveTeams, setAliveTeams] = useState([]); 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 => { + try { + const list = await api.teams.aliveList(); + if (!cancelled) setAliveTeams(list); + } catch { + // best-effort + } + }; + void fetchAlive(); + return () => { + cancelled = true; + }; + }, [electronMode, teams]); + + const filteredTeams = useMemo(() => { + 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 => {

- Teams доступен только в Electron-режиме + Teams is only available in Electron mode

- В browser mode доступ к локальным папкам `~/.claude/teams` недоступен. + In browser mode, access to local `~/.claude/teams` directories is not available.

@@ -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
{!canCreate ? (

- Доступно только в local Electron-режиме. + Only available in local Electron mode.

) : null} + + {teams.length > 0 ? ( +
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+ ) : null}
); @@ -135,7 +271,7 @@ export const TeamListView = (): React.JSX.Element => {
{renderHeader()}
- Загружаем команды... + Loading teams...
{createDialogElement}
@@ -148,7 +284,7 @@ export const TeamListView = (): React.JSX.Element => { {renderHeader()}
-

Не удалось загрузить команды

+

Failed to load teams

{teamsError}

@@ -181,48 +317,105 @@ export const TeamListView = (): React.JSX.Element => {
{renderHeader()} -
- {teams.map((team) => ( -
openTeamTab(team.teamName)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openTeamTab(team.teamName); - } - }} - > -
-

- {team.displayName} -

- -
-

- {team.description || 'Без описания'} -

-
- - Участников: {team.memberCount} - - - Задач: {team.taskCount} - -
-
- ))} -
+ {teamColorSet ? ( +
+ ) : null} +
+
+
+

+ {team.displayName} +

+ +
+
+ + +
+
+

+ {team.description || 'No description'} +

+
+ + Members: {team.memberCount} + + + Tasks: {team.taskCount} + +
+ {(() => { + const projects = getRecentProjects(team); + if (projects.length === 0) return null; + return ( +
+ + + {projects.map((p, i) => ( + + {i === 0 && status === 'running' ? ( + {folderName(p)} + ) : ( + folderName(p) + )} + {i < projects.length - 1 ? ', ' : ''} + + ))} + +
+ ); + })()} +
+
+ ); + })} +
+ )} {createDialogElement}
); diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index b2ca0bea..092c8725 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -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 ( +
+ +

Team launched — process alive

+ +
+ ); + } + if (isActive) { return (
@@ -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)]' )} diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx new file mode 100644 index 00000000..ba0966ba --- /dev/null +++ b/src/renderer/components/team/TeamSessionsSection.tsx @@ -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 ( +
+ + No project path linked +

Sessions will appear after team provisioning

+
+ ); + } + + if (!projectId) { + return ( +
+ + Project not found +

{projectPath}

+
+ ); + } + + if (sessionsLoading) { + return ( +
+ + Loading sessions... +
+ ); + } + + if (sessionsError) { + return ( +
+ + {sessionsError} +
+ ); + } + + if (sortedSessions.length === 0) { + return ( +
+ + No sessions found +
+ ); + } + + return ( +
+ {selectedSessionId !== null && ( + + )} + {sortedSessions.map((session) => ( + handleSessionClick(session)} + onToggleFilter={() => + onSelectSession(session.id === selectedSessionId ? null : session.id) + } + /> + ))} +
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 ( +
+ {isLead && } + + + +
+ + +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 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'); +} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 8d43f5e2..2593cb6b 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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; 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 => ( - {name} -); - -const CompactStatusLine = ({ - icon, - text, - className, -}: { - icon: React.ReactNode; - text: string; - className?: string; -}): React.JSX.Element => ( -
- {icon} - {text} -
-); - -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 ( - } - 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 ( - } - text={`${agentName} has shut down`} - className="bg-red-500/5 text-red-300" - /> - ); - } - const reason = getStringField(parsed, 'content') ?? 'declined'; - return ( - } - 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 ( - } - text={`Shutdown requested for ${recipient}`} - className="bg-amber-500/5 text-amber-300" - /> - ); + return 'Shutdown requested'; } if (type === 'shutdown_approved' || type === 'teammate_terminated') { - return ( - } - 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 ( - } - 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 => ( -
-

{autoSummary}

-
- - Raw JSON - -
-        {JSON.stringify(parsed, null, 2)}
-      
-
+
+ + + {name} + + + {label} +
); -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 ; + } + 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 ( -
-
-
- - -
-
- {onCreateTask && ( - - )} -

{timestamp}

-
-
-
- ); - } + const summaryText = message.summary || autoSummary || ''; return ( -
-
-
- -

- {message.from} - {message.to && message.to !== message.from ? ( - - {' → '} - {message.to} - - ) : null} -

- {messageType ? ( - - {messageType} - - ) : null} -
-
+
+ {/* Header */} +
+ {message.source === 'lead_session' ? ( + + ) : ( + + )} + + {/* Name badge */} + + {message.from} + + + {/* Role */} + {formattedRole ? ( + + {formattedRole} + + ) : null} + + {/* Message type label */} + {messageType ? ( + + {messageType} + + ) : null} + + {/* Lead session marker */} + {message.source === 'lead_session' ? ( + + session + + ) : null} + + {/* Recipient */} + {message.to && message.to !== message.from ? ( + + → {message.to} + + ) : null} + + {/* Summary */} + + {summaryText} + + + {/* Timestamp + create task */} +
{onCreateTask && ( )} -

{timestamp}

+ + {timestamp} +
- {message.summary ? ( -

{message.summary}

- ) : autoSummary && autoSummary !== messageType ? ( -

{autoSummary}

- ) : null} - {structured ? ( - - ) : ( - - )} + + {/* Content — always expanded */} +
+ {structured ? ( +
+ {autoSummary && autoSummary !== messageType ? ( +

{autoSummary}

+ ) : null} +
+ + Raw JSON + +
+                {JSON.stringify(structured, null, 2)}
+              
+
+
+ ) : ( + + )} +
); }; diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 0c9cbaef..9dadec9b 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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(); + 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 (
-

Нет сообщений

-

Отправьте сообщение участнику, чтобы увидеть активность.

+

No messages

+

Send a message to a member to see activity.

); } return ( -
- {messages.slice(0, 200).map((message) => ( - - ))} +
+ {messages.slice(0, 200).map((message, index) => { + const info = memberInfo.get(message.from); + return ( + + ); + })}
); }; diff --git a/src/renderer/components/team/activity/MessageComposer.tsx b/src/renderer/components/team/activity/MessageComposer.tsx deleted file mode 100644 index e3f48040..00000000 --- a/src/renderer/components/team/activity/MessageComposer.tsx +++ /dev/null @@ -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 ( -
- - - {open ? ( -
-
-
- - -
- -
- - setSummary(event.target.value)} - /> -
- -
- -