Merge pull request #18 from 777genius/dev

Dev
This commit is contained in:
Илия 2026-03-07 15:03:28 +02:00 committed by GitHub
commit c01461e90f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
148 changed files with 7250 additions and 3669 deletions

View file

@ -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>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>&nbsp;
<a href="https://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
View file

@ -0,0 +1,2 @@
dist/
node_modules/

View file

@ -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: 1128 chars, no `.`, `..`, `/`, `\`, null bytes |
| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it |
| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) |
---
## License
Same as the parent project: [AGPL-3.0](../LICENSE).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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]">
&ldquo;
</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'}

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

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

View file

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

View file

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

View file

@ -80,6 +80,7 @@ export const ImageLightbox = ({
}}
styles={{
container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' },
button: { padding: 16 },
}}
/>
);

View file

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

View file

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

View 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&apos;s
standard behavior.
</p>
</div>
);

View file

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

View file

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