commit
c01461e90f
148 changed files with 7250 additions and 3669 deletions
|
|
@ -10,7 +10,8 @@
|
|||
|
||||
<p align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||
<a href="https://discord.gg/m5gszZKG"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
@ -18,6 +19,7 @@
|
|||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
## What is this
|
||||
|
||||
A new approach to task management with AI agents.
|
||||
|
|
@ -47,7 +49,7 @@ A new approach to task management with AI agents.
|
|||
- **Attach code context** — reference files or snippets in messages, like in Cursor
|
||||
- **Notification system** — configurable alerts when tasks complete, agents need attention, or errors occur
|
||||
- **MCP integration** — supports the built-in `mcp-server` (see [mcp-server folder](./mcp-server)) for integrating external tools and extensible agent plugins out of the box
|
||||
|
||||
- **Post-compact context recovery** — when Claude compresses its context, the app restores the key team-management instructions so kanban/task-board coordination stays consistent and important operational context is not lost
|
||||
</details>
|
||||
|
||||
## Installation
|
||||
|
|
|
|||
2
mcp-server/.gitignore
vendored
Normal file
2
mcp-server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
node_modules/
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
# @claude-team/mcp-server
|
||||
|
||||
**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.**
|
||||
|
||||
Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui)
|
||||
- [Quick start](#quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Tools Reference](#tools-reference)
|
||||
- [Data Storage](#data-storage)
|
||||
- [Development](#development)
|
||||
- [Testing](#testing)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which:
|
||||
|
||||
- Stores task data as JSON under `~/.claude/tasks/{teamName}/`
|
||||
- Stores team config under `~/.claude/teams/{teamName}/`
|
||||
- Is installed automatically when you run Claude Agent Teams UI at least once
|
||||
|
||||
### Use cases
|
||||
|
||||
| Scenario | Description |
|
||||
|----------|-------------|
|
||||
| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat |
|
||||
| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use |
|
||||
| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages |
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐
|
||||
│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │
|
||||
│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │
|
||||
│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │
|
||||
└─────────────────────┘ └──────────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Relationship to Claude Agent Teams UI
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. |
|
||||
| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. |
|
||||
| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. |
|
||||
|
||||
Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`)
|
||||
2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build`
|
||||
3. Add to Cursor MCP config (`~/.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. Restart Cursor. The 13 tools will appear for the AI assistant.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js 20+**
|
||||
- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`)
|
||||
|
||||
If `teamctl.js` is missing, the server throws at startup:
|
||||
|
||||
```
|
||||
teamctl.js not found at ~/.claude/tools/teamctl.js.
|
||||
Make sure Claude Agent Teams UI has been run at least once,
|
||||
or set the TEAMCTL_PATH environment variable.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From the monorepo (development)
|
||||
|
||||
```bash
|
||||
cd mcp-server
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### As a dependency
|
||||
|
||||
```bash
|
||||
pnpm add @claude-team/mcp-server
|
||||
# or
|
||||
npm install @claude-team/mcp-server
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Cursor
|
||||
|
||||
Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With global install (`pnpm link` or `npm link`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "team-mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"claude-team-tools": {
|
||||
"command": "node",
|
||||
"args": ["/absolute/path/to/mcp-server/dist/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` |
|
||||
|
||||
---
|
||||
|
||||
## Tools Reference
|
||||
|
||||
All tools require a `team` parameter (team name, folder under `~/.claude/teams/`).
|
||||
|
||||
### Task CRUD
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` |
|
||||
| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) |
|
||||
| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) |
|
||||
| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals |
|
||||
| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` |
|
||||
|
||||
### Task collaboration
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` |
|
||||
| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected |
|
||||
| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board |
|
||||
| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB |
|
||||
|
||||
### Kanban board
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` |
|
||||
| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` |
|
||||
|
||||
### Code review
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) |
|
||||
|
||||
### Messaging
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications |
|
||||
|
||||
---
|
||||
|
||||
## Data Storage
|
||||
|
||||
| Location | Contents |
|
||||
|----------|----------|
|
||||
| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) |
|
||||
| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes |
|
||||
|
||||
Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes).
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm build` | Build with tsup → `dist/index.js` |
|
||||
| `pnpm dev` | Run with tsx (no build) |
|
||||
| `pnpm test` | Run Vitest tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm typecheck` | TypeScript check |
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
mcp-server/
|
||||
├── src/
|
||||
│ ├── index.ts # FastMCP server, registers all tools
|
||||
│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess
|
||||
│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout
|
||||
│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.)
|
||||
│ └── tools/
|
||||
│ ├── index.ts # registerAllTools()
|
||||
│ ├── task-create.ts
|
||||
│ ├── task-get.ts
|
||||
│ ├── ...
|
||||
│ └── message-send.ts
|
||||
├── test/
|
||||
│ ├── tools/ # Per-tool tests
|
||||
│ ├── teamctl-runner.test.ts
|
||||
│ ├── output-parser.test.ts
|
||||
│ └── schemas.test.ts
|
||||
├── package.json
|
||||
├── tsup.config.ts
|
||||
└── vitest.config.ts
|
||||
```
|
||||
|
||||
### Adding a new tool
|
||||
|
||||
1. Create `src/tools/your-tool.ts` with `register(server, runner)`
|
||||
2. Add to `ALL_TOOLS` in `src/tools/index.ts`
|
||||
3. Add Zod parameters and map to `teamctl` CLI args
|
||||
4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts`
|
||||
5. Add tests in `test/tools/your-tool.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required):
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` |
|
||||
| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes |
|
||||
| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it |
|
||||
| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Same as the parent project: [AGPL-3.0](../LICENSE).
|
||||
|
|
@ -1,18 +1,36 @@
|
|||
{
|
||||
"name": "@claude-team/mcp-server",
|
||||
"name": "agent-teams-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for managing Claude Agent Teams kanban board and tasks",
|
||||
"description": "MCP server for managing Claude Agent Teams kanban board and tasks via teamctl CLI",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"team-mcp-server": "dist/index.js"
|
||||
"agent-teams-mcp": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"mcp-server",
|
||||
"claude",
|
||||
"agent-teams",
|
||||
"kanban",
|
||||
"task-management",
|
||||
"model-context-protocol"
|
||||
],
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nickchernyy/agent-teams-mcp"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastmcp": "^3.34.0",
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { FastMCP } from 'fastmcp';
|
||||
import { TeamctlRunner } from './teamctl-runner.js';
|
||||
import { registerAllTools } from './tools/index.js';
|
||||
|
||||
const server = new FastMCP({
|
||||
name: 'claude-team-tools',
|
||||
version: '1.0.0',
|
||||
instructions: `MCP server for managing Claude Agent Teams kanban board and tasks.
|
||||
|
||||
Provides 13 tools for task CRUD, kanban board management, code reviews, and team messaging.
|
||||
All operations are backed by teamctl.js — the battle-tested CLI tool from Claude Agent Teams UI.
|
||||
|
||||
Data is stored as JSON files in ~/.claude/tasks/{teamName}/ and ~/.claude/teams/{teamName}/.`,
|
||||
});
|
||||
|
||||
const runner = new TeamctlRunner();
|
||||
|
||||
registerAllTools(server, runner);
|
||||
|
||||
server.start({ transportType: 'stdio' });
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/**
|
||||
* Parses teamctl stdout into structured results.
|
||||
*
|
||||
* teamctl outputs in three formats:
|
||||
* 1. JSON — task create/get/list, attach, message send, kanban reviewers list
|
||||
* 2. "OK ..." text — status changes, comments, links, kanban moves, reviews
|
||||
* 3. Plain text — task briefing (multi-line human-readable report)
|
||||
*/
|
||||
|
||||
/** Parse JSON from teamctl stdout (task create/get/list, attach, message send) */
|
||||
export function parseJsonOutput<T = unknown>(stdout: string): T {
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Empty output from teamctl (expected JSON)');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse teamctl JSON output: ${trimmed.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse "OK ..." acknowledgment lines from teamctl */
|
||||
export function parseOkOutput(stdout: string): string {
|
||||
const trimmed = stdout.trim();
|
||||
if (trimmed.startsWith('OK ')) {
|
||||
return trimmed.slice(3); // Strip "OK " prefix, keep the rest
|
||||
}
|
||||
// Some commands output just "OK\n"
|
||||
if (trimmed === 'OK') {
|
||||
return 'OK';
|
||||
}
|
||||
// Return as-is if format is unexpected — don't throw
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/** Return plain text as-is (briefing, help output) */
|
||||
export function parseTextOutput(stdout: string): string {
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format teamctl stderr into a user-friendly error message.
|
||||
* teamctl writes errors to stderr via `die(message)` and exits with code 1.
|
||||
*/
|
||||
export function formatError(stderr: string, stdout: string): string {
|
||||
const msg = stderr.trim() || stdout.trim();
|
||||
if (!msg) return 'Unknown teamctl error';
|
||||
return msg;
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { sep, isAbsolute } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Matches teamctl's `isSafePathSegment()`:
|
||||
* rejects empty, '.', '..', and strings containing '/', '\\', '\0', or '..'
|
||||
*/
|
||||
const safePathSegment = (label: string) =>
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.refine(
|
||||
(v) =>
|
||||
v.trim().length > 0 &&
|
||||
v !== '.' &&
|
||||
v !== '..' &&
|
||||
!v.includes('/') &&
|
||||
!v.includes('\\') &&
|
||||
!v.includes('\0') &&
|
||||
!v.includes('..'),
|
||||
{ message: `Invalid ${label}: must be a safe path segment` },
|
||||
);
|
||||
|
||||
/** Team name — folder inside `~/.claude/teams/` */
|
||||
export const teamNameSchema = safePathSegment('team name').describe(
|
||||
'Team name (folder in ~/.claude/teams/)',
|
||||
);
|
||||
|
||||
/** Numeric task ID produced by teamctl's highwatermark counter */
|
||||
export const taskIdSchema = z
|
||||
.string()
|
||||
.regex(/^\d{1,10}$/, 'Task ID must be a positive integer (e.g. "1", "42")')
|
||||
.describe('Numeric task ID');
|
||||
|
||||
/** Team member name — folder inside inboxes, safe path segment */
|
||||
export const memberNameSchema = safePathSegment('member name').describe(
|
||||
'Team member name',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums — match teamctl's normalizeStatus / normalizeColumn / etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const taskStatusSchema = z.enum([
|
||||
'pending',
|
||||
'in_progress',
|
||||
'completed',
|
||||
'deleted',
|
||||
]);
|
||||
|
||||
export const kanbanColumnSchema = z
|
||||
.enum(['review', 'approved'])
|
||||
.describe('Kanban column to move task to');
|
||||
|
||||
export const clarificationSchema = z
|
||||
.enum(['lead', 'user', 'clear'])
|
||||
.describe('Who needs to clarify: lead, user, or clear the flag');
|
||||
|
||||
export const linkTypeSchema = z
|
||||
.enum(['blocked-by', 'blocks', 'related'])
|
||||
.describe('Relationship type between tasks');
|
||||
|
||||
export const linkOperationSchema = z.enum(['link', 'unlink']);
|
||||
|
||||
export const reviewDecisionSchema = z.enum(['approve', 'request-changes']);
|
||||
|
||||
export const reviewerOperationSchema = z.enum(['list', 'add', 'remove']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composite schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Comma-separated task IDs sent as a single CLI argument */
|
||||
export const taskIdsArraySchema = z
|
||||
.array(taskIdSchema)
|
||||
.describe('Array of task IDs (e.g. ["1", "3"])');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File / attachment schemas — defence-in-depth for CLI arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Absolute file path without traversal sequences */
|
||||
export const filePathSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine((p) => isAbsolute(p), { message: 'Path must be absolute' })
|
||||
.refine((p) => !p.split(sep).includes('..'), {
|
||||
message: 'Path must not contain traversal sequences (..)',
|
||||
})
|
||||
.refine((p) => !p.includes('\0'), {
|
||||
message: 'Path must not contain null bytes',
|
||||
});
|
||||
|
||||
/** Safe filename — no path separators, no null bytes, reasonable length */
|
||||
export const safeFilenameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine(
|
||||
(f) => !f.includes('/') && !f.includes('\\') && !f.includes('\0'),
|
||||
{ message: 'Filename must not contain path separators or null bytes' },
|
||||
);
|
||||
|
||||
/** MIME type — standard type/subtype format */
|
||||
export const mimeTypeSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/,
|
||||
'Invalid MIME type format (expected type/subtype)',
|
||||
);
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TeamctlResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export interface ITeamctlRunner {
|
||||
execute(args: string[]): Promise<TeamctlResult>;
|
||||
}
|
||||
|
||||
export interface TeamctlRunnerOptions {
|
||||
/** Explicit path to teamctl.js. Falls back to TEAMCTL_PATH env, then default. */
|
||||
teamctlPath?: string;
|
||||
/** Max concurrent subprocess calls (default: 5) */
|
||||
maxConcurrent?: number;
|
||||
/** Subprocess timeout in ms (default: 10 000) */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semaphore — limits concurrent subprocess spawns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Semaphore {
|
||||
private current = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
if (this.current < this.max) {
|
||||
this.current++;
|
||||
return;
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
this.queue.push(() => {
|
||||
this.current++;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.current--;
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TeamctlRunner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TeamctlRunner implements ITeamctlRunner {
|
||||
readonly teamctlPath: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly semaphore: Semaphore;
|
||||
|
||||
constructor(options?: TeamctlRunnerOptions) {
|
||||
this.teamctlPath = resolveTeamctlPath(options?.teamctlPath);
|
||||
this.timeoutMs = options?.timeoutMs ?? 10_000;
|
||||
this.semaphore = new Semaphore(options?.maxConcurrent ?? 5);
|
||||
|
||||
// Fail fast if teamctl.js doesn't exist
|
||||
if (!existsSync(this.teamctlPath)) {
|
||||
throw new Error(
|
||||
`teamctl.js not found at ${this.teamctlPath}. ` +
|
||||
'Make sure Claude Agent Teams UI has been run at least once, ' +
|
||||
'or set the TEAMCTL_PATH environment variable.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async execute(args: string[]): Promise<TeamctlResult> {
|
||||
await this.semaphore.acquire();
|
||||
try {
|
||||
return await this.spawn(args);
|
||||
} finally {
|
||||
this.semaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
private spawn(args: string[]): Promise<TeamctlResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [this.teamctlPath, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: this.timeoutMs,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
|
||||
|
||||
let settled = false;
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`Failed to spawn teamctl: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
||||
const stderr = Buffer.concat(stderrChunks).toString('utf-8');
|
||||
|
||||
if (signal === 'SIGTERM') {
|
||||
const partial = stdout.slice(0, 500) || stderr.slice(0, 500);
|
||||
reject(
|
||||
new Error(
|
||||
`teamctl timed out after ${this.timeoutMs}ms` +
|
||||
(partial ? `. Partial output: ${partial}` : ''),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveTeamctlPath(explicit?: string): string {
|
||||
if (explicit) return explicit;
|
||||
|
||||
const fromEnv = process.env['TEAMCTL_PATH'];
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
// Default: ~/.claude/tools/teamctl.js
|
||||
return join(homedir(), '.claude', 'tools', 'teamctl.js');
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
|
||||
import { register as taskCreate } from './task-create.js';
|
||||
import { register as taskSetStatus } from './task-set-status.js';
|
||||
import { register as taskSetOwner } from './task-set-owner.js';
|
||||
import { register as taskGet } from './task-get.js';
|
||||
import { register as taskList } from './task-list.js';
|
||||
import { register as taskComment } from './task-comment.js';
|
||||
import { register as taskLink } from './task-link.js';
|
||||
import { register as taskBriefing } from './task-briefing.js';
|
||||
import { register as taskAttach } from './task-attach.js';
|
||||
import { register as kanbanMove } from './kanban-move.js';
|
||||
import { register as kanbanReviewers } from './kanban-reviewers.js';
|
||||
import { register as reviewAction } from './review-action.js';
|
||||
import { register as messageSend } from './message-send.js';
|
||||
|
||||
const ALL_TOOLS = [
|
||||
taskCreate,
|
||||
taskSetStatus,
|
||||
taskSetOwner,
|
||||
taskGet,
|
||||
taskList,
|
||||
taskComment,
|
||||
taskLink,
|
||||
taskBriefing,
|
||||
taskAttach,
|
||||
kanbanMove,
|
||||
kanbanReviewers,
|
||||
reviewAction,
|
||||
messageSend,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Register all 13 MCP tools with the server.
|
||||
* Each tool wraps a teamctl CLI command via the runner.
|
||||
*/
|
||||
export function registerAllTools(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
for (const register of ALL_TOOLS) {
|
||||
register(server, runner);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, kanbanColumnSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'kanban_move',
|
||||
description: `Move a task to a kanban column or clear it from the kanban board.
|
||||
|
||||
Columns: "review" (awaiting code review) or "approved" (review passed).
|
||||
Use operation "clear" to remove a task from the kanban board (returns to status-based display).
|
||||
Moving to a kanban column also sets the task status to "completed".`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
operation: z.enum(['set-column', 'clear']).describe('"set-column" to move, "clear" to remove from kanban'),
|
||||
column: kanbanColumnSchema.optional().describe('Target column (required for set-column)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
let cliArgs: string[];
|
||||
|
||||
if (args.operation === 'set-column') {
|
||||
if (!args.column) {
|
||||
throw new UserError('column is required when operation is "set-column"');
|
||||
}
|
||||
cliArgs = ['--team', args.team, 'kanban', 'set-column', args.task_id, args.column];
|
||||
} else {
|
||||
cliArgs = ['--team', args.team, 'kanban', 'clear', args.task_id];
|
||||
}
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to update kanban: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput, parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema, reviewerOperationSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'kanban_reviewers',
|
||||
description: `Manage the kanban board's reviewer list.
|
||||
|
||||
Operations:
|
||||
- "list": returns JSON array of reviewer names
|
||||
- "add": adds a reviewer (name required)
|
||||
- "remove": removes a reviewer (name required)`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
operation: reviewerOperationSchema.describe('"list", "add", or "remove"'),
|
||||
name: memberNameSchema.optional().describe('Reviewer name (required for add/remove)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
if (args.operation !== 'list' && !args.name) {
|
||||
throw new UserError(`name is required for "${args.operation}" operation`);
|
||||
}
|
||||
|
||||
const cliArgs = ['--team', args.team, 'kanban', 'reviewers', args.operation];
|
||||
if (args.name) cliArgs.push(args.name);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to manage reviewers: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
|
||||
// "list" returns JSON array, "add"/"remove" return "OK ..." text
|
||||
if (args.operation === 'list') {
|
||||
return parseJsonOutput(result.stdout);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'message_send',
|
||||
description: `Send an inbox message to a team member. Returns delivery confirmation JSON.
|
||||
|
||||
Messages appear in the member's inbox and can trigger notifications.
|
||||
The "source" field is automatically stripped for security — external callers cannot impersonate system notifications.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
to: memberNameSchema.describe('Recipient member name'),
|
||||
text: z.string().min(1).max(10000).describe('Message text'),
|
||||
summary: z.string().max(200).optional().describe('Short summary for notification preview'),
|
||||
from: memberNameSchema.optional().describe('Sender name'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'message', 'send', '--to', args.to, '--text', args.text];
|
||||
|
||||
if (args.summary) cliArgs.push('--summary', args.summary);
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to send message: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema, reviewDecisionSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'review_action',
|
||||
description: `Approve a task or request changes.
|
||||
|
||||
- "approve": marks the task as approved in kanban, optionally posts a review comment
|
||||
- "request-changes": removes from kanban, resets status to "in_progress", notifies the task owner with the change request comment`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
decision: reviewDecisionSchema.describe('"approve" or "request-changes"'),
|
||||
comment: z.string().max(5000).optional().describe('Review comment (required for request-changes)'),
|
||||
from: memberNameSchema.optional().describe('Reviewer name'),
|
||||
notify_owner: z.boolean().optional().describe('Notify the task owner (for approve)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
if (args.decision === 'request-changes' && !args.comment) {
|
||||
throw new UserError('comment is required when requesting changes');
|
||||
}
|
||||
|
||||
const cliArgs = ['--team', args.team, 'review', args.decision, args.task_id];
|
||||
|
||||
// approve uses --note for optional comment; request-changes uses --comment
|
||||
if (args.decision === 'request-changes' && args.comment) {
|
||||
cliArgs.push('--comment', args.comment);
|
||||
} else if (args.decision === 'approve' && args.comment) {
|
||||
cliArgs.push('--note', args.comment);
|
||||
}
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
if (args.notify_owner) cliArgs.push('--notify-owner');
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to ${args.decision}: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema, filePathSchema, safeFilenameSchema, mimeTypeSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_attach',
|
||||
description: `Attach a file to a task. Returns attachment metadata JSON.
|
||||
|
||||
Supports copy (default) or hardlink mode. MIME type is auto-detected from file content (PNG, JPEG, GIF, WebP, PDF, ZIP) with fallback to application/octet-stream. Max file size: 20 MB.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
file: filePathSchema.describe('Absolute path to the file to attach'),
|
||||
filename: safeFilenameSchema.optional().describe('Override stored filename'),
|
||||
mime_type: mimeTypeSchema.optional().describe('Override MIME type (auto-detected by default)'),
|
||||
mode: z.enum(['copy', 'link']).optional().describe('Storage mode: copy (default) or hardlink'),
|
||||
from: memberNameSchema.optional().describe('Uploader name'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'attach', args.task_id, '--file', args.file];
|
||||
|
||||
if (args.filename) cliArgs.push('--filename', args.filename);
|
||||
if (args.mime_type) cliArgs.push('--mime-type', args.mime_type);
|
||||
if (args.mode) cliArgs.push('--mode', args.mode);
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to attach file: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseTextOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_briefing',
|
||||
description: `Generate a text briefing for a team member showing their assigned tasks vs the team board.
|
||||
|
||||
Returns a human-readable multi-line report. Automatically filters out internal bookkeeping tasks.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
member: memberNameSchema.describe('Member name to generate briefing for'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'briefing', '--for', args.member];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to get briefing: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseTextOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_comment',
|
||||
description: `Add a comment to a task. Sends an inbox notification to the task owner (unless the commenter is the owner).`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
text: z.string().min(1).max(10000).describe('Comment text'),
|
||||
from: memberNameSchema.optional().describe('Author name (skips self-notification)'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'comment', args.task_id, '--text', args.text];
|
||||
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to add comment: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, memberNameSchema, taskIdsArraySchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
description: `Create a new task in a team's task board. Returns the created task JSON.
|
||||
|
||||
Behavior:
|
||||
- If owner is set and no blockers → status defaults to "in_progress"
|
||||
- If blocked_by specified → status defaults to "pending" (even with owner)
|
||||
- If notify is true, sends an inbox notification to the assigned owner`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
subject: z.string().min(1).max(500).describe('Task title'),
|
||||
description: z.string().max(5000).optional().describe('Detailed task description'),
|
||||
owner: memberNameSchema.optional().describe('Assign to a team member'),
|
||||
blocked_by: taskIdsArraySchema.optional().describe('Task IDs that block this task'),
|
||||
related: taskIdsArraySchema.optional().describe('Related (non-blocking) task IDs'),
|
||||
status: z.enum(['pending', 'in_progress']).optional().describe('Initial status override'),
|
||||
active_form: z.string().max(200).optional().describe('Active form hint for CLI display'),
|
||||
notify: z.boolean().optional().describe('Send inbox notification to owner'),
|
||||
from: memberNameSchema.optional().describe('Author name for notifications'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'create', '--subject', args.subject];
|
||||
|
||||
if (args.description) cliArgs.push('--description', args.description);
|
||||
if (args.owner) cliArgs.push('--owner', args.owner);
|
||||
if (args.blocked_by?.length) cliArgs.push('--blocked-by', args.blocked_by.join(','));
|
||||
if (args.related?.length) cliArgs.push('--related', args.related.join(','));
|
||||
if (args.status) cliArgs.push('--status', args.status);
|
||||
if (args.active_form) cliArgs.push('--activeForm', args.active_form);
|
||||
if (args.notify) cliArgs.push('--notify');
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to create task: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_get',
|
||||
description: `Get a single task by its ID. Returns the full task JSON including status, owner, comments, dependencies, work intervals, and status history.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'get', args.task_id];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to get task: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import {
|
||||
teamNameSchema,
|
||||
taskIdSchema,
|
||||
linkTypeSchema,
|
||||
linkOperationSchema,
|
||||
} from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_link',
|
||||
description: `Link or unlink task dependencies.
|
||||
|
||||
Relationship types:
|
||||
- "blocked-by": this task is blocked by the target
|
||||
- "blocks": this task blocks the target
|
||||
- "related": non-blocking relationship
|
||||
|
||||
Links are bidirectional: linking A blocked-by B also sets B blocks A.
|
||||
Circular dependencies are detected and rejected.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
operation: linkOperationSchema.describe('"link" to add, "unlink" to remove'),
|
||||
relationship: linkTypeSchema,
|
||||
target_id: taskIdSchema.describe('The other task ID to link/unlink'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = [
|
||||
'--team', args.team,
|
||||
'task', args.operation,
|
||||
args.task_id,
|
||||
`--${args.relationship}`, args.target_id,
|
||||
];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to ${args.operation} tasks: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseJsonOutput } from '../output-parser.js';
|
||||
import { teamNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_list',
|
||||
description: `List all tasks for a team. Returns a JSON array of task objects.
|
||||
|
||||
Note: includes internal bookkeeping tasks (metadata._internal). Filter client-side if needed.`,
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'list'];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to list tasks: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseJsonOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_set_owner',
|
||||
description: `Assign a task to a team member or clear the assignment.
|
||||
|
||||
Pass owner="clear" to unassign. Optionally sends an inbox notification to the new owner.`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
owner: z.union([memberNameSchema, z.literal('clear')]).describe('Member name to assign, or "clear" to unassign'),
|
||||
notify: z.boolean().optional().describe('Send inbox notification to new owner'),
|
||||
from: memberNameSchema.optional().describe('Author name for notification'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'set-owner', args.task_id, args.owner];
|
||||
|
||||
if (args.notify) cliArgs.push('--notify');
|
||||
if (args.from) cliArgs.push('--from', args.from);
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to set owner: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { z } from 'zod';
|
||||
import { UserError } from 'fastmcp';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner } from '../teamctl-runner.js';
|
||||
import { parseOkOutput } from '../output-parser.js';
|
||||
import { teamNameSchema, taskIdSchema, taskStatusSchema } from '../schemas.js';
|
||||
|
||||
export function register(server: FastMCP, runner: ITeamctlRunner): void {
|
||||
server.addTool({
|
||||
name: 'task_set_status',
|
||||
description: `Change the status of a task.
|
||||
|
||||
Valid transitions: pending → in_progress → completed → deleted (and back).
|
||||
Shortcuts: status "in_progress" is equivalent to "task start", "completed" to "task complete".
|
||||
Records status history and tracks work intervals (time spent in_progress).`,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
},
|
||||
parameters: z.object({
|
||||
team: teamNameSchema,
|
||||
task_id: taskIdSchema,
|
||||
status: taskStatusSchema.describe('New status for the task'),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const cliArgs = ['--team', args.team, 'task', 'set-status', args.task_id, args.status];
|
||||
|
||||
const result = await runner.execute(cliArgs);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new UserError(`Failed to set status: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
return parseOkOutput(result.stdout);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseJsonOutput,
|
||||
parseOkOutput,
|
||||
parseTextOutput,
|
||||
formatError,
|
||||
} from '../src/output-parser.js';
|
||||
|
||||
describe('parseJsonOutput', () => {
|
||||
it('parses valid JSON object', () => {
|
||||
const input = '{"id":"42","subject":"Fix bug"}\n';
|
||||
expect(parseJsonOutput(input)).toEqual({ id: '42', subject: 'Fix bug' });
|
||||
});
|
||||
|
||||
it('parses valid JSON array', () => {
|
||||
const input = '[{"id":"1"},{"id":"2"}]\n';
|
||||
expect(parseJsonOutput(input)).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
const input = ' \n {"ok":true} \n ';
|
||||
expect(parseJsonOutput(input)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('throws on empty output', () => {
|
||||
expect(() => parseJsonOutput('')).toThrow('Empty output');
|
||||
expect(() => parseJsonOutput(' \n ')).toThrow('Empty output');
|
||||
});
|
||||
|
||||
it('throws on invalid JSON', () => {
|
||||
expect(() => parseJsonOutput('not json')).toThrow('Failed to parse');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOkOutput', () => {
|
||||
it('strips "OK " prefix', () => {
|
||||
expect(parseOkOutput('OK task #1 status=completed\n')).toBe('task #1 status=completed');
|
||||
});
|
||||
|
||||
it('handles bare "OK"', () => {
|
||||
expect(parseOkOutput('OK\n')).toBe('OK');
|
||||
});
|
||||
|
||||
it('returns as-is for unexpected format', () => {
|
||||
expect(parseOkOutput('Something else')).toBe('Something else');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(parseOkOutput(' OK kanban #1 cleared \n')).toBe('kanban #1 cleared');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextOutput', () => {
|
||||
it('trims and returns text', () => {
|
||||
const briefing = '=== Task Briefing for alice ===\nTask #1: Fix bug\n';
|
||||
expect(parseTextOutput(briefing)).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(parseTextOutput('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatError', () => {
|
||||
it('uses stderr when available', () => {
|
||||
expect(formatError('Task not found: #42\n', '')).toBe('Task not found: #42');
|
||||
});
|
||||
|
||||
it('falls back to stdout', () => {
|
||||
expect(formatError('', 'Unexpected error\n')).toBe('Unexpected error');
|
||||
});
|
||||
|
||||
it('returns default for empty', () => {
|
||||
expect(formatError('', '')).toBe('Unknown teamctl error');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
teamNameSchema,
|
||||
taskIdSchema,
|
||||
memberNameSchema,
|
||||
taskStatusSchema,
|
||||
kanbanColumnSchema,
|
||||
clarificationSchema,
|
||||
linkTypeSchema,
|
||||
linkOperationSchema,
|
||||
reviewDecisionSchema,
|
||||
reviewerOperationSchema,
|
||||
taskIdsArraySchema,
|
||||
filePathSchema,
|
||||
safeFilenameSchema,
|
||||
mimeTypeSchema,
|
||||
} from '../src/schemas.js';
|
||||
|
||||
describe('teamNameSchema', () => {
|
||||
it('accepts valid team names', () => {
|
||||
expect(teamNameSchema.parse('acme')).toBe('acme');
|
||||
expect(teamNameSchema.parse('my-team')).toBe('my-team');
|
||||
expect(teamNameSchema.parse('My_Team')).toBe('My_Team');
|
||||
expect(teamNameSchema.parse('team123')).toBe('team123');
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => teamNameSchema.parse('')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => teamNameSchema.parse('..')).toThrow();
|
||||
expect(() => teamNameSchema.parse('.')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a/b')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a\\b')).toThrow();
|
||||
expect(() => teamNameSchema.parse('a..b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => teamNameSchema.parse('a\0b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => teamNameSchema.parse('a'.repeat(129))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskIdSchema', () => {
|
||||
it('accepts numeric IDs', () => {
|
||||
expect(taskIdSchema.parse('1')).toBe('1');
|
||||
expect(taskIdSchema.parse('42')).toBe('42');
|
||||
expect(taskIdSchema.parse('1234567890')).toBe('1234567890');
|
||||
});
|
||||
|
||||
it('accepts zero', () => {
|
||||
expect(taskIdSchema.parse('0')).toBe('0');
|
||||
});
|
||||
|
||||
it('accepts leading zeros', () => {
|
||||
expect(taskIdSchema.parse('007')).toBe('007');
|
||||
});
|
||||
|
||||
it('rejects non-numeric', () => {
|
||||
expect(() => taskIdSchema.parse('abc')).toThrow();
|
||||
expect(() => taskIdSchema.parse('')).toThrow();
|
||||
expect(() => taskIdSchema.parse('1.5')).toThrow();
|
||||
expect(() => taskIdSchema.parse('-1')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => taskIdSchema.parse('12345678901')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memberNameSchema', () => {
|
||||
it('accepts valid member names', () => {
|
||||
expect(memberNameSchema.parse('alice')).toBe('alice');
|
||||
expect(memberNameSchema.parse('bob-smith')).toBe('bob-smith');
|
||||
expect(memberNameSchema.parse('user_1')).toBe('user_1');
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => memberNameSchema.parse('..')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a/b')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a\\b')).toThrow();
|
||||
expect(() => memberNameSchema.parse('a..b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => memberNameSchema.parse('a\0b')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => memberNameSchema.parse('a'.repeat(129))).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => memberNameSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('enum schemas', () => {
|
||||
it('taskStatusSchema accepts valid values', () => {
|
||||
expect(taskStatusSchema.parse('pending')).toBe('pending');
|
||||
expect(taskStatusSchema.parse('in_progress')).toBe('in_progress');
|
||||
expect(taskStatusSchema.parse('completed')).toBe('completed');
|
||||
expect(taskStatusSchema.parse('deleted')).toBe('deleted');
|
||||
expect(() => taskStatusSchema.parse('invalid')).toThrow();
|
||||
});
|
||||
|
||||
it('kanbanColumnSchema accepts valid values', () => {
|
||||
expect(kanbanColumnSchema.parse('review')).toBe('review');
|
||||
expect(kanbanColumnSchema.parse('approved')).toBe('approved');
|
||||
expect(() => kanbanColumnSchema.parse('todo')).toThrow();
|
||||
expect(() => kanbanColumnSchema.parse('')).toThrow();
|
||||
});
|
||||
|
||||
it('clarificationSchema accepts valid values', () => {
|
||||
expect(clarificationSchema.parse('lead')).toBe('lead');
|
||||
expect(clarificationSchema.parse('user')).toBe('user');
|
||||
expect(clarificationSchema.parse('clear')).toBe('clear');
|
||||
expect(() => clarificationSchema.parse('nobody')).toThrow();
|
||||
});
|
||||
|
||||
it('linkTypeSchema accepts valid values', () => {
|
||||
expect(linkTypeSchema.parse('blocked-by')).toBe('blocked-by');
|
||||
expect(linkTypeSchema.parse('blocks')).toBe('blocks');
|
||||
expect(linkTypeSchema.parse('related')).toBe('related');
|
||||
});
|
||||
|
||||
it('linkOperationSchema accepts valid values', () => {
|
||||
expect(linkOperationSchema.parse('link')).toBe('link');
|
||||
expect(linkOperationSchema.parse('unlink')).toBe('unlink');
|
||||
});
|
||||
|
||||
it('reviewDecisionSchema accepts valid values', () => {
|
||||
expect(reviewDecisionSchema.parse('approve')).toBe('approve');
|
||||
expect(reviewDecisionSchema.parse('request-changes')).toBe('request-changes');
|
||||
});
|
||||
|
||||
it('reviewerOperationSchema accepts valid values', () => {
|
||||
expect(reviewerOperationSchema.parse('list')).toBe('list');
|
||||
expect(reviewerOperationSchema.parse('add')).toBe('add');
|
||||
expect(reviewerOperationSchema.parse('remove')).toBe('remove');
|
||||
});
|
||||
});
|
||||
|
||||
describe('taskIdsArraySchema', () => {
|
||||
it('accepts valid arrays', () => {
|
||||
expect(taskIdsArraySchema.parse(['1', '2', '3'])).toEqual(['1', '2', '3']);
|
||||
expect(taskIdsArraySchema.parse([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects arrays with invalid IDs', () => {
|
||||
expect(() => taskIdsArraySchema.parse(['abc'])).toThrow();
|
||||
expect(() => taskIdsArraySchema.parse(['1', 'bad'])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filePathSchema', () => {
|
||||
it('accepts absolute paths', () => {
|
||||
expect(filePathSchema.parse('/home/user/file.txt')).toBe('/home/user/file.txt');
|
||||
expect(filePathSchema.parse('/tmp/attachment.pdf')).toBe('/tmp/attachment.pdf');
|
||||
});
|
||||
|
||||
it('rejects relative paths', () => {
|
||||
expect(() => filePathSchema.parse('relative/path.txt')).toThrow();
|
||||
expect(() => filePathSchema.parse('./file.txt')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => filePathSchema.parse('/home/user/../etc/passwd')).toThrow();
|
||||
expect(() => filePathSchema.parse('/home/../../../etc/shadow')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => filePathSchema.parse('/home/user/\0evil')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty', () => {
|
||||
expect(() => filePathSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeFilenameSchema', () => {
|
||||
it('accepts valid filenames', () => {
|
||||
expect(safeFilenameSchema.parse('report.pdf')).toBe('report.pdf');
|
||||
expect(safeFilenameSchema.parse('my-file_v2.tar.gz')).toBe('my-file_v2.tar.gz');
|
||||
});
|
||||
|
||||
it('rejects path separators', () => {
|
||||
expect(() => safeFilenameSchema.parse('../../evil')).toThrow();
|
||||
expect(() => safeFilenameSchema.parse('dir/file')).toThrow();
|
||||
expect(() => safeFilenameSchema.parse('dir\\file')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => safeFilenameSchema.parse('file\0name')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects too long', () => {
|
||||
expect(() => safeFilenameSchema.parse('a'.repeat(256))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mimeTypeSchema', () => {
|
||||
it('accepts valid MIME types', () => {
|
||||
expect(mimeTypeSchema.parse('application/pdf')).toBe('application/pdf');
|
||||
expect(mimeTypeSchema.parse('image/png')).toBe('image/png');
|
||||
expect(mimeTypeSchema.parse('text/plain')).toBe('text/plain');
|
||||
expect(mimeTypeSchema.parse('application/octet-stream')).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('rejects invalid formats', () => {
|
||||
expect(() => mimeTypeSchema.parse('invalid')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('/pdf')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('application/')).toThrow();
|
||||
expect(() => mimeTypeSchema.parse('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TeamctlRunner } from '../src/teamctl-runner.js';
|
||||
import type { ITeamctlRunner, TeamctlResult } from '../src/teamctl-runner.js';
|
||||
|
||||
// We can't easily test the real subprocess without teamctl.js installed,
|
||||
// so we test the interface contract and error handling.
|
||||
|
||||
describe('TeamctlRunner', () => {
|
||||
it('throws if teamctl.js does not exist', () => {
|
||||
expect(
|
||||
() => new TeamctlRunner({ teamctlPath: '/nonexistent/teamctl.js' }),
|
||||
).toThrow('teamctl.js not found');
|
||||
});
|
||||
|
||||
it('resolves path from TEAMCTL_PATH env', () => {
|
||||
const original = process.env['TEAMCTL_PATH'];
|
||||
try {
|
||||
process.env['TEAMCTL_PATH'] = '/tmp/test-teamctl.js';
|
||||
// Will throw because file doesn't exist, but we can check the error message
|
||||
expect(
|
||||
() => new TeamctlRunner(),
|
||||
).toThrow('/tmp/test-teamctl.js');
|
||||
} finally {
|
||||
if (original !== undefined) {
|
||||
process.env['TEAMCTL_PATH'] = original;
|
||||
} else {
|
||||
delete process.env['TEAMCTL_PATH'];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mock runner for tool tests
|
||||
export function createMockRunner(
|
||||
responses: Map<string, TeamctlResult> | TeamctlResult,
|
||||
): ITeamctlRunner {
|
||||
return {
|
||||
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
|
||||
if (responses instanceof Map) {
|
||||
const key = args.join(' ');
|
||||
const result = responses.get(key);
|
||||
if (result) return result;
|
||||
// Fallback: check if any key is a prefix
|
||||
for (const [k, v] of responses) {
|
||||
if (key.startsWith(k)) return v;
|
||||
}
|
||||
return { stdout: '', stderr: 'No mock for: ' + key, exitCode: 1 };
|
||||
}
|
||||
return responses;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ITeamctlRunner interface', () => {
|
||||
it('mock runner returns success', async () => {
|
||||
const runner = createMockRunner({
|
||||
stdout: '{"id":"1","subject":"Test"}\n',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
const result = await runner.execute(['--team', 'test', 'task', 'create']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toHaveProperty('id', '1');
|
||||
});
|
||||
|
||||
it('mock runner returns error', async () => {
|
||||
const runner = createMockRunner({
|
||||
stdout: '',
|
||||
stderr: 'Task not found: #99\n',
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
const result = await runner.execute(['--team', 'test', 'task', 'get', '99']);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Task not found');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/kanban-move.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('kanban_move', () => {
|
||||
function setup(response = ok('OK kanban #1 column=review\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('kanban_move')! };
|
||||
}
|
||||
|
||||
it('builds set-column CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', operation: 'set-column', column: 'review' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'set-column', '1', 'review',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds clear CLI args', async () => {
|
||||
const { runner, tool } = setup(ok('OK kanban #1 cleared\n'));
|
||||
await tool.execute({ team: 'acme', task_id: '1', operation: 'clear' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'clear', '1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws UserError when set-column called without column', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', operation: 'set-column' }),
|
||||
).rejects.toThrow('column is required');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '99', operation: 'clear' }),
|
||||
).rejects.toThrow('Failed to update kanban');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/kanban-reviewers.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('kanban_reviewers', () => {
|
||||
function setup(response = ok('["alice","bob"]')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('kanban_reviewers')! };
|
||||
}
|
||||
|
||||
it('builds list CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', operation: 'list' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'reviewers', 'list',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns JSON array for list', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', operation: 'list' });
|
||||
expect(result).toEqual(['alice', 'bob']);
|
||||
});
|
||||
|
||||
it('builds add CLI args with name', async () => {
|
||||
const { runner, tool } = setup(ok('OK reviewer added\n'));
|
||||
await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'kanban', 'reviewers', 'add', 'charlie',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns OK text for add/remove', async () => {
|
||||
const { tool } = setup(ok('OK reviewer added\n'));
|
||||
const result = await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' });
|
||||
expect(result).toBe('reviewer added');
|
||||
});
|
||||
|
||||
it('throws UserError when add called without name', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'add' }),
|
||||
).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('throws UserError when remove called without name', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'remove' }),
|
||||
).rejects.toThrow('name is required');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('reviewer not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', operation: 'remove', name: 'nobody' }),
|
||||
).rejects.toThrow('Failed to manage reviewers');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/message-send.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('message_send', () => {
|
||||
const deliveryJson = '{"deliveredToInbox":true,"messageId":"msg_abc"}';
|
||||
|
||||
function setup(response = ok(deliveryJson)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('message_send')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'message', 'send', '--to', 'alice', '--text', 'Hello!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes summary and from flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', to: 'alice', text: 'Task done',
|
||||
summary: 'Completed task #1', from: 'bob',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--summary');
|
||||
expect(args[args.indexOf('--summary') + 1]).toBe('Completed task #1');
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('bob');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' });
|
||||
expect(result).toEqual({ deliveredToInbox: true, messageId: 'msg_abc' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('recipient inbox not found'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', to: 'nobody', text: 'Hi' }),
|
||||
).rejects.toThrow('Failed to send message');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { registerAllTools } from '../../src/tools/index.js';
|
||||
import { createMockRunner, createMockServer } from './test-helpers.js';
|
||||
|
||||
describe('registerAllTools', () => {
|
||||
it('registers exactly 13 tools', () => {
|
||||
const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const { server, tools } = createMockServer();
|
||||
registerAllTools(server, runner);
|
||||
expect(tools.size).toBe(13);
|
||||
});
|
||||
|
||||
it('registers all expected tool names', () => {
|
||||
const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const { server, tools } = createMockServer();
|
||||
registerAllTools(server, runner);
|
||||
|
||||
const expected = [
|
||||
'task_create', 'task_set_status', 'task_set_owner',
|
||||
'task_get', 'task_list', 'task_comment', 'task_link',
|
||||
'task_briefing', 'task_attach', 'kanban_move',
|
||||
'kanban_reviewers', 'review_action', 'message_send',
|
||||
];
|
||||
for (const name of expected) {
|
||||
expect(tools.has(name), `missing tool: ${name}`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/review-action.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('review_action', () => {
|
||||
function setup(response = ok('OK review #1 approved\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('review_action')! };
|
||||
}
|
||||
|
||||
it('builds approve CLI args (no comment)', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', decision: 'approve' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'review', 'approve', '1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds approve CLI args with --note (not --comment)', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', decision: 'approve', comment: 'LGTM' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
// approve uses --note, NOT --comment
|
||||
expect(args).toContain('--note');
|
||||
expect(args[args.indexOf('--note') + 1]).toBe('LGTM');
|
||||
expect(args).not.toContain('--comment');
|
||||
});
|
||||
|
||||
it('builds request-changes CLI args with --comment (not --note)', async () => {
|
||||
const { runner, tool } = setup(ok('OK review #1 requested changes\n'));
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', decision: 'request-changes', comment: 'Fix tests',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--comment');
|
||||
expect(args[args.indexOf('--comment') + 1]).toBe('Fix tests');
|
||||
expect(args).not.toContain('--note');
|
||||
});
|
||||
|
||||
it('throws when request-changes has no comment', async () => {
|
||||
const { tool } = setup();
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', decision: 'request-changes' }),
|
||||
).rejects.toThrow('comment is required when requesting changes');
|
||||
});
|
||||
|
||||
it('includes from and notify_owner flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', decision: 'approve',
|
||||
from: 'alice', notify_owner: true,
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--from');
|
||||
expect(args).toContain('--notify-owner');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not in review'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }),
|
||||
).rejects.toThrow('Failed to approve');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-attach.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_attach', () => {
|
||||
const attachJson = '{"id":"att_123","filename":"report.pdf","mimeType":"application/pdf"}';
|
||||
|
||||
function setup(response = ok(attachJson)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_attach')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'attach', '1', '--file', '/tmp/report.pdf',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all optional flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', file: '/tmp/file.pdf',
|
||||
filename: 'renamed.pdf', mime_type: 'application/pdf',
|
||||
mode: 'link', from: 'alice',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--filename');
|
||||
expect(args).toContain('--mime-type');
|
||||
expect(args).toContain('--mode');
|
||||
expect(args).toContain('--from');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' });
|
||||
expect(result).toEqual({ id: 'att_123', filename: 'report.pdf', mimeType: 'application/pdf' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('file too large'));
|
||||
await expect(
|
||||
tool.execute({ team: 'acme', task_id: '1', file: '/tmp/huge.bin' }),
|
||||
).rejects.toThrow('Failed to attach file');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-briefing.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_briefing', () => {
|
||||
const briefingText = '=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]\n';
|
||||
|
||||
function setup(response = ok(briefingText)) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_briefing')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', member: 'alice' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'briefing', '--for', 'alice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns trimmed plain text', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', member: 'alice' });
|
||||
expect(result).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('member not found'));
|
||||
await expect(tool.execute({ team: 'acme', member: 'nobody' })).rejects.toThrow('Failed to get briefing');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-comment.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_comment', () => {
|
||||
function setup(response = ok('OK comment added to task #1\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_comment')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', text: 'Looking good!' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'comment', '1', '--text', 'Looking good!',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes from flag', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', text: 'Done', from: 'alice' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('alice');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('task not found'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '99', text: 'Hi' })).rejects.toThrow('Failed to add comment');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-create.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_create', () => {
|
||||
function setup(response = ok('{"id":"1","subject":"Fix bug","status":"in_progress"}')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_create')! };
|
||||
}
|
||||
|
||||
it('builds minimal CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', subject: 'Fix bug' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'create', '--subject', 'Fix bug',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes all optional flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme',
|
||||
subject: 'Big task',
|
||||
description: 'Details here',
|
||||
owner: 'alice',
|
||||
blocked_by: ['1', '2'],
|
||||
related: ['3'],
|
||||
status: 'pending',
|
||||
active_form: 'Fixing bug',
|
||||
notify: true,
|
||||
from: 'bob',
|
||||
});
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--description');
|
||||
expect(args).toContain('--owner');
|
||||
expect(args).toContain('--blocked-by');
|
||||
expect(args[args.indexOf('--blocked-by') + 1]).toBe('1,2');
|
||||
expect(args).toContain('--related');
|
||||
expect(args[args.indexOf('--related') + 1]).toBe('3');
|
||||
expect(args).toContain('--status');
|
||||
expect(args).toContain('--activeForm');
|
||||
expect(args).toContain('--notify');
|
||||
expect(args).toContain('--from');
|
||||
});
|
||||
|
||||
it('skips empty blocked_by array', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', subject: 'Task', blocked_by: [] });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).not.toContain('--blocked-by');
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', subject: 'Fix bug' });
|
||||
expect(result).toEqual({ id: '1', subject: 'Fix bug', status: 'in_progress' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('team not found'));
|
||||
await expect(tool.execute({ team: 'bad', subject: 'X' })).rejects.toThrow('Failed to create task');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-get.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_get', () => {
|
||||
function setup(response = ok('{"id":"42","subject":"Test","status":"pending"}')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_get')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '42' });
|
||||
expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'get', '42']);
|
||||
});
|
||||
|
||||
it('returns parsed JSON', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '42' });
|
||||
expect(result).toEqual({ id: '42', subject: 'Test', status: 'pending' });
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('Task not found: #99'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '99' })).rejects.toThrow('Failed to get task');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-link.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_link', () => {
|
||||
function setup(response = ok('OK task #1 blocked-by #2\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_link')! };
|
||||
}
|
||||
|
||||
it('builds link CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'link',
|
||||
relationship: 'blocked-by', target_id: '2',
|
||||
});
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'link', '1', '--blocked-by', '2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds unlink CLI args', async () => {
|
||||
const { runner, tool } = setup(ok('OK task #1 unlinked from #2\n'));
|
||||
await tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'unlink',
|
||||
relationship: 'related', target_id: '3',
|
||||
});
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'unlink', '1', '--related', '3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('circular dependency'));
|
||||
await expect(
|
||||
tool.execute({
|
||||
team: 'acme', task_id: '1', operation: 'link',
|
||||
relationship: 'blocked-by', target_id: '1',
|
||||
}),
|
||||
).rejects.toThrow('Failed to link tasks');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-list.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_list', () => {
|
||||
function setup(response = ok('[{"id":"1"},{"id":"2"}]')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_list')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme' });
|
||||
expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'list']);
|
||||
});
|
||||
|
||||
it('returns parsed JSON array', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme' });
|
||||
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('team dir not found'));
|
||||
await expect(tool.execute({ team: 'bad' })).rejects.toThrow('Failed to list tasks');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-set-owner.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_set_owner', () => {
|
||||
function setup(response = ok('OK task #1 owner=alice\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_set_owner')! };
|
||||
}
|
||||
|
||||
it('builds args for assignment', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'alice' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-owner', '1', 'alice',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds args for clear', async () => {
|
||||
const { runner, tool } = setup(ok('OK task #1 owner=cleared\n'));
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'clear' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-owner', '1', 'clear',
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes notify and from flags', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', owner: 'alice', notify: true, from: 'bob' });
|
||||
const args = runner.execute.mock.calls[0]![0] as string[];
|
||||
expect(args).toContain('--notify');
|
||||
expect(args).toContain('--from');
|
||||
expect(args[args.indexOf('--from') + 1]).toBe('bob');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('member not found'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '1', owner: 'nobody' })).rejects.toThrow('Failed to set owner');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { register } from '../../src/tools/task-set-status.js';
|
||||
import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js';
|
||||
|
||||
describe('task_set_status', () => {
|
||||
function setup(response = ok('OK task #1 status=completed\n')) {
|
||||
const runner = createMockRunner(response);
|
||||
const { server, tools } = createMockServer();
|
||||
register(server, runner);
|
||||
return { runner, tool: tools.get('task_set_status')! };
|
||||
}
|
||||
|
||||
it('builds correct CLI args', async () => {
|
||||
const { runner, tool } = setup();
|
||||
await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
|
||||
expect(runner.execute).toHaveBeenCalledWith([
|
||||
'--team', 'acme', 'task', 'set-status', '1', 'completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns parsed OK text', async () => {
|
||||
const { tool } = setup();
|
||||
const result = await tool.execute({ team: 'acme', task_id: '1', status: 'completed' });
|
||||
expect(result).toBe('task #1 status=completed');
|
||||
});
|
||||
|
||||
it('throws on CLI failure', async () => {
|
||||
const { tool } = setup(fail('invalid transition'));
|
||||
await expect(tool.execute({ team: 'acme', task_id: '1', status: 'deleted' })).rejects.toThrow('Failed to set status');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
import { vi } from 'vitest';
|
||||
import type { FastMCP } from 'fastmcp';
|
||||
import type { ITeamctlRunner, TeamctlResult } from '../../src/teamctl-runner.js';
|
||||
|
||||
/**
|
||||
* Creates a mock ITeamctlRunner that records calls and returns predetermined results.
|
||||
*/
|
||||
export function createMockRunner(
|
||||
response: TeamctlResult | ((args: string[]) => TeamctlResult),
|
||||
): ITeamctlRunner & { execute: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
|
||||
return typeof response === 'function' ? response(args) : response;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Success response helpers */
|
||||
export const ok = (stdout: string): TeamctlResult => ({
|
||||
stdout,
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
});
|
||||
|
||||
export const fail = (stderr: string): TeamctlResult => ({
|
||||
stdout: '',
|
||||
stderr,
|
||||
exitCode: 1,
|
||||
});
|
||||
|
||||
/**
|
||||
* Captures registered tools via a mock FastMCP-like server.
|
||||
* Returns a map of tool name → { execute function, parameters schema }.
|
||||
*/
|
||||
export interface CapturedTool {
|
||||
name: string;
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
parameters: unknown;
|
||||
}
|
||||
|
||||
export function createMockServer(): {
|
||||
server: FastMCP;
|
||||
tools: Map<string, CapturedTool>;
|
||||
} {
|
||||
const tools = new Map<string, CapturedTool>();
|
||||
|
||||
const server = {
|
||||
addTool: (def: { name: string; execute: CapturedTool['execute']; parameters: unknown }) => {
|
||||
tools.set(def.name, {
|
||||
name: def.name,
|
||||
execute: def.execute,
|
||||
parameters: def.parameters,
|
||||
});
|
||||
},
|
||||
} as unknown as FastMCP;
|
||||
|
||||
return { server, tools };
|
||||
}
|
||||
|
|
@ -132,6 +132,7 @@
|
|||
"simple-git": "^3.32.3",
|
||||
"ssh-config": "^5.0.4",
|
||||
"ssh2": "^1.17.0",
|
||||
"strip-markdown": "^6.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unified": "^11.0.5",
|
||||
|
|
|
|||
|
|
@ -224,6 +224,9 @@ importers:
|
|||
ssh2:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
strip-markdown:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
|
|
@ -6046,6 +6049,9 @@ packages:
|
|||
strip-literal@3.1.0:
|
||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||
|
||||
strip-markdown@6.0.0:
|
||||
resolution: {integrity: sha512-mSa8FtUoX3ExJYDkjPUTC14xaBAn4Ik5GPQD45G5E2egAmeV3kHgVSTfIoSDggbF6Pk9stahVgqsLCNExv6jHw==}
|
||||
|
||||
strtok3@10.3.4:
|
||||
resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -13548,6 +13554,10 @@ snapshots:
|
|||
dependencies:
|
||||
js-tokens: 9.0.1
|
||||
|
||||
strip-markdown@6.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
||||
strtok3@10.3.4:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
|
|
|
|||
|
|
@ -553,7 +553,7 @@
|
|||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
},
|
||||
"apac.anthropic.claude-sonnet-4-6": {
|
||||
"au.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
|
||||
"cache_read_input_token_cost": 3.3e-7,
|
||||
|
|
|
|||
|
|
@ -137,78 +137,76 @@ export function registerSessionRoutes(app: FastifyInstance, services: HttpServic
|
|||
);
|
||||
|
||||
// Session detail
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
logger.error(
|
||||
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
||||
|
||||
// Check cache first
|
||||
let sessionDetail = services.dataCache.get(cacheKey);
|
||||
if (sessionDetail) {
|
||||
return sessionDetail;
|
||||
}
|
||||
|
||||
const fsType = services.projectScanner.getFileSystemProvider().type;
|
||||
// In SSH mode, avoid an extra deep metadata scan before full parse.
|
||||
const session = await services.projectScanner.getSessionWithOptions(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
{
|
||||
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
|
||||
}
|
||||
);
|
||||
if (!session) {
|
||||
logger.error(`Session not found: ${safeSessionId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse session messages
|
||||
const parsedSession = await services.sessionParser.parseSession(
|
||||
safeProjectId,
|
||||
safeSessionId
|
||||
);
|
||||
|
||||
// Resolve subagents
|
||||
const subagents = await services.subagentResolver.resolveSubagents(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
parsedSession.taskCalls,
|
||||
parsedSession.messages
|
||||
);
|
||||
session.hasSubagents = subagents.length > 0;
|
||||
|
||||
// Build session detail with chunks
|
||||
sessionDetail = services.chunkBuilder.buildSessionDetail(
|
||||
session,
|
||||
parsedSession.messages,
|
||||
subagents
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
services.dataCache.set(cacheKey, sessionDetail);
|
||||
|
||||
return sessionDetail;
|
||||
} catch (error) {
|
||||
app.get<{
|
||||
Params: { projectId: string; sessionId: string };
|
||||
Querystring: { bypassCache?: string };
|
||||
}>('/api/projects/:projectId/sessions/:sessionId', async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
if (!validatedProject.valid || !validatedSession.valid) {
|
||||
logger.error(
|
||||
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||
error
|
||||
`GET session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'unknown'}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId);
|
||||
const bypassCache = request.query?.bypassCache === 'true';
|
||||
|
||||
// Check cache first
|
||||
let sessionDetail = services.dataCache.get(cacheKey);
|
||||
if (sessionDetail && !bypassCache) {
|
||||
return sessionDetail;
|
||||
}
|
||||
|
||||
const fsType = services.projectScanner.getFileSystemProvider().type;
|
||||
// In SSH mode, avoid an extra deep metadata scan before full parse.
|
||||
const session = await services.projectScanner.getSessionWithOptions(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
{
|
||||
metadataLevel: fsType === 'ssh' ? 'light' : 'deep',
|
||||
}
|
||||
);
|
||||
if (!session) {
|
||||
logger.error(`Session not found: ${safeSessionId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse session messages
|
||||
const parsedSession = await services.sessionParser.parseSession(safeProjectId, safeSessionId);
|
||||
|
||||
// Resolve subagents
|
||||
const subagents = await services.subagentResolver.resolveSubagents(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
parsedSession.taskCalls,
|
||||
parsedSession.messages
|
||||
);
|
||||
session.hasSubagents = subagents.length > 0;
|
||||
|
||||
// Build session detail with chunks
|
||||
sessionDetail = services.chunkBuilder.buildSessionDetail(
|
||||
session,
|
||||
parsedSession.messages,
|
||||
subagents
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
services.dataCache.set(cacheKey, sessionDetail);
|
||||
|
||||
return sessionDetail;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in GET session-detail for ${request.params.projectId}/${request.params.sessionId}:`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Conversation groups
|
||||
app.get<{ Params: { projectId: string; sessionId: string } }>(
|
||||
|
|
|
|||
|
|
@ -15,63 +15,64 @@ import type { FastifyInstance } from 'fastify';
|
|||
const logger = createLogger('HTTP:subagents');
|
||||
|
||||
export function registerSubagentRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
|
||||
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId',
|
||||
async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
const validatedSubagent = validateSubagentId(request.params.subagentId);
|
||||
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
|
||||
logger.error(
|
||||
`GET subagent-detail rejected: ${
|
||||
validatedProject.error ??
|
||||
validatedSession.error ??
|
||||
validatedSubagent.error ??
|
||||
'Invalid parameters'
|
||||
}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
const safeSubagentId = validatedSubagent.value!;
|
||||
|
||||
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
|
||||
|
||||
// Check cache first
|
||||
let subagentDetail = services.dataCache.getSubagent(cacheKey);
|
||||
if (subagentDetail) {
|
||||
return subagentDetail;
|
||||
}
|
||||
|
||||
const fsProvider = services.projectScanner.getFileSystemProvider();
|
||||
const projectsDir = services.projectScanner.getProjectsDir();
|
||||
|
||||
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
safeSubagentId,
|
||||
services.sessionParser,
|
||||
services.subagentResolver,
|
||||
fsProvider,
|
||||
projectsDir
|
||||
app.get<{
|
||||
Params: { projectId: string; sessionId: string; subagentId: string };
|
||||
Querystring: { bypassCache?: string };
|
||||
}>('/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId', async (request) => {
|
||||
try {
|
||||
const validatedProject = validateProjectId(request.params.projectId);
|
||||
const validatedSession = validateSessionId(request.params.sessionId);
|
||||
const validatedSubagent = validateSubagentId(request.params.subagentId);
|
||||
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
|
||||
logger.error(
|
||||
`GET subagent-detail rejected: ${
|
||||
validatedProject.error ??
|
||||
validatedSession.error ??
|
||||
validatedSubagent.error ??
|
||||
'Invalid parameters'
|
||||
}`
|
||||
);
|
||||
|
||||
if (!builtDetail) {
|
||||
logger.error(`Subagent not found: ${safeSubagentId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
subagentDetail = builtDetail;
|
||||
services.dataCache.setSubagent(cacheKey, subagentDetail);
|
||||
|
||||
return subagentDetail;
|
||||
} catch (error) {
|
||||
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
|
||||
return null;
|
||||
}
|
||||
|
||||
const safeProjectId = validatedProject.value!;
|
||||
const safeSessionId = validatedSession.value!;
|
||||
const safeSubagentId = validatedSubagent.value!;
|
||||
const bypassCache = request.query?.bypassCache === 'true';
|
||||
|
||||
const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`;
|
||||
|
||||
// Check cache first
|
||||
let subagentDetail = services.dataCache.getSubagent(cacheKey);
|
||||
if (subagentDetail && !bypassCache) {
|
||||
return subagentDetail;
|
||||
}
|
||||
|
||||
const fsProvider = services.projectScanner.getFileSystemProvider();
|
||||
const projectsDir = services.projectScanner.getProjectsDir();
|
||||
|
||||
const builtDetail = await services.chunkBuilder.buildSubagentDetail(
|
||||
safeProjectId,
|
||||
safeSessionId,
|
||||
safeSubagentId,
|
||||
services.sessionParser,
|
||||
services.subagentResolver,
|
||||
fsProvider,
|
||||
projectsDir
|
||||
);
|
||||
|
||||
if (!builtDetail) {
|
||||
logger.error(`Subagent not found: ${safeSubagentId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
subagentDetail = builtDetail;
|
||||
services.dataCache.setSubagent(cacheKey, subagentDetail);
|
||||
|
||||
return subagentDetail;
|
||||
} catch (error) {
|
||||
logger.error(`Error in GET subagent-detail for ${request.params.subagentId}:`, error);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
CONTEXT_CHANGED,
|
||||
SSH_STATUS,
|
||||
TEAM_CHANGE,
|
||||
TEAM_TOOL_APPROVAL_EVENT,
|
||||
WINDOW_FULLSCREEN_CHANGED,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload
|
||||
} from '@preload/constants/ipcChannels';
|
||||
|
|
@ -42,7 +43,6 @@ import { join } from 'path';
|
|||
|
||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||
import { showTeamNativeNotification } from './ipc/teams';
|
||||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { TeamInboxReader } from './services/team/TeamInboxReader';
|
||||
|
|
@ -153,9 +153,7 @@ function extractNotificationContent(text: string): { summary: string; body: stri
|
|||
}
|
||||
|
||||
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
||||
// Check global toggle
|
||||
const config = configManager.getConfig();
|
||||
if (!config.notifications.enabled) return;
|
||||
|
||||
// Skip orphaned team directories without config.json (e.g., "default").
|
||||
// Claude Code may write to these when its internal teamContext is lost after session resume.
|
||||
|
|
@ -171,15 +169,19 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
if (!match) return;
|
||||
const memberName = match[1];
|
||||
|
||||
// Determine inbox type and check per-inbox toggle
|
||||
// Determine inbox type and per-type toggle state.
|
||||
// Storage is always unconditional; toggles only suppress the OS toast.
|
||||
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
|
||||
const isLeadInbox = leadName !== null && memberName === leadName;
|
||||
const isUserInbox = memberName === 'user';
|
||||
|
||||
if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return;
|
||||
if (isUserInbox && !config.notifications.notifyOnUserInbox) return;
|
||||
if (!isLeadInbox && !isUserInbox) return;
|
||||
|
||||
const suppressToast =
|
||||
!config.notifications.enabled ||
|
||||
(isLeadInbox && !config.notifications.notifyOnLeadInbox) ||
|
||||
(isUserInbox && !config.notifications.notifyOnUserInbox);
|
||||
|
||||
const key = `${teamName}:${memberName}`;
|
||||
|
||||
try {
|
||||
|
|
@ -204,7 +206,8 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
|
||||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
||||
|
||||
for (const msg of newMessages) {
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const msg = newMessages[i];
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||
|
|
@ -213,12 +216,20 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
const fromLabel = msg.from || 'Unknown';
|
||||
const extracted = extractNotificationContent(msg.text);
|
||||
const summary = msg.summary || extracted.summary;
|
||||
const msgId = msg.timestamp ?? String(prevCount + i);
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: teamDisplayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: extracted.body,
|
||||
});
|
||||
void notificationManager
|
||||
.addTeamNotification({
|
||||
teamEventType: isLeadInbox ? 'lead_inbox' : 'user_inbox',
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: fromLabel,
|
||||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`,
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check inbox messages for ${key}:`, error);
|
||||
|
|
@ -231,8 +242,7 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
*/
|
||||
async function notifyNewSentMessages(teamName: string): Promise<void> {
|
||||
const config = configManager.getConfig();
|
||||
if (!config.notifications.enabled) return;
|
||||
if (!config.notifications.notifyOnUserInbox) return;
|
||||
const suppressToast = !config.notifications.enabled || !config.notifications.notifyOnUserInbox;
|
||||
|
||||
try {
|
||||
const messages = await sentMessagesStore.readMessages(teamName);
|
||||
|
|
@ -255,7 +265,8 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
|
||||
const teamDisplayName = await resolveTeamDisplayName(teamName);
|
||||
|
||||
for (const msg of newMessages) {
|
||||
for (let i = 0; i < newMessages.length; i++) {
|
||||
const msg = newMessages[i];
|
||||
// Skip messages sent from our own UI
|
||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||
// Skip internal coordination noise
|
||||
|
|
@ -265,11 +276,18 @@ async function notifyNewSentMessages(teamName: string): Promise<void> {
|
|||
const extracted = extractNotificationContent(msg.text);
|
||||
const summary = msg.summary || extracted.summary;
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: teamDisplayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: extracted.body,
|
||||
});
|
||||
void notificationManager
|
||||
.addTeamNotification({
|
||||
teamEventType: 'user_inbox',
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: fromLabel,
|
||||
summary,
|
||||
body: extracted.body,
|
||||
dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`,
|
||||
suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to check sent messages for ${teamName}:`, error);
|
||||
|
|
@ -634,6 +652,12 @@ function initializeServices(): void {
|
|||
};
|
||||
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
|
||||
|
||||
teamProvisioningService.setToolApprovalEventEmitter((event) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event);
|
||||
}
|
||||
});
|
||||
|
||||
// startProcessHealthPolling() is deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor';
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
import {
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
|
|
@ -47,6 +46,7 @@ import {
|
|||
TEAM_SOFT_DELETE_TASK,
|
||||
TEAM_START_TASK,
|
||||
TEAM_STOP,
|
||||
TEAM_TOOL_APPROVAL_RESPOND,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||
|
|
@ -58,6 +58,7 @@ import {
|
|||
} from '@preload/constants/ipcChannels';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
|
||||
|
|
@ -67,6 +68,8 @@ import * as path from 'path';
|
|||
import { ConfigManager } from '../services/infrastructure/ConfigManager';
|
||||
import { NotificationManager } from '../services/infrastructure/NotificationManager';
|
||||
import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver';
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
|
||||
import {
|
||||
validateFromField,
|
||||
|
|
@ -76,13 +79,6 @@ import {
|
|||
validateTeamName,
|
||||
} from './guards';
|
||||
|
||||
/** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */
|
||||
const notifiedRateLimitKeys = new Set<string>();
|
||||
const RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
|
||||
import type {
|
||||
MemberStatsComputer,
|
||||
TeamDataService,
|
||||
|
|
@ -94,6 +90,7 @@ import type {
|
|||
AttachmentMeta,
|
||||
AttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
EffortLevel,
|
||||
GlobalTask,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
|
|
@ -126,7 +123,17 @@ import type {
|
|||
const logger = createLogger('IPC:teams');
|
||||
|
||||
/**
|
||||
* Check messages for rate limit indicators and fire native notifications for new ones.
|
||||
* In-memory set of rate-limit message keys already processed.
|
||||
* Independent of NotificationManager storage — survives notification deletion/pruning.
|
||||
* Without this, deleted rate-limit notifications would re-appear on next getData() scan.
|
||||
*/
|
||||
const seenRateLimitKeys = new Set<string>();
|
||||
const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
/**
|
||||
* Check messages for rate limit indicators and fire notifications for new ones.
|
||||
* Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
|
||||
* and NotificationManager dedupeKey (to prevent storage duplicates).
|
||||
*/
|
||||
function checkRateLimitMessages(
|
||||
messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
|
||||
|
|
@ -138,33 +145,29 @@ function checkRateLimitMessages(
|
|||
if (msg.from === 'user') continue;
|
||||
if (!isRateLimitMessage(msg.text)) continue;
|
||||
|
||||
// Prefix key with teamName to avoid collisions across teams
|
||||
const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
|
||||
const key = `${teamName}:${rawKey}`;
|
||||
if (notifiedRateLimitKeys.has(key)) continue;
|
||||
notifiedRateLimitKeys.add(key);
|
||||
const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
|
||||
|
||||
// Prevent unbounded memory growth
|
||||
if (notifiedRateLimitKeys.size > RATE_LIMIT_KEYS_MAX) {
|
||||
const first = notifiedRateLimitKeys.values().next().value!;
|
||||
notifiedRateLimitKeys.delete(first);
|
||||
// In-memory guard: prevents resurrection after user deletes the notification
|
||||
if (seenRateLimitKeys.has(dedupeKey)) continue;
|
||||
seenRateLimitKeys.add(dedupeKey);
|
||||
|
||||
// Evict oldest entries to prevent unbounded growth
|
||||
if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
|
||||
const first = seenRateLimitKeys.values().next().value;
|
||||
if (first) seenRateLimitKeys.delete(first);
|
||||
}
|
||||
|
||||
void NotificationManager.getInstance()
|
||||
.addError({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
sessionId: `team:${teamName}`,
|
||||
projectId: teamName,
|
||||
filePath: '',
|
||||
source: 'rate-limit',
|
||||
message: `[${msg.from}] ${msg.text.slice(0, 200)}`,
|
||||
triggerColor: 'red',
|
||||
triggerName: 'Rate Limit',
|
||||
context: {
|
||||
projectName: teamDisplayName,
|
||||
cwd: projectPath,
|
||||
},
|
||||
.addTeamNotification({
|
||||
teamEventType: 'rate_limit',
|
||||
teamName,
|
||||
teamDisplayName,
|
||||
from: msg.from,
|
||||
summary: `Rate limit: ${msg.from}`,
|
||||
body: msg.text.slice(0, 200),
|
||||
dedupeKey,
|
||||
projectPath,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
|
@ -246,6 +249,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment);
|
||||
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
|
||||
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
||||
ipcMain.handle(TEAM_TOOL_APPROVAL_RESPOND, handleToolApprovalRespond);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +304,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT);
|
||||
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
||||
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
||||
ipcMain.removeHandler(TEAM_TOOL_APPROVAL_RESPOND);
|
||||
}
|
||||
|
||||
function getTeamDataService(): TeamDataService {
|
||||
|
|
@ -544,6 +549,12 @@ function isProvisioningTeamName(teamName: string): boolean {
|
|||
return parts.every((p) => /^[a-z0-9]+$/.test(p));
|
||||
}
|
||||
|
||||
const VALID_EFFORT_LEVELS: readonly string[] = ['low', 'medium', 'high'];
|
||||
|
||||
function isValidEffort(value: unknown): value is EffortLevel {
|
||||
return typeof value === 'string' && VALID_EFFORT_LEVELS.includes(value);
|
||||
}
|
||||
|
||||
async function validateProvisioningRequest(
|
||||
request: unknown
|
||||
): Promise<{ valid: true; value: TeamCreateRequest } | { valid: false; error: string }> {
|
||||
|
|
@ -641,6 +652,9 @@ async function validateProvisioningRequest(
|
|||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -745,7 +759,10 @@ async function handleLaunchTeam(
|
|||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: isValidEffort(payload.effort) ? payload.effort : undefined,
|
||||
clearContext: payload.clearContext === true ? true : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
},
|
||||
(progress) => {
|
||||
try {
|
||||
|
|
@ -1932,19 +1949,39 @@ async function handleShowMessageNotification(
|
|||
if (!d.teamDisplayName || !d.from || !d.body) {
|
||||
return { success: false, error: 'Missing required fields (teamDisplayName, from, body)' };
|
||||
}
|
||||
if (!d.teamName) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing required field: teamName (needed for deep-link navigation)',
|
||||
};
|
||||
}
|
||||
|
||||
// Route through NotificationManager for unified storage + native toast.
|
||||
// dedupeKey is required from renderer — built from stable identifiers (taskId, teamName, etc.)
|
||||
const dedupeKey =
|
||||
d.dedupeKey ?? `msg:${d.teamName}:${d.from}:${d.summary ?? d.body.slice(0, 50)}`;
|
||||
|
||||
void NotificationManager.getInstance()
|
||||
.addTeamNotification({
|
||||
teamEventType: d.teamEventType ?? 'task_clarification',
|
||||
teamName: d.teamName,
|
||||
teamDisplayName: d.teamDisplayName,
|
||||
from: d.from,
|
||||
to: d.to,
|
||||
summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||||
body: d.body,
|
||||
dedupeKey,
|
||||
suppressToast: d.suppressToast,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: d.teamDisplayName,
|
||||
subtitle: d.summary ?? `${d.from} → ${d.to ?? 'team'}`,
|
||||
body: d.body,
|
||||
});
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a native OS notification for a team event.
|
||||
* Respects user's notification settings (enabled, snoozed).
|
||||
* Cross-platform: macOS, Linux, Windows via Electron Notification API.
|
||||
* @deprecated Use NotificationManager.addTeamNotification() instead for unified storage + toast.
|
||||
* Kept for backward compatibility with any remaining callers.
|
||||
*/
|
||||
export function showTeamNativeNotification(opts: {
|
||||
title: string;
|
||||
|
|
@ -1971,8 +2008,8 @@ export function showTeamNativeNotification(opts: {
|
|||
}
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedBody = opts.body.slice(0, 300);
|
||||
const iconPath = getAppIconPath();
|
||||
const truncatedBody = stripMarkdown(opts.body).slice(0, 300);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: opts.title,
|
||||
...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
|
||||
|
|
@ -2014,8 +2051,8 @@ async function handleAddTaskComment(
|
|||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||
if (typeof text !== 'string' || text.trim().length === 0)
|
||||
return { success: false, error: 'Comment text must be non-empty' };
|
||||
if (text.trim().length > 2000)
|
||||
return { success: false, error: 'Comment exceeds 2000 characters' };
|
||||
if (text.trim().length > MAX_TEXT_LENGTH)
|
||||
return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` };
|
||||
|
||||
const rawAttachments = Array.isArray(attachments) ? attachments : [];
|
||||
if (rawAttachments.length > MAX_ATTACHMENTS) {
|
||||
|
|
@ -2238,3 +2275,35 @@ async function handleDeleteTaskAttachment(
|
|||
await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleToolApprovalRespond(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
runId: unknown,
|
||||
requestId: unknown,
|
||||
allow: unknown,
|
||||
message?: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
if (typeof runId !== 'string' || runId.trim().length === 0) {
|
||||
return { success: false, error: 'runId must be a non-empty string' };
|
||||
}
|
||||
if (typeof requestId !== 'string' || requestId.trim().length === 0) {
|
||||
return { success: false, error: 'requestId must be a non-empty string' };
|
||||
}
|
||||
if (typeof allow !== 'boolean') {
|
||||
return { success: false, error: 'allow must be a boolean' };
|
||||
}
|
||||
return wrapTeamHandler('toolApprovalRespond', () =>
|
||||
getTeamProvisioningService().respondToToolApproval(
|
||||
validated.value!,
|
||||
runId,
|
||||
requestId,
|
||||
allow,
|
||||
typeof message === 'string' ? message : undefined
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,17 @@ export interface DetectedError {
|
|||
triggerId?: string;
|
||||
/** Human-readable name of the trigger that produced this notification */
|
||||
triggerName?: string;
|
||||
/** Notification domain: 'error' (default/undefined) or 'team' */
|
||||
category?: 'error' | 'team';
|
||||
/** For team notifications: specific event sub-type */
|
||||
teamEventType?:
|
||||
| 'rate_limit'
|
||||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change';
|
||||
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
|
||||
dedupeKey?: string;
|
||||
/** Additional context about the error */
|
||||
context: {
|
||||
/** Human-readable project name */
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
/**
|
||||
* NotificationManager service - Manages native macOS notifications and error history.
|
||||
* NotificationManager service - Manages native notifications and notification history.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Store error history at ~/.claude/claude-devtools-notifications.json (max 100 entries)
|
||||
* - Store notification history at ~/.claude/claude-devtools-notifications.json (max 100 entries)
|
||||
* - Show native notifications using Electron's Notification API (cross-platform)
|
||||
* - Implement throttling (5 seconds per unique error hash)
|
||||
* - Respect config.notifications.enabled and snoozedUntil
|
||||
* - Filter errors matching ignoredRegex patterns
|
||||
* - Filter errors from ignoredProjects
|
||||
* - Two adapters: addError() for error notifications, addTeamNotification() for team events
|
||||
* - Shared internal pipeline: storeNotification() for unconditional storage + IPC emission
|
||||
* - Two-level dedup: dedupeKey for storage dedup, toast throttle (5s) for native toasts
|
||||
* - Storage is unconditional — enabled/snoozed only affect native OS toasts
|
||||
* - Respect config.notifications.enabled and snoozedUntil for toasts
|
||||
* - Filter errors matching ignoredRegex patterns (error-specific)
|
||||
* - Filter errors from ignoredProjects (error-specific)
|
||||
* - Auto-prune notifications over 100 on startup
|
||||
* - Emit IPC events to renderer: notification:new, notification:updated
|
||||
*/
|
||||
|
||||
import { getAppIconPath } from '@main/utils/appIcon';
|
||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||
import { stripMarkdown } from '@main/utils/textFormatting';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type BrowserWindow, Notification } from 'electron';
|
||||
import { EventEmitter } from 'events';
|
||||
|
|
@ -23,6 +27,11 @@ import * as path from 'path';
|
|||
import { type DetectedError } from '../error/ErrorMessageBuilder';
|
||||
|
||||
const logger = createLogger('Service:NotificationManager');
|
||||
import {
|
||||
buildDetectedErrorFromTeam,
|
||||
type TeamNotificationPayload,
|
||||
} from '@main/utils/teamNotificationBuilder';
|
||||
|
||||
import { projectPathResolver } from '../discovery/ProjectPathResolver';
|
||||
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
||||
|
||||
|
|
@ -30,6 +39,8 @@ import { ConfigManager } from './ConfigManager';
|
|||
|
||||
// Re-export DetectedError for backward compatibility
|
||||
export type { DetectedError };
|
||||
// Re-export team notification types for callers
|
||||
export type { TeamEventType, TeamNotificationPayload } from '@main/utils/teamNotificationBuilder';
|
||||
|
||||
/**
|
||||
* Stored notification with read status.
|
||||
|
|
@ -235,18 +246,19 @@ export class NotificationManager extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* Checks if an error should be throttled.
|
||||
* Checks if a native toast should be throttled.
|
||||
* Uses dedupeKey if present, else falls back to projectId:message hash.
|
||||
*/
|
||||
private isThrottled(error: DetectedError): boolean {
|
||||
const hash = this.generateErrorHash(error);
|
||||
const lastSeen = this.throttleMap.get(hash);
|
||||
private isToastThrottled(error: DetectedError): boolean {
|
||||
const key = error.dedupeKey ?? this.generateErrorHash(error);
|
||||
const lastSeen = this.throttleMap.get(key);
|
||||
|
||||
if (lastSeen && Date.now() - lastSeen < THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update throttle map
|
||||
this.throttleMap.set(hash, Date.now());
|
||||
this.throttleMap.set(key, Date.now());
|
||||
|
||||
// Clean up old entries periodically
|
||||
this.cleanupThrottleMap();
|
||||
|
|
@ -348,81 +360,90 @@ export class NotificationManager extends EventEmitter {
|
|||
return ignoredRepositories.includes(identity.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error should generate a notification.
|
||||
*/
|
||||
private async shouldNotify(error: DetectedError): Promise<boolean> {
|
||||
// Check if notifications are enabled
|
||||
if (!this.areNotificationsEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if error is from an ignored repository
|
||||
if (await this.isFromIgnoredRepository(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if error matches an ignored regex
|
||||
if (this.matchesIgnoredRegex(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check throttling (for native toast dedup only — storage is unconditional)
|
||||
if (this.isThrottled(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Native Notifications
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Shows a native notification for an error.
|
||||
* Note: Electron's `subtitle` option only works on macOS.
|
||||
* On Windows/Linux, we prepend the subtitle to the body instead.
|
||||
* Closes over `stored` (StoredNotification) so click handler has full data.
|
||||
*/
|
||||
private showNativeNotification(error: DetectedError): void {
|
||||
// Guard against standalone/Docker mode where Electron's Notification API is unavailable
|
||||
private showErrorNativeNotification(stored: StoredNotification): void {
|
||||
if (!this.isNativeNotificationSupported()) return;
|
||||
|
||||
const config = this.configManager.getConfig();
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedMessage = stripMarkdown(stored.message).slice(0, 200);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: 'Claude Code Error',
|
||||
...(isMac ? { subtitle: stored.context.projectName } : {}),
|
||||
body: isMac ? truncatedMessage : `${stored.context.projectName}\n${truncatedMessage}`,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
this.handleNativeNotificationClick(stored);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a native notification for a team event.
|
||||
* Uses team-specific formatting (title = team name, subtitle = summary).
|
||||
*/
|
||||
private showTeamNativeNotification(
|
||||
stored: StoredNotification,
|
||||
payload: TeamNotificationPayload
|
||||
): void {
|
||||
if (!this.isNativeNotificationSupported()) return;
|
||||
|
||||
const config = this.configManager.getConfig();
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedBody = stripMarkdown(payload.body).slice(0, 300);
|
||||
const iconPath = isMac ? undefined : getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: payload.teamDisplayName,
|
||||
...(isMac ? { subtitle: payload.summary } : {}),
|
||||
body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
this.handleNativeNotificationClick(stored);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared click handler for native notifications — focuses window and emits deep-link.
|
||||
*/
|
||||
private handleNativeNotificationClick(stored: StoredNotification): void {
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
this.mainWindow.webContents.send('notification:clicked', stored);
|
||||
}
|
||||
this.emit('notification-clicked', stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard: checks if Electron's Notification API is available.
|
||||
*/
|
||||
private isNativeNotificationSupported(): boolean {
|
||||
if (
|
||||
typeof Notification === 'undefined' ||
|
||||
typeof Notification.isSupported !== 'function' ||
|
||||
!Notification.isSupported()
|
||||
) {
|
||||
logger.warn('Native notifications not supported');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = this.configManager.getConfig();
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const truncatedMessage = error.message.slice(0, 200);
|
||||
const iconPath = getAppIconPath();
|
||||
const notification = new Notification({
|
||||
title: 'Claude Code Error',
|
||||
...(isMac ? { subtitle: error.context.projectName } : {}),
|
||||
body: isMac ? truncatedMessage : `${error.context.projectName}\n${truncatedMessage}`,
|
||||
sound: config.notifications.soundEnabled ? 'default' : undefined,
|
||||
...(iconPath ? { icon: iconPath } : {}),
|
||||
});
|
||||
|
||||
notification.on('click', () => {
|
||||
// Focus app window
|
||||
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||
this.mainWindow.show();
|
||||
this.mainWindow.focus();
|
||||
|
||||
// Send deep link to renderer
|
||||
this.mainWindow.webContents.send('notification:clicked', error);
|
||||
}
|
||||
|
||||
// Emit event for other listeners
|
||||
this.emit('notification-clicked', error);
|
||||
});
|
||||
|
||||
notification.show();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -462,17 +483,21 @@ export class NotificationManager extends EventEmitter {
|
|||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Adds an error and shows a notification if enabled.
|
||||
* @param error - The detected error to add
|
||||
* @returns The stored notification, or null if filtered/throttled
|
||||
* Stores a notification unconditionally. Emits IPC events to renderer.
|
||||
* Returns null if dedupeKey already exists in storage (storage-level dedupe)
|
||||
* or if toolUseId-based dedup skips it.
|
||||
*/
|
||||
async addError(error: DetectedError): Promise<StoredNotification | null> {
|
||||
// Wait for async initialization to complete before modifying notifications.
|
||||
// Prevents a race where saveNotifications() overwrites not-yet-loaded data.
|
||||
private async storeNotification(error: DetectedError): Promise<StoredNotification | null> {
|
||||
if (this.initPromise) {
|
||||
await this.initPromise;
|
||||
}
|
||||
|
||||
// Storage-level dedupe by dedupeKey (persistent, lives as long as notification is in storage)
|
||||
if (error.dedupeKey) {
|
||||
const exists = this.notifications.some((n) => n.dedupeKey === error.dedupeKey);
|
||||
if (exists) return null;
|
||||
}
|
||||
|
||||
// Deduplicate by toolUseId: the same tool call can appear in both the
|
||||
// subagent JSONL file and the parent session JSONL (as a progress event).
|
||||
// Keep the subagent-annotated version (with subagentId) when possible.
|
||||
|
|
@ -510,12 +535,46 @@ export class NotificationManager extends EventEmitter {
|
|||
// Emit authoritative counters (total/unread) so renderer badge stays in sync.
|
||||
this.emitNotificationUpdated();
|
||||
|
||||
// Show native notification if enabled and not filtered
|
||||
if (await this.shouldNotify(error)) {
|
||||
this.showNativeNotification(error);
|
||||
return storedNotification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an error notification. Storage is unconditional; native toast respects
|
||||
* enabled/snoozed, ignored repos, ignored regex, and 5s throttle.
|
||||
*/
|
||||
async addError(error: DetectedError): Promise<StoredNotification | null> {
|
||||
const stored = await this.storeNotification(error);
|
||||
if (!stored) return null;
|
||||
|
||||
// Error-specific toast policy: repo filter + regex filter + enabled/snoozed + throttle
|
||||
if (
|
||||
this.areNotificationsEnabled() &&
|
||||
!(await this.isFromIgnoredRepository(error)) &&
|
||||
!this.matchesIgnoredRegex(error) &&
|
||||
!this.isToastThrottled(error)
|
||||
) {
|
||||
this.showErrorNativeNotification(stored);
|
||||
}
|
||||
|
||||
return storedNotification;
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a team notification. Storage is unconditional; native toast respects
|
||||
* enabled/snoozed, suppressToast flag, and 5s dedupeKey-based throttle.
|
||||
* Skips repo/regex filters (not applicable to team events).
|
||||
*/
|
||||
async addTeamNotification(payload: TeamNotificationPayload): Promise<StoredNotification | null> {
|
||||
const error = buildDetectedErrorFromTeam(payload);
|
||||
const stored = await this.storeNotification(error);
|
||||
if (!stored) return null;
|
||||
|
||||
// Team-specific toast policy: enabled/snoozed + suppressToast + dedupeKey throttle only
|
||||
if (!payload.suppressToast && this.areNotificationsEnabled() && !this.isToastThrottled(error)) {
|
||||
this.showTeamNativeNotification(stored, payload);
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ import type {
|
|||
TeamProvisioningProgress,
|
||||
TeamProvisioningState,
|
||||
TeamTask,
|
||||
ToolApprovalEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolCallMeta,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -201,6 +203,18 @@ interface ProvisioningRun {
|
|||
env: NodeJS.ProcessEnv;
|
||||
prompt: string;
|
||||
} | null;
|
||||
/** Pending tool approval requests awaiting user response (control_request protocol). */
|
||||
pendingApprovals: Map<string, ToolApprovalRequest>;
|
||||
/**
|
||||
* Post-compact context reinjection lifecycle.
|
||||
* - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject.
|
||||
* - postCompactReminderInFlight: the reminder turn has been injected via stdin, waiting for result.
|
||||
* - suppressPostCompactReminderOutput: true while processing a reminder turn — suppress
|
||||
* low-value acknowledgement text so the user doesn't see "OK, I'll remember that."
|
||||
*/
|
||||
pendingPostCompactReminder: boolean;
|
||||
postCompactReminderInFlight: boolean;
|
||||
suppressPostCompactReminderOutput: boolean;
|
||||
}
|
||||
|
||||
type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
|
@ -402,6 +416,16 @@ function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
|||
.join('\n');
|
||||
}
|
||||
|
||||
/** Compact roster: name + role only, no workflow details. Used for post-compact reminders. */
|
||||
function buildCompactMembersRoster(members: TeamCreateRequest['members']): string {
|
||||
return members
|
||||
.map((member) => {
|
||||
const rolePart = member.role?.trim() ? ` (${member.role.trim()})` : '';
|
||||
return `- ${member.name}${rolePart}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildMemberSpawnPrompt(
|
||||
member: TeamCreateRequest['members'][number],
|
||||
displayName: string,
|
||||
|
|
@ -549,6 +573,78 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the durable lead context — constraints, communication protocol, teamctl ops,
|
||||
* and agent block policy — that must survive context compaction.
|
||||
*
|
||||
* Used by: buildProvisioningPrompt, buildLaunchPrompt, and post-compact reinjection.
|
||||
*/
|
||||
function buildPersistentLeadContext(opts: {
|
||||
teamName: string;
|
||||
leadName: string;
|
||||
isSolo: boolean;
|
||||
members: TeamCreateRequest['members'];
|
||||
/** When true, emit a compact roster (name + role only, no workflows). Used for post-compact reminders. */
|
||||
compact?: boolean;
|
||||
}): string {
|
||||
const { teamName, leadName, isSolo, members, compact } = opts;
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const teamCtlOps = buildTeamCtlOpsInstructions(teamName, leadName);
|
||||
|
||||
const soloConstraint = isSolo
|
||||
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
|
||||
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
|
||||
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
|
||||
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
|
||||
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
|
||||
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
|
||||
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
|
||||
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
|
||||
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
|
||||
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
|
||||
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
|
||||
`\n - Only move a task to in_progress when you are actively starting work on it.` +
|
||||
`\n - Only move a task to completed when it is truly finished.` +
|
||||
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
|
||||
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
|
||||
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
|
||||
: '';
|
||||
|
||||
const membersBlock = compact ? buildCompactMembersRoster(members) : buildMembersPrompt(members);
|
||||
const membersFooter = membersBlock
|
||||
? `Members:\n${membersBlock}`
|
||||
: 'Members: (none — solo team lead)';
|
||||
|
||||
return `${languageInstruction}
|
||||
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite.
|
||||
- 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.
|
||||
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
||||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||
- Use the team task board for assigned/substantial work.
|
||||
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
|
||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
||||
|
||||
${teamCtlOps}
|
||||
|
||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
|
||||
Message formatting:
|
||||
${agentBlockPolicy}
|
||||
|
||||
${membersFooter}`;
|
||||
}
|
||||
|
||||
function buildAgentBlockUsagePolicy(): string {
|
||||
return `Agent-only formatting policy (applies to ALL messages you write):
|
||||
- Humans can see teammate inbox messages and coordination text in the UI.
|
||||
|
|
@ -634,42 +730,20 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
|||
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 processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
|
||||
const leadName =
|
||||
request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
||||
const projectName = path.basename(request.cwd);
|
||||
|
||||
const isSolo = request.members.length === 0;
|
||||
const soloConstraint = isSolo
|
||||
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
|
||||
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
|
||||
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
|
||||
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
|
||||
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
|
||||
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
|
||||
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
|
||||
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
|
||||
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
|
||||
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
|
||||
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
|
||||
`\n - Only move a task to in_progress when you are actively starting work on it.` +
|
||||
`\n - Only move a task to completed when it is truly finished.` +
|
||||
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
|
||||
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
|
||||
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
|
||||
: '';
|
||||
|
||||
const step3Block = isSolo
|
||||
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself ("${leadName}") as owner.\n` +
|
||||
? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself (“${leadName}”) as owner.\n` +
|
||||
` - Prefer fewer, broader tasks over many micro-tasks.\n` +
|
||||
` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` +
|
||||
` - The tasks will be executed after the team is launched separately.`
|
||||
|
|
@ -685,7 +759,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
|
||||
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
|
||||
- Review guidance:
|
||||
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
|
||||
- Prefer NOT creating a separate “review task”. Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
|
||||
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
|
||||
- Use --related to connect it to #X (non-blocking link).
|
||||
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
|
||||
|
|
@ -699,12 +773,12 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
// NOTE: taskProtocol & processRegistration are deliberately inlined into EACH member's spawn prompt
|
||||
// below, even though the text is identical across members. This duplicates ~4K chars per member
|
||||
// in the lead's context, but ensures the lead passes the EXACT protocol verbatim via Task tool.
|
||||
// Extracting them once and telling the lead to "insert the protocol block" risks hallucination
|
||||
// Extracting them once and telling the lead to “insert the protocol block” risks hallucination
|
||||
// or omission — the lead may rephrase rules, skip items, or forget to include them.
|
||||
// Cost: ~1K tokens per extra member. At 200K context window this is negligible.
|
||||
${request.members
|
||||
.map(
|
||||
(m) => ` For "${m.name}":
|
||||
(m) => ` For “${m.name}”:
|
||||
- prompt:
|
||||
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
|
||||
.split('\n')
|
||||
|
|
@ -713,53 +787,32 @@ ${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, process
|
|||
)
|
||||
.join('\n\n')}`;
|
||||
|
||||
const membersFooter = members ? `Members:\n${members}` : 'Members: (none — solo team lead)';
|
||||
const persistentContext = buildPersistentLeadContext({
|
||||
teamName: request.teamName,
|
||||
leadName,
|
||||
isSolo,
|
||||
members: request.members,
|
||||
});
|
||||
|
||||
return `Team Start [Agent Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"]
|
||||
return `Team Start [Agent Team: “${request.teamName}” | Project: “${projectName}” | Lead: “${leadName}”]
|
||||
|
||||
You are running in a non-interactive CLI session. Do not ask questions. Do everything in a single turn.
|
||||
You are "${leadName}", the team lead.
|
||||
You are “${leadName}”, the team lead.
|
||||
|
||||
Goal: Provision a Claude Code agent team${request.members.length === 0 ? ' (solo — lead only)' : ' with live teammates'}.
|
||||
${userPromptBlock}
|
||||
${languageInstruction}
|
||||
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite.
|
||||
- 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.
|
||||
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
||||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||
- Use the team task board for assigned/substantial work.
|
||||
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
|
||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
||||
|
||||
${teamCtlOps}
|
||||
|
||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
|
||||
Message formatting:
|
||||
${agentBlockPolicy}
|
||||
${persistentContext}
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
1) TeamCreate — create team "${request.teamName}":
|
||||
- description: "${description}"
|
||||
1) TeamCreate — create team “${request.teamName}”:
|
||||
- description: “${description}”
|
||||
|
||||
${step2Block}
|
||||
|
||||
${step3Block}
|
||||
|
||||
4) After all steps, output a short summary.
|
||||
|
||||
${membersFooter}
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
@ -769,39 +822,18 @@ function buildLaunchPrompt(
|
|||
tasks: TeamTask[],
|
||||
isResume: boolean
|
||||
): 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);
|
||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const taskBoardSnapshot = buildTaskBoardSnapshot(tasks);
|
||||
|
||||
const leadName = members.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead';
|
||||
const teamCtlOps = buildTeamCtlOpsInstructions(request.teamName, leadName);
|
||||
const projectName = path.basename(request.cwd);
|
||||
|
||||
const isSolo = members.length === 0;
|
||||
const soloConstraint = isSolo
|
||||
? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` +
|
||||
`\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` +
|
||||
`\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` +
|
||||
`\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` +
|
||||
`\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` +
|
||||
`\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` +
|
||||
`\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` +
|
||||
`\n - PROGRESS REPORTING (MANDATORY): Since you have no teammates, "user" is your only communication channel.` +
|
||||
`\n - SendMessage "user" at minimum: when you start a task (after marking it in_progress), when you complete a task, and when you hit a meaningful milestone/blocker/decision.` +
|
||||
`\n - Avoid long silent stretches. If something is taking longer than expected, send a brief update and the next step.` +
|
||||
`\n - TASK STATUS DISCIPLINE (MANDATORY):` +
|
||||
`\n - Only move a task to in_progress when you are actively starting work on it.` +
|
||||
`\n - Only move a task to completed when it is truly finished.` +
|
||||
`\n - Never bulk-move many tasks at the end — update status incrementally as you work.` +
|
||||
`\n - Default to working ONE task at a time (keep at most one task in_progress in solo mode), unless you explicitly need parallel background work (in that case explain why to "user").` +
|
||||
`\n - Record meaningful progress/decisions as task comments so the task board stays accurate and high-signal.`
|
||||
: '';
|
||||
|
||||
let step2And3Block: string;
|
||||
if (isSolo) {
|
||||
|
|
@ -872,9 +904,12 @@ ${memberSpawnInstructions}
|
|||
3) After spawning all members, check the task board. If any pending tasks are unassigned, assign them to appropriate members using teamctl.`;
|
||||
}
|
||||
|
||||
const membersFooter = membersBlock
|
||||
? `Members:\n${membersBlock}`
|
||||
: 'Members: (none — solo team lead)';
|
||||
const persistentContext = buildPersistentLeadContext({
|
||||
teamName: request.teamName,
|
||||
leadName,
|
||||
isSolo,
|
||||
members,
|
||||
});
|
||||
|
||||
const startLabel = isResume ? 'Team Start (resume)' : 'Team Start';
|
||||
|
||||
|
|
@ -885,31 +920,8 @@ You are "${leadName}", the team lead.
|
|||
|
||||
Goal: Reconnect with existing team "${request.teamName}" and resume pending work.
|
||||
${userPromptBlock}
|
||||
${languageInstruction}
|
||||
${taskBoardSnapshot}
|
||||
Constraints:
|
||||
- Do NOT call TeamDelete under any circumstances.
|
||||
- Do NOT use TodoWrite.
|
||||
- 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.
|
||||
- Do NOT spawn or create a member named "user". "user" is a reserved system name for the human operator — it is NOT a teammate.
|
||||
- Keep assistant text minimal.
|
||||
- NEVER send duplicate messages to the same member. One SendMessage per member per topic is enough.
|
||||
- Keep the task board high-signal: avoid creating tasks for trivial micro-items.
|
||||
- Use the team task board for assigned/substantial work.
|
||||
- DELEGATION-FIRST (behavior rule for ALL future turns): When "user" gives you work, your top priority is to (a) decompose into tasks, (b) create tasks on the team board, (c) assign them to teammates, and (d) SendMessage "user" a short confirmation (task IDs + owners). Do NOT start implementing yourself unless the team is truly in SOLO MODE (no teammates).
|
||||
- TaskCreate is optional for private planning only; do NOT use it for team-board tasks.
|
||||
- When messaging "user" (the human): NEVER mention teamctl.js, internal scripts, CLI commands, or file paths under ~/.claude/. The user sees messages in the UI — write plain human language. If a task needs a status update, do it yourself via Bash; never ask the user to run a command.${soloConstraint}
|
||||
|
||||
${teamCtlOps}
|
||||
|
||||
Communication protocol (CRITICAL — you are running headless, no one sees your text output):
|
||||
- When you receive a <teammate-message> from a teammate, ALWAYS reply using the SendMessage tool with the sender's name as recipient.
|
||||
- Your plain text output is invisible to teammates — they are separate processes and can only read their inbox.
|
||||
- Example: if you receive <teammate-message teammate_id="alice">...</teammate-message>, respond with SendMessage(type: "message", recipient: "alice", content: "your reply").
|
||||
|
||||
Message formatting:
|
||||
${agentBlockPolicy}
|
||||
${persistentContext}
|
||||
|
||||
Steps (execute in this exact order):
|
||||
|
||||
|
|
@ -918,11 +930,19 @@ Steps (execute in this exact order):
|
|||
${step2And3Block}
|
||||
|
||||
4) After all steps, output a short summary of reconnected members and what happens next.
|
||||
|
||||
${membersFooter}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unconditionally clears all post-compact reminder state on a run.
|
||||
* Called from cleanupRun, cancel, and error paths.
|
||||
*/
|
||||
function clearPostCompactReminderState(run: ProvisioningRun): void {
|
||||
run.pendingPostCompactReminder = false;
|
||||
run.postCompactReminderInFlight = false;
|
||||
run.suppressPostCompactReminderOutput = false;
|
||||
}
|
||||
|
||||
function updateProgress(
|
||||
run: ProvisioningRun,
|
||||
state: Exclude<TeamProvisioningState, 'idle'>,
|
||||
|
|
@ -1163,6 +1183,16 @@ export class TeamProvisioningService {
|
|||
this.teamChangeEmitter = emitter;
|
||||
}
|
||||
|
||||
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
|
||||
|
||||
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
|
||||
this.toolApprovalEventEmitter = emitter;
|
||||
}
|
||||
|
||||
private emitToolApprovalEvent(event: ToolApprovalEvent): void {
|
||||
this.toolApprovalEventEmitter?.(event);
|
||||
}
|
||||
|
||||
getLiveLeadProcessMessages(teamName: string): InboxMessage[] {
|
||||
return [...(this.liveLeadProcessMessages.get(teamName) ?? [])];
|
||||
}
|
||||
|
|
@ -1759,6 +1789,10 @@ export class TeamProvisioningService {
|
|||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
pendingApprovals: new Map(),
|
||||
pendingPostCompactReminder: false,
|
||||
postCompactReminderInFlight: false,
|
||||
suppressPostCompactReminderOutput: false,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -1787,8 +1821,9 @@ export class TeamProvisioningService {
|
|||
'user,project,local',
|
||||
'--disallowedTools',
|
||||
'TeamDelete,TodoWrite',
|
||||
'--dangerously-skip-permissions',
|
||||
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
|
||||
...(request.model ? ['--model', request.model] : []),
|
||||
...(request.effort ? ['--effort', request.effort] : []),
|
||||
];
|
||||
try {
|
||||
child = spawnCli(claudePath, spawnArgs, {
|
||||
|
|
@ -2018,6 +2053,7 @@ export class TeamProvisioningService {
|
|||
teamName: request.teamName,
|
||||
members: expectedMemberSpecs,
|
||||
cwd: request.cwd,
|
||||
skipPermissions: request.skipPermissions,
|
||||
};
|
||||
|
||||
const run: ProvisioningRun = {
|
||||
|
|
@ -2059,6 +2095,10 @@ export class TeamProvisioningService {
|
|||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
pendingApprovals: new Map(),
|
||||
pendingPostCompactReminder: false,
|
||||
postCompactReminderInFlight: false,
|
||||
suppressPostCompactReminderOutput: false,
|
||||
progress: {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
|
|
@ -2109,7 +2149,7 @@ export class TeamProvisioningService {
|
|||
'user,project,local',
|
||||
'--disallowedTools',
|
||||
'TeamDelete,TodoWrite',
|
||||
'--dangerously-skip-permissions',
|
||||
...(request.skipPermissions !== false ? ['--dangerously-skip-permissions'] : []),
|
||||
];
|
||||
if (previousSessionId) {
|
||||
launchArgs.push('--resume', previousSessionId);
|
||||
|
|
@ -2120,6 +2160,9 @@ export class TeamProvisioningService {
|
|||
if (request.model) {
|
||||
launchArgs.push('--model', request.model);
|
||||
}
|
||||
if (request.effort) {
|
||||
launchArgs.push('--effort', request.effort);
|
||||
}
|
||||
// New sessions: CLI creates its own ID. No --resume with synthetic name — docs say
|
||||
// --resume is for existing sessions and may show an interactive picker if not found.
|
||||
|
||||
|
|
@ -2917,7 +2960,11 @@ export class TeamProvisioningService {
|
|||
// Push each assistant text block as a separate live message (per-message pattern).
|
||||
// When the same assistant message includes SendMessage(to:"user"), skip text —
|
||||
// captureSendMessageToUser() handles it separately.
|
||||
if (!run.silentUserDmForward && !hasSendMessageToUser) {
|
||||
if (
|
||||
!run.silentUserDmForward &&
|
||||
!run.suppressPostCompactReminderOutput &&
|
||||
!hasSendMessageToUser
|
||||
) {
|
||||
const cleanText = stripAgentBlocks(text).trim();
|
||||
if (cleanText.length > 0) {
|
||||
run.leadMsgSeq += 1;
|
||||
|
|
@ -3033,6 +3080,12 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle control_request — tool approval protocol (only when --dangerously-skip-permissions is NOT set)
|
||||
if (msg.type === 'control_request') {
|
||||
this.handleControlRequest(run, msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'result') {
|
||||
const subtype =
|
||||
typeof msg.subtype === 'string'
|
||||
|
|
@ -3109,6 +3162,19 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
if (run.provisioningComplete) {
|
||||
// If this was a post-compact reminder turn completing, clear in-flight and suppress flags.
|
||||
// Preserve pendingPostCompactReminder if re-armed by a compact_boundary during this turn.
|
||||
if (run.postCompactReminderInFlight) {
|
||||
const hadPendingRearm = run.pendingPostCompactReminder;
|
||||
run.postCompactReminderInFlight = false;
|
||||
run.suppressPostCompactReminderOutput = false;
|
||||
logger.info(
|
||||
`[${run.teamName}] post-compact reminder turn completed${
|
||||
hadPendingRearm ? ' (follow-up reminder pending from re-compact)' : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
this.setLeadActivity(run, 'idle');
|
||||
}
|
||||
if (run.leadRelayCapture) {
|
||||
|
|
@ -3122,6 +3188,18 @@ export class TeamProvisioningService {
|
|||
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||
run.silentUserDmForwardClearHandle = null;
|
||||
}
|
||||
|
||||
// Deferred post-compact context reinjection: inject durable rules on first idle after compact.
|
||||
// Placed AFTER leadRelayCapture/silentUserDmForward cleanup so a previously-deferred
|
||||
// reminder can proceed now that the blocking conditions are cleared.
|
||||
if (
|
||||
run.provisioningComplete &&
|
||||
run.pendingPostCompactReminder &&
|
||||
!run.postCompactReminderInFlight
|
||||
) {
|
||||
void this.injectPostCompactReminder(run);
|
||||
}
|
||||
|
||||
if (!run.provisioningComplete && !run.cancelRequested) {
|
||||
void this.handleProvisioningTurnComplete(run);
|
||||
}
|
||||
|
|
@ -3155,7 +3233,16 @@ export class TeamProvisioningService {
|
|||
killProcessTree(run.child);
|
||||
this.cleanupRun(run);
|
||||
} else if (run.provisioningComplete) {
|
||||
// Post-provisioning error: process alive, waiting for input
|
||||
// Post-provisioning error: process alive, waiting for input.
|
||||
// Always clear all post-compact reminder state on error — prevents a stale pending
|
||||
// reminder from firing on the next unrelated successful turn.
|
||||
if (run.pendingPostCompactReminder || run.postCompactReminderInFlight) {
|
||||
const wasInFlight = run.postCompactReminderInFlight;
|
||||
clearPostCompactReminderState(run);
|
||||
logger.warn(
|
||||
`[${run.teamName}] post-compact reminder ${wasInFlight ? 'turn errored' : 'pending dropped'} — clearing (strict policy)`
|
||||
);
|
||||
}
|
||||
this.setLeadActivity(run, 'idle');
|
||||
}
|
||||
}
|
||||
|
|
@ -3193,10 +3280,315 @@ export class TeamProvisioningService {
|
|||
logger.info(
|
||||
`[${run.teamName}] compact_boundary — context will refresh on next turn${tokenInfo}`
|
||||
);
|
||||
|
||||
// Schedule post-compact context reinjection on next idle.
|
||||
// If a reminder is already in-flight, re-arm pending so a follow-up fires after it completes.
|
||||
// This handles the case where the reminder prompt itself triggers another compaction.
|
||||
if (run.provisioningComplete && !run.pendingPostCompactReminder) {
|
||||
run.pendingPostCompactReminder = true;
|
||||
logger.info(
|
||||
`[${run.teamName}] post-compact reminder scheduled for next idle${
|
||||
run.postCompactReminderInFlight ? ' (re-armed during in-flight reminder)' : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a post-compact context reminder into the lead process via stdin.
|
||||
* Reinjects durable lead rules (constraints, communication protocol, teamctl ops)
|
||||
* plus a fresh task board snapshot so the lead recovers full operational context
|
||||
* after context compaction.
|
||||
*
|
||||
* Policy: strict drop-after-attempt — one compact cycle gives at most one reminder turn.
|
||||
* If the injection fails (stdin not writable, process killed), we do not retry.
|
||||
*/
|
||||
private async injectPostCompactReminder(run: ProvisioningRun): Promise<void> {
|
||||
// Consume the pending flag immediately — strict one-shot policy.
|
||||
run.pendingPostCompactReminder = false;
|
||||
|
||||
// Guard: process must be alive and writable.
|
||||
if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] post-compact reminder skipped — process not writable or killed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: don't inject if another turn is actively processing (race with user send / inbox relay).
|
||||
if (run.leadActivityState !== 'idle') {
|
||||
logger.info(
|
||||
`[${run.teamName}] post-compact reminder deferred — lead is ${run.leadActivityState}, not idle`
|
||||
);
|
||||
// Re-arm so it triggers on next idle.
|
||||
run.pendingPostCompactReminder = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: don't inject while a relay capture is in-flight.
|
||||
if (run.leadRelayCapture) {
|
||||
logger.info(`[${run.teamName}] post-compact reminder deferred — relay capture in-flight`);
|
||||
run.pendingPostCompactReminder = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: don't inject while a silent DM forward is in progress.
|
||||
if (run.silentUserDmForward) {
|
||||
logger.info(
|
||||
`[${run.teamName}] post-compact reminder deferred — silent DM forward in progress`
|
||||
);
|
||||
run.pendingPostCompactReminder = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Read current team config for up-to-date members (may have changed since launch).
|
||||
let currentMembers: TeamCreateRequest['members'] = run.request.members;
|
||||
let leadName = 'team-lead';
|
||||
try {
|
||||
const config = await this.configReader.getConfig(run.teamName);
|
||||
if (config?.members) {
|
||||
const configLead = config.members.find((m) => m?.agentType === 'team-lead');
|
||||
leadName = configLead?.name?.trim() || 'team-lead';
|
||||
// Convert config members (excluding lead) to TeamCreateRequest member format.
|
||||
currentMembers = config.members
|
||||
.filter((m) => m?.agentType !== 'team-lead' && m?.name)
|
||||
.map((m) => ({
|
||||
name: m.name,
|
||||
role: m.role ?? undefined,
|
||||
}));
|
||||
} else {
|
||||
leadName =
|
||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||
'team-lead';
|
||||
}
|
||||
} catch {
|
||||
// Fallback to launch-time members if config is unavailable.
|
||||
leadName =
|
||||
run.request.members.find((m) => m.role?.toLowerCase().includes('lead'))?.name ||
|
||||
'team-lead';
|
||||
logger.warn(
|
||||
`[${run.teamName}] post-compact reminder: config unavailable, using launch-time members`
|
||||
);
|
||||
}
|
||||
const isSolo = currentMembers.length === 0;
|
||||
|
||||
// Build persistent lead context.
|
||||
const persistentContext = buildPersistentLeadContext({
|
||||
teamName: run.teamName,
|
||||
leadName,
|
||||
isSolo,
|
||||
members: currentMembers,
|
||||
compact: true,
|
||||
});
|
||||
|
||||
// Best-effort: fetch fresh task board snapshot.
|
||||
let taskBoardBlock = '';
|
||||
try {
|
||||
const taskReader = new TeamTaskReader();
|
||||
const tasks = await taskReader.getTasks(run.teamName);
|
||||
taskBoardBlock = buildTaskBoardSnapshot(tasks);
|
||||
} catch {
|
||||
// If tasks can't be read, inject without the snapshot.
|
||||
logger.warn(`[${run.teamName}] post-compact reminder: task board snapshot unavailable`);
|
||||
}
|
||||
|
||||
// Re-check guards after async work.
|
||||
if (!run.child?.stdin?.writable || run.processKilled || run.cancelRequested) {
|
||||
logger.warn(
|
||||
`[${run.teamName}] post-compact reminder aborted — process state changed during preparation`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (run.leadActivityState !== 'idle') {
|
||||
logger.info(
|
||||
`[${run.teamName}] post-compact reminder aborted — lead activity changed to ${run.leadActivityState as string}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = [
|
||||
`Context reminder (post-compaction) — your context was compacted. Here are your standing rules and current state:`,
|
||||
``,
|
||||
`You are "${leadName}", the team lead of team "${run.teamName}".`,
|
||||
`You are running in a non-interactive CLI session. Do not ask questions.`,
|
||||
``,
|
||||
persistentContext,
|
||||
taskBoardBlock.trim() ? `\n${taskBoardBlock}` : '',
|
||||
``,
|
||||
`This is a context-only reminder. Do NOT start new work or execute tasks in this turn. Reply with a single word: "OK".`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'user',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: message }],
|
||||
},
|
||||
});
|
||||
|
||||
run.postCompactReminderInFlight = true;
|
||||
run.suppressPostCompactReminderOutput = true;
|
||||
this.setLeadActivity(run, 'active');
|
||||
|
||||
try {
|
||||
const stdin = run.child.stdin;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdin.write(payload + '\n', (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
logger.info(`[${run.teamName}] post-compact reminder injected`);
|
||||
} catch (error) {
|
||||
// Strict drop-after-attempt — do not re-arm.
|
||||
clearPostCompactReminderState(run);
|
||||
this.setLeadActivity(run, 'idle');
|
||||
logger.warn(
|
||||
`[${run.teamName}] post-compact reminder injection failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a control_request message from CLI stream-json output.
|
||||
* `can_use_tool` → emits to renderer for manual approval.
|
||||
* All other subtypes (hook_callback, etc.) → auto-allowed to prevent deadlock.
|
||||
*/
|
||||
private handleControlRequest(run: ProvisioningRun, msg: Record<string, unknown>): void {
|
||||
const requestId = typeof msg.request_id === 'string' ? msg.request_id : null;
|
||||
if (!requestId) {
|
||||
logger.warn(`[${run.teamName}] control_request missing request_id, ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = msg.request as Record<string, unknown> | undefined;
|
||||
const subtype = request?.subtype;
|
||||
|
||||
// Non-`can_use_tool` subtypes (hook_callback, etc.) are auto-allowed to prevent
|
||||
// CLI deadlock — hooks are user-configured and should not block on manual approval.
|
||||
if (subtype !== 'can_use_tool') {
|
||||
logger.debug(
|
||||
`[${run.teamName}] control_request subtype=${String(subtype)}, auto-allowing to prevent deadlock`
|
||||
);
|
||||
this.autoAllowControlRequest(run, requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
const toolName = typeof request?.tool_name === 'string' ? request.tool_name : 'Unknown';
|
||||
const toolInput = (request?.input ?? {}) as Record<string, unknown>;
|
||||
|
||||
const approval: ToolApprovalRequest = {
|
||||
requestId,
|
||||
runId: run.runId,
|
||||
teamName: run.teamName,
|
||||
source: 'lead',
|
||||
toolName,
|
||||
toolInput,
|
||||
receivedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
run.pendingApprovals.set(requestId, approval);
|
||||
this.emitToolApprovalEvent(approval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately sends an "allow" control_response for a non-tool control_request.
|
||||
* Prevents CLI deadlock for hook_callback and other non-`can_use_tool` subtypes.
|
||||
*/
|
||||
private autoAllowControlRequest(run: ProvisioningRun, requestId: string): void {
|
||||
if (!run.child?.stdin?.writable) {
|
||||
logger.warn(`[${run.teamName}] Cannot auto-allow control_request: stdin not writable`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { behavior: 'allow' },
|
||||
},
|
||||
};
|
||||
|
||||
run.child.stdin.write(JSON.stringify(response) + '\n', (err) => {
|
||||
if (err) {
|
||||
logger.error(
|
||||
`[${run.teamName}] Failed to auto-allow control_request ${requestId}: ${err.message}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to a pending tool approval — sends control_response to CLI stdin.
|
||||
* Validates runId match and requestId existence before writing.
|
||||
*/
|
||||
async respondToToolApproval(
|
||||
teamName: string,
|
||||
runId: string,
|
||||
requestId: string,
|
||||
allow: boolean,
|
||||
message?: string
|
||||
): Promise<void> {
|
||||
const currentRunId = this.activeByTeam.get(teamName);
|
||||
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
|
||||
const run = this.runs.get(currentRunId);
|
||||
if (!run) throw new Error(`Run not found for team "${teamName}"`);
|
||||
|
||||
if (run.runId !== runId) {
|
||||
throw new Error(`Stale approval: runId mismatch (expected ${run.runId}, got ${runId})`);
|
||||
}
|
||||
|
||||
if (!run.pendingApprovals.has(requestId)) {
|
||||
throw new Error(`No pending approval with requestId "${requestId}"`);
|
||||
}
|
||||
|
||||
if (!run.child?.stdin?.writable) {
|
||||
throw new Error(`Team "${teamName}" process stdin is not writable`);
|
||||
}
|
||||
|
||||
// IMPORTANT: request_id is NESTED inside response, NOT top-level
|
||||
// (asymmetry with control_request — confirmed by Python SDK, Elixir SDK and issue #29991)
|
||||
const response = allow
|
||||
? {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { behavior: 'allow' },
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'success',
|
||||
request_id: requestId,
|
||||
response: { behavior: 'deny', message: message ?? 'User denied' },
|
||||
},
|
||||
};
|
||||
|
||||
const stdin = run.child.stdin;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stdin.write(JSON.stringify(response) + '\n', (err) => {
|
||||
if (err) {
|
||||
logger.error(`[${teamName}] Failed to write control_response: ${err.message}`);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Only delete AFTER successful write
|
||||
run.pendingApprovals.delete(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the first stream-json turn completes successfully.
|
||||
* Verifies provisioning files exist and marks as ready.
|
||||
|
|
@ -3391,6 +3783,7 @@ export class TeamProvisioningService {
|
|||
clearTimeout(run.silentUserDmForwardClearHandle);
|
||||
run.silentUserDmForwardClearHandle = null;
|
||||
}
|
||||
clearPostCompactReminderState(run);
|
||||
this.stopFilesystemMonitor(run);
|
||||
// Remove stream listeners to prevent data handlers firing on a cleaned-up run
|
||||
if (run.child) {
|
||||
|
|
@ -3402,6 +3795,11 @@ export class TeamProvisioningService {
|
|||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||
this.relayedLeadInboxFallbackKeys.delete(run.teamName);
|
||||
this.liveLeadProcessMessages.delete(run.teamName);
|
||||
// Dismiss any pending tool approvals for this run
|
||||
if (run.pendingApprovals.size > 0) {
|
||||
this.emitToolApprovalEvent({ dismissed: true, teamName: run.teamName, runId: run.runId });
|
||||
run.pendingApprovals.clear();
|
||||
}
|
||||
// Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines)
|
||||
this.runs.delete(run.runId);
|
||||
}
|
||||
|
|
|
|||
93
src/main/utils/teamNotificationBuilder.ts
Normal file
93
src/main/utils/teamNotificationBuilder.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Team notification builder — creates DetectedError objects from team event payloads.
|
||||
*
|
||||
* Pure utility with no service dependencies. Used by NotificationManager.addTeamNotification()
|
||||
* to convert domain-level team payloads into the unified notification format.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import type { DetectedError } from '../services/error/ErrorMessageBuilder';
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type TeamEventType =
|
||||
| 'rate_limit'
|
||||
| 'lead_inbox'
|
||||
| 'user_inbox'
|
||||
| 'task_clarification'
|
||||
| 'task_status_change';
|
||||
|
||||
/**
|
||||
* Domain payload for team notifications.
|
||||
* Single source of truth — both storage and native presentation are derived from this.
|
||||
*/
|
||||
export interface TeamNotificationPayload {
|
||||
teamEventType: TeamEventType;
|
||||
teamName: string;
|
||||
teamDisplayName: string;
|
||||
from: string;
|
||||
to?: string;
|
||||
summary: string;
|
||||
body: string;
|
||||
/** Stable key for storage deduplication. REQUIRED — no fallback to Date.now(). */
|
||||
dedupeKey: string;
|
||||
projectPath?: string;
|
||||
/**
|
||||
* When true, the notification is stored in-app but no native OS toast is shown.
|
||||
* Used when per-type toggle (e.g. notifyOnLeadInbox) is off — storage is unconditional,
|
||||
* but the user opted out of OS interruptions for this event type.
|
||||
*/
|
||||
suppressToast?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Config mapping
|
||||
// =============================================================================
|
||||
|
||||
interface TeamNotificationConfig {
|
||||
triggerName: string;
|
||||
triggerColor: TriggerColor;
|
||||
}
|
||||
|
||||
const TEAM_NOTIFICATION_CONFIG: Record<TeamEventType, TeamNotificationConfig> = {
|
||||
rate_limit: { triggerName: 'Rate Limit', triggerColor: 'red' },
|
||||
lead_inbox: { triggerName: 'Team Inbox', triggerColor: 'blue' },
|
||||
user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' },
|
||||
task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' },
|
||||
task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Builder
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Converts a team notification payload into a DetectedError for unified storage.
|
||||
* Uses `sessionId: 'team:{teamName}'` convention (established by rate-limit notifications).
|
||||
*/
|
||||
export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): DetectedError {
|
||||
const config = TEAM_NOTIFICATION_CONFIG[payload.teamEventType];
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
sessionId: `team:${payload.teamName}`,
|
||||
projectId: payload.teamName,
|
||||
filePath: '',
|
||||
source: payload.teamEventType,
|
||||
message: `[${payload.from}] ${payload.body.slice(0, 300)}`,
|
||||
category: 'team',
|
||||
teamEventType: payload.teamEventType,
|
||||
dedupeKey: payload.dedupeKey,
|
||||
triggerColor: config.triggerColor,
|
||||
triggerName: config.triggerName,
|
||||
context: {
|
||||
projectName: payload.teamDisplayName,
|
||||
cwd: payload.projectPath,
|
||||
},
|
||||
};
|
||||
}
|
||||
16
src/main/utils/textFormatting.ts
Normal file
16
src/main/utils/textFormatting.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import remarkParse from 'remark-parse';
|
||||
import stripMarkdownPlugin from 'strip-markdown';
|
||||
import { unified } from 'unified';
|
||||
|
||||
const processor = unified().use(remarkParse).use(stripMarkdownPlugin);
|
||||
|
||||
/**
|
||||
* Strips markdown formatting from text for use in plain-text contexts
|
||||
* like native OS notifications.
|
||||
*
|
||||
* Uses remark ecosystem (strip-markdown plugin) for reliable parsing.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
const result = processor.processSync(text);
|
||||
return String(result).trim();
|
||||
}
|
||||
|
|
@ -358,6 +358,12 @@ export const TEAM_GET_TASK_ATTACHMENT = 'team:getTaskAttachment';
|
|||
/** Delete an attachment from a task */
|
||||
export const TEAM_DELETE_TASK_ATTACHMENT = 'team:deleteTaskAttachment';
|
||||
|
||||
/** Push event: tool approval request or dismissal (main → renderer) */
|
||||
export const TEAM_TOOL_APPROVAL_EVENT = 'team:toolApprovalEvent';
|
||||
|
||||
/** Invoke: respond to a tool approval request (renderer → main) */
|
||||
export const TEAM_TOOL_APPROVAL_RESPOND = 'team:toolApprovalRespond';
|
||||
|
||||
// =============================================================================
|
||||
// CLI Installer API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ import {
|
|||
TEAM_SOFT_DELETE_TASK,
|
||||
TEAM_START_TASK,
|
||||
TEAM_STOP,
|
||||
TEAM_TOOL_APPROVAL_EVENT,
|
||||
TEAM_TOOL_APPROVAL_RESPOND,
|
||||
TEAM_UPDATE_CONFIG,
|
||||
TEAM_UPDATE_KANBAN,
|
||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||
|
|
@ -214,6 +216,7 @@ import type {
|
|||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamUpdateConfigRequest,
|
||||
ToolApprovalEvent,
|
||||
TriggerTestResult,
|
||||
UpdateKanbanPatch,
|
||||
WslClaudeRootCandidate,
|
||||
|
|
@ -975,6 +978,36 @@ const electronAPI: ElectronAPI = {
|
|||
);
|
||||
};
|
||||
},
|
||||
respondToToolApproval: async (
|
||||
teamName: string,
|
||||
runId: string,
|
||||
requestId: string,
|
||||
allow: boolean,
|
||||
message?: string
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(
|
||||
TEAM_TOOL_APPROVAL_RESPOND,
|
||||
teamName,
|
||||
runId,
|
||||
requestId,
|
||||
allow,
|
||||
message
|
||||
);
|
||||
},
|
||||
onToolApprovalEvent: (
|
||||
callback: (event: unknown, data: ToolApprovalEvent) => void
|
||||
): (() => void) => {
|
||||
ipcRenderer.on(
|
||||
TEAM_TOOL_APPROVAL_EVENT,
|
||||
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
||||
);
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener(
|
||||
TEAM_TOOL_APPROVAL_EVENT,
|
||||
callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void
|
||||
);
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ===== Review API =====
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ConfirmDialog } from './components/common/ConfirmDialog';
|
|||
import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
|
||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||
import { TabbedLayout } from './components/layout/TabbedLayout';
|
||||
import { ToolApprovalSheet } from './components/team/ToolApprovalSheet';
|
||||
import { useTheme } from './hooks/useTheme';
|
||||
import { api } from './api';
|
||||
import { useStore } from './store';
|
||||
|
|
@ -40,6 +41,7 @@ export const App = (): React.JSX.Element => {
|
|||
<ContextSwitchOverlay />
|
||||
<TabbedLayout />
|
||||
<ConfirmDialog />
|
||||
<ToolApprovalSheet />
|
||||
</TooltipProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -249,11 +249,16 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getSessionDetail = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
_options?: { bypassCache?: boolean }
|
||||
): Promise<SessionDetail | null> =>
|
||||
this.get<SessionDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`
|
||||
options?: { bypassCache?: boolean }
|
||||
): Promise<SessionDetail | null> => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.bypassCache) params.set('bypassCache', 'true');
|
||||
const qs = params.toString();
|
||||
const suffix = qs ? `?${qs}` : '';
|
||||
return this.get<SessionDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${suffix}`
|
||||
);
|
||||
};
|
||||
|
||||
getSessionMetrics = (projectId: string, sessionId: string): Promise<SessionMetrics | null> =>
|
||||
this.get<SessionMetrics | null>(
|
||||
|
|
@ -269,11 +274,16 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
projectId: string,
|
||||
sessionId: string,
|
||||
subagentId: string,
|
||||
_options?: { bypassCache?: boolean }
|
||||
): Promise<SubagentDetail | null> =>
|
||||
this.get<SubagentDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}`
|
||||
options?: { bypassCache?: boolean }
|
||||
): Promise<SubagentDetail | null> => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.bypassCache) params.set('bypassCache', 'true');
|
||||
const qs = params.toString();
|
||||
const suffix = qs ? `?${qs}` : '';
|
||||
return this.get<SubagentDetail | null>(
|
||||
`/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${suffix}`
|
||||
);
|
||||
};
|
||||
|
||||
getSessionGroups = (projectId: string, sessionId: string): Promise<ConversationGroup[]> =>
|
||||
this.get<ConversationGroup[]>(
|
||||
|
|
@ -884,6 +894,12 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): (() => void) => {
|
||||
return () => {};
|
||||
},
|
||||
respondToToolApproval: async (): Promise<void> => {
|
||||
throw new Error('Tool approval not available in browser mode');
|
||||
},
|
||||
onToolApprovalEvent: (): (() => void) => {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
// Review API stubs
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
import React, { useRef } from 'react';
|
||||
|
||||
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import {
|
||||
getToolContextTokens,
|
||||
getToolStatus,
|
||||
|
|
@ -70,6 +71,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
registerRef,
|
||||
}) => {
|
||||
const status = getToolStatus(linkedTool);
|
||||
const { isLight } = useTheme();
|
||||
const summary = getToolSummary(linkedTool.name, linkedTool.input);
|
||||
const summaryNode =
|
||||
searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||
|
|
@ -104,7 +106,7 @@ export const LinkedToolItem: React.FC<LinkedToolItemProps> = ({
|
|||
<span className="size-2.5 rounded-full" style={{ backgroundColor: colors.border }} />
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{ backgroundColor: colors.badge, color: colors.text }}
|
||||
style={{ backgroundColor: getThemedBadge(colors, isLight), color: colors.text }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -12,8 +12,13 @@ import {
|
|||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getSubagentTypeColorSet, getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import {
|
||||
getSubagentTypeColorSet,
|
||||
getTeamColorSet,
|
||||
getThemedBadge,
|
||||
} from '@renderer/constants/teamColors';
|
||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
||||
|
|
@ -80,6 +85,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
|
||||
// Team member colors (when this subagent is a team member)
|
||||
const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null;
|
||||
const { isLight } = useTheme();
|
||||
// Type-based colors for non-team subagents (from agent config or deterministic hash)
|
||||
const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null;
|
||||
|
||||
|
|
@ -233,7 +239,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: teamColors.badge,
|
||||
backgroundColor: getThemedBadge(teamColors, isLight),
|
||||
color: teamColors.text,
|
||||
border: `1px solid ${teamColors.border}40`,
|
||||
}}
|
||||
|
|
@ -305,7 +311,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: teamColors.badge,
|
||||
backgroundColor: getThemedBadge(teamColors, isLight),
|
||||
color: teamColors.text,
|
||||
border: `1px solid ${teamColors.border}40`,
|
||||
}}
|
||||
|
|
@ -316,7 +322,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide"
|
||||
style={{
|
||||
backgroundColor: typeColors!.badge,
|
||||
backgroundColor: getThemedBadge(typeColors!, isLight),
|
||||
color: typeColors!.text,
|
||||
border: `1px solid ${typeColors!.border}40`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import {
|
|||
CARD_ICON_MUTED,
|
||||
CARD_TEXT_LIGHT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { detectOperationalNoise } from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
|
|
@ -77,6 +78,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
highlightStyle,
|
||||
}) => {
|
||||
const colors = getTeamColorSet(teammateMessage.color);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
// Detect operational noise
|
||||
const noiseLabel = useMemo(
|
||||
|
|
@ -162,7 +164,7 @@ export const TeammateMessageItem: React.FC<TeammateMessageItemProps> = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
|
||||
import { PROSE_BODY } from '@renderer/constants/cssVariables';
|
||||
|
||||
import { FileLink, isRelativeUrl } from './viewers/FileLink';
|
||||
import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils';
|
||||
|
||||
import type { Components } from 'react-markdown';
|
||||
|
|
@ -77,17 +78,22 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
|
|||
),
|
||||
|
||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="no-underline hover:underline"
|
||||
style={{ color: 'var(--prose-link)' }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
a: ({ href, children }) => {
|
||||
if (href && isRelativeUrl(href)) {
|
||||
return <FileLink href={href}>{children}</FileLink>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="no-underline hover:underline"
|
||||
style={{ color: 'var(--prose-link)' }}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// Strong/Bold — inline element, no hl()
|
||||
strong: ({ children }) => (
|
||||
|
|
|
|||
147
src/renderer/components/chat/viewers/FileLink.tsx
Normal file
147
src/renderer/components/chat/viewers/FileLink.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* FileLink — clickable file path link for markdown content.
|
||||
* Opens the file in the built-in editor (team context) or copies the absolute path (session context).
|
||||
*
|
||||
* Follows the LocalImage pattern (MarkdownViewer.tsx) — a standalone React component
|
||||
* used inside react-markdown's `a` component factory.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { PROSE_LINK } from '@renderer/constants/cssVariables';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Check, FileCode } from 'lucide-react';
|
||||
|
||||
import type { AppState } from '@renderer/store/types';
|
||||
|
||||
// =============================================================================
|
||||
// Exported utilities
|
||||
// =============================================================================
|
||||
|
||||
/** Parse "path:line" format (e.g. "src/foo.ts:42") */
|
||||
export function parsePathWithLine(href: string): { filePath: string; line: number | null } {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(href);
|
||||
} catch {
|
||||
decoded = href;
|
||||
}
|
||||
const match = /^(.+?):(\d+)$/.exec(decoded);
|
||||
if (match) return { filePath: match[1], line: parseInt(match[2], 10) };
|
||||
return { filePath: decoded, line: null };
|
||||
}
|
||||
|
||||
/** Check if a URL is relative (not a protocol, not a hash, not data/mailto) */
|
||||
export function isRelativeUrl(url: string): boolean {
|
||||
return (
|
||||
!!url &&
|
||||
!url.startsWith('#') &&
|
||||
!url.includes('://') &&
|
||||
!url.startsWith('data:') &&
|
||||
!url.startsWith('mailto:')
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Internal helpers
|
||||
// =============================================================================
|
||||
|
||||
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
||||
const parts = `${baseDir}/${relativeSrc}`.split('/');
|
||||
const resolved: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part === '.' || part === '') continue;
|
||||
if (part === '..') {
|
||||
resolved.pop();
|
||||
} else {
|
||||
resolved.push(part);
|
||||
}
|
||||
}
|
||||
return '/' + resolved.join('/');
|
||||
}
|
||||
|
||||
/** Project path based on active tab context (avoids stale cross-tab state) */
|
||||
function selectContextProjectPath(s: AppState): string | null {
|
||||
const activeTab = s.openTabs.find((t) => t.id === s.activeTabId);
|
||||
if (!activeTab) return null;
|
||||
|
||||
switch (activeTab.type) {
|
||||
case 'team':
|
||||
return s.selectedTeamData?.config.projectPath ?? null;
|
||||
case 'session':
|
||||
return s.sessionDetail?.session?.projectPath ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectIsTeamTab(s: AppState): boolean {
|
||||
const activeTab = s.openTabs.find((t) => t.id === s.activeTabId);
|
||||
return activeTab?.type === 'team';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
interface FileLinkProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FileLink = React.memo(function FileLink({
|
||||
href,
|
||||
children,
|
||||
}: FileLinkProps): React.ReactElement {
|
||||
const projectPath = useStore(selectContextProjectPath);
|
||||
const isTeamTab = useStore(selectIsTeamTab);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<span className="font-mono text-xs" style={{ color: PROSE_LINK }}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const { filePath: relativePath, line } = parsePathWithLine(href);
|
||||
const absolutePath = resolveRelativePath(relativePath, projectPath);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isTeamTab) {
|
||||
const { revealFileInEditor, setPendingGoToLine } = useStore.getState();
|
||||
if (line !== null) setPendingGoToLine(line);
|
||||
revealFileInEditor(absolutePath);
|
||||
} else {
|
||||
void navigator.clipboard.writeText(absolutePath).then(
|
||||
() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
},
|
||||
() => {
|
||||
// Clipboard API may not be available in all contexts
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="inline-flex cursor-pointer items-center gap-0.5 rounded-sm px-0.5 no-underline hover:underline"
|
||||
style={{
|
||||
color: PROSE_LINK,
|
||||
backgroundColor: 'var(--path-highlight-bg)',
|
||||
}}
|
||||
title={isTeamTab ? absolutePath : `Click to copy: ${absolutePath}`}
|
||||
>
|
||||
<FileCode size={12} className="shrink-0 opacity-60" />
|
||||
{children}
|
||||
{copied && <Check size={10} className="shrink-0 text-green-400" />}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
|
|
@ -23,7 +23,8 @@ import {
|
|||
PROSE_TABLE_BORDER,
|
||||
PROSE_TABLE_HEADER_BG,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
type SearchContext,
|
||||
} from '../searchHighlightUtils';
|
||||
|
||||
import { FileLink, isRelativeUrl } from './FileLink';
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -72,18 +74,6 @@ function allowCustomProtocols(url: string): string {
|
|||
return defaultUrlTransform(url);
|
||||
}
|
||||
|
||||
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
|
||||
function isRelativeUrl(url: string): boolean {
|
||||
return (
|
||||
!!url &&
|
||||
!url.startsWith('http://') &&
|
||||
!url.startsWith('https://') &&
|
||||
!url.startsWith('data:') &&
|
||||
!url.startsWith('#') &&
|
||||
!url.startsWith('mailto:')
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a relative path to an absolute path given a base directory */
|
||||
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
||||
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
|
||||
|
|
@ -164,7 +154,10 @@ function hastToText(node: HastNode): string {
|
|||
// Component factories
|
||||
// =============================================================================
|
||||
|
||||
function createViewerMarkdownComponents(searchCtx: SearchContext | null): Components {
|
||||
function createViewerMarkdownComponents(
|
||||
searchCtx: SearchContext | null,
|
||||
isLight = false
|
||||
): Components {
|
||||
const hl = (children: React.ReactNode): React.ReactNode =>
|
||||
searchCtx ? highlightSearchInChildren(children, searchCtx) : children;
|
||||
|
||||
|
|
@ -225,7 +218,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
// malformed percent-encoding — use empty color
|
||||
}
|
||||
const colorSet = getTeamColorSet(color);
|
||||
const bg = colorSet.badge;
|
||||
const bg = getThemedBadge(colorSet, isLight);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
|
|
@ -255,6 +248,10 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
</TaskTooltip>
|
||||
);
|
||||
}
|
||||
// Relative file paths — open in built-in editor or copy path
|
||||
if (href && isRelativeUrl(href)) {
|
||||
return <FileLink href={href}>{children}</FileLink>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
|
|
@ -457,6 +454,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
}) => {
|
||||
const [showRaw, setShowRaw] = React.useState(false);
|
||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const isTooLarge = content.length > MAX_MARKDOWN_CHARS;
|
||||
const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS;
|
||||
|
|
@ -608,7 +606,11 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
// Create markdown components with optional search highlighting
|
||||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
||||
// useMemo would cache stale closures when parent re-renders without search deps changing
|
||||
const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
|
||||
const baseComponents = searchCtx
|
||||
? createViewerMarkdownComponents(searchCtx, isLight)
|
||||
: isLight
|
||||
? createViewerMarkdownComponents(null, true)
|
||||
: defaultComponents;
|
||||
|
||||
// When baseDir is set (editor preview), override img to load local files via IPC
|
||||
const components = baseDir
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const OngoingIndicator = ({
|
|||
<span className={`relative inline-flex rounded-full ${dotSize} bg-green-500`} />
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span className="text-sm" style={{ color: 'var(--info-text, #3b82f6)' }}>
|
||||
<span className="text-sm" style={{ color: 'var(--info-text)' }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -51,15 +51,12 @@ export const OngoingBanner = (): React.JSX.Element => {
|
|||
<div
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--info-bg, rgba(59, 130, 246, 0.1))',
|
||||
border: '1px solid var(--info-border, rgba(59, 130, 246, 0.3))',
|
||||
backgroundColor: 'var(--info-bg)',
|
||||
border: '1px solid var(--info-border)',
|
||||
}}
|
||||
>
|
||||
<Loader2
|
||||
className="size-4 shrink-0 animate-spin"
|
||||
style={{ color: 'var(--info-text, #3b82f6)' }}
|
||||
/>
|
||||
<span className="text-sm" style={{ color: 'var(--info-text, #3b82f6)' }}>
|
||||
<Loader2 className="size-4 shrink-0 animate-spin" style={{ color: 'var(--info-text)' }} />
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--info-text)' }}>
|
||||
Session is in progress...
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
|
|||
className="mb-1.5 flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-blue-400" />
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span>Updating app</span>
|
||||
<span className="tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{clampedPercent}%
|
||||
|
|
@ -48,7 +48,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
|
|||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-300 ease-out"
|
||||
className="h-full rounded-full bg-blue-600 transition-all duration-300 ease-out dark:bg-blue-500"
|
||||
style={{ width: `${clampedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,10 +34,13 @@ const VARIANT_STYLES: Record<BannerVariant, { border: string; bg: string }> = {
|
|||
loading: { border: 'var(--color-border)', bg: 'transparent' },
|
||||
error: { border: '#ef4444', bg: 'rgba(239, 68, 68, 0.06)' },
|
||||
success: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.04)' },
|
||||
info: { border: '#3b82f6', bg: 'rgba(59, 130, 246, 0.04)' },
|
||||
info: { border: 'var(--info-border)', bg: 'var(--info-bg)' },
|
||||
warning: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.06)' },
|
||||
};
|
||||
|
||||
/** Minimum banner height — prevents layout shift between states (loading → installed → checking). */
|
||||
const BANNER_MIN_H = 'min-h-[4.25rem]';
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
|
@ -180,7 +183,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (cliStatusError && !cliStatusLoading) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{
|
||||
borderColor: VARIANT_STYLES.error.border,
|
||||
backgroundColor: VARIANT_STYLES.error.bg,
|
||||
|
|
@ -211,7 +214,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (!cliStatusLoading) {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center justify-between gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 flex items-center justify-between gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
|
|
@ -232,7 +235,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
// Loading state: show spinner only while an actual request is in-flight.
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<Loader2
|
||||
|
|
@ -250,12 +253,12 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'downloading') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 space-y-2 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 space-y-2 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Downloading Claude CLI...
|
||||
</span>
|
||||
|
|
@ -292,11 +295,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...';
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{label}
|
||||
</span>
|
||||
|
|
@ -310,11 +313,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'installing') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-400" />
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Installing Claude CLI...
|
||||
</span>
|
||||
|
|
@ -328,7 +331,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'completed') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<CheckCircle className="size-4 shrink-0" style={{ color: '#4ade80' }} />
|
||||
|
|
@ -343,7 +346,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
if (installerState === 'error') {
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<ErrorDisplay error={installerError ?? 'Installation failed'} onRetry={handleInstall} />
|
||||
|
|
@ -446,7 +449,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
// Installed — show version, path, update info
|
||||
return (
|
||||
<div
|
||||
className="mb-6 rounded-lg border-l-4 px-4 py-3"
|
||||
className={`mb-6 rounded-lg border-l-4 px-4 py-3 ${BANNER_MIN_H}`}
|
||||
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ const RepositoryCard = ({
|
|||
<>
|
||||
<span className="text-text-muted">·</span>
|
||||
{taskCounts.inProgress > 0 && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-400">
|
||||
<span className="inline-flex items-center rounded-full bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
|
||||
{taskCounts.inProgress} active
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import { useCallback, useState } from 'react';
|
|||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import {
|
||||
|
|
@ -61,6 +62,7 @@ export const SortableTab = ({
|
|||
setRef,
|
||||
}: SortableTabProps): React.JSX.Element => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const isPinned = useStore(
|
||||
useShallow((s) =>
|
||||
|
|
@ -96,14 +98,14 @@ export const SortableTab = ({
|
|||
opacity: isDragging ? 0.3 : 1,
|
||||
backgroundColor: isActive
|
||||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
? getThemedBadge(teamColorSet, isLight)
|
||||
: 'var(--color-surface-raised)'
|
||||
: isHovered
|
||||
? teamColorSet
|
||||
? teamColorSet.badge
|
||||
? getThemedBadge(teamColorSet, isLight)
|
||||
: 'var(--color-surface-overlay)'
|
||||
: teamColorSet
|
||||
? teamColorSet.badge
|
||||
? getThemedBadge(teamColorSet, isLight)
|
||||
: 'transparent',
|
||||
color:
|
||||
isActive || isHovered
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
const [newTabHover, setNewTabHover] = useState(false);
|
||||
const [notificationsHover, setNotificationsHover] = useState(false);
|
||||
const [teamsHover, setTeamsHover] = useState(false);
|
||||
const [githubHover, setGithubHover] = useState(false);
|
||||
const [settingsHover, setSettingsHover] = useState(false);
|
||||
|
||||
// Context menu state
|
||||
|
|
@ -415,6 +416,27 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
<Users className="size-4" />
|
||||
</button>
|
||||
|
||||
{/* GitHub link */}
|
||||
<button
|
||||
onClick={() =>
|
||||
void window.electronAPI.openExternal(
|
||||
'https://github.com/777genius/claude_agent_teams_ui'
|
||||
)
|
||||
}
|
||||
onMouseEnter={() => setGithubHover(true)}
|
||||
onMouseLeave={() => setGithubHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: githubHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: githubHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="GitHub"
|
||||
>
|
||||
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings gear icon */}
|
||||
<button
|
||||
onClick={() => openSettingsTab()}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useState } from 'react';
|
|||
|
||||
import { getTriggerColorDef } from '@shared/constants/triggerColors';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ArrowRight, Bot, Check, Trash2 } from 'lucide-react';
|
||||
import { ArrowRight, Bot, Check, Trash2, Users } from 'lucide-react';
|
||||
|
||||
import type { DetectedError } from '@renderer/types/data';
|
||||
|
||||
|
|
@ -41,6 +41,7 @@ export const NotificationRow = ({
|
|||
const truncatedMessage = truncateMessage(error.message);
|
||||
const colorDef = getTriggerColorDef(error.triggerColor);
|
||||
const displayName = error.triggerName ?? error.source;
|
||||
const isTeamNotification = error.category === 'team' || error.sessionId?.startsWith('team:');
|
||||
|
||||
const handleArchiveClick = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -102,6 +103,19 @@ export const NotificationRow = ({
|
|||
<span className="truncate text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{projectName}
|
||||
</span>
|
||||
{isTeamNotification && !error.subagentId && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: 'var(--tag-bg)',
|
||||
border: '1px solid var(--tag-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
<Users className="size-3" />
|
||||
team
|
||||
</span>
|
||||
)}
|
||||
{error.subagentId && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium"
|
||||
|
|
|
|||
|
|
@ -7,12 +7,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { Combobox } from '@renderer/components/ui/combobox';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import { AGENT_LANGUAGE_OPTIONS, resolveLanguageName } from '@shared/utils/agentLanguage';
|
||||
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
||||
import { SettingRow, SettingsSectionHeader, SettingsToggle } from '../components';
|
||||
|
||||
import type { SafeConfig } from '../hooks/useSettingsConfig';
|
||||
import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
|
||||
|
|
@ -335,12 +336,24 @@ export const GeneralSection = ({
|
|||
|
||||
<SettingsSectionHeader title="Appearance" />
|
||||
<SettingRow label="Theme" description="Choose your preferred color theme">
|
||||
<SettingsSelect
|
||||
value={safeConfig.general.theme}
|
||||
options={THEME_OPTIONS}
|
||||
onChange={onThemeChange}
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50',
|
||||
safeConfig.general.theme === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onThemeChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Expand AI responses by default"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useCollapsedGroups } from '@renderer/hooks/useCollapsedGroups';
|
||||
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -13,10 +14,21 @@ import {
|
|||
groupTasksByProject,
|
||||
sortTasksByFreshness,
|
||||
} from '@renderer/utils/taskGrouping';
|
||||
import { Archive, ListTodo, Pin, Search, X } from 'lucide-react';
|
||||
import {
|
||||
Archive,
|
||||
ArrowUpDown,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ListTodo,
|
||||
Pin,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { Combobox, type ComboboxOption } from '../ui/combobox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||
import { TaskContextMenu } from './TaskContextMenu';
|
||||
|
|
@ -53,6 +65,58 @@ function saveGroupingMode(mode: TaskGroupingMode): void {
|
|||
}
|
||||
}
|
||||
|
||||
export type TaskSortMode = 'time' | 'project' | 'team';
|
||||
|
||||
const TASK_SORT_STORAGE_KEY = 'sidebarTasksSort';
|
||||
|
||||
const SORT_OPTIONS: { id: TaskSortMode; label: string }[] = [
|
||||
{ id: 'time', label: 'By time' },
|
||||
{ id: 'project', label: 'By project' },
|
||||
{ id: 'team', label: 'By team' },
|
||||
];
|
||||
|
||||
function loadSortMode(): TaskSortMode {
|
||||
try {
|
||||
const v = localStorage.getItem(TASK_SORT_STORAGE_KEY);
|
||||
if (v === 'time' || v === 'project' || v === 'team') return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'time';
|
||||
}
|
||||
|
||||
function saveSortMode(mode: TaskSortMode): void {
|
||||
try {
|
||||
localStorage.setItem(TASK_SORT_STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function applySortMode(tasks: GlobalTask[], mode: TaskSortMode): GlobalTask[] {
|
||||
const sorted = [...tasks];
|
||||
switch (mode) {
|
||||
case 'time':
|
||||
return sortTasksByFreshness(sorted);
|
||||
case 'project':
|
||||
return sorted.sort((a, b) => {
|
||||
const pa = a.projectPath ?? '';
|
||||
const pb = b.projectPath ?? '';
|
||||
const cmp = pa.localeCompare(pb);
|
||||
if (cmp !== 0) return cmp;
|
||||
return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? '');
|
||||
});
|
||||
case 'team':
|
||||
return sorted.sort((a, b) => {
|
||||
const cmp = a.teamDisplayName.localeCompare(b.teamDisplayName);
|
||||
if (cmp !== 0) return cmp;
|
||||
return (b.updatedAt ?? b.createdAt ?? '').localeCompare(a.updatedAt ?? a.createdAt ?? '');
|
||||
});
|
||||
default:
|
||||
return sortTasksByFreshness(sorted);
|
||||
}
|
||||
}
|
||||
|
||||
export interface GlobalTaskListProps {
|
||||
/** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */
|
||||
hideHeader?: boolean;
|
||||
|
|
@ -124,6 +188,8 @@ export const GlobalTaskList = ({
|
|||
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
|
||||
const [sortMode, setSortModeState] = useState<TaskSortMode>(loadSortMode);
|
||||
const [sortPopoverOpen, setSortPopoverOpen] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [renamingTaskKey, setRenamingTaskKey] = useState<string | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -139,6 +205,11 @@ export const GlobalTaskList = ({
|
|||
saveGroupingMode(mode);
|
||||
};
|
||||
|
||||
const setSortMode = (mode: TaskSortMode): void => {
|
||||
setSortModeState(mode);
|
||||
saveSortMode(mode);
|
||||
};
|
||||
|
||||
const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => {
|
||||
taskLocalState.renameTask(teamName, taskId, newSubject);
|
||||
setRenamingTaskKey(null);
|
||||
|
|
@ -265,11 +336,21 @@ export const GlobalTaskList = ({
|
|||
[filtered, taskLocalState]
|
||||
);
|
||||
|
||||
const sortedFlat = useMemo(() => sortTasksByFreshness(normalTasks), [normalTasks]);
|
||||
const sortedFlat = useMemo(() => applySortMode(normalTasks, sortMode), [normalTasks, sortMode]);
|
||||
const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]);
|
||||
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
|
||||
const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]);
|
||||
|
||||
// Collapsed group keys for each grouping mode
|
||||
const projectGroupKeys = useMemo(
|
||||
() => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey),
|
||||
[projectGroups]
|
||||
);
|
||||
const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]);
|
||||
|
||||
const projectCollapsed = useCollapsedGroups('project', projectGroupKeys);
|
||||
const timeCollapsed = useCollapsedGroups('time', timeGroupKeys);
|
||||
|
||||
const hasContent =
|
||||
pinnedTasks.length > 0 ||
|
||||
(groupingMode === 'none'
|
||||
|
|
@ -315,6 +396,44 @@ export const GlobalTaskList = ({
|
|||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<Popover open={sortPopoverOpen} onOpenChange={setSortPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 items-center justify-center rounded p-0.5 text-text-muted transition-colors hover:text-text-secondary data-[state=open]:bg-surface-raised data-[state=open]:text-text"
|
||||
>
|
||||
<ArrowUpDown className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="end" sideOffset={6}>
|
||||
<div className="flex flex-col">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSortMode(opt.id);
|
||||
setSortPopoverOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded px-2 py-1.5 text-[12px] transition-colors',
|
||||
sortMode === opt.id
|
||||
? 'bg-surface-raised text-text'
|
||||
: 'hover:bg-surface-raised/60 text-text-secondary hover:text-text'
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'size-3 shrink-0',
|
||||
sortMode === opt.id ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TaskFiltersPopover
|
||||
open={filtersPopoverOpen}
|
||||
onOpenChange={setFiltersPopoverOpen}
|
||||
|
|
@ -375,7 +494,7 @@ export const GlobalTaskList = ({
|
|||
<div className="flex shrink-0 items-center gap-1.5 px-2 py-1">
|
||||
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
||||
<div
|
||||
className="bg-surface-raised/60 inline-flex rounded-md p-0.5 text-[11px]"
|
||||
className="border-border-emphasis/40 inline-flex rounded-md border bg-[var(--color-surface)] p-0.5 text-[11px]"
|
||||
role="group"
|
||||
aria-label="Group by"
|
||||
>
|
||||
|
|
@ -389,7 +508,7 @@ export const GlobalTaskList = ({
|
|||
className={cn(
|
||||
'rounded px-2 py-0.5 transition-colors',
|
||||
groupingMode === mode
|
||||
? 'bg-surface-raised text-text shadow-sm'
|
||||
? 'ring-border-emphasis/60 bg-surface-raised text-text shadow-sm ring-1'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
)}
|
||||
>
|
||||
|
|
@ -469,54 +588,71 @@ export const GlobalTaskList = ({
|
|||
{groupingMode === 'project' &&
|
||||
projectGroups.map((group) => {
|
||||
if (group.tasks.length === 0) return null;
|
||||
const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey);
|
||||
let lastTeam: string | null = null;
|
||||
return (
|
||||
<div key={group.projectKey}>
|
||||
<div
|
||||
className="sticky top-0 z-10 flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => projectCollapsed.toggle(group.projectKey)}
|
||||
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold transition-colors"
|
||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||
>
|
||||
{isGroupCollapsed ? (
|
||||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||
)}
|
||||
<span
|
||||
className="inline-block size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: projectColor(group.projectLabel).border }}
|
||||
/>
|
||||
<span style={{ color: projectColor(group.projectLabel).text }}>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{ color: projectColor(group.projectLabel).text }}
|
||||
>
|
||||
{group.projectLabel}
|
||||
</span>
|
||||
</div>
|
||||
{group.tasks.map((task) => {
|
||||
const showTeamHeader = task.teamName !== lastTeam;
|
||||
lastTeam = task.teamName;
|
||||
return (
|
||||
<div key={`${task.teamName}-${task.id}`}>
|
||||
{showTeamHeader && (
|
||||
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||
Team: {task.teamDisplayName}
|
||||
</div>
|
||||
)}
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
|
||||
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||
{group.tasks.length}
|
||||
</span>
|
||||
</button>
|
||||
{!isGroupCollapsed &&
|
||||
group.tasks.map((task) => {
|
||||
const showTeamHeader = task.teamName !== lastTeam;
|
||||
lastTeam = task.teamName;
|
||||
return (
|
||||
<div key={`${task.teamName}-${task.id}`}>
|
||||
{showTeamHeader && (
|
||||
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||
Team: {task.teamDisplayName}
|
||||
</div>
|
||||
)}
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
hideTeamName
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
|
||||
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() =>
|
||||
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||
}
|
||||
/>
|
||||
</TaskContextMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
hideTeamName
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
}
|
||||
/>
|
||||
</TaskContextMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -524,50 +660,64 @@ export const GlobalTaskList = ({
|
|||
{groupingMode === 'time' &&
|
||||
categories.map((category) => {
|
||||
const tasks = grouped[category];
|
||||
const isGroupCollapsed = timeCollapsed.isCollapsed(category);
|
||||
let lastTeam: string | null = null;
|
||||
|
||||
return (
|
||||
<div key={category}>
|
||||
<div
|
||||
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => timeCollapsed.toggle(category)}
|
||||
className="hover:bg-surface-raised/40 sticky top-0 z-10 flex w-full cursor-pointer items-center gap-1 px-2 py-1.5 text-[11px] font-semibold text-text-secondary transition-colors"
|
||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||
>
|
||||
{dateCategoryLabels[category] ?? category}
|
||||
</div>
|
||||
{isGroupCollapsed ? (
|
||||
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
||||
)}
|
||||
<span className="truncate">{dateCategoryLabels[category] ?? category}</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] font-normal text-text-muted">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{tasks.map((task) => {
|
||||
const showTeamHeader = task.teamName !== lastTeam;
|
||||
lastTeam = task.teamName;
|
||||
{!isGroupCollapsed &&
|
||||
tasks.map((task) => {
|
||||
const showTeamHeader = task.teamName !== lastTeam;
|
||||
lastTeam = task.teamName;
|
||||
|
||||
return (
|
||||
<div key={`${task.teamName}-${task.id}`}>
|
||||
{showTeamHeader && (
|
||||
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||
Team: {task.teamDisplayName}
|
||||
</div>
|
||||
)}
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
|
||||
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
return (
|
||||
<div key={`${task.teamName}-${task.id}`}>
|
||||
{showTeamHeader && (
|
||||
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||
Team: {task.teamDisplayName}
|
||||
</div>
|
||||
)}
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
isPinned={taskLocalState.isPinned(task.teamName, task.id)}
|
||||
isArchived={taskLocalState.isArchived(task.teamName, task.id)}
|
||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||
onToggleArchive={() =>
|
||||
taskLocalState.toggleArchive(task.teamName, task.id)
|
||||
}
|
||||
/>
|
||||
</TaskContextMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||
>
|
||||
<SidebarTaskItem
|
||||
task={task}
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
getDisplaySubject={(t) =>
|
||||
taskLocalState.getRenamedSubject(t.teamName, t.id)
|
||||
}
|
||||
/>
|
||||
</TaskContextMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -78,6 +79,7 @@ export const SidebarTaskItem = ({
|
|||
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||
const teamMembers = useStore((s) => s.teamByName[task.teamName]?.members);
|
||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const isRenaming = renamingKey === `${task.teamName}:${task.id}`;
|
||||
const displaySubject = getDisplaySubject?.(task) ?? task.subject;
|
||||
|
|
@ -118,19 +120,24 @@ export const SidebarTaskItem = ({
|
|||
return colorName ? getTeamColorSet(colorName) : null;
|
||||
}, [teamMembers, task.owner]);
|
||||
|
||||
const ownerTextColor = useMemo(() => {
|
||||
if (!ownerColorSet) return undefined;
|
||||
return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text;
|
||||
}, [ownerColorSet, isLight]);
|
||||
|
||||
const projectLabel = useMemo(() => {
|
||||
if (!task.projectPath?.trim()) return null;
|
||||
return projectLabelFromPath(task.projectPath);
|
||||
}, [task.projectPath]);
|
||||
|
||||
const projectColorSet = useMemo(
|
||||
() => (projectLabel ? projectColor(projectLabel) : null),
|
||||
[projectLabel]
|
||||
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
|
||||
[projectLabel, isLight]
|
||||
);
|
||||
|
||||
const teamColor = useMemo(
|
||||
() => (showTeamName ? nameColorSet(task.teamDisplayName) : null),
|
||||
[showTeamName, task.teamDisplayName]
|
||||
() => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null),
|
||||
[showTeamName, task.teamDisplayName, isLight]
|
||||
);
|
||||
|
||||
const showTeamRow = showTeamName && !hideTeamName;
|
||||
|
|
@ -220,17 +227,19 @@ export const SidebarTaskItem = ({
|
|||
)}
|
||||
{!showTeamRow && (
|
||||
<>
|
||||
{projectLabel && <span className="opacity-40">·</span>}
|
||||
{projectLabel && <span className="opacity-100 dark:opacity-40">·</span>}
|
||||
<span
|
||||
className="shrink-0 opacity-60"
|
||||
style={ownerColorSet ? { color: ownerColorSet.text } : undefined}
|
||||
className="shrink-0 opacity-100 dark:opacity-60"
|
||||
style={ownerTextColor ? { color: ownerTextColor } : undefined}
|
||||
>
|
||||
{task.owner ?? 'unassigned'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{dateLabel && (
|
||||
<span className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
|
||||
<span
|
||||
className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-100 dark:opacity-70' : ''}`}
|
||||
>
|
||||
{dateLabel}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -242,14 +251,14 @@ export const SidebarTaskItem = ({
|
|||
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<span className="shrink-0 opacity-50">Team:</span>
|
||||
<span className="shrink-0 opacity-100 dark:opacity-50">Team:</span>
|
||||
<span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
|
||||
{task.teamDisplayName}
|
||||
</span>
|
||||
<span className="opacity-40">·</span>
|
||||
<span className="opacity-100 dark:opacity-40">·</span>
|
||||
<span
|
||||
className="shrink-0 opacity-60"
|
||||
style={ownerColorSet ? { color: ownerColorSet.text } : undefined}
|
||||
className="shrink-0 opacity-100 dark:opacity-60"
|
||||
style={ownerTextColor ? { color: ownerTextColor } : undefined}
|
||||
>
|
||||
{task.owner ?? 'unassigned'}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ export const TaskFiltersPopover = ({
|
|||
<Checkbox
|
||||
checked={filters.statusIds.has(opt.id)}
|
||||
onCheckedChange={() => toggleStatus(opt.id)}
|
||||
style={{ '--color-accent': opt.color } as React.CSSProperties}
|
||||
/>
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: opt.color }}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/comme
|
|||
|
||||
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
||||
|
||||
export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string }[] = [
|
||||
{ id: 'todo', label: 'TODO' },
|
||||
{ id: 'in_progress', label: 'IN PROGRESS' },
|
||||
{ id: 'done', label: 'DONE' },
|
||||
{ id: 'review', label: 'REVIEW' },
|
||||
{ id: 'approved', label: 'APPROVED' },
|
||||
export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string; color: string }[] = [
|
||||
{ id: 'todo', label: 'TODO', color: '#3b82f6' },
|
||||
{ id: 'in_progress', label: 'IN PROGRESS', color: '#eab308' },
|
||||
{ id: 'done', label: 'DONE', color: '#22c55e' },
|
||||
{ id: 'review', label: 'REVIEW', color: '#8b5cf6' },
|
||||
{ id: 'approved', label: 'APPROVED', color: '#16a34a' },
|
||||
];
|
||||
|
||||
export interface TaskFiltersState {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
|
|
@ -16,6 +16,7 @@ import type { TeamClaudeLogsResponse } from '@shared/types';
|
|||
const PAGE_SIZE = 100;
|
||||
const POLL_MS = 2000;
|
||||
const ONLINE_WINDOW_MS = 10_000;
|
||||
const LOAD_MORE_THRESHOLD_PX = 48;
|
||||
|
||||
type StreamType = 'stdout' | 'stderr';
|
||||
|
||||
|
|
@ -70,6 +71,40 @@ function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
|
|||
return out.join('\n');
|
||||
}
|
||||
|
||||
function getOverlapSize(
|
||||
existingLinesNewestFirst: string[],
|
||||
olderLinesNewestFirst: string[]
|
||||
): number {
|
||||
const maxOverlap = Math.min(existingLinesNewestFirst.length, olderLinesNewestFirst.length);
|
||||
|
||||
for (let size = maxOverlap; size > 0; size -= 1) {
|
||||
let matches = true;
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
if (
|
||||
existingLinesNewestFirst[existingLinesNewestFirst.length - size + i] !==
|
||||
olderLinesNewestFirst[i]
|
||||
) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matches) return size;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function appendOlderLines(
|
||||
existingLinesNewestFirst: string[],
|
||||
olderLinesNewestFirst: string[]
|
||||
): string[] {
|
||||
if (existingLinesNewestFirst.length === 0) return olderLinesNewestFirst;
|
||||
if (olderLinesNewestFirst.length === 0) return existingLinesNewestFirst;
|
||||
|
||||
const overlapSize = getOverlapSize(existingLinesNewestFirst, olderLinesNewestFirst);
|
||||
return existingLinesNewestFirst.concat(olderLinesNewestFirst.slice(overlapSize));
|
||||
}
|
||||
|
||||
type AssistantContentBlock =
|
||||
| { type: 'text'; text?: string }
|
||||
| { type: 'thinking'; thinking?: string }
|
||||
|
|
@ -192,13 +227,16 @@ function filterStreamJsonText(
|
|||
|
||||
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||
const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [loadedCount, setLoadedCount] = useState(PAGE_SIZE);
|
||||
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
|
||||
const [pendingNewCount, setPendingNewCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlightRef = useRef(false);
|
||||
const loadingMoreRef = useRef(false);
|
||||
const applyingPendingRef = useRef(false);
|
||||
const atTopRef = useRef(true);
|
||||
const latestRef = useRef<TeamClaudeLogsResponse | null>(null);
|
||||
const logContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -210,9 +248,15 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds),
|
||||
}));
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const isNearBottom = useCallback(
|
||||
(scrollTop: number, scrollHeight: number, clientHeight: number) => {
|
||||
return scrollHeight - scrollTop - clientHeight <= LOAD_MORE_THRESHOLD_PX;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
setLoadedCount(PAGE_SIZE);
|
||||
setData({ lines: [], total: 0, hasMore: false });
|
||||
setPending(null);
|
||||
setPendingNewCount(0);
|
||||
|
|
@ -255,7 +299,7 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
inFlightRef.current = true;
|
||||
try {
|
||||
setLoading(true);
|
||||
const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount });
|
||||
const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount });
|
||||
if (cancelled) return;
|
||||
latestRef.current = next;
|
||||
if (atTopRef.current) {
|
||||
|
|
@ -283,11 +327,55 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
cancelled = true;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [teamName, visibleCount]);
|
||||
}, [teamName, loadedCount]);
|
||||
|
||||
const loadOlderLogs = useCallback(async (): Promise<void> => {
|
||||
if (loadingMoreRef.current || inFlightRef.current) return;
|
||||
|
||||
const current = committedRef.current;
|
||||
if (!current.hasMore) return;
|
||||
|
||||
loadingMoreRef.current = true;
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
const older = await api.teams.getClaudeLogs(teamName, {
|
||||
offset: current.lines.length + pendingCountRef.current,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
|
||||
setData((prev) => ({
|
||||
...prev,
|
||||
lines: appendOlderLines(prev.lines, older.lines),
|
||||
total: older.total,
|
||||
hasMore: older.hasMore,
|
||||
updatedAt: older.updatedAt ?? prev.updatedAt,
|
||||
}));
|
||||
setLoadedCount((count) => count + PAGE_SIZE);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
loadingMoreRef.current = false;
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [teamName]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el || loading || loadingMore || !data.hasMore || data.lines.length === 0) return;
|
||||
|
||||
if (
|
||||
el.scrollHeight <= el.clientHeight ||
|
||||
isNearBottom(el.scrollTop, el.scrollHeight, el.clientHeight)
|
||||
) {
|
||||
void loadOlderLogs();
|
||||
}
|
||||
}, [data.hasMore, data.lines.length, isNearBottom, loadOlderLogs, loading, loadingMore]);
|
||||
|
||||
const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]);
|
||||
const badge = data.total > 0 ? data.total : undefined;
|
||||
const showMoreVisible = data.hasMore;
|
||||
const showMoreVisible = data.hasMore || loadingMore;
|
||||
|
||||
const headerExtra = online ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
|
||||
|
|
@ -310,17 +398,34 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
return filterStreamJsonText(data.lines, searchQuery, filter);
|
||||
}, [data.lines, searchQuery, filter]);
|
||||
|
||||
const applyPending = (): void => {
|
||||
const latest = latestRef.current ?? pending;
|
||||
if (!latest) return;
|
||||
setData(latest);
|
||||
setPending(null);
|
||||
setPendingNewCount(0);
|
||||
// Jump to newest
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = 0;
|
||||
const applyPending = useCallback(async (): Promise<void> => {
|
||||
if (applyingPendingRef.current) return;
|
||||
|
||||
applyingPendingRef.current = true;
|
||||
try {
|
||||
let latest = latestRef.current ?? pending;
|
||||
const expectedVisibleCount = latest ? Math.min(loadedCount, latest.total) : loadedCount;
|
||||
|
||||
if (!latest || latest.lines.length < expectedVisibleCount) {
|
||||
latest = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: loadedCount });
|
||||
latestRef.current = latest;
|
||||
}
|
||||
|
||||
setData(latest);
|
||||
setPending(null);
|
||||
setPendingNewCount(0);
|
||||
setError(null);
|
||||
|
||||
// Jump to newest
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
applyingPendingRef.current = false;
|
||||
}
|
||||
};
|
||||
}, [loadedCount, pending, teamName]);
|
||||
|
||||
return (
|
||||
<CollapsibleTeamSection
|
||||
|
|
@ -337,8 +442,8 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
|
||||
<span className="font-mono">{data.total}</span>
|
||||
Showing <span className="font-mono">{Math.min(data.total, data.lines.length)}</span>{' '}
|
||||
of <span className="font-mono">{data.total}</span>
|
||||
</>
|
||||
) : isAlive ? (
|
||||
'No logs yet.'
|
||||
|
|
@ -347,32 +452,36 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ClaudeLogsFilterPopover
|
||||
filter={filter}
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
onApply={setFilter}
|
||||
/>
|
||||
{data.total > 0 ? (
|
||||
<>
|
||||
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setSearchQuery('')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ClaudeLogsFilterPopover
|
||||
filter={filter}
|
||||
open={filterOpen}
|
||||
onOpenChange={setFilterOpen}
|
||||
onApply={setFilter}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{pendingNewCount > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -384,16 +493,6 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
+{pendingNewCount} new
|
||||
</Button>
|
||||
)}
|
||||
{showMoreVisible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||
>
|
||||
Show more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -409,18 +508,38 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
|||
containerRefCallback={(el) => {
|
||||
logContainerRef.current = el;
|
||||
}}
|
||||
onScroll={({ scrollTop }) => {
|
||||
onScroll={({ scrollTop, scrollHeight, clientHeight }) => {
|
||||
const atTop = scrollTop <= 8;
|
||||
atTopRef.current = atTop;
|
||||
if (atTop && pendingCountRef.current > 0) {
|
||||
applyPending();
|
||||
void applyPending();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNearBottom(scrollTop, scrollHeight, clientHeight)) {
|
||||
void loadOlderLogs();
|
||||
}
|
||||
}}
|
||||
footer={
|
||||
showMoreVisible ? (
|
||||
<div className="flex justify-center py-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void loadOlderLogs()}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading…' : 'Show more'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{!error && data.lines.length === 0 ? (
|
||||
{!error && data.lines.length === 0 && isAlive ? (
|
||||
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
||||
{loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}
|
||||
{loading ? 'Loading…' : 'No logs captured.'}
|
||||
</p>
|
||||
) : null}
|
||||
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { DisplayItemList } from '@renderer/components/chat/DisplayItemList';
|
||||
import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
|
||||
import { groupBySubagent, parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser';
|
||||
import { Bot, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
|
||||
import type { StreamJsonGroup, SubagentSection } from '@renderer/utils/streamJsonParser';
|
||||
|
||||
type CliLogsOrder = 'oldest-first' | 'newest-first';
|
||||
|
||||
|
|
@ -27,6 +27,8 @@ interface CliLogsRichViewProps {
|
|||
/** Optional local search query override for inline highlighting */
|
||||
searchQueryOverride?: string;
|
||||
className?: string;
|
||||
/** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,6 +151,83 @@ const StreamGroup = ({
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapsible section wrapping all groups from one subagent.
|
||||
* Collapsed by default.
|
||||
*/
|
||||
const SubagentSectionBlock = ({
|
||||
section,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
collapsedGroupIds,
|
||||
onGroupToggle,
|
||||
expandedItemIds,
|
||||
onItemClick,
|
||||
searchQueryOverride,
|
||||
}: {
|
||||
section: SubagentSection;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
collapsedGroupIds: Set<string>;
|
||||
onGroupToggle: (groupId: string) => void;
|
||||
expandedItemIds: Set<string>;
|
||||
onItemClick: (itemId: string) => void;
|
||||
searchQueryOverride?: string;
|
||||
}): React.JSX.Element => {
|
||||
const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-l-2 border-amber-500/30 bg-[var(--color-surface)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={cn(
|
||||
'shrink-0 text-amber-400 transition-transform duration-150',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<Bot size={13} className="shrink-0 text-amber-400" />
|
||||
<span className="min-w-0 truncate text-[11px] text-amber-300/80">
|
||||
{searchQueryOverride && searchQueryOverride.trim().length > 0
|
||||
? highlightQueryInText(label, searchQueryOverride, `${section.id}:section-summary`, {
|
||||
forceAllActive: true,
|
||||
})
|
||||
: label}
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 border-t border-amber-500/20 p-1.5">
|
||||
{section.groups.map((group) =>
|
||||
group.items.length === 1 ? (
|
||||
<FlatGroupItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={onItemClick}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
) : (
|
||||
<StreamGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
isExpanded={!collapsedGroupIds.has(group.id)}
|
||||
onToggle={() => onGroupToggle(group.id)}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={onItemClick}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CliLogsRichView = ({
|
||||
cliLogsTail,
|
||||
order = 'oldest-first',
|
||||
|
|
@ -156,6 +235,7 @@ export const CliLogsRichView = ({
|
|||
containerRefCallback,
|
||||
searchQueryOverride,
|
||||
className,
|
||||
footer,
|
||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToEdgeRef = useRef(true);
|
||||
|
|
@ -163,19 +243,29 @@ export const CliLogsRichView = ({
|
|||
// Tracks groups manually collapsed by user (default: all auto-expanded)
|
||||
const [collapsedGroupIds, setCollapsedGroupIds] = useState<Set<string>>(new Set());
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<string>>(new Set());
|
||||
// Subagent sections are collapsed by default; track which are expanded
|
||||
const [expandedSubagentIds, setExpandedSubagentIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]);
|
||||
const entries = useMemo(() => groupBySubagent(groups), [groups]);
|
||||
|
||||
// Derive expanded state: all groups expanded unless manually collapsed
|
||||
const expandedGroupIds = useMemo(() => {
|
||||
const expanded = new Set<string>();
|
||||
for (const group of groups) {
|
||||
if (!collapsedGroupIds.has(group.id)) {
|
||||
expanded.add(group.id);
|
||||
const addGroups = (gs: StreamJsonGroup[]): void => {
|
||||
for (const g of gs) {
|
||||
if (!collapsedGroupIds.has(g.id)) expanded.add(g.id);
|
||||
}
|
||||
};
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'group') {
|
||||
if (!collapsedGroupIds.has(entry.group.id)) expanded.add(entry.group.id);
|
||||
} else {
|
||||
addGroups(entry.section.groups);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}, [groups, collapsedGroupIds]);
|
||||
}, [entries, collapsedGroupIds]);
|
||||
|
||||
const computeShouldStickToEdge = useCallback(
|
||||
(el: HTMLDivElement): boolean => {
|
||||
|
|
@ -235,7 +325,19 @@ export const CliLogsRichView = ({
|
|||
});
|
||||
}, []);
|
||||
|
||||
if (groups.length === 0) {
|
||||
const handleSubagentToggle = useCallback((sectionId: string) => {
|
||||
setExpandedSubagentIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(sectionId)) {
|
||||
next.delete(sectionId);
|
||||
} else {
|
||||
next.add(sectionId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (entries.length === 0) {
|
||||
// cliLogsTail has data but no parseable assistant messages — show raw text fallback
|
||||
const hasContent = cliLogsTail.trim().length > 0;
|
||||
return (
|
||||
|
|
@ -267,11 +369,12 @@ export const CliLogsRichView = ({
|
|||
Waiting for CLI output...
|
||||
</p>
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
|
||||
const visibleEntries = order === 'newest-first' ? [...entries].reverse() : entries;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -290,28 +393,40 @@ export const CliLogsRichView = ({
|
|||
});
|
||||
}}
|
||||
>
|
||||
{visibleGroups.map((group) =>
|
||||
group.items.length === 1 ? (
|
||||
// Single item — render flat without collapsible group wrapper
|
||||
{visibleEntries.map((entry) =>
|
||||
entry.type === 'subagent-section' ? (
|
||||
<SubagentSectionBlock
|
||||
key={entry.section.id}
|
||||
section={entry.section}
|
||||
isExpanded={expandedSubagentIds.has(entry.section.id)}
|
||||
onToggle={() => handleSubagentToggle(entry.section.id)}
|
||||
collapsedGroupIds={collapsedGroupIds}
|
||||
onGroupToggle={handleGroupToggle}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
) : entry.group.items.length === 1 ? (
|
||||
<FlatGroupItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
key={entry.group.id}
|
||||
group={entry.group}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
) : (
|
||||
<StreamGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
isExpanded={expandedGroupIds.has(group.id)}
|
||||
onToggle={() => handleGroupToggle(group.id)}
|
||||
key={entry.group.id}
|
||||
group={entry.group}
|
||||
isExpanded={expandedGroupIds.has(entry.group.id)}
|
||||
onToggle={() => handleGroupToggle(entry.group.id)}
|
||||
expandedItemIds={expandedItemIds}
|
||||
onItemClick={handleItemClick}
|
||||
searchQueryOverride={searchQueryOverride}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const CollapsibleTeamSection = ({
|
|||
{secondaryBadge != null && secondaryBadge > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-400"
|
||||
className="bg-blue-500/20 px-1.5 py-0.5 text-[10px] font-normal leading-none text-blue-600 dark:text-blue-400"
|
||||
title={`${secondaryBadge} unread`}
|
||||
>
|
||||
{secondaryBadge} new
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import {
|
||||
getTeamColorSet,
|
||||
getThemedBadge,
|
||||
getThemedBorder,
|
||||
getThemedText,
|
||||
} from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||
|
||||
interface MemberBadgeProps {
|
||||
|
|
@ -24,14 +30,15 @@ export const MemberBadge = ({
|
|||
onClick,
|
||||
}: MemberBadgeProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(color ?? '');
|
||||
const { isLight } = useTheme();
|
||||
const avatarSize = size === 'md' ? 32 : 24;
|
||||
const avatarClass = size === 'md' ? 'size-6' : 'size-5';
|
||||
const textClass = size === 'md' ? 'text-xs' : 'text-[10px]';
|
||||
|
||||
const badgeStyle = {
|
||||
backgroundColor: colors.badge,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: getThemedText(colors, isLight),
|
||||
border: `1px solid ${getThemedBorder(colors, isLight)}40`,
|
||||
};
|
||||
|
||||
const avatar = (
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded';
|
||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||
|
|
@ -32,6 +34,8 @@ import {
|
|||
AlertTriangle,
|
||||
Bell,
|
||||
CheckCheck,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Code,
|
||||
Columns3,
|
||||
FolderOpen,
|
||||
|
|
@ -122,6 +126,7 @@ function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTask
|
|||
}
|
||||
|
||||
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||||
|
|
@ -306,6 +311,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
showNoise: false,
|
||||
});
|
||||
const [messagesFilterOpen, setMessagesFilterOpen] = useState(false);
|
||||
const [messagesCollapsed, setMessagesCollapsed] = useState(true);
|
||||
|
||||
// Open editor overlay when a file reveal is requested (e.g. from chip click)
|
||||
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
|
||||
|
|
@ -624,6 +630,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}, [data, timeWindow, messagesFilter, messagesSearchQuery, leadMemberName]);
|
||||
|
||||
const { readSet, markRead, markAllRead } = useTeamMessagesRead(teamName ?? '');
|
||||
const { expandedSet, toggle: toggleExpandOverride } = useTeamMessagesExpanded(teamName ?? '');
|
||||
const messagesUnreadCount = useMemo(
|
||||
() => filteredMessages.filter((m) => !m.read && !readSet.has(toMessageKey(m))).length,
|
||||
[filteredMessages, readSet]
|
||||
|
|
@ -956,7 +963,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
{headerColorSet ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
|
||||
style={{ backgroundColor: headerColorSet.badge }}
|
||||
style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
|
|
@ -1469,7 +1476,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
}
|
||||
defaultOpen
|
||||
action={
|
||||
<div className="flex items-center gap-2 pl-2">
|
||||
<div className="flex items-center gap-2 pl-2 pr-2">
|
||||
<div className="flex w-36 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
||||
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<input
|
||||
|
|
@ -1498,6 +1505,28 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
onOpenChange={setMessagesFilterOpen}
|
||||
onApply={setMessagesFilter}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto size-7 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMessagesCollapsed((v) => !v);
|
||||
}}
|
||||
>
|
||||
{messagesCollapsed ? (
|
||||
<ChevronsUpDown size={14} />
|
||||
) : (
|
||||
<ChevronsDownUp size={14} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
@ -1536,6 +1565,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
teamName={teamName}
|
||||
members={data.members}
|
||||
readState={{ readSet, getMessageKey: toMessageKey }}
|
||||
allCollapsed={messagesCollapsed}
|
||||
expandOverrides={expandedSet}
|
||||
onToggleExpandOverride={toggleExpandOverride}
|
||||
onMemberClick={setSelectedMember}
|
||||
onCreateTaskFromMessage={(subject, description) => {
|
||||
openCreateTaskDialog(subject, description);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
|
||||
|
|
@ -70,7 +71,7 @@ function folderName(fullPath: string): string {
|
|||
return getBaseName(fullPath) || fullPath;
|
||||
}
|
||||
|
||||
function renderMemberChips(members: TeamSummaryMember[]): React.JSX.Element {
|
||||
function renderMemberChips(members: TeamSummaryMember[], isLight: boolean): React.JSX.Element {
|
||||
const teamColorMap = buildMemberColorMap(members);
|
||||
return (
|
||||
<>
|
||||
|
|
@ -84,7 +85,7 @@ function renderMemberChips(members: TeamSummaryMember[]): React.JSX.Element {
|
|||
style={
|
||||
memberColor
|
||||
? {
|
||||
backgroundColor: memberColor.badge,
|
||||
backgroundColor: getThemedBadge(memberColor, isLight),
|
||||
color: memberColor.text,
|
||||
border: `1px solid ${memberColor.border}40`,
|
||||
}
|
||||
|
|
@ -177,6 +178,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
|||
};
|
||||
|
||||
export const TeamListView = (): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const electronMode = isElectronMode();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
|
||||
|
|
@ -553,17 +555,6 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
>
|
||||
Create Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={teamsLoading}
|
||||
onClick={() => {
|
||||
void fetchTeams();
|
||||
}}
|
||||
>
|
||||
{teamsLoading ? <RotateCcw className="size-3.5 animate-spin" /> : null}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!canCreate ? (
|
||||
|
|
@ -690,7 +681,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
{teamColorSet ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
|
||||
style={{ backgroundColor: teamColorSet.badge }}
|
||||
style={{ backgroundColor: getThemedBadge(teamColorSet, isLight) }}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
|
|
@ -771,7 +762,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{team.members && team.members.length > 0 ? (
|
||||
renderMemberChips(team.members)
|
||||
renderMemberChips(team.members, isLight)
|
||||
) : team.memberCount === 0 ? (
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
Solo
|
||||
|
|
@ -906,7 +897,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</p>
|
||||
{team.members && team.members.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{renderMemberChips(team.members)}
|
||||
{renderMemberChips(team.members, isLight)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export const TeamSessionsSection = ({
|
|||
{selectedSessionId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-blue-400 transition-colors hover:bg-blue-500/10"
|
||||
className="flex w-full items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs text-blue-600 transition-colors hover:bg-blue-500/10 dark:text-blue-400"
|
||||
onClick={() => onSelectSession(null)}
|
||||
>
|
||||
<FilterX size={12} />
|
||||
|
|
@ -201,7 +201,7 @@ const SessionRow = ({
|
|||
{isLead && (
|
||||
<>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span className="text-blue-400">lead</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">lead</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
238
src/renderer/components/team/ToolApprovalSheet.tsx
Normal file
238
src/renderer/components/team/ToolApprovalSheet.tsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { FileText, Search, Terminal } from 'lucide-react';
|
||||
|
||||
import type { ToolApprovalRequest } from '@shared/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool icon mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getToolIcon(toolName: string): React.JSX.Element {
|
||||
const cls = 'size-4 shrink-0';
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
return <Terminal className={cls} />;
|
||||
case 'Read':
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
case 'NotebookEdit':
|
||||
return <FileText className={cls} />;
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return <Search className={cls} />;
|
||||
default:
|
||||
return <Terminal className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smart input preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderToolInput(toolName: string, input: Record<string, unknown>): string {
|
||||
switch (toolName) {
|
||||
case 'Bash':
|
||||
return typeof input.command === 'string' ? input.command : JSON.stringify(input, null, 2);
|
||||
case 'Edit':
|
||||
case 'Read':
|
||||
case 'Write':
|
||||
case 'NotebookEdit':
|
||||
return typeof input.file_path === 'string' ? input.file_path : JSON.stringify(input, null, 2);
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return typeof input.pattern === 'string' ? input.pattern : JSON.stringify(input, null, 2);
|
||||
default:
|
||||
return JSON.stringify(input, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elapsed timer hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useElapsed(receivedAt: string): number {
|
||||
const [elapsed, setElapsed] = useState(() =>
|
||||
Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const computeElapsed = (): number =>
|
||||
Math.max(0, Math.floor((Date.now() - new Date(receivedAt).getTime()) / 1000));
|
||||
queueMicrotask(() => setElapsed(computeElapsed()));
|
||||
const id = setInterval(() => {
|
||||
setElapsed(computeElapsed());
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [receivedAt]);
|
||||
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ToolApprovalSheet: React.FC = () => {
|
||||
const pendingApprovals = useStore((s) => s.pendingApprovals);
|
||||
const respondToToolApproval = useStore((s) => s.respondToToolApproval);
|
||||
const teams = useStore((s) => s.teams);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const current: ToolApprovalRequest | undefined = pendingApprovals[0];
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [disabled, setDisabled] = useState(false);
|
||||
|
||||
const handleRespond = useCallback(
|
||||
(allow: boolean) => {
|
||||
if (!current || disabled) return;
|
||||
setDisabled(true);
|
||||
void respondToToolApproval(current.teamName, current.runId, current.requestId, allow).finally(
|
||||
() => {
|
||||
setTimeout(() => setDisabled(false), 200);
|
||||
}
|
||||
);
|
||||
},
|
||||
[current, disabled, respondToToolApproval]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleRespond(true);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleRespond(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleRespond]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const teamSummary = teams.find((t) => t.teamName === current.teamName);
|
||||
const teamColor = teamSummary?.color ? getTeamColorSet(teamSummary.color) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed bottom-4 left-1/2 z-[55] w-full max-w-[480px] -translate-x-1/2 rounded-lg border shadow-xl outline-none duration-200 animate-in fade-in slide-in-from-bottom-4"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between border-b px-4 py-2.5"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{getToolIcon(current.toolName)}
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
{current.toolName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{teamColor ? (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: getThemedBadge(teamColor, isLight),
|
||||
color: teamColor.text,
|
||||
border: `1px solid ${teamColor.border}`,
|
||||
}}
|
||||
>
|
||||
{teamSummary?.displayName ?? current.teamName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">{current.teamName}</span>
|
||||
)}
|
||||
<ElapsedDisplay receivedAt={current.receivedAt} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool input preview */}
|
||||
<div className="px-4 py-2.5">
|
||||
<pre
|
||||
className="custom-scrollbar max-h-[120px] overflow-auto whitespace-pre-wrap break-all rounded-md border p-2 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{renderToolInput(current.toolName, current.toolInput)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div
|
||||
className="flex items-center justify-between border-t px-4 py-2.5"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleRespond(true)}
|
||||
className="rounded-md px-3.5 py-1.5 text-xs font-medium text-white transition-colors disabled:opacity-50"
|
||||
style={{ backgroundColor: 'rgb(5, 150, 105)' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled)
|
||||
Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(16, 185, 129)' });
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
Object.assign(e.currentTarget.style, { backgroundColor: 'rgb(5, 150, 105)' });
|
||||
}}
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => handleRespond(false)}
|
||||
className="rounded-md border px-3.5 py-1.5 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'rgba(239, 68, 68, 0.5)',
|
||||
color: 'rgb(248, 113, 113)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled)
|
||||
Object.assign(e.currentTarget.style, {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
});
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' });
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
{pendingApprovals.length > 1 && (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">
|
||||
{pendingApprovals.length - 1} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Elapsed display sub-component (uses hook)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ElapsedDisplay = ({ receivedAt }: { receivedAt: string }): React.JSX.Element => {
|
||||
const elapsed = useElapsed(receivedAt);
|
||||
return (
|
||||
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">{elapsed}s</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -15,7 +15,7 @@ export const UnreadCommentsBadge = ({
|
|||
<span
|
||||
className={`inline-flex items-center gap-0.5 rounded-full px-1.5 py-0 text-[10px] font-medium ${
|
||||
unreadCount > 0
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
? 'bg-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-[var(--color-surface-raised)] text-[var(--color-text-muted)]'
|
||||
}`}
|
||||
title={unreadCount > 0 ? `${unreadCount} unread` : 'All read'}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
|
@ -19,6 +20,7 @@ export const ActiveTasksBlock = ({
|
|||
onMemberClick,
|
||||
onTaskClick,
|
||||
}: ActiveTasksBlockProps): React.JSX.Element | null => {
|
||||
const { isLight } = useTheme();
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const taskMap = new Map(tasks.map((t) => [t.id, t]));
|
||||
const working = members.filter((m) => m.currentTaskId != null);
|
||||
|
|
@ -61,7 +63,7 @@ export const ActiveTasksBlock = ({
|
|||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
|
|
@ -73,7 +75,7 @@ export const ActiveTasksBlock = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||
|
|
@ -13,7 +13,8 @@ import {
|
|||
CARD_ICON_MUTED,
|
||||
CARD_TEXT_LIGHT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import {
|
||||
getMessageTypeLabel,
|
||||
getStructuredMessageSummary,
|
||||
|
|
@ -26,8 +27,10 @@ import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
|||
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
|
||||
import { AlertTriangle, ChevronRight, ListPlus, RefreshCw, Reply } from 'lucide-react';
|
||||
|
||||
import { isManagedCollapseState } from './collapseState';
|
||||
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
|
||||
|
||||
import type { ActivityCollapseState } from './collapseState';
|
||||
import type { TeamColorSet } from '@renderer/constants/teamColors';
|
||||
import type { InboxMessage } from '@shared/types';
|
||||
|
||||
|
|
@ -52,6 +55,8 @@ interface ActivityItemProps {
|
|||
onRestartTeam?: () => void;
|
||||
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||
zebraShade?: boolean;
|
||||
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
||||
collapseState?: ActivityCollapseState;
|
||||
}
|
||||
|
||||
function getStringField(obj: StructuredMessage, key: string): string | null {
|
||||
|
|
@ -138,6 +143,33 @@ function getSystemMessageLabel(text: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Labels to highlight in task assignment / review messages (bold in markdown). */
|
||||
const TASK_MESSAGE_LABELS = [
|
||||
'New task assigned to you:',
|
||||
'Description:',
|
||||
'Task approved',
|
||||
'Task needs fixes',
|
||||
'Review changes requested',
|
||||
'Changes requested:',
|
||||
'Comments:',
|
||||
'Reviewer:',
|
||||
'Related:',
|
||||
'Blocked by:',
|
||||
'Blocks:',
|
||||
];
|
||||
|
||||
/** Make known structural labels bold in system/task messages. */
|
||||
function highlightSystemLabels(text: string, isSystem: boolean): string {
|
||||
if (!isSystem) return text;
|
||||
let result = text;
|
||||
for (const label of TASK_MESSAGE_LABELS) {
|
||||
// Escape any regex-special chars in the label, match at line start or after newline
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
result = result.replace(new RegExp(`(^|\\n)(${escaped})`, 'g'), '$1**$2**');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Detect authentication/authorization errors that may be resolved by restarting. */
|
||||
const AUTH_ERROR_PATTERNS = [
|
||||
/OAuth token has expired/i,
|
||||
|
|
@ -153,8 +185,8 @@ const AUTH_ERROR_PATTERNS = [
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||
function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||
export function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(/#(\d+)\b/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -162,7 +194,10 @@ function linkifyTaskIdsInMarkdown(text: string): string {
|
|||
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
|
||||
* Greedy match: longer names are tried first to avoid partial matches.
|
||||
*/
|
||||
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
|
||||
export function linkifyMentionsInMarkdown(
|
||||
text: string,
|
||||
memberColorMap: Map<string, string>
|
||||
): string {
|
||||
if (memberColorMap.size === 0) return text;
|
||||
// Sort by name length descending for greedy matching
|
||||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||
|
|
@ -178,7 +213,7 @@ function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, str
|
|||
}
|
||||
/** Render `#<digits>` in plain text as clickable inline elements with TaskTooltip. */
|
||||
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
||||
return text.split(/(#\d+)/g).map((part, i) => {
|
||||
return text.split(/(#\d+\b)/g).map((part, i) => {
|
||||
const match = /^#(\d+)$/.exec(part);
|
||||
if (!match) return <span key={i}>{part}</span>;
|
||||
const taskId = match[1];
|
||||
|
|
@ -186,7 +221,7 @@ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.
|
|||
<TaskTooltip key={i} taskId={taskId}>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer font-medium text-blue-400 hover:underline"
|
||||
className="cursor-pointer font-medium text-blue-600 hover:underline dark:text-blue-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(taskId);
|
||||
|
|
@ -213,8 +248,10 @@ export const ActivityItem = ({
|
|||
onTaskIdClick,
|
||||
onRestartTeam,
|
||||
zebraShade,
|
||||
collapseState,
|
||||
}: ActivityItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||
const { isLight } = useTheme();
|
||||
const formattedRole = formatAgentRole(memberRole);
|
||||
|
||||
const timestamp = Number.isNaN(Date.parse(message.timestamp))
|
||||
|
|
@ -231,9 +268,9 @@ export const ActivityItem = ({
|
|||
// Never collapse rate limit messages as noise — they must be visible
|
||||
const noiseLabel = structured && !rateLimited ? getNoiseLabel(structured) : null;
|
||||
|
||||
// System/automated messages start collapsed (but not rate limits)
|
||||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||
const isManaged = isManagedCollapseState(collapseState);
|
||||
const isExpanded = isManaged ? !collapseState.isCollapsed : true;
|
||||
|
||||
// Strip agent-only blocks + normalize escape sequences (before linkification)
|
||||
const strippedText = useMemo(() => {
|
||||
|
|
@ -254,11 +291,12 @@ export const ActivityItem = ({
|
|||
// Linkify task IDs (always, for TaskTooltip) + @mentions for display
|
||||
const displayText = useMemo(() => {
|
||||
if (!strippedText) return null;
|
||||
let result = linkifyTaskIdsInMarkdown(strippedText);
|
||||
let result = highlightSystemLabels(strippedText, !!systemLabel);
|
||||
result = linkifyTaskIdsInMarkdown(result);
|
||||
if (memberColorMap && memberColorMap.size > 0)
|
||||
result = linkifyMentionsInMarkdown(result, memberColorMap);
|
||||
return result;
|
||||
}, [strippedText, memberColorMap]);
|
||||
}, [strippedText, memberColorMap, systemLabel]);
|
||||
|
||||
const rawSummary =
|
||||
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
|
||||
|
|
@ -282,9 +320,16 @@ export const ActivityItem = ({
|
|||
onCreateTask?.(subject, description);
|
||||
};
|
||||
|
||||
const isHeaderClickable = Boolean(systemLabel);
|
||||
const isHeaderClickable = isManaged ? collapseState.canToggle : false;
|
||||
const showChevron = isHeaderClickable;
|
||||
const isUserSent = message.source === 'user_sent';
|
||||
const isSystemMessage = message.from === 'system';
|
||||
const onManagedToggle = isManaged ? collapseState.onToggle : undefined;
|
||||
const handleHeaderToggle = isHeaderClickable
|
||||
? (): void => {
|
||||
onManagedToggle?.();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -310,7 +355,7 @@ export const ActivityItem = ({
|
|||
? '3px solid var(--tool-result-error-text)'
|
||||
: isSystemMessage
|
||||
? '3px solid var(--system-activity-accent)'
|
||||
: `3px solid ${colors.border}`,
|
||||
: `3px solid ${getThemedBorder(colors, isLight)}`,
|
||||
}}
|
||||
>
|
||||
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
|
||||
|
|
@ -322,13 +367,13 @@ export const ActivityItem = ({
|
|||
'flex items-center gap-2 px-3 py-2',
|
||||
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
||||
].join(' ')}
|
||||
onClick={isHeaderClickable ? () => setIsExpanded((v) => !v) : undefined}
|
||||
onClick={handleHeaderToggle}
|
||||
onKeyDown={
|
||||
isHeaderClickable
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setIsExpanded((v) => !v);
|
||||
handleHeaderToggle?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -337,8 +382,8 @@ export const ActivityItem = ({
|
|||
{isUnread ? (
|
||||
<span className="size-2 shrink-0 rounded-full bg-blue-500" title="Unread" aria-hidden />
|
||||
) : null}
|
||||
{/* Chevron for collapsible system messages */}
|
||||
{systemLabel ? (
|
||||
{/* Chevron for collapsible messages */}
|
||||
{showChevron ? (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 transition-transform duration-150"
|
||||
style={{
|
||||
|
|
@ -483,7 +528,10 @@ export const ActivityItem = ({
|
|||
</details>
|
||||
</div>
|
||||
) : parsedReply ? (
|
||||
<ReplyQuoteBlock reply={parsedReply} memberColor={memberColorMap?.get(parsedReply.agentName)} />
|
||||
<ReplyQuoteBlock
|
||||
reply={parsedReply}
|
||||
memberColor={memberColorMap?.get(parsedReply.agentName)}
|
||||
/>
|
||||
) : displayText ? (
|
||||
<ExpandableContent>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
|
||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||
import { AnimatedHeightReveal } from './AnimatedHeightReveal';
|
||||
import { findNewestMessageIndex, resolveTimelineCollapseState } from './collapseState';
|
||||
import { groupTimelineItems, isLeadThought, LeadThoughtsGroupRow } from './LeadThoughtsGroup';
|
||||
import { useNewItemKeys } from './useNewItemKeys';
|
||||
|
||||
import type { ActivityCollapseState } from './collapseState';
|
||||
import type { TimelineItem } from './LeadThoughtsGroup';
|
||||
import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
|
|
@ -26,6 +31,12 @@ interface ActivityTimelineProps {
|
|||
onTaskIdClick?: (taskId: string) => void;
|
||||
/** Called when the user clicks "Restart team" on an auth error message. */
|
||||
onRestartTeam?: () => void;
|
||||
/** When true, collapse all message bodies — show only headers with expand chevrons. */
|
||||
allCollapsed?: boolean;
|
||||
/** Set of stable message keys that the user has manually expanded in collapsed mode. */
|
||||
expandOverrides?: Set<string>;
|
||||
/** Called when user toggles expand/collapse override on a specific message. */
|
||||
onToggleExpandOverride?: (key: string) => void;
|
||||
}
|
||||
|
||||
const VIEWPORT_THRESHOLD = 0.15;
|
||||
|
|
@ -47,6 +58,7 @@ const MessageRowWithObserver = ({
|
|||
onVisible,
|
||||
onTaskIdClick,
|
||||
onRestartTeam,
|
||||
collapseState,
|
||||
}: {
|
||||
message: InboxMessage;
|
||||
teamName: string;
|
||||
|
|
@ -63,6 +75,7 @@ const MessageRowWithObserver = ({
|
|||
onVisible?: (message: InboxMessage) => void;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
onRestartTeam?: () => void;
|
||||
collapseState?: ActivityCollapseState;
|
||||
}): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reportedRef = useRef(false);
|
||||
|
|
@ -95,7 +108,7 @@ const MessageRowWithObserver = ({
|
|||
}, [onVisible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||
<AnimatedHeightReveal animate={isNew} containerRef={ref}>
|
||||
<ActivityItem
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
|
|
@ -110,8 +123,9 @@ const MessageRowWithObserver = ({
|
|||
onReply={onReply}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
collapseState={collapseState}
|
||||
/>
|
||||
</div>
|
||||
</AnimatedHeightReveal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -126,14 +140,12 @@ export const ActivityTimeline = ({
|
|||
onMessageVisible,
|
||||
onTaskIdClick,
|
||||
onRestartTeam,
|
||||
allCollapsed,
|
||||
expandOverrides,
|
||||
onToggleExpandOverride,
|
||||
}: ActivityTimelineProps): React.JSX.Element => {
|
||||
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
||||
|
||||
// --- New-message animation tracking ---
|
||||
const knownKeysRef = useRef<Set<string>>(new Set<string>());
|
||||
const isInitializedRef = useRef(false);
|
||||
const prevVisibleCountRef = useRef(visibleCount);
|
||||
|
||||
const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
|
||||
const memberInfo = new Map<string, { role?: string; color?: string }>();
|
||||
if (members) {
|
||||
|
|
@ -214,10 +226,7 @@ export const ActivityTimeline = ({
|
|||
return result;
|
||||
}, [timelineItems]);
|
||||
|
||||
// Determine which items are "new" (should animate).
|
||||
/* eslint-disable react-hooks/refs -- intentional ref access during render for animation tracking */
|
||||
|
||||
const newItemKeys = useMemo(() => {
|
||||
const timelineItemKeys = useMemo(() => {
|
||||
const getItemKey = (item: TimelineItem): string => {
|
||||
if (item.type === 'lead-thoughts') {
|
||||
// Stable key: identify group by its first thought, not by count (which changes)
|
||||
|
|
@ -227,43 +236,14 @@ export const ActivityTimeline = ({
|
|||
return `${msg.messageId ?? item.originalIndex}-${msg.timestamp}-${msg.from}`;
|
||||
};
|
||||
|
||||
const allKeys: string[] = [];
|
||||
for (const item of timelineItems) {
|
||||
allKeys.push(getItemKey(item));
|
||||
}
|
||||
return timelineItems.map(getItemKey);
|
||||
}, [timelineItems]);
|
||||
|
||||
// First render: seed known keys, no animations
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
for (const key of allKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Pagination expansion ("Show more" / "Show all"): add keys silently
|
||||
const isPaginationExpansion = visibleCount > prevVisibleCountRef.current;
|
||||
prevVisibleCountRef.current = visibleCount;
|
||||
|
||||
if (isPaginationExpansion) {
|
||||
for (const key of allKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
// Normal update: unknown keys are new items
|
||||
const newKeys = new Set<string>();
|
||||
for (const key of allKeys) {
|
||||
if (!knownKeysRef.current.has(key)) {
|
||||
newKeys.add(key);
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
}
|
||||
return newKeys;
|
||||
}, [timelineItems, visibleCount]);
|
||||
/* eslint-enable react-hooks/refs -- end animation tracking block */
|
||||
const newItemKeys = useNewItemKeys({
|
||||
itemKeys: timelineItemKeys,
|
||||
paginationKey: visibleCount,
|
||||
resetKey: teamName,
|
||||
});
|
||||
|
||||
const handleShowMore = (): void => {
|
||||
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||
|
|
@ -273,15 +253,6 @@ export const ActivityTimeline = ({
|
|||
setVisibleCount(Infinity);
|
||||
};
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||
<p>No messages</p>
|
||||
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getItemSessionId = (item: TimelineItem): string | undefined =>
|
||||
item.type === 'lead-thoughts'
|
||||
? item.group.thoughts[0].leadSessionId
|
||||
|
|
@ -291,6 +262,40 @@ export const ActivityTimeline = ({
|
|||
const pinnedThoughtGroup = timelineItems[0]?.type === 'lead-thoughts' ? timelineItems[0] : null;
|
||||
const startIndex = pinnedThoughtGroup ? 1 : 0;
|
||||
|
||||
// Determine the index of the "newest" non-thought timeline item (for auto-expand).
|
||||
const newestMessageIndex = useMemo(() => {
|
||||
return findNewestMessageIndex(timelineItems);
|
||||
}, [timelineItems]);
|
||||
|
||||
/**
|
||||
* Compute the externally managed collapse state for an item in the timeline.
|
||||
* In collapsed mode we always keep the newest real message open, keep the pinned
|
||||
* thought group open, and let localStorage overrides reopen older items.
|
||||
*/
|
||||
const getItemCollapseState = useCallback(
|
||||
(stableKey: string, itemIndex: number): ActivityCollapseState =>
|
||||
resolveTimelineCollapseState({
|
||||
allCollapsed,
|
||||
itemIndex,
|
||||
newestMessageIndex,
|
||||
isPinnedThoughtGroup: itemIndex === 0 && pinnedThoughtGroup != null,
|
||||
isExpandedOverride: expandOverrides?.has(stableKey) ?? false,
|
||||
onToggleOverride: onToggleExpandOverride
|
||||
? () => onToggleExpandOverride(stableKey)
|
||||
: undefined,
|
||||
}),
|
||||
[allCollapsed, newestMessageIndex, pinnedThoughtGroup, expandOverrides, onToggleExpandOverride]
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-3 text-xs text-[var(--color-text-muted)]">
|
||||
<p>No messages</p>
|
||||
<p className="mt-1 text-[11px]">Send a message to a member to see activity.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Pinned (newest) thought group — always at top */}
|
||||
|
|
@ -300,6 +305,8 @@ export const ActivityTimeline = ({
|
|||
const firstThought = group.thoughts[0];
|
||||
const info = memberInfo.get(firstThought.from);
|
||||
const itemKey = `thoughts-${firstThought.messageId ?? pinnedThoughtGroup.originalIndices[0]}`;
|
||||
const stableKey = toMessageKey(firstThought);
|
||||
const collapseState = getItemCollapseState(stableKey, 0);
|
||||
return (
|
||||
<LeadThoughtsGroupRow
|
||||
key={itemKey}
|
||||
|
|
@ -309,6 +316,10 @@ export const ActivityTimeline = ({
|
|||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(0)}
|
||||
collapseState={collapseState}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
onReply={onReplyToMessage}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
@ -328,9 +339,11 @@ export const ActivityTimeline = ({
|
|||
className="flex items-center gap-3"
|
||||
style={{ paddingTop: 90, paddingBottom: 90 }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-blue-400/30" />
|
||||
<span className="whitespace-nowrap text-[11px] text-blue-400">New session</span>
|
||||
<div className="h-px flex-1 bg-blue-400/30" />
|
||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
<span className="whitespace-nowrap text-[11px] font-medium text-blue-600 dark:text-blue-400">
|
||||
New session
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -341,6 +354,8 @@ export const ActivityTimeline = ({
|
|||
const firstThought = group.thoughts[0];
|
||||
const info = memberInfo.get(firstThought.from);
|
||||
const itemKey = `thoughts-${firstThought.messageId ?? item.originalIndices[0]}`;
|
||||
const stableKey = toMessageKey(firstThought);
|
||||
const collapseState = getItemCollapseState(stableKey, realIndex);
|
||||
return (
|
||||
<React.Fragment key={itemKey}>
|
||||
{sessionSeparator}
|
||||
|
|
@ -351,6 +366,10 @@ export const ActivityTimeline = ({
|
|||
isNew={newItemKeys.has(itemKey)}
|
||||
onVisible={onMessageVisible}
|
||||
zebraShade={zebraShadeSet.has(realIndex)}
|
||||
collapseState={collapseState}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
onReply={onReplyToMessage}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -362,6 +381,8 @@ export const ActivityTimeline = ({
|
|||
const recipientColor =
|
||||
recipientInfo?.color ?? (message.to ? colorMap.get(message.to) : undefined);
|
||||
const messageKey = `${message.messageId ?? item.originalIndex}-${message.timestamp}-${message.from}`;
|
||||
const stableKey = toMessageKey(message);
|
||||
const collapseState = getItemCollapseState(stableKey, realIndex);
|
||||
const isUnread = readState
|
||||
? !message.read && !readState.readSet.has(readState.getMessageKey(message))
|
||||
: !message.read;
|
||||
|
|
@ -384,6 +405,7 @@ export const ActivityTimeline = ({
|
|||
onVisible={onMessageVisible}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
collapseState={collapseState}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -411,7 +433,7 @@ export const ActivityTimeline = ({
|
|||
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">
|
||||
+{hiddenCount} older
|
||||
</span>
|
||||
<span className="h-3 w-px bg-blue-400/30" />
|
||||
<span className="h-3 w-px bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
<button
|
||||
onClick={handleShowMore}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text)]"
|
||||
|
|
@ -420,7 +442,7 @@ export const ActivityTimeline = ({
|
|||
</button>
|
||||
{hiddenCount > MESSAGES_PAGE_SIZE && (
|
||||
<>
|
||||
<span className="h-3 w-px bg-blue-400/30" />
|
||||
<span className="h-3 w-px bg-blue-600/30 dark:bg-blue-400/30" />
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
|
||||
|
|
|
|||
103
src/renderer/components/team/activity/AnimatedHeightReveal.tsx
Normal file
103
src/renderer/components/team/activity/AnimatedHeightReveal.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { CSSProperties, MutableRefObject, PropsWithChildren, Ref } from 'react';
|
||||
|
||||
export const ENTRY_REVEAL_ANIMATION_MS = 700;
|
||||
export const ENTRY_REVEAL_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
interface AnimatedHeightRevealProps extends PropsWithChildren {
|
||||
animate?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
containerRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: Ref<T> | undefined, value: T | null): void {
|
||||
if (!ref) return;
|
||||
if (typeof ref === 'function') {
|
||||
ref(value);
|
||||
return;
|
||||
}
|
||||
const mutableRef = ref as MutableRefObject<T | null>;
|
||||
mutableRef.current = value;
|
||||
}
|
||||
|
||||
export const AnimatedHeightReveal = ({
|
||||
animate,
|
||||
className,
|
||||
style,
|
||||
containerRef,
|
||||
children,
|
||||
}: AnimatedHeightRevealProps): JSX.Element => {
|
||||
const [shouldAnimateOnMount] = useState(() => Boolean(animate));
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const [prefersReducedMotion] = useState(
|
||||
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
() => !animate || window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
);
|
||||
|
||||
const setWrapperRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
wrapperRef.current = node;
|
||||
assignRef(containerRef, node);
|
||||
},
|
||||
[containerRef]
|
||||
);
|
||||
|
||||
const clearPendingAnimation = useCallback(() => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAnimateOnMount || prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
setIsExpanded(true);
|
||||
animationFrameRef.current = null;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearPendingAnimation();
|
||||
};
|
||||
}, [clearPendingAnimation, shouldAnimateOnMount, prefersReducedMotion]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearPendingAnimation();
|
||||
},
|
||||
[clearPendingAnimation]
|
||||
);
|
||||
|
||||
const shouldTransition = shouldAnimateOnMount && !prefersReducedMotion && isExpanded;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setWrapperRef}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateRows: isExpanded ? '1fr' : '0fr',
|
||||
opacity: isExpanded ? 1 : 0,
|
||||
transition: shouldTransition
|
||||
? [
|
||||
`grid-template-rows ${ENTRY_REVEAL_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`,
|
||||
`opacity ${ENTRY_REVEAL_ANIMATION_MS}ms ease`,
|
||||
].join(', ')
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ minHeight: 0, overflow: 'hidden' }}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
|
|
@ -12,8 +13,19 @@ import {
|
|||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Reply } from 'lucide-react';
|
||||
|
||||
import { linkifyMentionsInMarkdown, linkifyTaskIdsInMarkdown } from './ActivityItem';
|
||||
import {
|
||||
AnimatedHeightReveal,
|
||||
ENTRY_REVEAL_ANIMATION_MS,
|
||||
ENTRY_REVEAL_EASING,
|
||||
} from './AnimatedHeightReveal';
|
||||
import { isManagedCollapseState } from './collapseState';
|
||||
|
||||
import type { ActivityCollapseState } from './collapseState';
|
||||
import type { InboxMessage, ToolCallMeta } from '@shared/types';
|
||||
|
||||
export interface LeadThoughtGroup {
|
||||
|
|
@ -43,6 +55,8 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
|||
const result: TimelineItem[] = [];
|
||||
let pendingThoughts: InboxMessage[] = [];
|
||||
let pendingIndices: number[] = [];
|
||||
const hasSameLeadSession = (a: InboxMessage, b: InboxMessage): boolean =>
|
||||
(a.leadSessionId ?? null) === (b.leadSessionId ?? null);
|
||||
|
||||
const flushThoughts = (): void => {
|
||||
if (pendingThoughts.length === 0) return;
|
||||
|
|
@ -58,6 +72,10 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
|||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i];
|
||||
if (isLeadThought(msg)) {
|
||||
const previousThought = pendingThoughts[pendingThoughts.length - 1];
|
||||
if (previousThought && !hasSameLeadSession(previousThought, msg)) {
|
||||
flushThoughts();
|
||||
}
|
||||
pendingThoughts.push(msg);
|
||||
pendingIndices.push(i);
|
||||
} else {
|
||||
|
|
@ -71,7 +89,9 @@ export function groupTimelineItems(messages: InboxMessage[]): TimelineItem[] {
|
|||
|
||||
const VIEWPORT_THRESHOLD = 0.15;
|
||||
const LIVE_WINDOW_MS = 5_000;
|
||||
const COLLAPSED_THOUGHTS_HEIGHT = 200;
|
||||
const AUTO_SCROLL_THRESHOLD = 30;
|
||||
const THOUGHT_HEIGHT_ANIMATION_MS = ENTRY_REVEAL_ANIMATION_MS;
|
||||
|
||||
interface LeadThoughtsGroupRowProps {
|
||||
group: LeadThoughtGroup;
|
||||
|
|
@ -82,6 +102,14 @@ interface LeadThoughtsGroupRowProps {
|
|||
canBeLive?: boolean;
|
||||
/** When true, apply a subtle lighter background for zebra-striped lists. */
|
||||
zebraShade?: boolean;
|
||||
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
||||
collapseState?: ActivityCollapseState;
|
||||
/** Called when a task ID link (e.g. #10) is clicked in thought text. */
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
/** Map of member name → color name for @mention badge rendering. */
|
||||
memberColorMap?: Map<string, string>;
|
||||
/** Called when user clicks the reply button on a thought. */
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
|
|
@ -160,6 +188,244 @@ const ToolSummaryTooltipContent = ({
|
|||
return <span>{toolSummary ?? ''}</span>;
|
||||
};
|
||||
|
||||
interface LeadThoughtItemProps {
|
||||
thought: InboxMessage;
|
||||
showDivider: boolean;
|
||||
shouldAnimate: boolean;
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
memberColorMap?: Map<string, string>;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
}
|
||||
|
||||
const LeadThoughtItem = ({
|
||||
thought,
|
||||
showDivider,
|
||||
shouldAnimate,
|
||||
onTaskIdClick,
|
||||
memberColorMap,
|
||||
onReply,
|
||||
}: LeadThoughtItemProps): JSX.Element => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const previousHeightRef = useRef<number | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const cleanupTimerRef = useRef<number | null>(null);
|
||||
|
||||
const displayContent = useMemo(() => {
|
||||
let text = thought.text.replace(/\n/g, ' \n');
|
||||
text = linkifyTaskIdsInMarkdown(text);
|
||||
if (memberColorMap && memberColorMap.size > 0) {
|
||||
text = linkifyMentionsInMarkdown(text, memberColorMap);
|
||||
}
|
||||
return text;
|
||||
}, [thought.text, memberColorMap]);
|
||||
|
||||
const clearPendingAnimation = useCallback(() => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (cleanupTimerRef.current !== null) {
|
||||
window.clearTimeout(cleanupTimerRef.current);
|
||||
cleanupTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetWrapperStyles = useCallback(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
wrapper.style.height = 'auto';
|
||||
wrapper.style.opacity = '1';
|
||||
wrapper.style.overflow = 'visible';
|
||||
wrapper.style.transition = '';
|
||||
wrapper.style.willChange = '';
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!wrapper || !content) return;
|
||||
|
||||
const applyTransition = (targetHeight: number): void => {
|
||||
wrapper.style.transition = [
|
||||
`height ${THOUGHT_HEIGHT_ANIMATION_MS}ms ${ENTRY_REVEAL_EASING}`,
|
||||
`opacity ${THOUGHT_HEIGHT_ANIMATION_MS}ms ease`,
|
||||
].join(', ');
|
||||
wrapper.style.height = `${Math.max(targetHeight, 0)}px`;
|
||||
wrapper.style.opacity = '1';
|
||||
};
|
||||
|
||||
const scheduleTransition = (targetHeight: number): void => {
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
applyTransition(targetHeight);
|
||||
});
|
||||
};
|
||||
|
||||
const animateHeight = (
|
||||
targetHeight: number,
|
||||
startHeight: number,
|
||||
startOpacity: number
|
||||
): void => {
|
||||
clearPendingAnimation();
|
||||
wrapper.style.transition = 'none';
|
||||
wrapper.style.overflow = 'hidden';
|
||||
wrapper.style.height = `${Math.max(startHeight, 0)}px`;
|
||||
wrapper.style.opacity = `${startOpacity}`;
|
||||
wrapper.style.willChange = 'height, opacity';
|
||||
// Force layout reflow so the browser registers the starting values
|
||||
const _reflow = wrapper.offsetHeight;
|
||||
if (_reflow < -1) return; // unreachable — prevents unused-variable lint
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(() => {
|
||||
scheduleTransition(targetHeight);
|
||||
});
|
||||
|
||||
cleanupTimerRef.current = window.setTimeout(() => {
|
||||
resetWrapperStyles();
|
||||
cleanupTimerRef.current = null;
|
||||
}, THOUGHT_HEIGHT_ANIMATION_MS + 40);
|
||||
};
|
||||
|
||||
const syncHeight = (nextHeight: number, animateFromZero: boolean): void => {
|
||||
const previousHeight = previousHeightRef.current;
|
||||
previousHeightRef.current = nextHeight;
|
||||
|
||||
if (!shouldAnimate) {
|
||||
resetWrapperStyles();
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousHeight === null) {
|
||||
if (nextHeight > 0 && animateFromZero) {
|
||||
animateHeight(nextHeight, 0, 0);
|
||||
} else {
|
||||
resetWrapperStyles();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(nextHeight - previousHeight) < 1) return;
|
||||
|
||||
const renderedHeight = wrapper.getBoundingClientRect().height;
|
||||
animateHeight(nextHeight, renderedHeight > 0 ? renderedHeight : previousHeight, 1);
|
||||
};
|
||||
|
||||
syncHeight(content.getBoundingClientRect().height, true);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const nextHeight = entries[0]?.contentRect.height ?? content.getBoundingClientRect().height;
|
||||
syncHeight(nextHeight, false);
|
||||
});
|
||||
observer.observe(content);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearPendingAnimation();
|
||||
resetWrapperStyles();
|
||||
};
|
||||
}, [clearPendingAnimation, resetWrapperStyles, shouldAnimate]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearPendingAnimation();
|
||||
},
|
||||
[clearPendingAnimation]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<div ref={contentRef}>
|
||||
{showDivider && (
|
||||
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
|
||||
<hr
|
||||
className="flex-1 border-0"
|
||||
style={{
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
/>
|
||||
<span className="shrink-0 font-mono text-[9px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formatTimeWithSec(thought.timestamp)}
|
||||
</span>
|
||||
<hr
|
||||
className="flex-1 border-0"
|
||||
style={{
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="group/thought relative flex text-[11px]">
|
||||
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const taskId = link.getAttribute('href')?.replace('task://', '');
|
||||
if (taskId) onTaskIdClick(taskId);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer content={displayContent} maxHeight="max-h-none" bare />
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute right-1 top-0.5 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/thought:opacity-100">
|
||||
{onReply ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReply(thought);
|
||||
}}
|
||||
>
|
||||
<Reply size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Reply</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<CopyButton text={thought.text} inline />
|
||||
</div>
|
||||
</div>
|
||||
{thought.toolSummary && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="mb-[7px] cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
🔧 {thought.toolSummary}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-[420px] font-mono text-[11px]"
|
||||
>
|
||||
<ToolSummaryTooltipContent
|
||||
toolCalls={thought.toolCalls}
|
||||
toolSummary={thought.toolSummary}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LeadThoughtsGroupRow = ({
|
||||
group,
|
||||
memberColor,
|
||||
|
|
@ -167,10 +433,17 @@ export const LeadThoughtsGroupRow = ({
|
|||
onVisible,
|
||||
canBeLive,
|
||||
zebraShade,
|
||||
collapseState,
|
||||
onTaskIdClick,
|
||||
memberColorMap,
|
||||
onReply,
|
||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const isUserScrolledUpRef = useRef(false);
|
||||
const distanceFromBottomRef = useRef(0);
|
||||
const scrollSyncFrameRef = useRef<number | null>(null);
|
||||
const isTeamAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false);
|
||||
const leadActivity = useStore((s) => {
|
||||
const teamName = s.selectedTeamName;
|
||||
|
|
@ -227,6 +500,16 @@ export const LeadThoughtsGroupRow = ({
|
|||
[canBeLive, isTeamAlive, leadActivity, leadContextUpdatedAt, newest.timestamp]
|
||||
);
|
||||
const [isLive, setIsLive] = useState(computeIsLive);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [needsTruncation, setNeedsTruncation] = useState(false);
|
||||
const isManaged = isManagedCollapseState(collapseState);
|
||||
const isBodyVisible = isManaged ? !collapseState.isCollapsed : true;
|
||||
const canToggleBodyVisibility = isManaged && collapseState.canToggle;
|
||||
const handleBodyToggle = canToggleBodyVisibility
|
||||
? (): void => {
|
||||
collapseState.onToggle?.();
|
||||
}
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional immediate sync to avoid 1s stale gap
|
||||
|
|
@ -258,26 +541,122 @@ export const LeadThoughtsGroupRow = ({
|
|||
return () => observer.disconnect();
|
||||
}, [onVisible, thoughts]);
|
||||
|
||||
// Auto-scroll when new thoughts arrive
|
||||
const clearPendingScrollSync = useCallback(() => {
|
||||
if (scrollSyncFrameRef.current !== null) {
|
||||
cancelAnimationFrame(scrollSyncFrameRef.current);
|
||||
scrollSyncFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const queueScrollSync = useCallback(
|
||||
(mode: 'bottom' | 'preserve') => {
|
||||
clearPendingScrollSync();
|
||||
scrollSyncFrameRef.current = requestAnimationFrame(() => {
|
||||
scrollSyncFrameRef.current = requestAnimationFrame(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl || expanded || !isBodyVisible) {
|
||||
scrollSyncFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScrollTop =
|
||||
mode === 'bottom'
|
||||
? scrollEl.scrollHeight - scrollEl.clientHeight
|
||||
: scrollEl.scrollHeight - scrollEl.clientHeight - distanceFromBottomRef.current;
|
||||
|
||||
scrollEl.scrollTop = Math.max(0, nextScrollTop);
|
||||
if (mode === 'bottom') {
|
||||
distanceFromBottomRef.current = 0;
|
||||
isUserScrolledUpRef.current = false;
|
||||
}
|
||||
scrollSyncFrameRef.current = null;
|
||||
});
|
||||
});
|
||||
},
|
||||
[clearPendingScrollSync, expanded, isBodyVisible]
|
||||
);
|
||||
|
||||
const syncScrollableBody = useCallback(
|
||||
(forceScrollToBottom = false) => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const contentEl = contentRef.current;
|
||||
if (!scrollEl || !contentEl) return;
|
||||
|
||||
const nextNeedsTruncation = contentEl.scrollHeight > COLLAPSED_THOUGHTS_HEIGHT + 1;
|
||||
setNeedsTruncation((prev) => (prev === nextNeedsTruncation ? prev : nextNeedsTruncation));
|
||||
|
||||
if (expanded || !isBodyVisible) return;
|
||||
if (!nextNeedsTruncation) {
|
||||
clearPendingScrollSync();
|
||||
distanceFromBottomRef.current = 0;
|
||||
isUserScrolledUpRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceScrollToBottom || !isUserScrolledUpRef.current) {
|
||||
queueScrollSync('bottom');
|
||||
return;
|
||||
}
|
||||
|
||||
queueScrollSync('preserve');
|
||||
},
|
||||
[clearPendingScrollSync, expanded, isBodyVisible, queueScrollSync]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isBodyVisible) return;
|
||||
const contentEl = contentRef.current;
|
||||
if (!contentEl) return;
|
||||
|
||||
syncScrollableBody(true);
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
syncScrollableBody();
|
||||
});
|
||||
observer.observe(contentEl);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [isBodyVisible, syncScrollableBody]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearPendingScrollSync();
|
||||
},
|
||||
[clearPendingScrollSync]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || isUserScrolledUpRef.current) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, [chronologicalThoughts]);
|
||||
if (isBodyVisible) return;
|
||||
clearPendingScrollSync();
|
||||
}, [clearPendingScrollSync, isBodyVisible]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (expanded) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
const distanceFromBottom = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight);
|
||||
distanceFromBottomRef.current = distanceFromBottom;
|
||||
isUserScrolledUpRef.current = distanceFromBottom > AUTO_SCROLL_THRESHOLD;
|
||||
}, [expanded]);
|
||||
|
||||
const handleCollapse = useCallback(() => {
|
||||
isUserScrolledUpRef.current = false;
|
||||
distanceFromBottomRef.current = 0;
|
||||
setExpanded(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (scrollEl) {
|
||||
scrollEl.scrollTop = scrollEl.scrollHeight;
|
||||
}
|
||||
ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}
|
||||
style={{ overflowAnchor: 'none' }}
|
||||
>
|
||||
<AnimatedHeightReveal animate={isNew} containerRef={ref} style={{ overflowAnchor: 'none' }}>
|
||||
<article
|
||||
className="group rounded-md [overflow:clip]"
|
||||
style={{
|
||||
|
|
@ -287,16 +666,51 @@ export const LeadThoughtsGroupRow = ({
|
|||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex select-none items-center gap-2 px-3 py-1.5">
|
||||
{/* Live / offline indicator */}
|
||||
{isLive ? (
|
||||
<span className="pointer-events-none relative inline-flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-zinc-500" />
|
||||
)}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */}
|
||||
<div
|
||||
role={canToggleBodyVisibility ? 'button' : undefined}
|
||||
tabIndex={canToggleBodyVisibility ? 0 : undefined}
|
||||
className={[
|
||||
'flex select-none items-center gap-2 px-3 py-1.5',
|
||||
canToggleBodyVisibility ? 'cursor-pointer' : '',
|
||||
].join(' ')}
|
||||
onClick={handleBodyToggle}
|
||||
onKeyDown={
|
||||
canToggleBodyVisibility
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleBodyToggle?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Chevron for collapse mode */}
|
||||
{canToggleBodyVisibility ? (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 transition-transform duration-150"
|
||||
style={{
|
||||
color: CARD_ICON_MUTED,
|
||||
transform: isBodyVisible ? 'rotate(90deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* Lead avatar with optional live indicator */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(leadName, 24)}
|
||||
alt=""
|
||||
className="size-5 rounded-full bg-[var(--color-surface-raised)]"
|
||||
loading="lazy"
|
||||
/>
|
||||
{isLive ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{thoughts.length} thoughts
|
||||
|
|
@ -323,80 +737,74 @@ export const LeadThoughtsGroupRow = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Scrollable body — fixed height, always visible */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="border-t"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
maxHeight: '200px',
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'var(--scrollbar-thumb) transparent',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{chronologicalThoughts.map((thought, idx) => (
|
||||
<div key={thought.messageId ?? idx} className="thought-expand-in">
|
||||
{idx > 0 && (
|
||||
<div className="mx-auto flex w-2/5 items-center justify-center gap-[5px] py-px">
|
||||
<hr
|
||||
className="flex-1 border-0"
|
||||
style={{
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="shrink-0 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{formatTimeWithSec(thought.timestamp)}
|
||||
</span>
|
||||
<hr
|
||||
className="flex-1 border-0"
|
||||
style={{
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-[11px]">
|
||||
<div className="min-w-0 flex-1 [&_>div>div]:p-0" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
<MarkdownViewer
|
||||
content={thought.text.replace(/\n/g, ' \n')}
|
||||
maxHeight="max-h-none"
|
||||
bare
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{thought.toolSummary && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
🔧 {thought.toolSummary}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="max-w-[420px] font-mono text-[11px]"
|
||||
>
|
||||
<ToolSummaryTooltipContent
|
||||
toolCalls={thought.toolCalls}
|
||||
toolSummary={thought.toolSummary}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Scrollable body — live thoughts follow bottom unless user scrolls up */}
|
||||
{isBodyVisible ? (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="border-t"
|
||||
style={{
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
maxHeight: expanded || !needsTruncation ? 'none' : `${COLLAPSED_THOUGHTS_HEIGHT}px`,
|
||||
overflowY: expanded ? 'visible' : needsTruncation ? 'auto' : 'hidden',
|
||||
scrollbarWidth: expanded || !needsTruncation ? undefined : 'thin',
|
||||
scrollbarColor:
|
||||
expanded || !needsTruncation ? undefined : 'var(--scrollbar-thumb) transparent',
|
||||
overflowAnchor: 'none',
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
{chronologicalThoughts.map((thought, idx) => (
|
||||
<LeadThoughtItem
|
||||
key={thought.messageId ?? idx}
|
||||
thought={thought}
|
||||
showDivider={idx > 0}
|
||||
shouldAnimate={isLive && idx === chronologicalThoughts.length - 1}
|
||||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={memberColorMap}
|
||||
onReply={onReply}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
{isBodyVisible && !expanded && needsTruncation ? (
|
||||
<div
|
||||
className="pointer-events-none flex justify-center pt-1"
|
||||
style={{ transform: 'translateY(-20px)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-sm transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(true);
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isBodyVisible && expanded && needsTruncation ? (
|
||||
<div
|
||||
className="pointer-events-none sticky bottom-0 z-10 flex justify-center pb-1 pt-2"
|
||||
style={{ transform: 'translateY(-20px)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto flex items-center gap-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] px-2.5 py-1 text-[11px] text-[var(--color-text-muted)] shadow-sm transition-colors hover:bg-[var(--color-surface)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCollapse();
|
||||
}}
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatedHeightReveal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CARD_BG, CARD_BORDER_STYLE, CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
|
|
@ -18,6 +19,7 @@ export const PendingRepliesBlock = ({
|
|||
pendingRepliesByMember,
|
||||
onMemberClick,
|
||||
}: PendingRepliesBlockProps): React.JSX.Element | null => {
|
||||
const { isLight } = useTheme();
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
const pending = Object.entries(pendingRepliesByMember)
|
||||
.map(([name, sentAtMs]) => ({
|
||||
|
|
@ -62,7 +64,7 @@ export const PendingRepliesBlock = ({
|
|||
type="button"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
|
|
@ -75,7 +77,7 @@ export const PendingRepliesBlock = ({
|
|||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wide"
|
||||
style={{
|
||||
backgroundColor: colors.badge,
|
||||
backgroundColor: getThemedBadge(colors, isLight),
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}40`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -29,15 +29,15 @@ export const ReplyQuoteBlock = ({
|
|||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Quote block — styled like SendMessageDialog */}
|
||||
<div className="relative overflow-hidden rounded-md border border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
|
||||
<div className="relative overflow-hidden rounded-md border border-blue-400/20 bg-blue-100/40 py-2 pl-3 pr-2 dark:border-blue-500/20 dark:bg-blue-950/20">
|
||||
{/* Decorative quotation mark */}
|
||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-400/[0.08]">
|
||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[48px] leading-none text-blue-600/[0.08] dark:text-blue-400/[0.08]">
|
||||
“
|
||||
</span>
|
||||
|
||||
{/* "Replying to" + MemberBadge */}
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-blue-300/60">Replying to</span>
|
||||
<span className="text-[10px] text-blue-600/60 dark:text-blue-300/60">Replying to</span>
|
||||
<MemberBadge name={reply.agentName} color={memberColor} size="sm" />
|
||||
</div>
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export const ReplyQuoteBlock = ({
|
|||
{isLong ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
|
||||
className="mt-0.5 text-[10px] text-blue-600/60 hover:text-blue-700 dark:text-blue-400/60 dark:hover:text-blue-300"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded ? 'less' : 'more'}
|
||||
|
|
|
|||
66
src/renderer/components/team/activity/collapseState.ts
Normal file
66
src/renderer/components/team/activity/collapseState.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
export interface DefaultActivityCollapseState {
|
||||
mode: 'default';
|
||||
}
|
||||
|
||||
export interface ManagedActivityCollapseState {
|
||||
mode: 'managed';
|
||||
isCollapsed: boolean;
|
||||
canToggle: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export type ActivityCollapseState = DefaultActivityCollapseState | ManagedActivityCollapseState;
|
||||
|
||||
export interface TimelineItemLike {
|
||||
type: 'message' | 'lead-thoughts';
|
||||
}
|
||||
|
||||
interface ResolveTimelineCollapseStateArgs {
|
||||
allCollapsed?: boolean;
|
||||
itemIndex: number;
|
||||
newestMessageIndex: number;
|
||||
isPinnedThoughtGroup: boolean;
|
||||
isExpandedOverride: boolean;
|
||||
onToggleOverride?: () => void;
|
||||
}
|
||||
|
||||
export function isManagedCollapseState(
|
||||
collapseState: ActivityCollapseState | undefined
|
||||
): collapseState is ManagedActivityCollapseState {
|
||||
return collapseState?.mode === 'managed';
|
||||
}
|
||||
|
||||
export function findNewestMessageIndex(items: readonly TimelineItemLike[]): number {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i]?.type === 'message') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function resolveTimelineCollapseState({
|
||||
allCollapsed,
|
||||
itemIndex,
|
||||
newestMessageIndex,
|
||||
isPinnedThoughtGroup,
|
||||
isExpandedOverride,
|
||||
onToggleOverride,
|
||||
}: ResolveTimelineCollapseStateArgs): ActivityCollapseState {
|
||||
if (!allCollapsed) {
|
||||
return { mode: 'default' };
|
||||
}
|
||||
|
||||
if (isPinnedThoughtGroup || itemIndex === newestMessageIndex) {
|
||||
return {
|
||||
mode: 'managed',
|
||||
isCollapsed: false,
|
||||
canToggle: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'managed',
|
||||
isCollapsed: !isExpandedOverride,
|
||||
canToggle: onToggleOverride != null,
|
||||
onToggle: onToggleOverride,
|
||||
};
|
||||
}
|
||||
56
src/renderer/components/team/activity/useNewItemKeys.ts
Normal file
56
src/renderer/components/team/activity/useNewItemKeys.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
interface UseNewItemKeysOptions {
|
||||
itemKeys: string[];
|
||||
paginationKey?: number;
|
||||
resetKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which currently visible items are newly mounted since the last committed render.
|
||||
* Pagination expansions are treated as non-animated so "Show more" does not replay enter motion.
|
||||
*/
|
||||
export function useNewItemKeys({
|
||||
itemKeys,
|
||||
paginationKey = 0,
|
||||
resetKey,
|
||||
}: UseNewItemKeysOptions): Set<string> {
|
||||
const knownKeysRef = useRef<Set<string>>(new Set());
|
||||
const isInitializedRef = useRef(false);
|
||||
const prevPaginationKeyRef = useRef(paginationKey);
|
||||
|
||||
useEffect(() => {
|
||||
knownKeysRef.current = new Set();
|
||||
isInitializedRef.current = false;
|
||||
prevPaginationKeyRef.current = paginationKey;
|
||||
}, [resetKey]);
|
||||
|
||||
const isPaginationExpansion =
|
||||
isInitializedRef.current && paginationKey > prevPaginationKeyRef.current;
|
||||
|
||||
const newItemKeys = useMemo(() => {
|
||||
if (!isInitializedRef.current || isPaginationExpansion) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
const next = new Set<string>();
|
||||
for (const key of itemKeys) {
|
||||
if (!knownKeysRef.current.has(key)) {
|
||||
next.add(key);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}, [isPaginationExpansion, itemKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
}
|
||||
for (const key of itemKeys) {
|
||||
knownKeysRef.current.add(key);
|
||||
}
|
||||
prevPaginationKeyRef.current = paginationKey;
|
||||
}, [itemKeys, paginationKey]);
|
||||
|
||||
return newItemKeys;
|
||||
}
|
||||
|
|
@ -8,12 +8,14 @@ import type { AttachmentPayload } from '@shared/types';
|
|||
interface AttachmentPreviewItemProps {
|
||||
attachment: AttachmentPayload;
|
||||
onRemove: (id: string) => void;
|
||||
onPreview?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AttachmentPreviewItem = ({
|
||||
attachment,
|
||||
onRemove,
|
||||
onPreview,
|
||||
disabled,
|
||||
}: AttachmentPreviewItemProps): React.JSX.Element => {
|
||||
const dataUrl = `data:${attachment.mimeType};base64,${attachment.data}`;
|
||||
|
|
@ -25,7 +27,7 @@ export const AttachmentPreviewItem = ({
|
|||
<Ban size={18} className="text-red-400" />
|
||||
</div>
|
||||
) : null}
|
||||
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" />
|
||||
<AttachmentThumbnail src={dataUrl} alt={attachment.filename} size="sm" onClick={onPreview} />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="max-w-[100px] truncate text-[11px] text-[var(--color-text-secondary)]">
|
||||
{attachment.filename}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AlertCircle, X } from 'lucide-react';
|
||||
|
||||
import { AttachmentPreviewItem } from './AttachmentPreviewItem';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
const ANIMATION_MS = 400;
|
||||
|
||||
interface AttachmentPreviewListProps {
|
||||
attachments: AttachmentPayload[];
|
||||
onRemove: (id: string) => void;
|
||||
|
|
@ -23,23 +28,122 @@ export const AttachmentPreviewList = ({
|
|||
disabled,
|
||||
disabledHint,
|
||||
}: AttachmentPreviewListProps): React.JSX.Element | null => {
|
||||
if (attachments.length === 0 && !error) return null;
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [exitingIds, setExitingIds] = useState<Set<string>>(new Set());
|
||||
// Track IDs known on previous render to detect newly added items
|
||||
const knownIdsRef = useRef<Set<string>>(new Set());
|
||||
const [enteringIds, setEnteringIds] = useState<Set<string>>(new Set());
|
||||
const exitTimersRef = useRef<Map<string, number>>(new Map());
|
||||
const enterTimersRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Detect newly added attachments
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(attachments.map((a) => a.id));
|
||||
const newIds = new Set<string>();
|
||||
for (const id of currentIds) {
|
||||
if (!knownIdsRef.current.has(id)) {
|
||||
newIds.add(id);
|
||||
}
|
||||
}
|
||||
knownIdsRef.current = currentIds;
|
||||
|
||||
if (newIds.size === 0) return;
|
||||
|
||||
queueMicrotask(() => {
|
||||
setEnteringIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of newIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
// Clear entering state after animation completes
|
||||
for (const id of newIds) {
|
||||
const timer = window.setTimeout(() => {
|
||||
setEnteringIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
enterTimersRef.current.delete(id);
|
||||
}, ANIMATION_MS);
|
||||
enterTimersRef.current.set(id, timer);
|
||||
}
|
||||
}, [attachments]);
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
const exitTimers = exitTimersRef.current;
|
||||
const enterTimers = enterTimersRef.current;
|
||||
return () => {
|
||||
for (const t of exitTimers.values()) window.clearTimeout(t);
|
||||
for (const t of enterTimers.values()) window.clearTimeout(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
// Start exit animation
|
||||
setExitingIds((prev) => new Set(prev).add(id));
|
||||
|
||||
// Actually remove after animation
|
||||
const timer = window.setTimeout(() => {
|
||||
setExitingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
exitTimersRef.current.delete(id);
|
||||
onRemove(id);
|
||||
}, ANIMATION_MS);
|
||||
exitTimersRef.current.set(id, timer);
|
||||
},
|
||||
[onRemove]
|
||||
);
|
||||
|
||||
// Include exiting items that are no longer in attachments (they were removed by parent)
|
||||
// This shouldn't normally happen since we delay onRemove, but guard against it.
|
||||
const visibleAttachments = attachments;
|
||||
|
||||
if (visibleAttachments.length === 0 && exitingIds.size === 0 && !error) return null;
|
||||
|
||||
const lightboxSlides = visibleAttachments.map((att) => ({
|
||||
src: `data:${att.mimeType};base64,${att.data}`,
|
||||
alt: att.filename,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 px-1">
|
||||
{attachments.length > 0 ? (
|
||||
{visibleAttachments.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto py-1">
|
||||
{attachments.map((att) => (
|
||||
<AttachmentPreviewItem
|
||||
key={att.id}
|
||||
attachment={att}
|
||||
onRemove={onRemove}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{visibleAttachments.map((att, i) => {
|
||||
const isExiting = exitingIds.has(att.id);
|
||||
const isEntering = enteringIds.has(att.id);
|
||||
return (
|
||||
<div
|
||||
key={att.id}
|
||||
style={{
|
||||
transition: `transform ${ANIMATION_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity ${ANIMATION_MS}ms ease`,
|
||||
transform: isExiting ? 'scale(0)' : isEntering ? undefined : 'scale(1)',
|
||||
opacity: isExiting ? 0 : 1,
|
||||
transformOrigin: 'center center',
|
||||
animation: isEntering
|
||||
? `att-scale-in ${ANIMATION_MS}ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<AttachmentPreviewItem
|
||||
attachment={att}
|
||||
onRemove={handleRemove}
|
||||
onPreview={() => setLightboxIndex(i)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{disabled && disabledHint && attachments.length > 0 ? (
|
||||
{disabled && disabledHint && visibleAttachments.length > 0 ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5"
|
||||
style={{ backgroundColor: 'var(--warning-bg)', color: 'var(--warning-text)' }}
|
||||
|
|
@ -63,6 +167,14 @@ export const AttachmentPreviewList = ({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{lightboxIndex !== null && lightboxSlides[lightboxIndex] ? (
|
||||
<ImageLightbox
|
||||
open
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
slides={lightboxSlides}
|
||||
index={lightboxIndex}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export const ImageLightbox = ({
|
|||
}}
|
||||
styles={{
|
||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
|
||||
button: { padding: 16 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -21,17 +21,20 @@ import {
|
|||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { SkipPermissionsCheckbox } from './SkipPermissionsCheckbox';
|
||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
|
|
@ -49,6 +52,7 @@ const TEAM_COLOR_NAMES = [
|
|||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type {
|
||||
EffortLevel,
|
||||
Project,
|
||||
TeamCreateRequest,
|
||||
TeamProvisioningMemberInput,
|
||||
|
|
@ -200,6 +204,7 @@ export const CreateTeamDialog = ({
|
|||
onOpenTeam,
|
||||
}: CreateTeamDialogProps): React.JSX.Element => {
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
const { isLight } = useTheme();
|
||||
|
||||
const [teamName, setTeamName] = useState('');
|
||||
const descriptionDraft = useDraftPersistence({ key: 'createTeam:description' });
|
||||
|
|
@ -233,6 +238,12 @@ export const CreateTeamDialog = ({
|
|||
const [extendedContext, setExtendedContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||
);
|
||||
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||
);
|
||||
const [selectedEffort, setSelectedEffortRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
|
||||
);
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
setSelectedModelRaw(value);
|
||||
|
|
@ -244,6 +255,16 @@ export const CreateTeamDialog = ({
|
|||
localStorage.setItem('team:lastExtendedContext', String(value));
|
||||
};
|
||||
|
||||
const setSkipPermissions = (value: boolean): void => {
|
||||
setSkipPermissionsRaw(value);
|
||||
localStorage.setItem('team:lastSkipPermissions', String(value));
|
||||
};
|
||||
|
||||
const setSelectedEffort = (value: string): void => {
|
||||
setSelectedEffortRaw(value);
|
||||
localStorage.setItem('team:lastSelectedEffort', value);
|
||||
};
|
||||
|
||||
const resetUIState = (): void => {
|
||||
setLocalError(null);
|
||||
setFieldErrors({});
|
||||
|
|
@ -473,6 +494,8 @@ export const CreateTeamDialog = ({
|
|||
cwd: effectiveCwd,
|
||||
prompt: prompt.trim() || undefined,
|
||||
model: effectiveModel,
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
skipPermissions,
|
||||
}),
|
||||
[
|
||||
sanitizedTeamName,
|
||||
|
|
@ -483,6 +506,8 @@ export const CreateTeamDialog = ({
|
|||
effectiveCwd,
|
||||
prompt,
|
||||
effectiveModel,
|
||||
selectedEffort,
|
||||
skipPermissions,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -795,50 +820,25 @@ export const CreateTeamDialog = ({
|
|||
onValueChange={setSelectedModel}
|
||||
id="create-model"
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={selectedEffort}
|
||||
onValueChange={setSelectedEffort}
|
||||
id="create-effort"
|
||||
/>
|
||||
<ExtendedContextCheckbox
|
||||
id="create-extended-context"
|
||||
checked={extendedContext}
|
||||
onCheckedChange={setExtendedContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
{launchTeam && (
|
||||
<SkipPermissionsCheckbox
|
||||
id="create-skip-permissions"
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={setSkipPermissions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCreate && prepareState === 'ready' ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{prepareMessage}</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -876,7 +876,7 @@ export const CreateTeamDialog = ({
|
|||
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colorSet.badge,
|
||||
backgroundColor: getThemedBadge(colorSet, isLight),
|
||||
borderColor: isSelected ? colorSet.border : 'transparent',
|
||||
}}
|
||||
title={colorName}
|
||||
|
|
@ -899,36 +899,79 @@ export const CreateTeamDialog = ({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{canOpenExistingTeam ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onOpenTeam(request.teamName);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Open Existing Team
|
||||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canCreate && launchTeam && prepareState === 'ready' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
||||
{prepareMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{canOpenExistingTeam ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onOpenTeam(request.teamName);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Open Existing Team
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors';
|
||||
import { CUSTOM_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ export const EditTeamDialog = ({
|
|||
onClose,
|
||||
onSaved,
|
||||
}: EditTeamDialogProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [name, setName] = useState(currentName);
|
||||
const [description, setDescription] = useState(currentDescription);
|
||||
const [color, setColor] = useState(currentColor);
|
||||
|
|
@ -191,7 +193,7 @@ export const EditTeamDialog = ({
|
|||
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: colorSet.badge,
|
||||
backgroundColor: getThemedBadge(colorSet, isLight),
|
||||
borderColor: isSelected ? colorSet.border : 'transparent',
|
||||
}}
|
||||
title={colorName}
|
||||
|
|
|
|||
55
src/renderer/components/team/dialogs/EffortLevelSelector.tsx
Normal file
55
src/renderer/components/team/dialogs/EffortLevelSelector.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Brain } from 'lucide-react';
|
||||
|
||||
const EFFORT_OPTIONS = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'high', label: 'High' },
|
||||
] as const;
|
||||
|
||||
export interface EffortLevelSelectorProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const EffortLevelSelector: React.FC<EffortLevelSelectorProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
id,
|
||||
}) => (
|
||||
<div className="mb-3">
|
||||
<Label htmlFor={id} className="label-optional mb-1.5 block">
|
||||
Effort level (optional)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain size={16} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{EFFORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-muted)]">
|
||||
Controls how much reasoning Claude invests before responding. Default uses Claude's
|
||||
standard behavior.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -18,7 +18,7 @@ export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = (
|
|||
disabled = false,
|
||||
}) => (
|
||||
<>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked && !disabled}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
||||
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import {
|
||||
|
|
@ -23,12 +24,14 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react';
|
||||
|
||||
import { EffortLevelSelector } from './EffortLevelSelector';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
||||
|
||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type {
|
||||
EffortLevel,
|
||||
Project,
|
||||
ResolvedTeamMember,
|
||||
TeamLaunchRequest,
|
||||
|
|
@ -78,6 +81,12 @@ export const LaunchTeamDialog = ({
|
|||
const [extendedContext, setExtendedContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||
);
|
||||
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
||||
);
|
||||
const [selectedEffort, setSelectedEffortRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
|
||||
);
|
||||
const [clearContext, setClearContext] = useState(false);
|
||||
const [conflictDismissed, setConflictDismissed] = useState(false);
|
||||
|
||||
|
|
@ -91,6 +100,16 @@ export const LaunchTeamDialog = ({
|
|||
localStorage.setItem('team:lastExtendedContext', String(value));
|
||||
};
|
||||
|
||||
const setSkipPermissions = (value: boolean): void => {
|
||||
setSkipPermissionsRaw(value);
|
||||
localStorage.setItem('team:lastSkipPermissions', String(value));
|
||||
};
|
||||
|
||||
const setSelectedEffort = (value: string): void => {
|
||||
setSelectedEffortRaw(value);
|
||||
localStorage.setItem('team:lastSelectedEffort', value);
|
||||
};
|
||||
|
||||
const resetFormState = (): void => {
|
||||
setLocalError(null);
|
||||
setIsSubmitting(false);
|
||||
|
|
@ -282,7 +301,9 @@ export const LaunchTeamDialog = ({
|
|||
cwd: effectiveCwd,
|
||||
prompt: promptDraft.value.trim() || undefined,
|
||||
model: computeEffectiveTeamModel(selectedModel, extendedContext),
|
||||
effort: (selectedEffort as EffortLevel) || undefined,
|
||||
clearContext: clearContext || undefined,
|
||||
skipPermissions,
|
||||
});
|
||||
resetFormState();
|
||||
onClose();
|
||||
|
|
@ -425,12 +446,22 @@ export const LaunchTeamDialog = ({
|
|||
onValueChange={setSelectedModel}
|
||||
id="launch-model"
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
value={selectedEffort}
|
||||
onValueChange={setSelectedEffort}
|
||||
id="launch-effort"
|
||||
/>
|
||||
<ExtendedContextCheckbox
|
||||
id="launch-extended-context"
|
||||
checked={extendedContext}
|
||||
onCheckedChange={setExtendedContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
<SkipPermissionsCheckbox
|
||||
id="launch-skip-permissions"
|
||||
checked={skipPermissions}
|
||||
onCheckedChange={setSkipPermissions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -475,62 +506,70 @@ export const LaunchTeamDialog = ({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'ready' ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{prepareMessage}</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
<DialogFooter className="pt-4 sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
{prepareState === 'idle' || prepareState === 'loading' ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
||||
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
<span>
|
||||
{prepareMessage ??
|
||||
(prepareState === 'idle'
|
||||
? 'Warming up CLI environment...'
|
||||
: 'Preparing environment...')}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={isSubmitting || prepareState !== 'ready'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Launching...
|
||||
</>
|
||||
) : (
|
||||
'Launch'
|
||||
)}
|
||||
</Button>
|
||||
{prepareState === 'ready' ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
||||
<CheckCircle2 className="size-3.5 shrink-0" />
|
||||
<span>
|
||||
{prepareWarnings.length > 0
|
||||
? 'CLI environment ready (with notes)'
|
||||
: 'CLI environment ready'}
|
||||
</span>
|
||||
</div>
|
||||
{prepareMessage ? (
|
||||
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
||||
{prepareMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{prepareWarnings.length > 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5 pl-5">
|
||||
{prepareWarnings.map((warning) => (
|
||||
<p key={warning} className="text-[11px] text-sky-300">
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{prepareState === 'failed' ? <div /> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
disabled={isSubmitting || prepareState !== 'ready'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
Launching...
|
||||
</>
|
||||
) : (
|
||||
'Launch'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue