diff --git a/README.md b/README.md index 32936bd9..37712043 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@

Latest Release  - CI Status + CI Status  + Discord

@@ -18,6 +19,7 @@


+ ## 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 ## Installation diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 00000000..763301fc --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/mcp-server/README.md b/mcp-server/README.md deleted file mode 100644 index f6b0d7de..00000000 --- a/mcp-server/README.md +++ /dev/null @@ -1,301 +0,0 @@ -# @claude-team/mcp-server - -**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.** - -Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app. - ---- - -## Table of Contents - -- [Overview](#overview) -- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui) -- [Quick start](#quick-start) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Configuration](#configuration) -- [Tools Reference](#tools-reference) -- [Data Storage](#data-storage) -- [Development](#development) -- [Testing](#testing) -- [Troubleshooting](#troubleshooting) -- [License](#license) - ---- - -## Overview - -Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which: - -- Stores task data as JSON under `~/.claude/tasks/{teamName}/` -- Stores team config under `~/.claude/teams/{teamName}/` -- Is installed automatically when you run Claude Agent Teams UI at least once - -### Use cases - -| Scenario | Description | -|----------|-------------| -| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat | -| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use | -| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages | - -### Architecture - -``` -┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐ -│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │ -│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │ -│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │ -└─────────────────────┘ └──────────────────────┘ └─────────────────┘ -``` - ---- - -## Relationship to Claude Agent Teams UI - -| Component | Role | -|-----------|------| -| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. | -| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. | -| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. | - -Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data. - ---- - -## Quick start - -1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`) -2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build` -3. Add to Cursor MCP config (`~/.cursor/mcp.json`): - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] - } - } -} -``` - -4. Restart Cursor. The 13 tools will appear for the AI assistant. - ---- - -## Prerequisites - -- **Node.js 20+** -- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`) - -If `teamctl.js` is missing, the server throws at startup: - -``` -teamctl.js not found at ~/.claude/tools/teamctl.js. -Make sure Claude Agent Teams UI has been run at least once, -or set the TEAMCTL_PATH environment variable. -``` - ---- - -## Installation - -### From the monorepo (development) - -```bash -cd mcp-server -pnpm install -pnpm build -``` - -### As a dependency - -```bash -pnpm add @claude-team/mcp-server -# or -npm install @claude-team/mcp-server -``` - ---- - -## Configuration - -### Cursor - -Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] - } - } -} -``` - -With global install (`pnpm link` or `npm link`): - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "team-mcp-server" - } - } -} -``` - -### Claude Desktop - -Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent: - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/absolute/path/to/mcp-server/dist/index.js"] - } - } -} -``` - -### Environment variables - -| Variable | Description | -|----------|-------------| -| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` | - ---- - -## Tools Reference - -All tools require a `team` parameter (team name, folder under `~/.claude/teams/`). - -### Task CRUD - -| Tool | Description | -|------|-------------| -| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` | -| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) | -| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) | -| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals | -| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` | - -### Task collaboration - -| Tool | Description | -|------|-------------| -| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` | -| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected | -| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board | -| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB | - -### Kanban board - -| Tool | Description | -|------|-------------| -| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` | -| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` | - -### Code review - -| Tool | Description | -|------|-------------| -| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) | - -### Messaging - -| Tool | Description | -|------|-------------| -| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications | - ---- - -## Data Storage - -| Location | Contents | -|----------|----------| -| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) | -| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes | - -Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes). - ---- - -## Development - -### Scripts - -| Command | Description | -|---------|-------------| -| `pnpm build` | Build with tsup → `dist/index.js` | -| `pnpm dev` | Run with tsx (no build) | -| `pnpm test` | Run Vitest tests | -| `pnpm test:watch` | Watch mode | -| `pnpm typecheck` | TypeScript check | - -### Project structure - -``` -mcp-server/ -├── src/ -│ ├── index.ts # FastMCP server, registers all tools -│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess -│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout -│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.) -│ └── tools/ -│ ├── index.ts # registerAllTools() -│ ├── task-create.ts -│ ├── task-get.ts -│ ├── ... -│ └── message-send.ts -├── test/ -│ ├── tools/ # Per-tool tests -│ ├── teamctl-runner.test.ts -│ ├── output-parser.test.ts -│ └── schemas.test.ts -├── package.json -├── tsup.config.ts -└── vitest.config.ts -``` - -### Adding a new tool - -1. Create `src/tools/your-tool.ts` with `register(server, runner)` -2. Add to `ALL_TOOLS` in `src/tools/index.ts` -3. Add Zod parameters and map to `teamctl` CLI args -4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts` -5. Add tests in `test/tools/your-tool.test.ts` - ---- - -## Testing - -Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required): - -```bash -pnpm test -``` - -For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`. - ---- - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` | -| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes | -| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it | -| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) | - ---- - -## License - -Same as the parent project: [AGPL-3.0](../LICENSE). diff --git a/mcp-server/package.json b/mcp-server/package.json index ac6f5a7d..b16663fa 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -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", diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts deleted file mode 100644 index a30a5b06..00000000 --- a/mcp-server/src/index.ts +++ /dev/null @@ -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' }); diff --git a/mcp-server/src/output-parser.ts b/mcp-server/src/output-parser.ts deleted file mode 100644 index e2b7c1a5..00000000 --- a/mcp-server/src/output-parser.ts +++ /dev/null @@ -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(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; -} diff --git a/mcp-server/src/schemas.ts b/mcp-server/src/schemas.ts deleted file mode 100644 index cc808e0b..00000000 --- a/mcp-server/src/schemas.ts +++ /dev/null @@ -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)', - ); diff --git a/mcp-server/src/teamctl-runner.ts b/mcp-server/src/teamctl-runner.ts deleted file mode 100644 index d9892106..00000000 --- a/mcp-server/src/teamctl-runner.ts +++ /dev/null @@ -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; -} - -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 { - if (this.current < this.max) { - this.current++; - return; - } - return new Promise((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 { - await this.semaphore.acquire(); - try { - return await this.spawn(args); - } finally { - this.semaphore.release(); - } - } - - private spawn(args: string[]): Promise { - 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'); -} diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts deleted file mode 100644 index 6a4da6f9..00000000 --- a/mcp-server/src/tools/index.ts +++ /dev/null @@ -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); - } -} diff --git a/mcp-server/src/tools/kanban-move.ts b/mcp-server/src/tools/kanban-move.ts deleted file mode 100644 index 565a836f..00000000 --- a/mcp-server/src/tools/kanban-move.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/kanban-reviewers.ts b/mcp-server/src/tools/kanban-reviewers.ts deleted file mode 100644 index 03569247..00000000 --- a/mcp-server/src/tools/kanban-reviewers.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/message-send.ts b/mcp-server/src/tools/message-send.ts deleted file mode 100644 index 0693bd33..00000000 --- a/mcp-server/src/tools/message-send.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/review-action.ts b/mcp-server/src/tools/review-action.ts deleted file mode 100644 index 64bad45d..00000000 --- a/mcp-server/src/tools/review-action.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-attach.ts b/mcp-server/src/tools/task-attach.ts deleted file mode 100644 index a0e9830d..00000000 --- a/mcp-server/src/tools/task-attach.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-briefing.ts b/mcp-server/src/tools/task-briefing.ts deleted file mode 100644 index 9273a6d7..00000000 --- a/mcp-server/src/tools/task-briefing.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-comment.ts b/mcp-server/src/tools/task-comment.ts deleted file mode 100644 index 951ef0a9..00000000 --- a/mcp-server/src/tools/task-comment.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-create.ts b/mcp-server/src/tools/task-create.ts deleted file mode 100644 index 5c08e4e7..00000000 --- a/mcp-server/src/tools/task-create.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-get.ts b/mcp-server/src/tools/task-get.ts deleted file mode 100644 index cf56ec6d..00000000 --- a/mcp-server/src/tools/task-get.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-link.ts b/mcp-server/src/tools/task-link.ts deleted file mode 100644 index 0d43021c..00000000 --- a/mcp-server/src/tools/task-link.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-list.ts b/mcp-server/src/tools/task-list.ts deleted file mode 100644 index 37c05bfd..00000000 --- a/mcp-server/src/tools/task-list.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-set-owner.ts b/mcp-server/src/tools/task-set-owner.ts deleted file mode 100644 index 4cfd04bf..00000000 --- a/mcp-server/src/tools/task-set-owner.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/src/tools/task-set-status.ts b/mcp-server/src/tools/task-set-status.ts deleted file mode 100644 index a5b96294..00000000 --- a/mcp-server/src/tools/task-set-status.ts +++ /dev/null @@ -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); - }, - }); -} diff --git a/mcp-server/test/output-parser.test.ts b/mcp-server/test/output-parser.test.ts deleted file mode 100644 index 52f8179f..00000000 --- a/mcp-server/test/output-parser.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/schemas.test.ts b/mcp-server/test/schemas.test.ts deleted file mode 100644 index cfd8b5f6..00000000 --- a/mcp-server/test/schemas.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/mcp-server/test/teamctl-runner.test.ts b/mcp-server/test/teamctl-runner.test.ts deleted file mode 100644 index 54b703f7..00000000 --- a/mcp-server/test/teamctl-runner.test.ts +++ /dev/null @@ -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 | TeamctlResult, -): ITeamctlRunner { - return { - execute: vi.fn(async (args: string[]): Promise => { - 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'); - }); -}); diff --git a/mcp-server/test/tools/kanban-move.test.ts b/mcp-server/test/tools/kanban-move.test.ts deleted file mode 100644 index e0468131..00000000 --- a/mcp-server/test/tools/kanban-move.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/kanban-reviewers.test.ts b/mcp-server/test/tools/kanban-reviewers.test.ts deleted file mode 100644 index ee854535..00000000 --- a/mcp-server/test/tools/kanban-reviewers.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/message-send.test.ts b/mcp-server/test/tools/message-send.test.ts deleted file mode 100644 index 8bb206e8..00000000 --- a/mcp-server/test/tools/message-send.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/register-all.test.ts b/mcp-server/test/tools/register-all.test.ts deleted file mode 100644 index d562525e..00000000 --- a/mcp-server/test/tools/register-all.test.ts +++ /dev/null @@ -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); - } - }); -}); diff --git a/mcp-server/test/tools/review-action.test.ts b/mcp-server/test/tools/review-action.test.ts deleted file mode 100644 index 952f1ae9..00000000 --- a/mcp-server/test/tools/review-action.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-attach.test.ts b/mcp-server/test/tools/task-attach.test.ts deleted file mode 100644 index e8b63fe5..00000000 --- a/mcp-server/test/tools/task-attach.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-briefing.test.ts b/mcp-server/test/tools/task-briefing.test.ts deleted file mode 100644 index b915e994..00000000 --- a/mcp-server/test/tools/task-briefing.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-comment.test.ts b/mcp-server/test/tools/task-comment.test.ts deleted file mode 100644 index 2c340e48..00000000 --- a/mcp-server/test/tools/task-comment.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-create.test.ts b/mcp-server/test/tools/task-create.test.ts deleted file mode 100644 index 5866e9f9..00000000 --- a/mcp-server/test/tools/task-create.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-get.test.ts b/mcp-server/test/tools/task-get.test.ts deleted file mode 100644 index 79a3d97f..00000000 --- a/mcp-server/test/tools/task-get.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-link.test.ts b/mcp-server/test/tools/task-link.test.ts deleted file mode 100644 index 7e16b802..00000000 --- a/mcp-server/test/tools/task-link.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-list.test.ts b/mcp-server/test/tools/task-list.test.ts deleted file mode 100644 index be1a25af..00000000 --- a/mcp-server/test/tools/task-list.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-set-owner.test.ts b/mcp-server/test/tools/task-set-owner.test.ts deleted file mode 100644 index d9859066..00000000 --- a/mcp-server/test/tools/task-set-owner.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/task-set-status.test.ts b/mcp-server/test/tools/task-set-status.test.ts deleted file mode 100644 index 0ca68d0d..00000000 --- a/mcp-server/test/tools/task-set-status.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/mcp-server/test/tools/test-helpers.ts b/mcp-server/test/tools/test-helpers.ts deleted file mode 100644 index fb0a8e01..00000000 --- a/mcp-server/test/tools/test-helpers.ts +++ /dev/null @@ -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 } { - return { - execute: vi.fn(async (args: string[]): Promise => { - 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) => Promise; - parameters: unknown; -} - -export function createMockServer(): { - server: FastMCP; - tools: Map; -} { - const tools = new Map(); - - 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 }; -} diff --git a/package.json b/package.json index 4db5dd7d..446a4711 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80ad3475..3d4fa02b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/resources/pricing.json b/resources/pricing.json index 78856312..3f65c218 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -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, diff --git a/src/main/http/sessions.ts b/src/main/http/sessions.ts index 9697d43c..a3bc267c 100644 --- a/src/main/http/sessions.ts +++ b/src/main/http/sessions.ts @@ -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 } }>( diff --git a/src/main/http/subagents.ts b/src/main/http/subagents.ts index 8b66c3d4..27d4241d 100644 --- a/src/main/http/subagents.ts +++ b/src/main/http/subagents.ts @@ -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; } - ); + }); } diff --git a/src/main/index.ts b/src/main/index.ts index 40db5d09..3a2df714 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { - // 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 { 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 { 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 { 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. diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 85f36da7..3d5aa7c0 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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(); -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(); +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> { + 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 + ) + ); +} diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 6f3ad900..1aaf2eed 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -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 */ diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index d9427ce8..22381588 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -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 { - // 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 { - // 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 { 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 { + 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 { + 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; } /** diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e80a3342..b7268270 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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; + /** + * 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 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 ..., 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 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 ..., 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 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 ..., 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, @@ -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 { + // 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((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): 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 | 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; + + 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 { + 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((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); } diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts new file mode 100644 index 00000000..6f0dc403 --- /dev/null +++ b/src/main/utils/teamNotificationBuilder.ts @@ -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 = { + 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, + }, + }; +} diff --git a/src/main/utils/textFormatting.ts b/src/main/utils/textFormatting.ts new file mode 100644 index 00000000..c22f6d34 --- /dev/null +++ b/src/main/utils/textFormatting.ts @@ -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(); +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 72722ee0..690be05b 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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 // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 9f361333..c67e5c02 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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( + 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 ===== diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index a66f4a40..9469af07 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 => { + ); diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 033bebe5..cd3552b8 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -249,11 +249,16 @@ export class HttpAPIClient implements ElectronAPI { getSessionDetail = ( projectId: string, sessionId: string, - _options?: { bypassCache?: boolean } - ): Promise => - this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}` + options?: { bypassCache?: boolean } + ): Promise => { + const params = new URLSearchParams(); + if (options?.bypassCache) params.set('bypassCache', 'true'); + const qs = params.toString(); + const suffix = qs ? `?${qs}` : ''; + return this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}${suffix}` ); + }; getSessionMetrics = (projectId: string, sessionId: string): Promise => this.get( @@ -269,11 +274,16 @@ export class HttpAPIClient implements ElectronAPI { projectId: string, sessionId: string, subagentId: string, - _options?: { bypassCache?: boolean } - ): Promise => - this.get( - `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}` + options?: { bypassCache?: boolean } + ): Promise => { + const params = new URLSearchParams(); + if (options?.bypassCache) params.set('bypassCache', 'true'); + const qs = params.toString(); + const suffix = qs ? `?${qs}` : ''; + return this.get( + `/api/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(subagentId)}${suffix}` ); + }; getSessionGroups = (projectId: string, sessionId: string): Promise => this.get( @@ -884,6 +894,12 @@ export class HttpAPIClient implements ElectronAPI { ): (() => void) => { return () => {}; }, + respondToToolApproval: async (): Promise => { + throw new Error('Tool approval not available in browser mode'); + }, + onToolApprovalEvent: (): (() => void) => { + return () => {}; + }, }; // Review API stubs diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index 033d4c7f..2b7c4a51 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -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 = ({ 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 = ({ {name} diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index 7f653642..39b7482c 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -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 = ({ // 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 = ({ = ({ = ({ = ({ highlightStyle, }) => { const colors = getTeamColorSet(teammateMessage.color); + const { isLight } = useTheme(); // Detect operational noise const noiseLabel = useMemo( @@ -162,7 +164,7 @@ export const TeammateMessageItem: React.FC = ({ ( - - {children} - - ), + a: ({ href, children }) => { + if (href && isRelativeUrl(href)) { + return {children}; + } + return ( + + {children} + + ); + }, // Strong/Bold — inline element, no hl() strong: ({ children }) => ( diff --git a/src/renderer/components/chat/viewers/FileLink.tsx b/src/renderer/components/chat/viewers/FileLink.tsx new file mode 100644 index 00000000..5a2c29c7 --- /dev/null +++ b/src/renderer/components/chat/viewers/FileLink.tsx @@ -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 ( + + {children} + + ); + } + + 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 ( + + + {children} + {copied && } + + ); +}); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 460640b0..02c18af0 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 ( ); } + // Relative file paths — open in built-in editor or copy path + if (href && isRelativeUrl(href)) { + return {children}; + } return ( = ({ }) => { 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 = ({ // 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 diff --git a/src/renderer/components/common/OngoingIndicator.tsx b/src/renderer/components/common/OngoingIndicator.tsx index 81245738..8a0473ab 100644 --- a/src/renderer/components/common/OngoingIndicator.tsx +++ b/src/renderer/components/common/OngoingIndicator.tsx @@ -34,7 +34,7 @@ export const OngoingIndicator = ({ {showLabel && ( - + {label} )} @@ -51,15 +51,12 @@ export const OngoingBanner = (): React.JSX.Element => {
- - + + Session is in progress...
diff --git a/src/renderer/components/common/UpdateBanner.tsx b/src/renderer/components/common/UpdateBanner.tsx index c377aae1..3a0c0270 100644 --- a/src/renderer/components/common/UpdateBanner.tsx +++ b/src/renderer/components/common/UpdateBanner.tsx @@ -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)' }} > - + Updating app {clampedPercent}% @@ -48,7 +48,7 @@ export const UpdateBanner = (): React.JSX.Element | null => { style={{ backgroundColor: 'var(--color-border)' }} >
diff --git a/src/renderer/components/dashboard/CliStatusBanner.tsx b/src/renderer/components/dashboard/CliStatusBanner.tsx index 98d6052f..46652a8c 100644 --- a/src/renderer/components/dashboard/CliStatusBanner.tsx +++ b/src/renderer/components/dashboard/CliStatusBanner.tsx @@ -34,10 +34,13 @@ const VARIANT_STYLES: Record = { 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 (
{ if (!cliStatusLoading) { return (
@@ -232,7 +235,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { // Loading state: show spinner only while an actual request is in-flight. return (
{ if (installerState === 'downloading') { return (
- + Downloading Claude CLI... @@ -292,11 +295,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { installerState === 'checking' ? 'Checking latest version...' : 'Verifying checksum...'; return (
- + {label} @@ -310,11 +313,11 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState === 'installing') { return (
- + Installing Claude CLI... @@ -328,7 +331,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState === 'completed') { return (
@@ -343,7 +346,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { if (installerState === 'error') { return (
@@ -446,7 +449,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { // Installed — show version, path, update info return (
diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index e02380cb..afcc0b00 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -287,7 +287,7 @@ const RepositoryCard = ({ <> · {taskCounts.inProgress > 0 && ( - + {taskCounts.inProgress} active )} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 499849f4..2be1cc5d 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -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 diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 5b59053c..6f8a167d 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -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 => { + {/* GitHub link */} + + {/* Settings gear icon */} + ))} +
{ + 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(loadGroupingMode); + const [sortMode, setSortModeState] = useState(loadSortMode); + const [sortPopoverOpen, setSortPopoverOpen] = useState(false); const [showArchived, setShowArchived] = useState(false); const [renamingTaskKey, setRenamingTaskKey] = useState(null); const searchInputRef = useRef(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 = ({ )} + + + + + +
+ {SORT_OPTIONS.map((opt) => ( + + ))} +
+
+
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 (
-
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 ? ( + + ) : ( + + )} - + {group.projectLabel} -
- {group.tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - 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)} - > - + {group.tasks.length} + + + {!isGroupCollapsed && + group.tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + - 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) } - /> - -
- ); - })} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> +
+
+ ); + })}
); })} @@ -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 (
-
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} -
+ {isGroupCollapsed ? ( + + ) : ( + + )} + {dateCategoryLabels[category] ?? category} + + {tasks.length} + + - {tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; + {!isGroupCollapsed && + tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - 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)} - > - + {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + - 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) } - /> - -
- ); - })} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + +
+ ); + })}
); })} diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 2fc40d9b..e1aad9c8 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -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 && ·} + {projectLabel && ·} {task.owner ?? 'unassigned'} )} {dateLabel && ( - + {dateLabel} )} @@ -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)' }} > - Team: + Team: {task.teamDisplayName} - · + · {task.owner ?? 'unassigned'} diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx index 5fe94de2..f0f48152 100644 --- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx +++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx @@ -81,6 +81,11 @@ export const TaskFiltersPopover = ({ toggleStatus(opt.id)} + style={{ '--color-accent': opt.color } as React.CSSProperties} + /> + {opt.label} diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts index 3ef596fd..34a1a466 100644 --- a/src/renderer/components/sidebar/taskFiltersState.ts +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -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 { diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx index aa1c6ae8..1616706b 100644 --- a/src/renderer/components/team/ClaudeLogsSection.tsx +++ b/src/renderer/components/team/ClaudeLogsSection.tsx @@ -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({ lines: [], total: 0, hasMore: false }); const [pending, setPending] = useState(null); const [pendingNewCount, setPendingNewCount] = useState(0); const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); const inFlightRef = useRef(false); + const loadingMoreRef = useRef(false); + const applyingPendingRef = useRef(false); const atTopRef = useRef(true); const latestRef = useRef(null); const logContainerRef = useRef(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 => { + 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 ? ( @@ -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 => { + 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 ( {data.total > 0 ? ( <> - Showing {Math.min(data.total, visibleCount)} of{' '} - {data.total} + Showing {Math.min(data.total, data.lines.length)}{' '} + of {data.total} ) : isAlive ? ( 'No logs yet.' @@ -347,32 +452,36 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J )}
-
- - 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 && ( - - )} -
- + {data.total > 0 ? ( + <> +
+ + 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 && ( + + )} +
+ + + ) : null} {pendingNewCount > 0 && ( - )}
@@ -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 ? ( +
+ +
+ ) : null + } /> ) : null} - {!error && data.lines.length === 0 ? ( + {!error && data.lines.length === 0 && isAlive ? (

- {loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'} + {loading ? 'Loading…' : 'No logs captured.'}

) : null} {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? ( diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx index 986a763b..3abd0139 100644 --- a/src/renderer/components/team/CliLogsRichView.tsx +++ b/src/renderer/components/team/CliLogsRichView.tsx @@ -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; + onGroupToggle: (groupId: string) => void; + expandedItemIds: Set; + onItemClick: (itemId: string) => void; + searchQueryOverride?: string; +}): React.JSX.Element => { + const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`; + + return ( +
+ + {isExpanded && ( +
+ {section.groups.map((group) => + group.items.length === 1 ? ( + + ) : ( + onGroupToggle(group.id)} + expandedItemIds={expandedItemIds} + onItemClick={onItemClick} + searchQueryOverride={searchQueryOverride} + /> + ) + )} +
+ )} +
+ ); +}; + 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(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>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); + // Subagent sections are collapsed by default; track which are expanded + const [expandedSubagentIds, setExpandedSubagentIds] = useState>(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(); - 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...

)} + {footer}
); } - const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups; + const visibleEntries = order === 'newest-first' ? [...entries].reverse() : entries; return (
- {visibleGroups.map((group) => - group.items.length === 1 ? ( - // Single item — render flat without collapsible group wrapper + {visibleEntries.map((entry) => + entry.type === 'subagent-section' ? ( + handleSubagentToggle(entry.section.id)} + collapsedGroupIds={collapsedGroupIds} + onGroupToggle={handleGroupToggle} + expandedItemIds={expandedItemIds} + onItemClick={handleItemClick} + searchQueryOverride={searchQueryOverride} + /> + ) : entry.group.items.length === 1 ? ( ) : ( 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}
); }; diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 5b513f4d..fb78dd64 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -106,7 +106,7 @@ export const CollapsibleTeamSection = ({ {secondaryBadge != null && secondaryBadge > 0 && ( {secondaryBadge} new diff --git a/src/renderer/components/team/MemberBadge.tsx b/src/renderer/components/team/MemberBadge.tsx index bca37d17..ff431671 100644 --- a/src/renderer/components/team/MemberBadge.tsx +++ b/src/renderer/components/team/MemberBadge.tsx @@ -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 = ( diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index f80056e7..575ed393 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -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(null); const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(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 ? (
) : null}
+
+ + + + + + {messagesCollapsed ? 'Expand all messages' : 'Collapse all messages'} + +
} > @@ -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); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 9633d7af..cc01abbd 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -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(null); @@ -553,17 +555,6 @@ export const TeamListView = (): React.JSX.Element => { > Create Team -
{!canCreate ? ( @@ -690,7 +681,7 @@ export const TeamListView = (): React.JSX.Element => { {teamColorSet ? (
) : null}
{
{team.members && team.members.length > 0 ? ( - renderMemberChips(team.members) + renderMemberChips(team.members, isLight) ) : team.memberCount === 0 ? ( Solo @@ -906,7 +897,7 @@ export const TeamListView = (): React.JSX.Element => {

{team.members && team.members.length > 0 && (
- {renderMemberChips(team.members)} + {renderMemberChips(team.members, isLight)}
)}
diff --git a/src/renderer/components/team/TeamSessionsSection.tsx b/src/renderer/components/team/TeamSessionsSection.tsx index 0a500389..13929447 100644 --- a/src/renderer/components/team/TeamSessionsSection.tsx +++ b/src/renderer/components/team/TeamSessionsSection.tsx @@ -130,7 +130,7 @@ export const TeamSessionsSection = ({ {selectedSessionId !== null && (
diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx new file mode 100644 index 00000000..3cbfa141 --- /dev/null +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -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 ; + case 'Read': + case 'Edit': + case 'Write': + case 'NotebookEdit': + return ; + case 'Grep': + case 'Glob': + return ; + default: + return ; + } +} + +// --------------------------------------------------------------------------- +// Smart input preview +// --------------------------------------------------------------------------- + +function renderToolInput(toolName: string, input: Record): 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(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 ( +
+ {/* Header */} +
+
+ {getToolIcon(current.toolName)} + + {current.toolName} + +
+
+ {teamColor ? ( + + {teamSummary?.displayName ?? current.teamName} + + ) : ( + {current.teamName} + )} + +
+
+ + {/* Tool input preview */} +
+
+          {renderToolInput(current.toolName, current.toolInput)}
+        
+
+ + {/* Actions */} +
+
+ + +
+ {pendingApprovals.length > 1 && ( + + {pendingApprovals.length - 1} pending + + )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Elapsed display sub-component (uses hook) +// --------------------------------------------------------------------------- + +const ElapsedDisplay = ({ receivedAt }: { receivedAt: string }): React.JSX.Element => { + const elapsed = useElapsed(receivedAt); + return ( + {elapsed}s + ); +}; diff --git a/src/renderer/components/team/UnreadCommentsBadge.tsx b/src/renderer/components/team/UnreadCommentsBadge.tsx index 14ead314..6e39115b 100644 --- a/src/renderer/components/team/UnreadCommentsBadge.tsx +++ b/src/renderer/components/team/UnreadCommentsBadge.tsx @@ -15,7 +15,7 @@ export const UnreadCommentsBadge = ({ 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'} diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index a93993ad..76fd2f7d 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -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 = ({ 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 `#` 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 { +export function linkifyMentionsInMarkdown( + text: string, + memberColorMap: Map +): 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` 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 {part}; const taskId = match[1]; @@ -186,7 +221,7 @@ function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.
) : parsedReply ? ( - + ) : displayText ? ( 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; + /** 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(null); const reportedRef = useRef(false); @@ -95,7 +108,7 @@ const MessageRowWithObserver = ({ }, [onVisible]); return ( -
+ -
+ ); }; @@ -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>(new Set()); - const isInitializedRef = useRef(false); - const prevVisibleCountRef = useRef(visibleCount); - const colorMap = members ? buildMemberColorMap(members) : new Map(); const memberInfo = new Map(); 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(); - } - - // 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(); - } - - // Normal update: unknown keys are new items - const newKeys = new Set(); - 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 ( -
-

No messages

-

Send a message to a member to see activity.

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

No messages

+

Send a message to a member to see activity.

+
+ ); + } + return (
{/* 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 ( ); })()} @@ -328,9 +339,11 @@ export const ActivityTimeline = ({ className="flex items-center gap-3" style={{ paddingTop: 90, paddingBottom: 90 }} > -
- New session -
+
+ + New session + +
); } @@ -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 ( {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} /> ); @@ -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} /> ); @@ -411,7 +433,7 @@ export const ActivityTimeline = ({ +{hiddenCount} older - + + + Reply + + ) : null} + +
+
+ {thought.toolSummary && ( + + +
+ 🔧 {thought.toolSummary} +
+
+ + + +
+ )} +
+
+ ); +}; + 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(null); const scrollRef = useRef(null); + const contentRef = useRef(null); const isUserScrolledUpRef = useRef(false); + const distanceFromBottomRef = useRef(0); + const scrollSyncFrameRef = useRef(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 ( -
+
{/* Header */} -
- {/* Live / offline indicator */} - {isLive ? ( - - - - - ) : ( - - )} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button + tabIndex + onKeyDown below; nested tooltips prevent native button */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleBodyToggle?.(); + } + } + : undefined + } + > + {/* Chevron for collapse mode */} + {canToggleBodyVisibility ? ( + + ) : null} + {/* Lead avatar with optional live indicator */} +
+ + {isLive ? ( + + + + + ) : null} +
{thoughts.length} thoughts @@ -323,80 +737,74 @@ export const LeadThoughtsGroupRow = ({ )}
- {/* Scrollable body — fixed height, always visible */} -
- {chronologicalThoughts.map((thought, idx) => ( -
- {idx > 0 && ( -
-
- - {formatTimeWithSec(thought.timestamp)} - -
-
- )} -
-
- -
-
- {thought.toolSummary && ( - - -
- 🔧 {thought.toolSummary} -
-
- - - -
- )} + {/* Scrollable body — live thoughts follow bottom unless user scrolls up */} + {isBodyVisible ? ( +
+
+ {chronologicalThoughts.map((thought, idx) => ( + 0} + shouldAnimate={isLive && idx === chronologicalThoughts.length - 1} + onTaskIdClick={onTaskIdClick} + memberColorMap={memberColorMap} + onReply={onReply} + /> + ))}
- ))} -
+
+ ) : null}
-
+ {isBodyVisible && !expanded && needsTruncation ? ( +
+ +
+ ) : null} + {isBodyVisible && expanded && needsTruncation ? ( +
+ +
+ ) : null} + ); }; diff --git a/src/renderer/components/team/activity/PendingRepliesBlock.tsx b/src/renderer/components/team/activity/PendingRepliesBlock.tsx index 6a535c42..04dea364 100644 --- a/src/renderer/components/team/activity/PendingRepliesBlock.tsx +++ b/src/renderer/components/team/activity/PendingRepliesBlock.tsx @@ -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 = ({ {/* Quote block — styled like SendMessageDialog */} -
+
{/* Decorative quotation mark */} - + {/* "Replying to" + MemberBadge */}
- Replying to + Replying to
@@ -50,7 +50,7 @@ export const ReplyQuoteBlock = ({ {isLong ? (
) : null} - +
{attachment.filename} diff --git a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx index 362fe301..fbc3c402 100644 --- a/src/renderer/components/team/attachments/AttachmentPreviewList.tsx +++ b/src/renderer/components/team/attachments/AttachmentPreviewList.tsx @@ -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(null); + const [exitingIds, setExitingIds] = useState>(new Set()); + // Track IDs known on previous render to detect newly added items + const knownIdsRef = useRef>(new Set()); + const [enteringIds, setEnteringIds] = useState>(new Set()); + const exitTimersRef = useRef>(new Map()); + const enterTimersRef = useRef>(new Map()); + + // Detect newly added attachments + useEffect(() => { + const currentIds = new Set(attachments.map((a) => a.id)); + const newIds = new Set(); + 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 (
- {attachments.length > 0 ? ( + {visibleAttachments.length > 0 ? (
- {attachments.map((att) => ( - - ))} + {visibleAttachments.map((att, i) => { + const isExiting = exitingIds.has(att.id); + const isEntering = enteringIds.has(att.id); + return ( +
+ setLightboxIndex(i)} + disabled={disabled} + /> +
+ ); + })}
) : null} - {disabled && disabledHint && attachments.length > 0 ? ( + {disabled && disabledHint && visibleAttachments.length > 0 ? (
) : null} + {lightboxIndex !== null && lightboxSlides[lightboxIndex] ? ( + setLightboxIndex(null)} + slides={lightboxSlides} + index={lightboxIndex} + /> + ) : null}
); }; diff --git a/src/renderer/components/team/attachments/ImageLightbox.tsx b/src/renderer/components/team/attachments/ImageLightbox.tsx index f5733612..4d7b6c1e 100644 --- a/src/renderer/components/team/attachments/ImageLightbox.tsx +++ b/src/renderer/components/team/attachments/ImageLightbox.tsx @@ -80,6 +80,7 @@ export const ImageLightbox = ({ }} styles={{ container: { backgroundColor: 'rgba(0, 0, 0, 0.85)', backdropFilter: 'blur(8px)' }, + button: { padding: 16 }, }} /> ); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 0b7391d5..31c16f4a 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -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" /> + + {launchTeam && ( + + )}
- - {canCreate && (prepareState === 'idle' || prepareState === 'loading') ? ( -
- - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -
- ) : null} - - {canCreate && prepareState === 'ready' ? ( -
-
- - - {prepareWarnings.length > 0 - ? 'CLI environment ready (with notes)' - : 'CLI environment ready'} - -
- {prepareMessage ? ( -

{prepareMessage}

- ) : null} - {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

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

) : null} - - {canOpenExistingTeam ? ( - + ) : null} + - ) : null} - - + +
diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 8e61aea7..e6fb7277 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -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} diff --git a/src/renderer/components/team/dialogs/EffortLevelSelector.tsx b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx new file mode 100644 index 00000000..6ffe6773 --- /dev/null +++ b/src/renderer/components/team/dialogs/EffortLevelSelector.tsx @@ -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 = ({ + value, + onValueChange, + id, +}) => ( +
+ +
+ +
+ {EFFORT_OPTIONS.map((opt) => ( + + ))} +
+
+

+ Controls how much reasoning Claude invests before responding. Default uses Claude's + standard behavior. +

+
+); diff --git a/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx index 7a93ac04..839fad49 100644 --- a/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx +++ b/src/renderer/components/team/dialogs/ExtendedContextCheckbox.tsx @@ -18,7 +18,7 @@ export const ExtendedContextCheckbox: React.FC = ( disabled = false, }) => ( <> -
+
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" /> + +
@@ -475,62 +506,70 @@ export const LaunchTeamDialog = ({

) : null} - {prepareState === 'idle' || prepareState === 'loading' ? ( -
- - - {prepareMessage ?? - (prepareState === 'idle' - ? 'Warming up CLI environment...' - : 'Preparing environment...')} - -
- ) : null} - - {prepareState === 'ready' ? ( -
-
- - - {prepareWarnings.length > 0 - ? 'CLI environment ready (with notes)' - : 'CLI environment ready'} - -
- {prepareMessage ? ( -

{prepareMessage}

- ) : null} - {prepareWarnings.length > 0 ? ( -
- {prepareWarnings.map((warning) => ( -

- {warning} -

- ))} + +
+ {prepareState === 'idle' || prepareState === 'loading' ? ( +
+ + + {prepareMessage ?? + (prepareState === 'idle' + ? 'Warming up CLI environment...' + : 'Preparing environment...')} +
) : null} -
- ) : null} - - - + {prepareState === 'ready' ? ( +
+
+ + + {prepareWarnings.length > 0 + ? 'CLI environment ready (with notes)' + : 'CLI environment ready'} + +
+ {prepareMessage ? ( +

+ {prepareMessage} +

+ ) : null} + {prepareWarnings.length > 0 ? ( +
+ {prepareWarnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} +
+ ) : null} + + {prepareState === 'failed' ?
: null} +
+ +
+ + +
diff --git a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx index 62759c8d..60a49eb9 100644 --- a/src/renderer/components/team/dialogs/ProjectPathSelector.tsx +++ b/src/renderer/components/team/dialogs/ProjectPathSelector.tsx @@ -6,7 +6,7 @@ import { Combobox } from '@renderer/components/ui/combobox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { cn } from '@renderer/lib/utils'; -import { Check } from 'lucide-react'; +import { Check, FolderOpen } from 'lucide-react'; import type { Project } from '@shared/types'; @@ -102,34 +102,40 @@ export const ProjectPathSelector = ({ {cwdMode === 'project' ? (
- ({ - value: project.path, - label: project.name, - description: project.path, - }))} - value={selectedProjectPath} - onValueChange={onSelectedProjectPathChange} - placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} - searchPlaceholder="Search project by name or path" - emptyMessage="Nothing found" - disabled={projectsLoading || projects.length === 0} - renderOption={(option, isSelected, query) => ( - <> - -
-

- {renderHighlightedText(option.label, query)} -

-

- {renderHighlightedText(option.description ?? '', query)} -

-
- - )} - /> +
+ + ({ + value: project.path, + label: project.name, + description: project.path, + }))} + value={selectedProjectPath} + onValueChange={onSelectedProjectPathChange} + placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} + searchPlaceholder="Search project by name or path" + emptyMessage="Nothing found" + disabled={projectsLoading || projects.length === 0} + renderOption={(option, isSelected, query) => ( + <> + +
+

+ {renderHighlightedText(option.label, query)} +

+

+ {renderHighlightedText(option.description ?? '', query)} +

+
+ + )} + /> +
{!selectedProjectPath ? (

Select a project from the list @@ -137,12 +143,15 @@ export const ProjectPathSelector = ({ ) : null} {projectsError ?

{projectsError}

: null} {!projectsLoading && projects.length === 0 ? ( -

No projects found, switch to custom path.

+

+ No projects found, switch to custom path. +

) : null}
) : (
-
+
+ (null); @@ -115,7 +110,6 @@ export const SendMessageDialog = ({ useEffect(() => { if (open && !prevOpenRef.current) { setMember(defaultRecipient ?? ''); - setSummary(''); setQuote(quotedMessage); setQuoteExpanded(false); prevResultRef.current = lastResult; @@ -145,7 +139,6 @@ export const SendMessageDialog = ({ if (lastResult && lastResult !== prevResultRef.current) { prevResultRef.current = lastResult; setMember(''); - setSummary(''); setPendingAutoClose(true); } }, [open, lastResult]); @@ -181,13 +174,12 @@ export const SendMessageDialog = ({ const trimmedText = textDraft.value.trim(); const serialized = serializeChipsWithText(trimmedText, chipDraft.chips); const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized; - const remaining = MAX_MESSAGE_LENGTH - finalText.length; + const remaining = MAX_TEXT_LENGTH - finalText.length; const canSend = member.trim().length > 0 && finalText.length > 0 && - finalText.length <= MAX_MESSAGE_LENGTH && - summary.trim().length > 0 && + finalText.length <= MAX_TEXT_LENGTH && !sending && !attachmentsBlocked; @@ -201,12 +193,7 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; - onSend( - member.trim(), - finalText, - summary.trim(), - attachments.length > 0 ? attachments : undefined - ); + onSend(member.trim(), finalText, trimmedText, attachments.length > 0 ? attachments : undefined); textDraft.clearDraft(); chipDraft.clearChipDraft(); clearAttachments(); @@ -267,7 +254,7 @@ export const SendMessageDialog = ({ return ( {quote ? ( -
+
{/* Decorative quotation mark */} - + @@ -353,7 +340,7 @@ export const SendMessageDialog = ({
- -
- - setSummary(e.target.value)} - /> -

- Shown as notification preview. Team lead also sees this for peer messages. -

-
- - - - ); diff --git a/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx new file mode 100644 index 00000000..d71e2974 --- /dev/null +++ b/src/renderer/components/team/dialogs/SkipPermissionsCheckbox.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { Checkbox } from '@renderer/components/ui/checkbox'; +import { Label } from '@renderer/components/ui/label'; +import { AlertTriangle, Info } from 'lucide-react'; + +interface SkipPermissionsCheckboxProps { + id: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +export const SkipPermissionsCheckbox: React.FC = ({ + id, + checked, + onCheckedChange, +}) => ( + <> +
+ onCheckedChange(value === true)} + /> + +
+ {checked ? ( +
+
+ +

+ Unleash Claude's full power — no interruptions asking for permission. Autonomous + mode — all tools execute without confirmation. Be cautious with untrusted code. +

+
+
+ ) : ( +
+
+ +

Manual mode — you'll approve or deny each tool call in real-time.

+
+
+ )} + +); diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 2c7cca1f..9fa24aff 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -8,12 +8,12 @@ import { useStore } from '@renderer/store'; import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { MAX_TEXT_LENGTH } from '@shared/constants'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types'; -const MAX_COMMENT_LENGTH = 2000; const MAX_ATTACHMENTS = 5; const MAX_FILE_SIZE = 20 * 1024 * 1024; const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); @@ -64,10 +64,10 @@ export const TaskCommentInput = ({ ); const trimmed = draft.value.trim(); - const remaining = MAX_COMMENT_LENGTH - trimmed.length; + const remaining = MAX_TEXT_LENGTH - trimmed.length; const canSubmit = (trimmed.length > 0 || pendingAttachments.length > 0) && - trimmed.length <= MAX_COMMENT_LENGTH && + trimmed.length <= MAX_TEXT_LENGTH && !addingComment; const addFiles = useCallback((files: FileList | File[]) => { @@ -253,7 +253,7 @@ export const TaskCommentInput = ({ onModEnter={() => void handleSubmit()} minRows={2} maxRows={8} - maxLength={MAX_COMMENT_LENGTH} + maxLength={MAX_TEXT_LENGTH} disabled={addingComment} cornerAction={
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 1415949b..4302ae9b 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -1,7 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { CopyButton } from '@renderer/components/common/CopyButton'; +import { AnimatedHeightReveal } from '@renderer/components/team/activity/AnimatedHeightReveal'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; +import { useNewItemKeys } from '@renderer/components/team/activity/useNewItemKeys'; import { ImageLightbox } from '@renderer/components/team/attachments/ImageLightbox'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { ExpandableContent } from '@renderer/components/ui/ExpandableContent'; @@ -14,6 +17,7 @@ import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessage import { isImageMimeType } from '@renderer/utils/attachmentUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { MAX_TEXT_LENGTH } from '@shared/constants'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { formatDistanceToNow } from 'date-fns'; import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react'; @@ -30,7 +34,6 @@ function normalizeLiteralNewlines(text: string): string { return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t'); } -const MAX_COMMENT_LENGTH = 2000; const INITIAL_VISIBLE_COMMENTS = 30; const VISIBLE_COMMENTS_STEP = 50; const MAX_COMMENTS_TO_RENDER = 2000; @@ -54,7 +57,7 @@ interface TaskCommentsSectionProps { /** Convert `#` in plain text to markdown links with task:// protocol. */ function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(/#(\d+)/g, '[#$1](task://$1)'); + return text.replace(/#(\d+)\b/g, '[#$1](task://$1)'); } /** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */ @@ -89,13 +92,17 @@ export const TaskCommentsSection = ({ const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); const [previewImageUrl, setPreviewImageUrl] = useState(null); - // Reset local UI state when team/task changes. - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + // Reset local UI state when team/task changes using the + // "adjust state during render" pattern (no effect needed). + // See: https://react.dev/reference/react/useState#storing-information-from-previous-renders + const resetKey = `${teamName}:${taskId}`; + const [prevResetKey, setPrevResetKey] = useState(resetKey); + if (resetKey !== prevResetKey) { + setPrevResetKey(resetKey); setVisibleCount(INITIAL_VISIBLE_COMMENTS); setReplyTo(null); setPreviewImageUrl(null); - }, [teamName, taskId]); + } const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -118,6 +125,16 @@ export const TaskCommentsSection = ({ [sortedComments, visibleCount] ); + const visibleCommentIds = useMemo( + () => visibleComments.map((comment) => comment.id), + [visibleComments] + ); + const newCommentIds = useNewItemKeys({ + itemKeys: visibleCommentIds, + paginationKey: visibleCount, + resetKey: `${teamName}:${taskId}`, + }); + const mentionSuggestions = useMemo( () => members.map((m) => ({ @@ -130,8 +147,8 @@ export const TaskCommentsSection = ({ ); const trimmed = draft.value.trim(); - const remaining = MAX_COMMENT_LENGTH - trimmed.length; - const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment; + const remaining = MAX_TEXT_LENGTH - trimmed.length; + const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_TEXT_LENGTH && !addingComment; const handleSubmit = useCallback(async () => { if (!canSubmit) return; @@ -170,126 +187,134 @@ export const TaskCommentsSection = ({
{visibleComments.map((comment, index) => ( -
-
- - {comment.type === 'review_approved' ? ( - - - Approved + +
+
+ + {comment.type === 'review_approved' ? ( + + + Approved + + ) : comment.type === 'review_request' ? ( + + + Review requested + + ) : null} + + {(() => { + const date = new Date(comment.createdAt); + return isNaN(date.getTime()) + ? 'unknown time' + : formatDistanceToNow(date, { addSuffix: true }); + })()} - ) : comment.type === 'review_request' ? ( - - - Review requested - - ) : null} - - {(() => { - const date = new Date(comment.createdAt); - return isNaN(date.getTime()) - ? 'unknown time' - : formatDistanceToNow(date, { addSuffix: true }); - })()} - - - - - - Reply to comment - -
- {(() => { - const reply = parseMessageReply(comment.text); - const rawForDisplay = reply ? reply.replyText : comment.text; - const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); - return ( - - {reply ? ( - + + + + Reply to comment + + + + +
+ {(() => { + const reply = parseMessageReply(comment.text); + const rawForDisplay = reply ? reply.replyText : comment.text; + const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); + return ( + + {reply ? ( + -
- )} - - ); - })()} - {comment.attachments && comment.attachments.length > 0 ? ( - - ) : null} -
+ ) : ( + { + const link = ( + e.target as HTMLElement + ).closest('a[href^="task://"]'); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const id = link.getAttribute('href')?.replace('task://', ''); + if (id) onTaskIdClick(id); + } + } + : undefined + } + > + { + let t = linkifyTaskIdsInMarkdown(displayText); + if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); + return t; + })()} + maxHeight="max-h-none" + bare + /> + + )} + + ); + })()} + {comment.attachments && comment.attachments.length > 0 ? ( + + ) : null} +
+ ))}
@@ -357,7 +382,7 @@ export const TaskCommentsSection = ({ onModEnter={() => void handleSubmit()} minRows={2} maxRows={8} - maxLength={MAX_COMMENT_LENGTH} + maxLength={MAX_TEXT_LENGTH} disabled={addingComment} cornerAction={
) : currentTask.description ? ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - startEditDescription(); - } - }} - > +
- + + + + + Edit description +
) : ( - ); }; + +// --------------------------------------------------------------------------- +// Comment images grid — accumulated images from task comments +// --------------------------------------------------------------------------- + +interface CommentImageItem { + attachment: TaskAttachmentMeta; + commentText: string; + commentAuthor: string; +} + +const CommentImagesGrid = ({ + items, + teamName, + taskId, +}: { + items: CommentImageItem[]; + teamName: string; + taskId: string; +}): React.JSX.Element => { + const [previewUrl, setPreviewUrl] = useState(null); + + return ( +
+
+ + From comments +
+
+ {items.map((item) => ( + + ))} +
+ {previewUrl ? ( + setPreviewUrl(null)} + src={previewUrl} + alt="Comment attachment" + /> + ) : null} +
+ ); +}; + +const CommentImageThumbnail = ({ + item, + teamName, + taskId, + onPreview, +}: { + item: CommentImageItem; + teamName: string; + taskId: string; + onPreview: (dataUrl: string) => void; +}): React.JSX.Element => { + const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); + const [thumbUrl, setThumbUrl] = useState(null); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const base64 = await getTaskAttachmentData( + teamName, + taskId, + item.attachment.id, + item.attachment.mimeType + ); + if (!cancelled && base64) { + setThumbUrl(`data:${item.attachment.mimeType};base64,${base64}`); + } + } catch { + // ignore + } + })(); + return () => { + cancelled = true; + }; + }, [teamName, taskId, item.attachment.id, item.attachment.mimeType, getTaskAttachmentData]); + + // Truncate comment text for tooltip + const tooltipText = `${item.commentAuthor}: ${item.commentText.length > 200 ? item.commentText.slice(0, 200) + '...' : item.commentText}`; + + return ( + + +
thumbUrl && onPreview(thumbUrl)} + > + {thumbUrl ? ( + {item.attachment.filename} + ) : ( + + )} +
+ {item.attachment.filename} +
+
+
+ + {tooltipText} + +
+ ); +}; diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx index b823b767..47aa0b5f 100644 --- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx @@ -725,7 +725,7 @@ export const ProjectEditorOverlay = ({ {/* External change banner */} {activeTabId && externalChanges[activeTabId] && ( -
+
{externalChanges[activeTabId] === 'delete' diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index e86968a7..302cb4a3 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -268,9 +268,7 @@ export const KanbanTaskCard = ({
- {task.owner ? ( - - ) : null} + {task.owner ? : null} {!compact && }
{task.needsClarification ? ( @@ -278,7 +276,7 @@ export const KanbanTaskCard = ({ className={`mt-1 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${ task.needsClarification === 'user' ? 'bg-red-500/15 text-red-400' - : 'bg-blue-500/15 text-blue-400' + : 'bg-blue-500/15 text-blue-600 dark:text-blue-400' }`} > @@ -307,7 +305,7 @@ export const KanbanTaskCard = ({ {hasBlocks ? (
- + Blocks diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 9f38bb8a..5ca9608a 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,6 +1,7 @@ import { Badge } from '@renderer/components/ui/badge'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -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 { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; @@ -47,6 +48,7 @@ export const MemberCard = ({ const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); const colors = getTeamColorSet(memberColor); + const { isLight } = useTheme(); const pending = taskCounts?.pending ?? 0; const inProgress = taskCounts?.inProgress ?? 0; const completed = taskCounts?.completed ?? 0; @@ -59,7 +61,7 @@ export const MemberCard = ({ className="group relative cursor-pointer rounded px-2 py-1.5" style={{ borderLeft: `3px solid ${colors.border}`, - backgroundColor: colors.badge, + background: `linear-gradient(to right, ${getThemedBadge(colors, isLight)}, transparent)`, }} title={member.currentTaskId ? `Current task: ${member.currentTaskId}` : undefined} role="button" diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index 14d426f0..c6401b23 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@renderer/store'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { MemberCard } from './MemberCard'; @@ -32,6 +33,8 @@ export const MemberList = ({ onAssignTask, onOpenTask, }: MemberListProps): React.JSX.Element => { + const sidebarCollapsed = useStore((s) => s.sidebarCollapsed); + const gridClass = sidebarCollapsed ? 'grid grid-cols-2 gap-1' : 'grid grid-cols-1 gap-1'; const activeMembers = members .filter((m) => !m.removedAt) .sort((a, b) => { @@ -75,14 +78,16 @@ export const MemberList = ({ }; return ( -
- {activeMembers.map((member) => renderCard(member, false))} +
+
{activeMembers.map((member) => renderCard(member, false))}
{removedMembers.length > 0 && ( <>
Removed ({removedMembers.length})
- {removedMembers.map((member) => renderCard(member, true))} +
+ {removedMembers.map((member) => renderCard(member, true))} +
)}
diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 3701a851..ebe6d36b 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -46,6 +46,8 @@ interface MemberLogsTabProps { onPreviewOnlineChange?: (isOnline: boolean) => void; } +const PREVIEW_PAGE_SIZE = 4; + export const MemberLogsTab = ({ teamName, memberName, @@ -74,10 +76,10 @@ export const MemberLogsTab = ({ const refreshHideTimeoutRef = useRef | null>(null); const [error, setError] = useState(null); const [expandedId, setExpandedId] = useState(null); - const expandedIdRef = useRef(null); const [detailChunks, setDetailChunks] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [previewChunks, setPreviewChunks] = useState(null); + const [previewVisibleCount, setPreviewVisibleCount] = useState(PREVIEW_PAGE_SIZE); useEffect(() => { return () => { @@ -89,10 +91,6 @@ export const MemberLogsTab = ({ }; }, []); - useEffect(() => { - expandedIdRef.current = expandedId; - }, [expandedId]); - const beginRefreshing = useCallback((): void => { if (refreshCountRef.current === 0) { refreshBeganAtRef.current = Date.now(); @@ -189,11 +187,17 @@ export const MemberLogsTab = ({ return null; }, [shouldShowPreview, showLeadPreview, showSubagentPreview, sortedLogs, taskOwner]); - const previewMessages = useMemo((): SubagentPreviewMessage[] => { + const allPreviewMessages = useMemo((): SubagentPreviewMessage[] => { if (!previewChunks || previewChunks.length === 0) return []; - return extractSubagentPreviewMessages(previewChunks, 4); + return extractSubagentPreviewMessages(previewChunks); }, [previewChunks]); + const previewMessages = useMemo((): SubagentPreviewMessage[] => { + return allPreviewMessages.slice(0, previewVisibleCount); + }, [allPreviewMessages, previewVisibleCount]); + + const previewHasMore = allPreviewMessages.length > previewVisibleCount; + const previewOnline = useMemo((): boolean => { const newest = previewMessages[0]; if (!newest) return false; @@ -214,6 +218,20 @@ export const MemberLogsTab = ({ onPreviewOnlineChange?.(previewOnline); }, [onPreviewOnlineChange, previewOnline]); + useEffect(() => { + setPreviewVisibleCount(PREVIEW_PAGE_SIZE); + }, [previewLog?.kind, previewLog?.sessionId]); + + useEffect(() => { + if (allPreviewMessages.length === 0) { + setPreviewVisibleCount(PREVIEW_PAGE_SIZE); + return; + } + setPreviewVisibleCount((prev) => + Math.max(PREVIEW_PAGE_SIZE, Math.min(prev, allPreviewMessages.length)) + ); + }, [allPreviewMessages.length]); + useEffect(() => { return () => onPreviewOnlineChange?.(false); }, [onPreviewOnlineChange]); @@ -259,16 +277,6 @@ export const MemberLogsTab = ({ setLogs(nextLogs); hasLoadedRef.current = true; } - - // Keep expanded session details in sync with the same refresh - // cadence as the summary (counts/titles) while "Updating..." is shown. - if (!cancelled && didBeginRefreshing) { - try { - await refreshExpandedDetailFromLogs(nextLogs); - } catch { - // Keep last successful detail view; avoid flicker on transient failures. - } - } } catch (e) { if (!cancelled) { setError(e instanceof Error ? e.message : 'Unknown error'); @@ -312,26 +320,6 @@ export const MemberLogsTab = ({ [] ); - const refreshExpandedDetailFromLogs = useCallback( - async (nextLogs: MemberLogSummary[]): Promise => { - const rowId = expandedIdRef.current; - if (!rowId) return; - if (!isMountedRef.current) return; - - const nextExpanded = nextLogs.find((log) => getRowId(log) === rowId); - if (!nextExpanded) return; - - const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; - if (!shouldAutoRefreshSummary && !nextExpanded.isOngoing) return; - - const next = await fetchDetailForLog(nextExpanded, { bypassCache: true }); - if (!isMountedRef.current) return; - // Ensure new reference so memoized transforms update. - setDetailChunks(next ? [...next] : null); - }, - [fetchDetailForLog, getRowId, taskId, taskStatus] - ); - useEffect(() => { if (!shouldShowPreview) { setPreviewChunks(null); @@ -396,10 +384,7 @@ export const MemberLogsTab = ({ useEffect(() => { const shouldAutoRefreshSummary = taskId != null && taskStatus === 'in_progress'; if (!expandedLogSummary) return; - // When task logs are auto-refreshing, the summary refresh loop also refreshes - // expanded details to keep everything in sync (and avoid duplicate requests). - if (shouldAutoRefreshSummary) return; - if (!expandedLogSummary.isOngoing) return; + if (!shouldAutoRefreshSummary && !expandedLogSummary.isOngoing) return; let cancelled = false; @@ -417,6 +402,7 @@ export const MemberLogsTab = ({ } }; + void refreshDetail(); const interval = setInterval(() => void refreshDetail(), 5000); return () => { @@ -493,6 +479,8 @@ export const MemberLogsTab = ({ setPreviewVisibleCount((prev) => prev + PREVIEW_PAGE_SIZE)} /> ) : null} {sortedLogs.map((log) => ( @@ -605,21 +593,18 @@ function formatRelativeTime(isoString: string): string { return date.toLocaleDateString(); } -function extractSubagentPreviewMessages( - chunks: EnhancedChunk[], - limit: number -): SubagentPreviewMessage[] { +function extractSubagentPreviewMessages(chunks: EnhancedChunk[]): SubagentPreviewMessage[] { const conversation = transformChunksToConversation(chunks, [], false); const out: SubagentPreviewMessage[] = []; - // Collect newest-first and stop as soon as we have enough. - for (let i = conversation.items.length - 1; i >= 0 && out.length < limit; i--) { + // Collect newest-first. + for (let i = conversation.items.length - 1; i >= 0; i--) { const item = conversation.items[i]; if (item.type === 'ai') { const enhanced = enhanceAIGroup(item.group); const items = enhanced.displayItems ?? []; - for (let j = items.length - 1; j >= 0 && out.length < limit; j--) { + for (let j = items.length - 1; j >= 0; j--) { const di = items[j]; if (di.type === 'output' && di.content.trim()) { out.push({ diff --git a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx index fba5b10a..504b5421 100644 --- a/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx +++ b/src/renderer/components/team/members/SubagentRecentMessagesPreview.tsx @@ -1,5 +1,8 @@ +import { useState } from 'react'; + import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { format } from 'date-fns'; +import { ChevronDown, ChevronUp } from 'lucide-react'; export type SubagentPreviewMessageKind = | 'output' @@ -23,57 +26,85 @@ export interface SubagentPreviewMessage { interface SubagentRecentMessagesPreviewProps { messages: SubagentPreviewMessage[]; memberName?: string; + hasMore?: boolean; + onLoadMore?: () => void; } export const SubagentRecentMessagesPreview = ({ messages, memberName, + hasMore = false, + onLoadMore, }: SubagentRecentMessagesPreviewProps): React.JSX.Element | null => { + const [expandedAll, setExpandedAll] = useState(false); + if (!messages.length) return null; return (
-
+
Latest messages{memberName ? ` — ${memberName}` : ''}
-
- {format(messages[0].timestamp, 'h:mm:ss a')} -
-
- {messages.map((m) => ( -
-
-
- {m.label ? ( - - {m.label} - - ) : ( - {m.kind} - )} +
+ {messages.map((m, index) => ( +
+
+
+
-
+
{format(m.timestamp, 'h:mm:ss a')}
- {m.kind === 'tool_result' ? ( -
-                {m.content}
-              
- ) : ( -
- -
- )} + {index < messages.length - 1 ? ( +
+ ) : null}
))} + + {hasMore && onLoadMore ? ( +
+ +
+ ) : null} +
+ +
+ {!expandedAll ? ( + + ) : ( + + )}
); diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index ec12000b..d0f5e452 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -6,14 +6,13 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; -import { useAttachments } from '@renderer/hooks/useAttachments'; -import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; -import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useComposerDraft } from '@renderer/hooks/useComposerDraft'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -33,8 +32,6 @@ interface MessageComposerProps { ) => void; } -const MAX_MESSAGE_LENGTH = 4000; - /** Circular progress indicator for lead context usage. */ const _ContextRing = ({ ctx }: { ctx: LeadContextUsage }): React.JSX.Element => { const size = 26; @@ -114,24 +111,12 @@ export const MessageComposer = ({ const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead'); const next = lead?.name ?? members[0]?.name ?? ''; if (next && next !== recipient) { - setRecipient(next); + queueMicrotask(() => setRecipient(next)); } }, [members, recipient]); const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); - const draft = useDraftPersistence({ key: `compose:${teamName}` }); - const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); - const { - attachments, - error: attachmentError, - canAddMore, - addFiles, - removeAttachment, - clearAttachments, - clearError: clearAttachmentError, - handlePaste, - handleDrop, - } = useAttachments({ persistenceKey: `compose:${teamName}:attachments` }); + const draft = useComposerDraft(teamName); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -146,7 +131,7 @@ export const MessageComposer = ({ [members, colorMap] ); - const trimmed = draft.value.trim(); + const trimmed = draft.text.trim(); const selectedMember = members.find((m) => m.name === recipient); const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined; @@ -157,12 +142,12 @@ export const MessageComposer = ({ // isLeadAgentRecipient ? s.leadContextByTeam[teamName] : undefined // ); const supportsAttachments = isLeadRecipient; - const canAttach = supportsAttachments && canAddMore; - const attachmentsBlocked = attachments.length > 0 && !supportsAttachments; + const canAttach = supportsAttachments && draft.canAddMore; + const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments; const canSend = recipient.length > 0 && trimmed.length > 0 && - trimmed.length <= MAX_MESSAGE_LENGTH && + trimmed.length <= MAX_TEXT_LENGTH && !sending && !attachmentsBlocked; @@ -172,10 +157,15 @@ export const MessageComposer = ({ const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; - const serialized = serializeChipsWithText(trimmed, chipDraft.chips); + const serialized = serializeChipsWithText(trimmed, draft.chips); // Summary should stay compact (no expanded chip markdown) - onSend(recipient, serialized, trimmed, attachments.length > 0 ? attachments : undefined); - }, [canSend, recipient, trimmed, onSend, attachments, chipDraft.chips]); + onSend( + recipient, + serialized, + trimmed, + draft.attachments.length > 0 ? draft.attachments : undefined + ); + }, [canSend, recipient, trimmed, onSend, draft.attachments, draft.chips]); // Clear draft only after send completes successfully (sending: true → false, no error) useEffect(() => { @@ -183,12 +173,9 @@ export const MessageComposer = ({ pendingSendRef.current = false; if (!sendError) { draft.clearDraft(); - chipDraft.clearChipDraft(); - clearAttachments(); } } - // eslint-disable-next-line react-hooks/exhaustive-deps -- clearChipDraft is stable (useCallback with []) - }, [sending, sendError, draft, clearAttachments, chipDraft.clearChipDraft]); + }, [sending, sendError, draft]); const handleKeyDownCapture = useCallback( (e: React.KeyboardEvent) => { @@ -201,15 +188,16 @@ export const MessageComposer = ({ [handleSend] ); + const { addFiles: draftAddFiles } = draft; const handleFileInputChange = useCallback( (e: React.ChangeEvent) => { const input = e.target; if (input.files?.length) { - void addFiles(input.files); + void draftAddFiles(input.files); } input.value = ''; }, - [addFiles] + [draftAddFiles] ); const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -231,23 +219,25 @@ export const MessageComposer = ({ e.preventDefault(); }, []); + const { handleDrop: draftHandleDrop } = draft; const handleDropWrapper = useCallback( (e: React.DragEvent) => { dragCounterRef.current = 0; setIsDragOver(false); - if (canAttach) handleDrop(e); + if (canAttach) draftHandleDrop(e); }, - [canAttach, handleDrop] + [canAttach, draftHandleDrop] ); + const { handlePaste: draftHandlePaste } = draft; const handlePasteWrapper = useCallback( (e: React.ClipboardEvent) => { - if (canAttach) handlePaste(e); + if (canAttach) draftHandlePaste(e); }, - [canAttach, handlePaste] + [canAttach, draftHandlePaste] ); - const remaining = MAX_MESSAGE_LENGTH - trimmed.length; + const remaining = MAX_TEXT_LENGTH - trimmed.length; return (
-
+
{isLeadRecipient ? ( <> {!isTeamAlive ? 'Team must be online to attach images' - : !canAddMore + : !draft.canAddMore ? 'Maximum attachments reached' : 'Attach images (paste or drag & drop)'} +
+ +
- ) : null} + ) : ( + + )} -
+
{!isTeamAlive ? ( Team offline @@ -407,29 +416,20 @@ export const MessageComposer = ({
- - diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 55f3baec..cfb5d0e1 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -14,7 +14,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {task.id} {task.subject} - {task.owner ?? 'Unassigned'} + + {task.owner ?? 'Unassigned'} + {task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY ? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label @@ -29,7 +31,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {blocksIds.length > 0 ? ( - {blocksIds.map((id) => `#${id}`).join(', ')} + + {blocksIds.map((id) => `#${id}`).join(', ')} + ) : ( {'\u2014'} )} diff --git a/src/renderer/components/ui/MemberSelect.tsx b/src/renderer/components/ui/MemberSelect.tsx index 4d43f290..3b9b1384 100644 --- a/src/renderer/components/ui/MemberSelect.tsx +++ b/src/renderer/components/ui/MemberSelect.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; -import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'; +import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -39,6 +40,7 @@ export const MemberSelect = ({ const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(''); const listboxId = React.useId(); + const { isLight } = useTheme(); const colorMap = React.useMemo(() => buildMemberColorMap(members), [members]); const selectedMember = React.useMemo( @@ -66,7 +68,7 @@ export const MemberSelect = ({ (null); const backdropRef = React.useRef(null); const [scrollTop, setScrollTop] = React.useState(0); + const { isLight } = useTheme(); // --- File search activation --- const enableFiles = !!projectPath; @@ -599,7 +601,7 @@ export const MentionableTextarea = React.forwardRef [ 'Tip: Use @ to mention team members or search files', - 'Tip: Mention "create a task" to add it to the board', + 'Tip: Mention "delegate a task to a teammate" to add it to the kanban', "Tip: Don't overload the team lead with tasks — ask them to delegate to teammates", ], [] @@ -653,7 +655,7 @@ export const MentionableTextarea = React.forwardRef = { - blue: { border: '#3b82f6', badge: 'rgba(59, 130, 246, 0.15)', text: '#60a5fa' }, - green: { border: '#22c55e', badge: 'rgba(34, 197, 94, 0.15)', text: '#4ade80' }, - red: { border: '#ef4444', badge: 'rgba(239, 68, 68, 0.15)', text: '#f87171' }, - yellow: { border: '#eab308', badge: 'rgba(234, 179, 8, 0.15)', text: '#facc15' }, - purple: { border: '#a855f7', badge: 'rgba(168, 85, 247, 0.15)', text: '#c084fc' }, - cyan: { border: '#06b6d4', badge: 'rgba(6, 182, 212, 0.15)', text: '#22d3ee' }, - orange: { border: '#f97316', badge: 'rgba(249, 115, 22, 0.15)', text: '#fb923c' }, - pink: { border: '#ec4899', badge: 'rgba(236, 72, 153, 0.15)', text: '#f472b6' }, - magenta: { border: '#d946ef', badge: 'rgba(217, 70, 239, 0.15)', text: '#e879f9' }, + blue: { + border: '#3b82f6', + badge: 'rgba(59, 130, 246, 0.15)', + badgeLight: 'rgba(59, 130, 246, 0.12)', + text: '#60a5fa', + textLight: '#2563eb', + }, + green: { + border: '#22c55e', + badge: 'rgba(34, 197, 94, 0.15)', + badgeLight: 'rgba(34, 197, 94, 0.12)', + text: '#4ade80', + textLight: '#16a34a', + }, + red: { + border: '#ef4444', + badge: 'rgba(239, 68, 68, 0.15)', + badgeLight: 'rgba(239, 68, 68, 0.12)', + text: '#f87171', + textLight: '#dc2626', + }, + yellow: { + border: '#eab308', + badge: 'rgba(234, 179, 8, 0.15)', + badgeLight: 'rgba(161, 98, 7, 0.12)', + text: '#facc15', + textLight: '#a16207', + }, + purple: { + border: '#a855f7', + badge: 'rgba(168, 85, 247, 0.15)', + badgeLight: 'rgba(168, 85, 247, 0.12)', + text: '#c084fc', + textLight: '#7c3aed', + }, + cyan: { + border: '#06b6d4', + badge: 'rgba(6, 182, 212, 0.15)', + badgeLight: 'rgba(6, 182, 212, 0.12)', + text: '#22d3ee', + textLight: '#0891b2', + }, + orange: { + border: '#f97316', + badge: 'rgba(249, 115, 22, 0.15)', + badgeLight: 'rgba(249, 115, 22, 0.12)', + text: '#fb923c', + textLight: '#c2410c', + }, + pink: { + border: '#ec4899', + badge: 'rgba(236, 72, 153, 0.15)', + badgeLight: 'rgba(236, 72, 153, 0.12)', + text: '#f472b6', + textLight: '#db2777', + }, + magenta: { + border: '#d946ef', + badge: 'rgba(217, 70, 239, 0.15)', + badgeLight: 'rgba(217, 70, 239, 0.12)', + text: '#e879f9', + textLight: '#a21caf', + }, /** Reserved for the human user — never assigned to team members. */ - user: { border: '#f5f5f4', badge: 'rgba(245, 245, 244, 0.12)', text: '#d6d3d1' }, + user: { + border: '#f5f5f4', + borderLight: '#a8a29e', + badge: 'rgba(245, 245, 244, 0.12)', + badgeLight: 'rgba(120, 113, 108, 0.14)', + text: '#d6d3d1', + textLight: '#44403c', + }, }; const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue; @@ -76,3 +143,25 @@ export function getTeamColorSet(colorName: string): TeamColorSet { return DEFAULT_COLOR; } + +/** + * Get the appropriate badge background for the current theme. + * Uses badgeLight in light theme when available, falls back to badge. + */ +export function getThemedBadge(colorSet: TeamColorSet, isLight: boolean): string { + return isLight && colorSet.badgeLight ? colorSet.badgeLight : colorSet.badge; +} + +/** + * Get the appropriate text color for the current theme. + */ +export function getThemedText(colorSet: TeamColorSet, isLight: boolean): string { + return isLight && colorSet.textLight ? colorSet.textLight : colorSet.text; +} + +/** + * Get the appropriate border color for the current theme. + */ +export function getThemedBorder(colorSet: TeamColorSet, isLight: boolean): string { + return isLight && colorSet.borderLight ? colorSet.borderLight : colorSet.border; +} diff --git a/src/renderer/hooks/useCollapsedGroups.ts b/src/renderer/hooks/useCollapsedGroups.ts new file mode 100644 index 00000000..c2c582a6 --- /dev/null +++ b/src/renderer/hooks/useCollapsedGroups.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react'; + +/** + * Manages collapsed/expanded state for group headers with localStorage persistence. + * Each grouping mode gets a unique prefix to avoid key collisions. + */ + +const STORAGE_PREFIX = 'taskGroupCollapsed'; + +function storageKey(prefix: string, groupKey: string): string { + return `${STORAGE_PREFIX}:${prefix}:${groupKey}`; +} + +function loadCollapsedSet(prefix: string, groupKeys: string[]): Set { + const set = new Set(); + try { + for (const key of groupKeys) { + if (localStorage.getItem(storageKey(prefix, key)) === '1') { + set.add(key); + } + } + } catch { + /* ignore storage errors */ + } + return set; +} + +export function useCollapsedGroups(prefix: string, groupKeys: string[]) { + // Re-initialize when prefix or keys change + const [collapsed, setCollapsed] = useState>(() => + loadCollapsedSet(prefix, groupKeys) + ); + + // Sync with new keys when they change (e.g. new projects appear) + // We use a key string to detect changes without deep comparison + const keysFingerprint = groupKeys.join('\0'); + const [prevFingerprint, setPrevFingerprint] = useState(keysFingerprint); + const [prevPrefix, setPrevPrefix] = useState(prefix); + + if (keysFingerprint !== prevFingerprint || prefix !== prevPrefix) { + setPrevFingerprint(keysFingerprint); + setPrevPrefix(prefix); + setCollapsed(loadCollapsedSet(prefix, groupKeys)); + } + + const isCollapsed = useCallback((groupKey: string) => collapsed.has(groupKey), [collapsed]); + + const toggle = useCallback( + (groupKey: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + const key = storageKey(prefix, groupKey); + try { + if (next.has(groupKey)) { + next.delete(groupKey); + localStorage.removeItem(key); + } else { + next.add(groupKey); + localStorage.setItem(key, '1'); + } + } catch { + /* ignore storage errors */ + } + return next; + }); + }, + [prefix] + ); + + return { isCollapsed, toggle } as const; +} diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts new file mode 100644 index 00000000..ac22af8e --- /dev/null +++ b/src/renderer/hooks/useComposerDraft.ts @@ -0,0 +1,452 @@ +/** + * Unified composer draft hook — atomic persistence of text + chips + attachments. + * + * Replaces the trio of `useDraftPersistence`, `useChipDraftPersistence`, and + * `useAttachments` for the team `MessageComposer`. + * + * Key guarantees: + * - Single IndexedDB key per team (`composer:`), no TTL. + * - Race-safe: late async load never overwrites fresh user input. + * - Debounced writes with immediate flush on unmount and lifecycle transitions. + * - Legacy migration from three-key format on first load. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + type ComposerDraftSnapshot, + composerDraftStorage, +} from '@renderer/services/composerDraftStorage'; +import { + fileToAttachmentPayload, + MAX_FILES, + MAX_TOTAL_SIZE, + validateAttachment, +} from '@renderer/utils/attachmentUtils'; + +import type { InlineChip } from '@renderer/types/inlineChip'; +import type { AttachmentPayload } from '@shared/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface UseComposerDraftResult { + // Text + text: string; + setText: (v: string) => void; + + // Chips + chips: InlineChip[]; + addChip: (chip: InlineChip) => void; + removeChip: (chipId: string) => void; + + // Attachments + attachments: AttachmentPayload[]; + attachmentError: string | null; + canAddMore: boolean; + addFiles: (files: FileList | File[]) => Promise; + removeAttachment: (id: string) => void; + clearAttachments: () => void; + clearAttachmentError: () => void; + handlePaste: (event: React.ClipboardEvent) => void; + handleDrop: (event: React.DragEvent) => void; + + // Status + isSaved: boolean; + isLoaded: boolean; + + // Clear all + clearDraft: () => void; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEBOUNCE_MS = 400; + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useComposerDraft(teamName: string): UseComposerDraftResult { + const [text, setTextState] = useState(''); + const [chips, setChipsState] = useState([]); + const [attachments, setAttachmentsState] = useState([]); + const [attachmentError, setAttachmentError] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const [isLoaded, setIsLoaded] = useState(false); + + // Refs for latest values — avoids stale closures in callbacks + const textRef = useRef(''); + const chipsRef = useRef([]); + const attachmentsRef = useRef([]); + const teamNameRef = useRef(teamName); + const mountedRef = useRef(true); + + // Track whether user has interacted since last load to prevent race + const userTouchedRef = useRef(false); + + // Debounce timer + const timerRef = useRef | null>(null); + const pendingRef = useRef<{ teamName: string; snapshot: ComposerDraftSnapshot } | null>(null); + + // Keep teamNameRef in sync + useEffect(() => { + teamNameRef.current = teamName; + }, [teamName]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // --------------------------------------------------------------------------- + // Persist helpers + // --------------------------------------------------------------------------- + + const buildSnapshot = useCallback((): ComposerDraftSnapshot => { + return { + version: 1, + teamName: teamNameRef.current, + text: textRef.current, + chips: chipsRef.current, + attachments: attachmentsRef.current, + updatedAt: Date.now(), + }; + }, []); + + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current != null) { + const pending = pendingRef.current; + pendingRef.current = null; + const isEmpty = + pending.snapshot.text.length === 0 && + pending.snapshot.chips.length === 0 && + pending.snapshot.attachments.length === 0; + if (isEmpty) { + void composerDraftStorage.deleteSnapshot(pending.teamName); + } else { + void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot); + } + } + }, []); + + const scheduleSave = useCallback(() => { + const snapshot = buildSnapshot(); + pendingRef.current = { teamName: teamNameRef.current, snapshot }; + + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + const pending = pendingRef.current; + pendingRef.current = null; + if (pending == null) return; + + const isEmpty = + pending.snapshot.text.length === 0 && + pending.snapshot.chips.length === 0 && + pending.snapshot.attachments.length === 0; + if (isEmpty) { + void composerDraftStorage.deleteSnapshot(pending.teamName); + if (mountedRef.current) setIsSaved(true); + } else { + void composerDraftStorage.saveSnapshot(pending.teamName, pending.snapshot).then(() => { + if (mountedRef.current) setIsSaved(true); + }); + } + }, DEBOUNCE_MS); + }, [buildSnapshot]); + + // --------------------------------------------------------------------------- + // Apply snapshot to state + // --------------------------------------------------------------------------- + + const applySnapshot = useCallback((snap: ComposerDraftSnapshot) => { + textRef.current = snap.text; + chipsRef.current = snap.chips; + attachmentsRef.current = snap.attachments; + setTextState(snap.text); + setChipsState(snap.chips); + setAttachmentsState(snap.attachments); + }, []); + + // --------------------------------------------------------------------------- + // Load on mount / teamName change + // --------------------------------------------------------------------------- + + useEffect(() => { + let cancelled = false; + flushPending(); + userTouchedRef.current = false; + + // Reset to empty for the new teamName. + // Wrapped in queueMicrotask to avoid synchronous setState inside effect body. + const empty = composerDraftStorage.emptySnapshot(teamName); + queueMicrotask(() => { + if (cancelled) return; + applySnapshot(empty); + setIsSaved(false); + setIsLoaded(false); + setAttachmentError(null); + }); + + void (async () => { + // Try loading unified snapshot first + let snapshot = await composerDraftStorage.loadSnapshot(teamName); + + // If none found, try legacy migration + if (snapshot == null) { + snapshot = await composerDraftStorage.migrateLegacy(teamName); + } + + if (cancelled) return; + + // Race protection: if user already started typing, don't overwrite + if (userTouchedRef.current) { + if (mountedRef.current) setIsLoaded(true); + return; + } + + if (snapshot != null) { + // Validate attachment limits + const totalSize = snapshot.attachments.reduce((sum, a) => sum + a.size, 0); + if (totalSize > MAX_TOTAL_SIZE || snapshot.attachments.length > MAX_FILES) { + snapshot = { ...snapshot, attachments: [] }; + } + + applySnapshot(snapshot); + setIsSaved(true); + } + + if (mountedRef.current) setIsLoaded(true); + })(); + + return () => { + cancelled = true; + }; + }, [teamName, flushPending, applySnapshot]); + + // Flush on unmount + useEffect(() => { + return () => { + flushPending(); + }; + }, [flushPending]); + + // --------------------------------------------------------------------------- + // Text + // --------------------------------------------------------------------------- + + const setText = useCallback( + (v: string) => { + userTouchedRef.current = true; + textRef.current = v; + setTextState(v); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + // --------------------------------------------------------------------------- + // Chips + // --------------------------------------------------------------------------- + + const addChip = useCallback( + (chip: InlineChip) => { + userTouchedRef.current = true; + const next = [...chipsRef.current, chip]; + chipsRef.current = next; + setChipsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const removeChip = useCallback( + (chipId: string) => { + userTouchedRef.current = true; + const next = chipsRef.current.filter((c) => c.id !== chipId); + chipsRef.current = next; + setChipsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + // --------------------------------------------------------------------------- + // Attachments + // --------------------------------------------------------------------------- + + const totalSize = attachments.reduce((sum, a) => sum + a.size, 0); + const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE; + + const addFiles = useCallback( + async (files: FileList | File[]) => { + userTouchedRef.current = true; + setAttachmentError(null); + const fileArray = Array.from(files); + if (fileArray.length === 0) return; + + let batchSize = 0; + for (const file of fileArray) { + const validation = validateAttachment(file); + if (!validation.valid) { + setAttachmentError(validation.error); + return; + } + batchSize += file.size; + } + + const newPayloads: AttachmentPayload[] = []; + for (const file of fileArray) { + try { + const payload = await fileToAttachmentPayload(file); + newPayloads.push(payload); + } catch { + setAttachmentError(`Failed to read file: ${file.name}`); + return; + } + } + + const prev = attachmentsRef.current; + if (prev.length + newPayloads.length > MAX_FILES) { + setAttachmentError(`Maximum ${MAX_FILES} attachments allowed`); + return; + } + const currentTotal = prev.reduce((sum, a) => sum + a.size, 0); + if (currentTotal + batchSize > MAX_TOTAL_SIZE) { + setAttachmentError('Total attachment size exceeds 20MB limit'); + return; + } + + const next = [...prev, ...newPayloads]; + attachmentsRef.current = next; + setAttachmentsState(next); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const removeAttachment = useCallback( + (id: string) => { + userTouchedRef.current = true; + const next = attachmentsRef.current.filter((a) => a.id !== id); + attachmentsRef.current = next; + setAttachmentsState(next); + setAttachmentError(null); + setIsSaved(false); + scheduleSave(); + }, + [scheduleSave] + ); + + const clearAttachments = useCallback(() => { + userTouchedRef.current = true; + attachmentsRef.current = []; + setAttachmentsState([]); + setAttachmentError(null); + setIsSaved(false); + scheduleSave(); + }, [scheduleSave]); + + const clearAttachmentError = useCallback(() => { + setAttachmentError(null); + }, []); + + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + const items = event.clipboardData?.items; + if (!items) return; + + const imageFiles: File[] = []; + for (const item of Array.from(items)) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + + if (imageFiles.length > 0) { + event.preventDefault(); + void addFiles(imageFiles); + } + }, + [addFiles] + ); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + const files = event.dataTransfer?.files; + if (!files?.length) return; + + const allFiles = Array.from(files); + const imageFiles = allFiles.filter((f) => f.type.startsWith('image/')); + if (imageFiles.length > 0) { + void addFiles(imageFiles); + } else if (allFiles.length > 0) { + setAttachmentError('Only image files are supported'); + } + }, + [addFiles] + ); + + // --------------------------------------------------------------------------- + // Clear all + // --------------------------------------------------------------------------- + + const clearDraft = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + pendingRef.current = null; + + textRef.current = ''; + chipsRef.current = []; + attachmentsRef.current = []; + + setTextState(''); + setChipsState([]); + setAttachmentsState([]); + setAttachmentError(null); + setIsSaved(false); + + void composerDraftStorage.deleteSnapshot(teamNameRef.current); + }, []); + + return { + text, + setText, + chips, + addChip, + removeChip, + attachments, + attachmentError, + canAddMore, + addFiles, + removeAttachment, + clearAttachments, + clearAttachmentError, + handlePaste, + handleDrop, + isSaved, + isLoaded, + clearDraft, + }; +} diff --git a/src/renderer/hooks/useTeamMessagesExpanded.ts b/src/renderer/hooks/useTeamMessagesExpanded.ts new file mode 100644 index 00000000..cdf52491 --- /dev/null +++ b/src/renderer/hooks/useTeamMessagesExpanded.ts @@ -0,0 +1,34 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { + addExpanded, + getExpandedOverrides, + removeExpanded, +} from '@renderer/utils/teamMessageExpandStorage'; + +export function useTeamMessagesExpanded(teamName: string): { + expandedSet: Set; + toggle: (messageKey: string) => void; +} { + const [version, setVersion] = useState(0); + const expandedSet = useMemo(() => { + if (version < 0) return new Set(); + return teamName ? getExpandedOverrides(teamName) : new Set(); + }, [teamName, version]); + + const toggle = useCallback( + (messageKey: string) => { + if (!teamName) return; + const existing = getExpandedOverrides(teamName); + if (existing.has(messageKey)) { + removeExpanded(teamName, messageKey); + } else { + addExpanded(teamName, messageKey); + } + setVersion((v) => v + 1); + }, + [teamName] + ); + + return { expandedSet, toggle }; +} diff --git a/src/renderer/hooks/useTheme.ts b/src/renderer/hooks/useTheme.ts index dc6bc9f3..19b689c8 100644 --- a/src/renderer/hooks/useTheme.ts +++ b/src/renderer/hooks/useTheme.ts @@ -32,11 +32,12 @@ export function useTheme(): { // Initialize from cache to prevent flash try { const cached = localStorage.getItem(THEME_CACHE_KEY); - if (cached === 'light') return 'light'; + if (cached === 'light' || cached === 'dark') return cached; } catch { // localStorage may not be available } - return 'dark'; + // No cache — detect system preference for flash-free first launch + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }); // Fetch config on mount if not loaded. @@ -50,7 +51,7 @@ export function useTheme(): { }, [appConfig, configLoading, fetchConfig]); // Get configured theme - const configuredTheme: Theme = appConfig?.general?.theme ?? 'dark'; + const configuredTheme: Theme = appConfig?.general?.theme ?? 'system'; // Get system theme preference const getSystemTheme = useCallback((): ResolvedTheme => { diff --git a/src/renderer/index.css b/src/renderer/index.css index fec49dd6..01a4a51b 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -15,6 +15,7 @@ --color-text: #f1f5f9; --color-text-secondary: #94a3b8; --color-text-muted: #64748b; + --color-accent: #818cf8; /* Accent — indigo-400, visible on dark surfaces */ /* Scrollbar colors */ --scrollbar-thumb: rgba(148, 163, 184, 0.15); @@ -187,6 +188,11 @@ --system-activity-border: rgba(59, 130, 246, 0.12); --system-activity-accent: rgba(96, 165, 250, 0.5); + /* Info style — banners, status indicators */ + --info-bg: rgba(59, 130, 246, 0.08); + --info-border: rgba(59, 130, 246, 0.25); + --info-text: #60a5fa; + /* Assessment severity colors (badges, health indicators) */ --assess-good: #4ade80; --assess-warning: #fbbf24; @@ -236,6 +242,7 @@ --color-text: #1c1b19; /* Warm near-black text */ --color-text-secondary: #4d4b46; /* Warm secondary text */ --color-text-muted: #6d6b65; /* Warm muted text */ + --color-accent: #4f46e5; /* Accent — indigo-600, visible on light surfaces */ /* Assessment severity colors - darker for light backgrounds */ --assess-good: #16a34a; @@ -410,9 +417,14 @@ --card-separator: #d5d3cf; /* System activity messages */ - --system-activity-bg: rgba(59, 130, 246, 0.06); - --system-activity-border: rgba(59, 130, 246, 0.15); - --system-activity-accent: rgba(37, 99, 235, 0.5); + --system-activity-bg: rgba(59, 130, 246, 0.08); + --system-activity-border: rgba(59, 130, 246, 0.25); + --system-activity-accent: rgba(37, 99, 235, 0.7); + + /* Info style — banners, status indicators */ + --info-bg: rgba(59, 130, 246, 0.1); + --info-border: rgba(37, 99, 235, 0.3); + --info-text: #2563eb; /* Sticky Context button - transparent glass */ --context-btn-bg: rgba(0, 0, 0, 0.06); @@ -612,21 +624,6 @@ body { } } -@keyframes message-enter { - from { - opacity: 0; - transform: translateY(-8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.message-enter-animate { - animation: message-enter 300ms ease-out both; -} - @keyframes chat-message-enter { from { opacity: 0; @@ -658,6 +655,17 @@ body { animation: thought-expand 350ms ease-out both; } +@keyframes att-scale-in { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + .skeleton-card { animation: skeleton-fade-in 0.4s ease-out both; position: relative; @@ -773,3 +781,21 @@ body { linear-gradient(-45deg, transparent 75%, #e2e8f0 75%); background-color: #ffffff; } + +/* Lightbox toolbar buttons — enlarge hit targets and fix macOS hit-testing */ +.yarl__toolbar { + z-index: 1; +} + +.yarl__toolbar .yarl__button { + min-width: 44px; + min-height: 44px; + /* filter: drop-shadow() causes hit-test glitches on macOS/Electron compositing layers */ + filter: none; + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8); +} + +.yarl__toolbar .yarl__button > svg { + pointer-events: none; + filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 0.8)); +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 98249d50..7adb5d28 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -42,10 +42,7 @@ } :root.light #splash-text { color: #52525b; } :root.light #splash-noise { opacity: 0.02; } - :root.light .splash-logo-bg { fill: #e4e4e7; } - :root.light .splash-node-fill { fill: #52525b; } - :root.light .splash-core-fill { fill: #fafafa; } - :root.light .splash-edge { stroke: #71717a; } + :root.light #splash-logo { filter: drop-shadow(0 2px 8px rgba(0,0,0,0.15)); }