diff --git a/README.md b/README.md index 4e1a084e..788f3ac4 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,10 @@ pnpm dist # macOS + Windows + Linux ## TODO -- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc. -- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them) +- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc. +- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop +- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills) +- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models --- diff --git a/docs/diff-view-implementation-plan.md b/docs/iterations/diff-view/diff-view-implementation-plan.md similarity index 100% rename from docs/diff-view-implementation-plan.md rename to docs/iterations/diff-view/diff-view-implementation-plan.md diff --git a/docs/research/competitors.md b/docs/research/competitors.md new file mode 100644 index 00000000..ea8c23ea --- /dev/null +++ b/docs/research/competitors.md @@ -0,0 +1,332 @@ +# Конкуренты: платформы для оркестрации AI-агентов + +> Дата: 2026-03-04 +> Статус: Исследование завершено + +## Цель + +Оценить конкурентный ландшафт для концепции **"self-hosted web dashboard для оркестрации команд AI coding-агентов с workflow automation"**. + +--- + +## Часть 1: Multi-Agent фреймворки + +### CrewAI + +| Параметр | Данные | +|---|---| +| **GitHub** | [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI) | +| **Stars** | ~44,600 | +| **Open Source** | Да, MIT | +| **Web UI** | CrewAI Studio (cloud, визуальный no-code билдер) | +| **MCP** | Да — нативный (`mcps` на агентах, `MCPServerAdapter`, экспорт crew как MCP-сервер) | +| **Multi-agent** | Да — основная концепция. Role-based crews, sequential/parallel/conditional архитектуры, A2A делегирование | +| **Self-hosted** | Да — on-premise, AWS/Azure/GCP VPC | +| **Модели** | Любые LLM через LiteLLM | +| **Pricing** | Free (self-host), $99/мес (Basic), до $120K/год (Ultra) | + +**Дифференциатор:** Самый популярный multi-agent фреймворк. 60% Fortune 500. Мощная memory-система. +**Слабость:** Python-only. Studio — cloud, не полноценный self-hosted web UI. Нет IDE. Нет фокуса на coding. + +--- + +### LangGraph Studio (LangChain) + +| Параметр | Данные | +|---|---| +| **GitHub** | [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph) | +| **Stars** | ~25,500 | +| **Open Source** | Да, MIT | +| **Web UI** | LangGraph Studio v2 (браузер), Open Agent Platform (open-source no-code) | +| **MCP** | Да — `langchain-mcp-adapters`, каждый deployed agent = MCP endpoint | +| **Multi-agent** | Да — Swarm, Supervisor, handoff паттерны | +| **Self-hosted** | Частично (Developer tier free, Enterprise — full self-hosted) | +| **Модели** | Любые через LangChain | +| **Pricing** | Developer: free. Plus: $39/seat/мес. Enterprise: от $100K+/год | + +**Дифференциатор:** Graph-based DAG архитектура, durable execution, глубокая интеграция с LangSmith. +**Слабость:** Высокий порог входа (code-first). Сложная ценовая структура. Studio больше для дебага. + +--- + +### AutoGen Studio (Microsoft) + +| Параметр | Данные | +|---|---| +| **GitHub** | [microsoft/autogen](https://github.com/microsoft/autogen) | +| **Stars** | ~50,400 | +| **Open Source** | Да, MIT | +| **Web UI** | Да — drag-and-drop web UI для multi-agent workflows | +| **MCP** | Да — `autogen_ext.tools.mcp` (Stdio, SSE, Streamable HTTP) | +| **Multi-agent** | Да — async event-driven архитектура | +| **Self-hosted** | Да — полностью (Python package) | +| **Модели** | Любые LLM | +| **Pricing** | Полностью бесплатно | + +**Дифференциатор:** Microsoft Research, 50K+ stars, полностью бесплатно. +**Слабость:** ⚠️ **В РЕЖИМЕ MAINTENANCE!** Мержится с Semantic Kernel в "Microsoft Agent Framework". Новые фичи не будут. Миграция неизбежна. + +--- + +## Часть 2: Coding Agent платформы + +### ⭐ OpenHands (formerly OpenDevin) — БЛИЖАЙШИЙ КОНКУРЕНТ + +| Параметр | Данные | +|---|---| +| **GitHub** | [All-Hands-AI/OpenHands](https://github.com/All-Hands-AI/OpenHands) | +| **Stars** | ~64,000+ | +| **Open Source** | Да, MIT | +| **Web UI** | Да — полноценный: shell, browser, VSCode-editor, planner, VNC desktop | +| **MCP** | Да — typed tool system с MCP integration | +| **Multi-agent** | Да — hierarchical agent delegation, AgentHub (CodeActAgent, BrowserAgent, Micro-agents) | +| **Self-hosted** | Да — Docker (MIT, free). Enterprise — VPC/Kubernetes | +| **Модели** | Model-agnostic: Claude, GPT, open-source через litellm | +| **Pricing** | Open Source: free. Cloud Growth: $500/мес. Enterprise: custom | + +**Дифференциатор:** Самый близкий по духу. Web UI для coding agents, multi-agent, sandbox execution, REST+WebSocket API. MIT лицензия. $18.8M funding. + +**Слабость:** Нет визуального workflow editor. Нет Kanban/task management. Нет team provisioning UI. UI для individual sessions, не для управления командами. + +--- + +### Cursor + +| Параметр | Данные | +|---|---| +| **Stars** | N/A (closed-source, VS Code fork) | +| **Open Source** | Нет | +| **Web UI** | Нет — desktop IDE | +| **MCP** | Да — first-class MCP, Apps, Marketplace | +| **Multi-agent** | Да — до 8 parallel agents через git worktrees, Background Agents, BugBot | +| **Self-hosted** | Нет | +| **Pricing** | Free (50 req/мес), Pro $20/мес, Ultra $200/мес | + +**Дифференциатор:** $500M ARR, $10B valuation. Единственный с multi-agent parallel execution для coding. +**Слабость:** Closed-source, desktop-only, нет self-hosted, vendor lock-in. Это IDE, не платформа оркестрации. + +--- + +### Windsurf (formerly Codeium → Cognition AI) + +| Параметр | Данные | +|---|---| +| **Open Source** | Нет (только Vim/Neovim плагины) | +| **Web UI** | Нет — desktop IDE | +| **MCP** | Да — Stdio и HTTP MCP, Marketplace | +| **Multi-agent** | Нет | +| **Self-hosted** | Enterprise: cloud/hybrid/self-hosted | +| **Pricing** | Free (25 credits/мес), Pro $15/мес, Teams $30/user/мес | + +**Дифференциатор:** #1 в LogRocket AI Dev Tool Rankings (Feb 2026). Куплен Cognition AI ($250M) → интеграция с Devin. +**Слабость:** Closed-source, desktop-only, нет multi-agent. Acquisition создаёт неопределённость. + +--- + +### Cody / Sourcegraph Amp + +| Параметр | Данные | +|---|---| +| **Open Source** | Был open-source (Apache), теперь private repo. Open-core | +| **Web UI** | Частично — web-search по коду, Batch Changes UI | +| **MCP** | Да — MCP tools (GitHub, Sentry, Linear) | +| **Multi-agent** | Нет | +| **Pricing** | Free. Enterprise: $19-59/user/мес | + +**Дифференциатор:** Deep Search по огромным кодобазам. 10 лет code intelligence. +**Слабость:** Уже не полностью open-source. Не orchestration-платформа. + +--- + +## Часть 3: Workflow/AI App платформы + +### Dify + +| Параметр | Данные | +|---|---| +| **GitHub** | [langgenius/dify](https://github.com/langgenius/dify) | +| **Stars** | ~119K | +| **Open Source** | Модифицированная Apache 2.0 | +| **Web UI** | Да — полноценный drag-and-drop visual builder, playground, LLMOps дашборд | +| **MCP** | Да — двусторонний (v1.6.0) | +| **Multi-agent** | Частично — workflows с Agent Nodes | +| **Self-hosted** | Да — Docker/K8s | +| **Модели** | Сотни LLM | + +**Дифференциатор:** Самая зрелая LLMOps платформа. 100K+ stars. Визуальные workflows, RAG, MCP. +**Слабость:** Не заточен на coding-агентов. Ограничения лицензии (no multi-tenant без коммерческой). + +--- + +### n8n + +| Параметр | Данные | +|---|---| +| **GitHub** | [n8n-io/n8n](https://github.com/n8n-io/n8n) | +| **Stars** | ~177K | +| **Open Source** | Sustainable Use License | +| **Web UI** | Да — лучший визуальный workflow editor в категории | +| **MCP** | Да — двусторонний (MCP Client + MCP Server Trigger) | +| **Multi-agent** | Скоро — "advanced multi-agent" в анонсах | +| **Self-hosted** | Да — Docker/K8s, бесплатно | +| **Модели** | OpenAI, Claude, Gemini через интеграции | + +**Дифференциатор:** 177K stars, 500+ интеграций, $180M Series C ($2.5B оценка). Лучший workflow editor. +**Слабость:** Workflow automation, не AI agent orchestration. AI — дополнение, не ядро. SUL лицензия. + +--- + +### Langflow + +| Параметр | Данные | +|---|---| +| **GitHub** | [langflow-ai/langflow](https://github.com/langflow-ai/langflow) | +| **Stars** | ~130-140K | +| **Open Source** | MIT | +| **Web UI** | Да — drag-and-drop builder | +| **MCP** | Да — flows как MCP server | +| **Self-hosted** | Да — Docker/pip | + +**Дифференциатор:** Самый низкий порог входа, MIT, построен на LangChain. +**Слабость:** General-purpose, не coding-specific. + +--- + +### Flowise + +| Параметр | Данные | +|---|---| +| **GitHub** | [FlowiseAI/Flowise](https://github.com/FlowiseAI/Flowise) | +| **Stars** | ~43K | +| **Open Source** | MIT | +| **Web UI** | Да — drag-and-drop, Agentflow V2 (multi-agent) | +| **MCP** | Не подтверждено | +| **Self-hosted** | Да — Docker | + +**Дифференциатор:** Чистая MIT лицензия. Enterprise-фичи (RBAC, SSO). Клиенты — Accenture, Deloitte. +**Слабость:** Меньше чем Dify. Не coding-specific. + +--- + +### Activepieces + +| Параметр | Данные | +|---|---| +| **GitHub** | [activepieces/activepieces](https://github.com/activepieces/activepieces) | +| **Stars** | ~20K | +| **Open Source** | MIT | +| **Web UI** | Да — flow builder | +| **MCP** | Да — 400+ MCP серверов | +| **Self-hosted** | Да — Docker | + +**Дифференциатор:** MIT, 280+ pieces как MCP, TypeScript-based. +**Слабость:** Zapier-альтернатива для бизнес-автоматизации, не для coding. + +--- + +## Часть 4: Single-agent coding tools (не прямые конкуренты) + +| Инструмент | Stars | Open Source | Web UI | Multi-Agent | Комментарий | +|---|---|---|---|---|---| +| **Aider** | 34K | Apache 2.0 | Минимальный (browser mode) | Нет | Лучший CLI pair programmer. Git-native | +| **SWE-agent** | 14K | MIT | Нет | Нет | Академический, SoTA бенчмарки. CLI only | +| **bolt.diy** | 15K | MIT* | Да (browser IDE) | Нет | Full-stack в браузере. WebContainers ограничения | +| **Continue.dev** | 26K | Apache 2.0 | Нет (IDE ext) | Нет | VS Code/JetBrains assistant. MCP support | +| **Devon** | 3.5K | AGPL | Нет | Нет | Ранняя стадия, малая популярность | +| **v0.dev** | N/A | Нет | Да | Нет | Vercel SaaS. Prompt-to-React. Закрытый | + +--- + +## Часть 5: Мёртвые/замороженные проекты + +| Проект | Stars | Статус | Причина | +|---|---|---|---| +| **AgentGPT** | 32K | ⚠️ Архивирован (янв 2026) | Reworkd сменил фокус. GPT-only, no multi-agent | +| **AutoGen** | 50.4K | ⚠️ Maintenance mode | Мержится с Semantic Kernel. Миграция неизбежна | + +--- + +## Сводная таблица + +| Платформа | Stars | Web UI | MCP | Multi-Agent | Self-Host | Open Source | Coding | Workflow Editor | +|---|---|---|---|---|---|---|---|---| +| **n8n** | 177K | ✅ Лучший | ✅ | Скоро | ✅ | SUL | ❌ | ✅ | +| **Langflow** | 130K | ✅ | ✅ | Частично | ✅ | MIT | ❌ | ✅ | +| **Dify** | 119K | ✅ | ✅ | Частично | ✅ | Apache* | ❌ | ✅ | +| **OpenHands** | 64K | ✅ | ✅ | ✅ | ✅ | MIT | ✅ | ❌ | +| **AutoGen** | 50K | ✅ | ✅ | ✅ | ✅ | MIT | ❌ | ❌ (⚠️ maint.) | +| **CrewAI** | 45K | Cloud | ✅ | ✅ | ✅ | MIT | ❌ | Cloud | +| **Flowise** | 43K | ✅ | ? | ✅ | ✅ | MIT | ❌ | ✅ | +| **Aider** | 34K | Мин. | Community | ❌ | ✅ | Apache | ✅ | ❌ | +| **LangGraph** | 26K | ✅ | ✅ | ✅ | Частично | MIT | ❌ | ❌ | +| **Continue** | 26K | ❌ (IDE) | ✅ | ❌ | ✅ | Apache | ✅ | ❌ | +| **Cursor** | N/A | ❌ (IDE) | ✅ | ✅ (8 par.) | ❌ | ❌ | ✅ | ❌ | +| **Windsurf** | N/A | ❌ (IDE) | ✅ | ❌ | Enterprise | ❌ | ✅ | ❌ | + +--- + +## Выводы: наше позиционирование + +### Прямых конкурентов НЕТ + +Ни одна существующая платформа не совмещает всё это: +- ✅ Self-hosted web UI +- ✅ Фокус на coding-агентах +- ✅ Multi-agent team orchestration +- ✅ Visual workflow editor +- ✅ Task management (Kanban) +- ✅ MCP/Skills поддержка + +### Ближайшие конкуренты по отдельным аспектам + +``` + Coding Focus + ↑ + │ + OpenHands ────┤──── Cursor + (Web UI, │ (Multi-agent, + MIT, │ но closed, + multi-agent) │ desktop-only) + │ + ─────────────────┼──────────────────→ Workflow Editor + │ + CrewAI ───────┤──── Dify / n8n + (Multi-agent │ (Workflow, + framework) │ Web UI, + │ MCP) + │ +``` + +### Наш проект = пересечение трёх категорий + +1. **OpenHands** → coding agent + Web UI + multi-agent → но нет workflow editor, нет kanban +2. **Dify / n8n** → workflow orchestration + визуальный editor + MCP → но не coding-specific +3. **Claude Code Teams** → native team orchestration → но нет web UI, нет workflow automation + +**Уникальная ценность:** "OpenHands для команд с workflow automation" или "Dify/n8n, специализированный на coding-агентах" + +### Ключевые дифференциаторы + +- Web UI специально для Claude Code teams (не generic builder) +- Визуализация реальных coding sessions (timeline, chunks, tool calls) — уже есть +- Team provisioning и task management (Kanban) — уже есть +- Visual workflow editor для сборки agent pipelines +- Self-hosted с полным контролем данных +- Фокус на наблюдаемость (context tracking, token usage, cost) + +Sources: +- [CrewAI](https://crewai.com/) | [GitHub](https://github.com/crewAIInc/crewAI) | [MCP Docs](https://docs.crewai.com/en/mcp/overview) +- [LangGraph](https://www.langchain.com/langgraph) | [GitHub](https://github.com/langchain-ai/langgraph) +- [AutoGen](https://microsoft.github.io/autogen) | [GitHub](https://github.com/microsoft/autogen) | [Merger Discussion](https://github.com/microsoft/autogen/discussions/7066) +- [OpenHands](https://openhands.dev/) | [GitHub](https://github.com/All-Hands-AI/OpenHands) | [SDK Paper](https://arxiv.org/html/2511.03690v1) +- [Dify](https://dify.ai/) | [GitHub](https://github.com/langgenius/dify) | [MCP Blog](https://dify.ai/blog/v1-6-0-built-in-two-way-mcp-support) +- [n8n](https://n8n.io/) | [GitHub](https://github.com/n8n-io/n8n) +- [Langflow](https://www.langflow.org/) | [GitHub](https://github.com/langflow-ai/langflow) +- [Flowise](https://flowiseai.com/) | [GitHub](https://github.com/FlowiseAI/Flowise) +- [Cursor](https://cursor.com/) | [Features](https://cursor.com/features) +- [Windsurf](https://windsurf.com/) +- [Aider](https://aider.chat/) | [GitHub](https://github.com/Aider-AI/aider) +- [bolt.diy](https://github.com/stackblitz-labs/bolt.diy) +- [Continue.dev](https://www.continue.dev/) | [GitHub](https://github.com/continuedev/continue) +- [AgentGPT](https://github.com/reworkd/AgentGPT) (архивирован) +- [Activepieces](https://github.com/activepieces/activepieces) diff --git a/docs/diff-view-research.md b/docs/research/diff-view-research.md similarity index 100% rename from docs/diff-view-research.md rename to docs/research/diff-view-research.md diff --git a/docs/research/local-image-storage.md b/docs/research/local-image-storage.md new file mode 100644 index 00000000..b7c8e5c3 --- /dev/null +++ b/docs/research/local-image-storage.md @@ -0,0 +1,164 @@ +# Local Image Storage in Electron — Research & Recommendations + +## Context + +This document evaluates approaches for storing images/attachments locally in our Electron app (draft attachments in task descriptions, team-related images). The app uses Electron 28.x, React 18, and the main process already manages file I/O via IPC. + +--- + +## Approach 1: Filesystem + SQLite Metadata (Recommended) + +**How it works:** Store image files on disk under `app.getPath('userData')/attachments/`, serve them to the renderer via a custom `protocol.handle` scheme (`app://attachments/...`), and track metadata (path, original name, size, hash, created date, linked entity) in a `better-sqlite3` table. + +### Pros +- Best I/O performance — direct filesystem reads, no serialization overhead. +- `protocol.handle` (Electron 28+) is the modern, secure way to serve local files without disabling `webSecurity`. +- `better-sqlite3` is synchronous, zero-dependency after rebuild, and already proven in the Electron ecosystem. +- Thumbnails generated once by `sharp` and stored alongside originals — no re-computation. +- Simple garbage collection: query metadata for orphaned entries, `fs.unlink` the files. +- No practical per-file size limit (limited only by disk space). + +### Cons +- Requires `electron-rebuild` for `better-sqlite3` native bindings. +- Two storage systems to maintain (fs + sqlite). +- Path traversal must be prevented in the custom protocol handler (standard pattern, well-documented). + +### Key implementation details + +**Custom protocol (secure file serving):** +```ts +protocol.registerSchemesAsPrivileged([{ + scheme: 'app-img', + privileges: { standard: true, secure: true, supportFetchAPI: true } +}]); + +app.whenReady().then(() => { + protocol.handle('app-img', (req) => { + const { pathname } = new URL(req.url); + const base = path.join(app.getPath('userData'), 'attachments'); + const resolved = path.resolve(base, pathname.slice(1)); + const rel = path.relative(base, resolved); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { + return new Response('Forbidden', { status: 403 }); + } + return net.fetch(pathToFileURL(resolved).toString()); + }); +}); +``` + +**Thumbnail generation (sharp):** +```ts +import sharp from 'sharp'; + +async function createThumbnail(inputPath: string, outputPath: string) { + await sharp(inputPath) + .resize(300, 300, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 80 }) + .toFile(outputPath); +} +``` + +**Metadata schema (better-sqlite3):** +```sql +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, -- uuid + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + hash TEXT NOT NULL, -- sha256 for dedup + thumb_path TEXT, + entity_type TEXT, -- 'task' | 'team' | 'draft' + entity_id TEXT, + created_at INTEGER NOT NULL, + accessed_at INTEGER +); +``` + +**Garbage collection:** Periodic sweep (on app start or every N hours) — find rows where `entity_id` no longer exists in the app state, delete file + row. Also enforce a configurable max total size (e.g. 500 MB) with LRU eviction based on `accessed_at`. + +--- + +## Approach 2: Filesystem Only (Simplest) + +**How it works:** Store images and a sidecar `.json` metadata file per attachment under `userData/attachments/{uuid}/`. No database. + +### Pros +- Zero native dependencies — no `better-sqlite3` rebuild. +- Trivially portable (copy the folder). +- Simple to implement and debug. + +### Cons +- Querying metadata (e.g. "all images for task X") requires scanning directory + reading JSON files — O(n). +- No transactional integrity between metadata and files. +- Deduplication harder without indexed hashes. +- Garbage collection requires full directory walk. + +### When to choose this +If the total number of attachments is expected to stay under ~200 and complex queries are not needed. Good enough for an MVP. + +--- + +## Approach 3: IndexedDB (Renderer-Side) + +**How it works:** Store images as Blobs in IndexedDB (via Dexie.js wrapper) directly in the renderer process. + +### Pros +- No native dependencies at all. +- Built-in browser API, well-documented. +- Transactional, supports indexes and queries. + +### Cons +- **Performance:** Every read/write goes through Chromium's abstraction layers — significantly slower than direct fs I/O for large files. +- **Renderer-only:** Cannot be accessed from the main process without IPC round-trips. +- **Quota:** Chromium imposes storage quotas (varies, but often ~60% of disk on desktop). +- **Backup risk:** Data lives inside Chromium's internal LevelDB files — not human-readable, not easily portable. +- **Multi-window:** Concurrent access from multiple BrowserWindows can cause issues. + +### When to choose this +Only if the app is being designed as a pure web app with Electron as a thin shell, and images are small (< 5 MB each). + +--- + +## Comparison Matrix + +| Criterion | FS + SQLite | FS Only | IndexedDB | +|---------------------------|-----------------|----------------|----------------| +| Read/write performance | Excellent | Excellent | Moderate | +| Query capability | Full SQL | Manual scan | IndexedDB API | +| Native deps required | better-sqlite3 | None | None | +| Max file size | Disk limit | Disk limit | ~2 GB (blob) | +| Garbage collection | SQL query + unlink | Dir walk | Cursor iterate | +| Security (serving to renderer) | protocol.handle | protocol.handle | N/A (in-process) | +| Deduplication | Hash index | Manual | Hash index | +| Complexity | Medium | Low | Medium | +| Multi-window safe | Yes (main proc) | Yes (main proc)| Risky | + +--- + +## Recommendation + +**Use Approach 1 (Filesystem + SQLite Metadata)** for this project. + +Rationale: +1. The app already runs significant logic in the main process (file watchers, JSONL parsing, IPC handlers) — adding `better-sqlite3` fits the existing architecture. +2. `protocol.handle` is the Electron 28+ standard for secure local file serving; we should adopt it. +3. `sharp` handles thumbnailing efficiently (sub-millisecond per image) and outputs WebP for smaller sizes. +4. SQL metadata enables fast lookups by entity, dedup by hash, and clean GC queries. +5. The FS-only approach (Approach 2) is a valid MVP fallback if we want to avoid native deps initially, with a clear migration path to Approach 1 later. + +### Size Limits & Quotas (Suggested Defaults) +- Max single file: 20 MB (covers high-res screenshots). +- Max total storage: 500 MB (configurable in settings). +- Thumbnail size: 300px max dimension, WebP quality 80. +- GC trigger: on app start + every 6 hours while running. +- Orphan grace period: 24 hours (allows undo of deletions). + +### Libraries to Use +| Library | Version (as of 2026) | Purpose | +|---------|---------------------|---------| +| `better-sqlite3` | 11.x | Metadata storage | +| `sharp` | 0.33.x | Thumbnail generation, format conversion | +| `uuid` | 10.x (or `crypto.randomUUID()`) | Attachment IDs | + +### Migration Path +Start with Approach 2 (FS-only) if speed matters. Add `better-sqlite3` when we need querying or the attachment count grows. The file layout (`userData/attachments/{uuid}.{ext}`) stays the same — only the metadata layer changes. diff --git a/docs/research/markdown-rendering-pipeline.md b/docs/research/markdown-rendering-pipeline.md new file mode 100644 index 00000000..66e4a670 --- /dev/null +++ b/docs/research/markdown-rendering-pipeline.md @@ -0,0 +1,143 @@ +# Message Text Pipeline & Markdown Rendering — Research + +## Context + +Investigating why markdown formatting may appear as plain text in certain UI surfaces. + +--- + +## 1. Full Text Pipeline: MessageComposer -> Inbox -> ActivityItem + +### Step-by-step flow + +``` +MessageComposer.tsx (renderer) + |-- User types into MentionableTextarea (plain text + chip tokens) + |-- On send: serializeChipsWithText(text, chips) converts chip tokens to markdown fences + |-- Calls onSend(recipient, serialized, serialized, attachments?) + | +teamSlice.ts (renderer store) + |-- sendTeamMessage() -> api.teams.sendMessage(teamName, request) + | +TeamInboxWriter.ts (main process) + |-- Writes InboxMessage to JSON file at teams/{teamName}/inboxes/{member}.json + |-- `text` field stored verbatim — NO sanitization, NO escaping + |-- JSON.stringify with null,2 (pretty-print) — safe for any string content + | +FileWatcher detects change -> store loads inbox data + | +ActivityItem.tsx (renderer) + |-- stripAgentBlocks(message.text) removes ```info_for_agent``` blocks + |-- linkifyTaskIdsInMarkdown() converts #123 to [#123](task://123) + |-- Passes result to +``` + +### Conclusion: Markdown IS preserved end-to-end + +The text field is stored as-is in JSON. No sanitization or escaping strips markdown formatting. `serializeChipsWithText()` even enriches plain text with markdown code fences for code chips. + +--- + +## 2. TaskDetailDialog Description Rendering + +### Current implementation (TaskDetailDialog.tsx, lines 539-566) + +**Read mode (not editing):** +```tsx + +``` + +**Edit preview mode (lines 494-501):** +```tsx + +``` + +### Conclusion: TaskDetailDialog DOES use MarkdownViewer + +The description is rendered with `` in read mode. Markdown should render correctly here. If it appears as plain text, the issue is upstream — the description content itself may not contain markdown formatting (e.g., the task was created with plain text by CLI tooling, not from the UI). + +--- + +## 3. Sanitization/Escaping Analysis + +| Layer | Sanitization | Impact on Markdown | +|-------|-------------|-------------------| +| `serializeChipsWithText()` | Replaces chip tokens with markdown — additive | **None** (enriches) | +| `TeamInboxWriter.sendMessage()` | None — stores `request.text` verbatim | **None** | +| `JSON.stringify/parse` | Standard JSON encoding | **None** (reversible) | +| `stripAgentBlocks()` | Removes ````info_for_agent``` blocks only | **None** (targeted) | +| `linkifyTaskIdsInMarkdown()` | Converts `#123` to `[#123](task://123)` | **None** (additive) | +| `ReactMarkdown` | Parses markdown to HTML | **This IS the rendering** | + +**No layer strips or escapes markdown formatting.** The pipeline is clean. + +--- + +## 4. Effect of MarkdownViewer `bare` Prop + +From `MarkdownViewer.tsx` (lines 561-571): + +```tsx +
+``` + +**`bare` only affects the wrapper div styling:** +- `bare={true}`: No background, no border, no shadow — for embedding inside cards. +- `bare={false}` (default): Adds `CODE_BG` background, `CODE_BORDER` border, `rounded-lg shadow-sm`. + +**`bare` does NOT affect markdown parsing or rendering.** The same `ReactMarkdown` component with `remarkGfm` and `rehype-highlight` is used regardless. + +--- + +## 5. Where Markdown Might Appear as Plain Text + +### Identified scenarios + +1. **Task descriptions from CLI tooling (teamctl.js / Claude agents)** + Task descriptions are set via `node teamctl.js task create` or `TaskCreate` tool calls. Agents typically write plain text descriptions, not markdown. The description content itself lacks formatting — MarkdownViewer renders it correctly, but there's nothing to format. + +2. **Task comments from agents** + Same issue — `task comment --text "..."` passes plain text. However, TaskCommentsSection.tsx (line 246) correctly uses ``. + +3. **Structured JSON messages in ActivityItem** + When `parseStructuredAgentMessage()` returns a match, the structured path renders `autoSummary` as a `

` tag and shows raw JSON in a `

` block — no MarkdownViewer. This is intentional for JSON protocol messages. + +4. **Summary text in ActivityItem header** + Line 375-377: `summaryText` is rendered as plain `` in the header row. This is by design — summaries are short single-line previews. + +5. **ReplyQuoteBlock path in ActivityItem** + When `parsedReply` is detected, it renders `` instead of MarkdownViewer. The quote block may not support full markdown — worth checking. + +### NOT an issue + +- Description in TaskDetailDialog: correctly uses MarkdownViewer. +- Activity feed messages: correctly uses MarkdownViewer for displayText. +- Task comments: correctly uses MarkdownViewer. + +--- + +## 6. Proposed Fixes + +### Fix 1: No code change needed for the rendering pipeline +The pipeline is correct. All text display surfaces that show long-form content already use MarkdownViewer. + +### Fix 2: If specific content appears unformatted, the fix is upstream +Ensure that agents/tooling that create tasks or comments use markdown formatting in their text. For example, the `teamctl.js` task create command could document that `--description` supports markdown. + +### Fix 3 (Optional): ReplyQuoteBlock markdown support +`ReplyQuoteBlock` renders the reply body. If it currently shows plain text, wrap body content in `` for consistency. (Needs verification — separate from this research scope.) + +--- + +## Summary + +| Question | Answer | +|----------|--------| +| Is markdown preserved in the pipeline? | Yes — no sanitization strips it | +| Does TaskDetailDialog use MarkdownViewer? | Yes — both read mode and edit preview | +| Does any escaping strip formatting? | No — all transformations are additive or targeted | +| Does `bare` affect rendering? | No — only wrapper styling (bg/border/shadow) | +| Why might text appear unformatted? | Source content (from agents/CLI) is plain text, not markdown | diff --git a/docs/research/web-deployment.md b/docs/research/web-deployment.md new file mode 100644 index 00000000..fe6e0bfe --- /dev/null +++ b/docs/research/web-deployment.md @@ -0,0 +1,183 @@ +# Web Deployment: Electron → Web Hybrid + +> Дата: 2026-03-04 +> Статус: Исследование завершено, рекомендация выбрана + +## Цель + +Запускать приложение на удалённом сервере с доступом через веб-интерфейс (браузер), сохраняя при этом десктопную Electron-версию. + +## Исследованные варианты + +--- + +### 1. `electron-to-web` — Drop-in замена IPC → WebSocket + +- **npm**: [electron-to-web](https://libraries.io/npm/electron-to-web) +- **GitHub**: [lsadehaan/electron-to-web](https://github.com/lsadehaan/electron-to-web) +- **Версия**: 0.2.0 (первый релиз — январь 2026) +- **Загрузки**: 0/неделю +- **Зависимости**: `ws`, `json-rpc-2.0` +- **Лицензия**: MIT + +**Как работает**: Меняешь 2 импорта (`'electron'` → `'electron-to-web/main'`), и IPC автоматически конвертируется в JSON-RPC over WebSocket. Маппинг: `ipcRenderer.invoke()` → JSON-RPC request, `webContents.send()` → JSON-RPC notification. + +**Плюсы**: минимальный объём изменений. +**Минусы**: пакет совсем сырой (v0.2.0, один автор, 0 загрузок). Для продакшна не готов. + +| Надёжность | Уверенность | +|------------|-------------| +| 3/10 | 8/10 | + +--- + +### 2. `electron-common-ipc` — IPC-шина с WebSocket + +- **GitHub**: [emmkimme/electron-common-ipc](https://github.com/emmkimme/electron-common-ipc) +- **Версия**: 16.0.4 (зрелый проект) +- **Загрузки**: ~35/неделю +- **Суб-пакет**: `@electron-common-ipc/web-socket-browser` + +**Как работает**: EventEmitter-like API для обмена данными между любыми процессами (Node, Electron Main/Renderer). Есть WebSocket-расширение для браузера. + +**Плюсы**: зрелый пакет, много версий. +**Минусы**: низкоуровневая шина, не drop-in замена Electron IPC. Придётся адаптировать все handlers вручную. + +| Надёжность | Уверенность | +|------------|-------------| +| 5/10 | 7/10 | + +--- + +### 3. Neutralino.js — встроенный cloud mode + +- **Сайт**: [neutralino.js.org](https://neutralino.js.org/) +- **GitHub**: [neutralinojs/neutralinojs](https://github.com/neutralinojs/neutralinojs) (~22k stars) +- **Документация**: [Modes](https://neutralino.js.org/docs/configuration/modes/) + +**Как работает**: Альтернативный фреймворк с 4 режимами из коробки: + +| Режим | Описание | +|------------|---------------------------------------| +| `window` | Нативное окно (как Electron) | +| `browser` | Открывает в дефолтном браузере | +| `chrome` | Chrome App Mode | +| `cloud` | Запускает как веб-сервер по сети | + +Cloud mode — именно то, что нужно для серверного деплоя. Но это **другой фреймворк**, не совместимый с Electron. + +**Плюсы**: встроенная поддержка web-деплоя, лёгкий (~2MB vs ~150MB Electron). +**Минусы**: требует полной переписки приложения. + +| Надёжность | Уверенность | +|------------|-------------| +| 7/10 | 6/10 | + +--- + +### ✅ 4. DIY Transport Abstraction (Slack Pattern) — РЕКОМЕНДУЕТСЯ + +- **Источник**: [Slack Engineering: Interop's Labyrinth](https://slack.engineering/interops-labyrinth-sharing-code-between-web-electron-apps/) +- **Источник**: [Slack Engineering: Building Hybrid Applications](https://slack.engineering/building-hybrid-applications-with-electron/) + +**Как работает**: Абстракция транспортного слоя — фронтенд не знает, работает он через Electron IPC или через WebSocket. Один интерфейс, две реализации: + +```typescript +// Интерфейс транспорта +interface IpcTransport { + invoke(channel: string, ...args: unknown[]): Promise; + on(channel: string, cb: (...args: unknown[]) => void): () => void; +} + +// Electron — для десктоп-версии +class ElectronTransport implements IpcTransport { + invoke(ch, ...args) { return window.api[ch](...args); } + on(ch, cb) { return window.api.on(ch, cb); } +} + +// WebSocket — для веб-версии +class WebSocketTransport implements IpcTransport { + private ws: WebSocket; + invoke(ch, ...args) { /* JSON-RPC через WS */ } + on(ch, cb) { /* подписка на WS сообщения */ } +} +``` + +На сервере: Express/Fastify + WebSocket, обработчики зеркалят существующие IPC handlers. + +**Плюсы**: +- Проверено Slack в продакшне (миллионы пользователей) +- Полный контроль над архитектурой +- Никаких внешних зависимостей с сомнительным качеством +- ~100 строк кода для абстракции +- Сохраняет обе версии (десктоп + веб) + +**Минусы**: нужно написать самим (но объём небольшой). + +| Надёжность | Уверенность | +|------------|-------------| +| **9/10** | **9/10** | + +--- + +### ✅ 5. Vite Web Build + DIY — РЕКОМЕНДУЕТСЯ + +Расширение варианта 4, адаптированное под наш стек. + +**Как работает**: `src/renderer/` — уже обычное React SPA на Vite. Его можно собрать отдельно стандартным `vite build` для веба, подменив IPC через абстракцию из п.4. + +Архитектура: +``` +Сервер (VPS) +├── Node.js сервер (Express/Fastify) +│ ├── HTTP API (зеркало IPC handlers из src/main/) +│ ├── WebSocket (live updates — замена FileWatcher events) +│ └── Claude Code процессы (spawn/manage) +└── Static files (React SPA — vite build из src/renderer/) + +Браузер (где угодно) +└── React SPA → HTTP/WS → сервер +``` + +**Что нужно сделать**: +1. Абстракция транспорта (`IpcTransport` interface) — ~100 LOC +2. `ElectronTransport` — обёртка над `window.api` — ~50 LOC +3. `WebSocketTransport` — JSON-RPC через WS — ~150 LOC +4. Node.js HTTP/WS сервер, зеркалящий IPC handlers — ~300-500 LOC +5. Vite конфиг для web-сборки — ~30 LOC +6. Auth для веб-версии (JWT/session) — ~200 LOC + +**Плюсы**: +- Минимальные изменения в существующем коде +- Renderer остаётся тем же React SPA +- Работает с текущим стеком (Vite + React + Zustand + Tailwind) +- Electron-версия продолжает работать как раньше +- Latency: JSON-RPC добавляет ~1-2ms vs нативный IPC + +**Минусы**: нужно поддерживать серверную часть отдельно. + +| Надёжность | Уверенность | +|------------|-------------| +| **8/10** | **8/10** | + +--- + +## Сводная таблица + +| Подход | Объём работы | Надёжность | Риск | +|--------------------------|---------------------------|------------|-----------------------------| +| `electron-to-web` | Минимальный (2 импорта) | 3/10 | Пакет v0.2.0, 0 загрузок | +| `electron-common-ipc` | Средний | 5/10 | Нужна адаптация handlers | +| Neutralino.js | Огромный (переписать всё) | 7/10 | Другой фреймворк | +| **DIY (Slack pattern)** | **Средний (~3-4 дня)** | **9/10** | **Минимальный** | +| **Vite web build + DIY** | **Средний (~3-4 дня)** | **8/10** | **Минимальный** | + +## Решение + +**Вариант 4 + 5** — DIY абстракция транспорта (по паттерну Slack) + отдельная Vite web-сборка. Это единственный подход, который: + +- Проверен в продакшне крупными компаниями +- Не зависит от сырых/нишевых пакетов +- Сохраняет обратную совместимость с Electron-версией +- Требует умеренного объёма работы (~3-4 дня) +- Даёт полный контроль над архитектурой diff --git a/docs/research/workflow-editors.md b/docs/research/workflow-editors.md new file mode 100644 index 00000000..f785f02b --- /dev/null +++ b/docs/research/workflow-editors.md @@ -0,0 +1,202 @@ +# Workflow Editor: визуальные библиотеки и паттерны + +> Дата: 2026-03-04 +> Статус: Исследование завершено + +## Цель + +Выбрать библиотеку для визуального node-based workflow editor в React-приложении для оркестрации AI-агентов. + +--- + +## Часть 1: Библиотеки + +### ✅ @xyflow/react (React Flow) — ОДНОЗНАЧНЫЙ ЛИДЕР + +| Параметр | Значение | +|---|---| +| **GitHub** | [xyflow/xyflow](https://github.com/xyflow/xyflow) | +| **npm** | [@xyflow/react](https://www.npmjs.com/package/@xyflow/react) | +| **Stars** | ~35,500 | +| **npm загрузки/неделю** | ~4,900,000 (старый `reactflow` + новый `@xyflow/react`) | +| **Версия** | v12.10.1 (2025) | +| **Лицензия** | MIT | +| **Bundle size** | ~40-50 kB min+gzip | +| **TypeScript** | Полная поддержка, написан на TypeScript | +| **React** | Нативная React-библиотека | + +**Ключевые фичи:** +- Drag & drop нод, zoom/pan, minimap, controls — из коробки +- Кастомные ноды и edges = обычные React-компоненты +- Hooks API (`useNodes`, `useEdges`, `useReactFlow`) +- Новые React Flow Components на базе **shadcn/ui** (2025) +- Performance: перерисовываются только изменённые ноды + +**Кто использует:** Stripe, n8n (Vue Flow), Langflow, Flowise, **Dify** — 4 из 8 крупнейших AI-платформ + +**Надёжность: 10/10 | Уверенность: 10/10** + +--- + +### Rete.js + +| Параметр | Значение | +|---|---| +| **GitHub** | [retejs/rete](https://github.com/retejs/rete) | +| **Stars** | ~11,900 | +| **npm загрузки/неделю** | ~42,700 | +| **Лицензия** | MIT | +| **React** | Через плагин `rete-react-plugin` | + +Фреймворк-агностик (React, Vue, Angular, Svelte). Более "инженерный" подход с типизированными портами. Rete Studio — уникальная фича code ↔ visual. Но значительно меньше комьюнити и слабее документация. + +**Надёжность: 7/10 | Уверенность: 8/10** + +--- + +### AntV X6 + +| Параметр | Значение | +|---|---| +| **GitHub** | [antvis/X6](https://github.com/antvis/X6) | +| **Stars** | ~6,400 | +| **npm загрузки/неделю** | ~68,200 | +| **Лицензия** | MIT | + +Enterprise-grade, часть экосистемы Ant Design. SVG/HTML рендеринг. Но документация преимущественно на китайском, React-интеграция через обёртки. + +**Надёжность: 7/10 | Уверенность: 8/10** + +--- + +### JointJS / JointJS+ + +| Параметр | Значение | +|---|---| +| **GitHub** | [clientIO/joint](https://github.com/clientIO/joint) | +| **Stars** | ~5,170 | +| **Лицензия** | MPL 2.0 (open source) / Commercial (JointJS+ от $2,990) | + +Самая зрелая библиотека (с 2010). BPMN 2.0, UML, ERD, Visio import/export. Но коммерческая лицензия дорогая, open source часть ограничена. + +**Надёжность: 8/10 | Уверенность: 8/10** + +--- + +### Drawflow, Flume, BaklavaJS, LiteGraph.js, jsPlumb + +| Библиотека | Stars | React | TypeScript | Статус | +|---|---|---|---|---| +| Drawflow | 6,000 | Нет | Нет | Заброшен | +| LiteGraph.js | 7,800 | Нет (Canvas2D) | Нет | ComfyUI мигрирует на Vue | +| jsPlumb | 7,800 | Через Toolkit ($990) | Частично | Community заброшен | +| Flume | 1,500 | Да | Частично | Полу-заброшен | +| BaklavaJS | 1,500 | Нет (только Vue) | Да | Нишевый | + +Все перечисленные **не подходят** для нашего проекта из-за отсутствия React-поддержки, TypeScript, или заброшенности. + +--- + +## Сводная таблица + +| Библиотека | Stars | npm/нед. | React | TS | Лицензия | Цена | Рекомендация | +|---|---|---|---|---|---|---|---| +| **@xyflow/react** | 35,500 | 4,900,000 | Нативный | Да | MIT | Free | ✅ **Выбор** | +| Rete.js | 11,900 | 42,700 | Плагин | Да | MIT | Free | Альтернатива | +| AntV X6 | 6,400 | 68,200 | Обёртка | Да | MIT | Free | Для Ant Design | +| JointJS | 5,200 | 51,900 | @joint/react | Да | MPL/Comm. | от $2,990 | Enterprise | +| Остальные | <8K | <15K | Нет/Частично | Нет | MIT | Free | Не подходят | + +--- + +## Часть 2: Как AI-платформы реализуют workflow editors + +### Кто какую библиотеку использует + +| Платформа | Stars | Библиотека | Фреймворк | +|---|---|---|---| +| **n8n** | ~177K | Vue Flow (Vue-порт React Flow) | Vue 3 + Pinia | +| **Langflow** | ~130K | React Flow | React + FastAPI | +| **Dify** | ~119K | React Flow | Next.js + React 19 + Flask | +| **Flowise** | ~43K | React Flow | React + Express | +| **ComfyUI** | ~103K | LiteGraph.js → Vue (мигрирует) | Vue 3 + Pinia | +| **Rivet** | ~4.5K | Кастомный canvas | React + Tauri | +| **Promptflow** | ~11K | Кастомный DAG в VS Code | VS Code webview | +| **Haystack** | ~24K | Закрытый фронтенд | Python + hosted UI | + +**React Flow используют 4 из 8 платформ** — де-факто стандарт. + +--- + +### Лучшие UX-паттерны для заимствования + +| Паттерн | Из платформы | Описание | +|---|---|---| +| **Relationships Panel** | Dify | Shift+click подсвечивает связи узла, затемняя остальное | +| **Real-time data flow** | Rivet | При execution видны данные, текущие через каждый wire | +| **Inline prompt editor** | Dify, Langflow | Редактирование промпта прямо в узле на канвасе | +| **AI Graph Creator** | Rivet | CMD+I — AI создает/редактирует граф по промпту | +| **Prompt optimization** | Dify | AI-ассистент автоматически оптимизирует промпт | +| **Auto-fix code** | Dify | Если Code-узел падает, AI генерирует исправление | +| **HITL checkpoint** | Flowise | Агент останавливается и запрашивает confirmation у человека | +| **Flow-as-subflow** | Flowise, Rivet | Flow вызывает другой flow как функцию | +| **YAML/JSON export** | Rivet, Promptflow | Графы как код = version control и diff | +| **Typed & colored ports** | ComfyUI, Dify | Цветовое кодирование портов по типу данных | +| **Playground/Chat** | Langflow, Dify | Встроенный чат для тестирования без деплоя | +| **Variable live tracking** | Dify | При debug-запуске видны значения переменных в каждом узле | + +--- + +### Главный тренд 2025: Agent Node + +Dify, Flowise, Langflow — все добавили **"Agent Node"** — узел, где LLM сам решает какие tools вызывать. Workflow перестаёт быть чисто детерминированным DAG-ом и включает LLM-driven branching. + +--- + +### React Flow vs Custom Canvas — когда что + +| Подход | Когда использовать | Пример | +|---|---|---| +| **React Flow** | <500 нод, нужна быстрая разработка, Rich UI | Dify, Langflow, Flowise | +| **Custom Canvas2D** | Тысячи нод, max performance | ComfyUI (но мигрирует на Vue!) | +| **Кастомный canvas** | Desktop app, полный контроль | Rivet (Tauri) | + +ComfyUI мигрирует с Canvas2D на Vue-компоненты — показатель того, что **гибкость UI важнее raw performance**. + +--- + +## Решение + +**@xyflow/react (React Flow)** — единственный обоснованный выбор: + +1. 4 из 8 крупнейших AI-платформ используют React Flow +2. ~5M загрузок/неделю, 35.5K stars, MIT лицензия +3. Нативный React, TypeScript, shadcn-совместимые компоненты +4. Кастомные ноды = обычные React-компоненты (идеально для нашего стека) +5. Отличная документация и активная команда + +### Конкретный план интеграции + +```bash +pnpm add @xyflow/react +``` + +Типы нод для нашего workflow editor: +- **AgentNode** — Claude Code агент (настройка модели, промпта, tools) +- **TeamNode** — группа агентов с ролями +- **TaskNode** — задача для агента +- **ConditionNode** — IF/ELSE ветвление +- **MCP ServerNode** — подключение MCP-сервера +- **SkillNode** — подключение skill/plugin +- **TriggerNode** — запуск workflow (cron, webhook, manual) +- **OutputNode** — результат (файл, PR, сообщение) + +Sources: +- [xyflow/xyflow GitHub](https://github.com/xyflow/xyflow) +- [React Flow Components (shadcn)](https://xyflow.com/blog/react-flow-components) +- [React Flow Showcase](https://reactflow.dev/showcase) +- [Dify Workflow Source](https://github.com/langgenius/dify/blob/main/web/app/components/workflow/index.tsx) +- [n8n Canvas Architecture](https://deepwiki.com/n8n-io/n8n-docs/2.2-editor-ui) +- [Flowise Agentflow V2](https://docs.flowiseai.com/using-flowise/agentflowv2) +- [Rivet GitHub](https://github.com/Ironclad/rivet) +- [ComfyUI Nodes 2.0](https://docs.comfy.org/interface/nodes-2) diff --git a/src/main/index.ts b/src/main/index.ts index ff729dae..7adf3dc0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -438,48 +438,60 @@ function wireFileWatcherEvents(context: ServiceContext): void { } httpServer?.broadcast('team-change', event); - // Process inbox change events — relay to lead + native OS notifications. + // Process inbox and task change events. try { if (!event || typeof event !== 'object') return; const row = event as { type?: unknown; teamName?: unknown; detail?: unknown }; - if (row.type !== 'inbox') return; if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return; const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; - // Auto-relay direct messages to live team lead process (no UI dependency). - if (teamProvisioningService.isTeamAlive(teamName)) { - void teamProvisioningService - .relayLeadInboxMessages(teamName) - .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`)); + // --- Inbox change events: relay to lead + native OS notifications --- + if (row.type === 'inbox') { + // Auto-relay direct messages to live team lead process (no UI dependency). + if (teamProvisioningService.isTeamAlive(teamName)) { + void teamProvisioningService + .relayLeadInboxMessages(teamName) + .catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`)); + } + + // Show native OS notification for new inbox messages (debounced per inbox). + if (detail.startsWith('inboxes/')) { + const timerKey = `${teamName}:${detail}`; + const existing = inboxNotifyTimers.get(timerKey); + if (existing) clearTimeout(existing); + inboxNotifyTimers.set( + timerKey, + setTimeout(() => { + inboxNotifyTimers.delete(timerKey); + void notifyNewInboxMessages(teamName, detail).catch(() => undefined); + }, INBOX_NOTIFY_DEBOUNCE_MS) + ); + } + + // Show native OS notification for new lead → user messages (sentMessages.json). + if (detail === 'sentMessages.json') { + const timerKey = `${teamName}:sentMessages`; + const existing = inboxNotifyTimers.get(timerKey); + if (existing) clearTimeout(existing); + inboxNotifyTimers.set( + timerKey, + setTimeout(() => { + inboxNotifyTimers.delete(timerKey); + void notifyNewSentMessages(teamName).catch(() => undefined); + }, INBOX_NOTIFY_DEBOUNCE_MS) + ); + } } - // Show native OS notification for new inbox messages (debounced per inbox). - if (detail.startsWith('inboxes/')) { - const timerKey = `${teamName}:${detail}`; - const existing = inboxNotifyTimers.get(timerKey); - if (existing) clearTimeout(existing); - inboxNotifyTimers.set( - timerKey, - setTimeout(() => { - inboxNotifyTimers.delete(timerKey); - void notifyNewInboxMessages(teamName, detail).catch(() => undefined); - }, INBOX_NOTIFY_DEBOUNCE_MS) - ); - } - - // Show native OS notification for new lead → user messages (sentMessages.json). - if (detail === 'sentMessages.json') { - const timerKey = `${teamName}:sentMessages`; - const existing = inboxNotifyTimers.get(timerKey); - if (existing) clearTimeout(existing); - inboxNotifyTimers.set( - timerKey, - setTimeout(() => { - inboxNotifyTimers.delete(timerKey); - void notifyNewSentMessages(teamName).catch(() => undefined); - }, INBOX_NOTIFY_DEBOUNCE_MS) - ); + // --- Task change events: notify lead when teammate starts a task via CLI --- + if (row.type === 'task' && detail.endsWith('.json') && teamDataService) { + const taskId = detail.replace('.json', ''); + void teamDataService + .notifyLeadOnTeammateTaskStart(teamName, taskId) + .catch((e: unknown) => + logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`) + ); } } catch { // ignore diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 13be0714..29206169 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -23,6 +23,7 @@ import { TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, + TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, @@ -49,6 +50,9 @@ import { TEAM_UPDATE_TASK_FIELDS, TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_STATUS, + TEAM_SAVE_TASK_ATTACHMENT, + TEAM_GET_TASK_ATTACHMENT, + TEAM_DELETE_TASK_ATTACHMENT, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design } from '@preload/constants/ipcChannels'; import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban'; @@ -75,6 +79,7 @@ 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, @@ -84,16 +89,19 @@ import type { } from '../services'; import type { AttachmentFileData, + AttachmentMediaType, AttachmentMeta, AttachmentPayload, CreateTaskRequest, GlobalTask, IpcResult, KanbanColumnId, + LeadContextUsage, MemberFullStats, MemberLogSummary, SendMessageRequest, SendMessageResult, + TaskAttachmentMeta, TaskComment, TeamConfig, TeamCreateConfigRequest, @@ -165,6 +173,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null; let memberStatsComputer: MemberStatsComputer | null = null; const attachmentStore = new TeamAttachmentStore(); +const taskAttachmentStore = new TeamTaskAttachmentStore(); const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file @@ -222,6 +231,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments); ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess); ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); + ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext); ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask); ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask); ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); @@ -229,6 +239,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification); ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship); ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship); + ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment); + ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment); + ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment); logger.info('Team handlers registered'); } @@ -271,6 +284,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_ATTACHMENTS); ipcMain.removeHandler(TEAM_KILL_PROCESS); ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); + ipcMain.removeHandler(TEAM_LEAD_CONTEXT); ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); ipcMain.removeHandler(TEAM_RESTORE_TASK); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); @@ -278,6 +292,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION); ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP); ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP); + ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT); + ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT); + ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT); } function getTeamDataService(): TeamDataService { @@ -1280,12 +1297,21 @@ async function handleUpdateTaskOwner( return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' }; } - if (owner !== null && (typeof owner !== 'string' || owner.length === 0)) { - return { success: false, error: 'owner must be a non-empty string or null' }; + let nextOwner: string | null = null; + if (owner !== null) { + const validatedOwner = validateMemberName(owner); + if (!validatedOwner.valid) { + return { success: false, error: validatedOwner.error ?? 'Invalid owner' }; + } + nextOwner = validatedOwner.value!; } return wrapTeamHandler('updateTaskOwner', () => - getTeamDataService().updateTaskOwner(validatedTeamName.value!, validatedTaskId.value!, owner) + getTeamDataService().updateTaskOwner( + validatedTeamName.value!, + validatedTaskId.value!, + nextOwner + ) ); } @@ -1510,6 +1536,19 @@ async function handleLeadActivity( ); } +async function handleLeadContext( + _event: IpcMainInvokeEvent, + teamName: unknown +): Promise> { + const validated = validateTeamName(teamName); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid teamName' }; + } + return wrapTeamHandler('leadContext', async () => + getTeamProvisioningService().getLeadContextUsage(validated.value!) + ); +} + async function handleStopTeam( _event: IpcMainInvokeEvent, teamName: unknown @@ -1680,9 +1719,9 @@ async function handleUpdateTaskFields( ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; - if (typeof taskId !== 'string' || !taskId.trim()) { - return { success: false, error: 'taskId must be a non-empty string' }; - } + const vTask = validateTaskId(taskId); + if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + const tid = vTask.value!; if (!fields || typeof fields !== 'object') { return { success: false, error: 'fields must be an object' }; } @@ -1698,7 +1737,7 @@ async function handleUpdateTaskFields( } const validFields: { subject?: string; description?: string } = {}; - if (typeof subject === 'string') validFields.subject = subject; + if (typeof subject === 'string') validFields.subject = subject.trim(); if (typeof description === 'string') validFields.description = description; if (Object.keys(validFields).length === 0) { @@ -1707,7 +1746,7 @@ async function handleUpdateTaskFields( return wrapTeamHandler('updateTaskFields', async () => { const tn = vTeam.value!; - await getTeamDataService().updateTaskFields(tn, taskId, validFields); + await getTeamDataService().updateTaskFields(tn, tid, validFields); // Notify the lead about updated task fields const provisioning = getTeamProvisioningService(); @@ -1716,12 +1755,12 @@ async function handleUpdateTaskFields( if (validFields.subject) changedParts.push('title'); if (validFields.description !== undefined) changedParts.push('description'); const message = - `Task #${taskId} has been updated by the user (changed: ${changedParts.join(', ')}). ` + + `Task #${tid} has been updated by the user (changed: ${changedParts.join(', ')}). ` + `New title: "${validFields.subject ?? '(unchanged)'}".`; try { await provisioning.sendMessageToTeam(tn, message); } catch { - logger.warn(`Failed to notify lead about task fields update for #${taskId} in ${tn}`); + logger.warn(`Failed to notify lead about task fields update for #${tid} in ${tn}`); } } }); @@ -1897,7 +1936,8 @@ async function handleAddTaskComment( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, - text: unknown + text: unknown, + attachments?: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; @@ -1908,9 +1948,54 @@ async function handleAddTaskComment( if (text.trim().length > 2000) return { success: false, error: 'Comment exceeds 2000 characters' }; - return wrapTeamHandler('addTaskComment', () => - getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim()) - ); + const rawAttachments = Array.isArray(attachments) ? attachments : []; + if (rawAttachments.length > MAX_ATTACHMENTS) { + return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` }; + } + + return wrapTeamHandler('addTaskComment', async () => { + // Save comment attachments (images). Done inside wrapTeamHandler so failures return IpcResult. + let savedAttachments: TaskAttachmentMeta[] | undefined; + if (rawAttachments.length > 0) { + savedAttachments = []; + for (const att of rawAttachments) { + if (!att || typeof att !== 'object') { + throw new Error('Invalid attachment data'); + } + const a = att as Record; + if ( + typeof a.id !== 'string' || + typeof a.filename !== 'string' || + typeof a.mimeType !== 'string' || + typeof a.base64Data !== 'string' || + a.base64Data.length === 0 || + !ALLOWED_ATTACHMENT_TYPES.has(a.mimeType) + ) { + throw new Error('Invalid attachment data'); + } + const safeId = a.id.trim(); + if (safeId.includes('/') || safeId.includes('\\') || safeId.includes('..')) { + throw new Error('Invalid attachment ID'); + } + const meta = await taskAttachmentStore.saveAttachment( + vTeam.value!, + vTask.value!, + safeId, + a.filename, + a.mimeType as AttachmentMediaType, + a.base64Data + ); + savedAttachments.push(meta); + } + } + + return getTeamDataService().addTaskComment( + vTeam.value!, + vTask.value!, + text.trim(), + savedAttachments + ); + }); } const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const; @@ -1975,3 +2060,122 @@ async function handleRemoveTaskRelationship( ) ); } + +// --------------------------------------------------------------------------- +// Task Attachment Handlers +// --------------------------------------------------------------------------- + +async function handleSaveTaskAttachment( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + attachmentId: unknown, + filename: unknown, + mimeType: unknown, + base64Data: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + const vTask = validateTaskId(taskId); + if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { + return { success: false, error: 'attachmentId must be a non-empty string' }; + } + if (typeof filename !== 'string' || filename.trim().length === 0) { + return { success: false, error: 'filename must be a non-empty string' }; + } + if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { + return { + success: false, + error: `mimeType must be one of: ${[...ALLOWED_ATTACHMENT_TYPES].join(', ')}`, + }; + } + if (typeof base64Data !== 'string' || base64Data.length === 0) { + return { success: false, error: 'base64Data must be a non-empty string' }; + } + // Sanitize IDs against path traversal + const safeAttId = attachmentId.trim(); + if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) { + return { success: false, error: 'Invalid attachmentId' }; + } + + return wrapTeamHandler('saveTaskAttachment', async () => { + const meta = await taskAttachmentStore.saveAttachment( + vTeam.value!, + vTask.value!, + safeAttId, + filename as string, + mimeType as AttachmentMediaType, + base64Data as string + ); + // Write metadata into the task JSON + await getTeamDataService().addTaskAttachment(vTeam.value!, vTask.value!, meta); + return meta; + }); +} + +async function handleGetTaskAttachment( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + attachmentId: unknown, + mimeType: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + const vTask = validateTaskId(taskId); + if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { + return { success: false, error: 'attachmentId must be a non-empty string' }; + } + if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { + return { success: false, error: 'Invalid mimeType' }; + } + const safeAttId = attachmentId.trim(); + if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) { + return { success: false, error: 'Invalid attachmentId' }; + } + + return wrapTeamHandler('getTaskAttachment', () => + taskAttachmentStore.getAttachment( + vTeam.value!, + vTask.value!, + safeAttId, + mimeType as AttachmentMediaType + ) + ); +} + +async function handleDeleteTaskAttachment( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + attachmentId: unknown, + mimeType: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + const vTask = validateTaskId(taskId); + if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + if (typeof attachmentId !== 'string' || attachmentId.trim().length === 0) { + return { success: false, error: 'attachmentId must be a non-empty string' }; + } + if (typeof mimeType !== 'string' || !ALLOWED_ATTACHMENT_TYPES.has(mimeType)) { + return { success: false, error: 'Invalid mimeType' }; + } + const safeAttId = attachmentId.trim(); + if (safeAttId.includes('/') || safeAttId.includes('\\') || safeAttId.includes('..')) { + return { success: false, error: 'Invalid attachmentId' }; + } + + return wrapTeamHandler('deleteTaskAttachment', async () => { + await taskAttachmentStore.deleteAttachment( + vTeam.value!, + vTask.value!, + safeAttId, + mimeType as AttachmentMediaType + ); + // Remove metadata from task JSON + await getTeamDataService().removeTaskAttachment(vTeam.value!, vTask.value!, safeAttId); + }); +} diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index a7c86dad..c9056f04 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -703,6 +703,11 @@ export class ProjectScanner { let startIndex = 0; if (cursor) { try { + // Defensive limit: cursor originates from a query param / IPC input and should be tiny. + // Prevent pathological memory allocation on Buffer.from(cursor, 'base64'). + if (cursor.length > 4096) { + throw new Error('cursor too large'); + } const decoded = JSON.parse( Buffer.from(cursor, 'base64').toString('utf8') ) as SessionCursor; diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index c402c895..cd4d36d7 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -12,11 +12,25 @@ const logger = createLogger('Service:TeamAttachmentStore'); const ATTACHMENTS_DIR = 'attachments'; export class TeamAttachmentStore { + private assertSafePathSegment(label: string, value: string): void { + if ( + value.length === 0 || + value.includes('/') || + value.includes('\\') || + value.includes('..') || + value.includes('\0') + ) { + throw new Error(`Invalid ${label}`); + } + } + private getDir(teamName: string): string { + this.assertSafePathSegment('teamName', teamName); return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR); } private getFilePath(teamName: string, messageId: string): string { + this.assertSafePathSegment('messageId', messageId); return path.join(this.getDir(teamName), `${messageId}.json`); } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 64fd6c37..2f9cc319 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -65,6 +65,8 @@ const TASK_MAP_YIELD_EVERY = 250; export class TeamDataService { private processHealthTimer: ReturnType | null = null; private processHealthTeams = new Set(); + /** Tracks notified task-start transitions to avoid duplicate lead notifications. */ + private notifiedTaskStarts = new Set(); constructor( private readonly configReader: TeamConfigReader = new TeamConfigReader(), @@ -873,6 +875,48 @@ export class TeamDataService { await this.taskWriter.updateStatus(teamName, taskId, status, actor); } + /** + * Called when a task file changes on disk (e.g. teammate CLI wrote it). + * If the latest statusHistory entry shows a non-user actor started the task, + * sends an inbox notification to the team lead. + */ + async notifyLeadOnTeammateTaskStart(teamName: string, taskId: string): Promise { + try { + const tasks = await this.taskReader.getTasks(teamName); + const task = tasks.find((t) => t.id === taskId); + if (!task) return; + + const history = task.statusHistory; + if (!Array.isArray(history) || history.length === 0) return; + + const last = history[history.length - 1]; + if (last.to !== 'in_progress') return; + if (!last.actor || last.actor === 'user') return; + + // Dedup: only notify once per unique transition (keyed by team+task+timestamp). + const dedupKey = `${teamName}:${taskId}:${last.timestamp}`; + if (this.notifiedTaskStarts.has(dedupKey)) return; + this.notifiedTaskStarts.add(dedupKey); + // Prevent unbounded growth in long-running sessions. + if (this.notifiedTaskStarts.size > 500) { + const first = this.notifiedTaskStarts.values().next().value!; + this.notifiedTaskStarts.delete(first); + } + + const leadName = await this.resolveLeadName(teamName); + if (this.isLeadOwner(last.actor, leadName)) return; + + await this.sendMessage(teamName, { + member: leadName, + from: last.actor, + text: `Task #${task.id} "${task.subject}" has been started by ${last.actor}.`, + summary: `Task #${task.id} started`, + }); + } catch (error) { + logger.warn(`[TeamDataService] notifyLeadOnTeammateTaskStart failed: ${error}`); + } + } + async softDeleteTask(teamName: string, taskId: string): Promise { await this.taskWriter.softDelete(teamName, taskId, 'user'); } @@ -897,6 +941,22 @@ export class TeamDataService { await this.taskWriter.updateFields(teamName, taskId, fields); } + async addTaskAttachment( + teamName: string, + taskId: string, + meta: import('@shared/types').TaskAttachmentMeta + ): Promise { + await this.taskWriter.addAttachment(teamName, taskId, meta); + } + + async removeTaskAttachment( + teamName: string, + taskId: string, + attachmentId: string + ): Promise { + await this.taskWriter.removeAttachment(teamName, taskId, attachmentId); + } + async setTaskNeedsClarification( teamName: string, taskId: string, @@ -923,8 +983,15 @@ export class TeamDataService { await this.taskWriter.removeRelationship(teamName, taskId, targetId, type); } - async addTaskComment(teamName: string, taskId: string, text: string): Promise { - const comment = await this.taskWriter.addComment(teamName, taskId, text); + async addTaskComment( + teamName: string, + taskId: string, + text: string, + attachments?: import('@shared/types').TaskAttachmentMeta[] + ): Promise { + const comment = await this.taskWriter.addComment(teamName, taskId, text, { + attachments, + }); try { const [tasks, toolPath, config] = await Promise.all([ diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 74da9ae6..a6a44fe8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -37,6 +37,7 @@ import { TeamTaskReader } from './TeamTaskReader'; import type { InboxMessage, + LeadContextUsage, TeamChangeEvent, TeamCreateRequest, TeamCreateResponse, @@ -58,7 +59,7 @@ const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; const SHELL_ENV_TIMEOUT_MS = 12000; // const CLI_PREPARE_TIMEOUT_MS = 10000; -const PROBE_CACHE_TTL_MS = 60_000; +const PROBE_CACHE_TTL_MS = 10 * 60_000; const PREFLIGHT_TIMEOUT_MS = 30000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; const PREFLIGHT_AUTH_MAX_RETRIES = 2; @@ -154,6 +155,13 @@ interface ProvisioningRun { authFailureRetried: boolean; /** Set to true while auth-failure respawn is in progress to prevent duplicate handling. */ authRetryInProgress: boolean; + /** Tracks lead process context window usage from stream-json usage data. */ + leadContextUsage: { + currentTokens: number; + contextWindow: number; + lastUsageMessageId: string | null; + lastEmittedAt: number; + } | null; /** Saved spawn context for auth-failure respawn. */ spawnContext: { claudePath: string; @@ -1014,6 +1022,16 @@ export class TeamProvisioningService { return run.leadActivityState; } + getLeadContextUsage(teamName: string): LeadContextUsage | null { + const runId = this.activeByTeam.get(teamName); + if (!runId) return null; + const run = this.runs.get(runId); + if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null; + const { currentTokens, contextWindow } = run.leadContextUsage; + const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() }; + } + private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void { if (run.leadActivityState === state) return; run.leadActivityState = state; @@ -1024,6 +1042,33 @@ export class TeamProvisioningService { }); } + private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000; + + private emitLeadContextUsage(run: ProvisioningRun): void { + if (!run.leadContextUsage || !run.provisioningComplete) return; + const now = Date.now(); + if ( + now - run.leadContextUsage.lastEmittedAt < + TeamProvisioningService.CONTEXT_EMIT_THROTTLE_MS + ) { + return; + } + run.leadContextUsage.lastEmittedAt = now; + const { currentTokens, contextWindow } = run.leadContextUsage; + const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0; + const payload: LeadContextUsage = { + currentTokens, + contextWindow, + percent, + updatedAt: new Date().toISOString(), + }; + this.teamChangeEmitter?.({ + type: 'lead-context', + teamName: run.teamName, + detail: JSON.stringify(payload), + }); + } + async warmup(): Promise { try { if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) { @@ -1433,6 +1478,7 @@ export class TeamProvisioningService { provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', + leadContextUsage: null, authFailureRetried: false, authRetryInProgress: false, spawnContext: null, @@ -1716,6 +1762,7 @@ export class TeamProvisioningService { provisioningOutputParts: [], detectedSessionId: null, leadActivityState: 'active', + leadContextUsage: null, authFailureRetried: false, authRetryInProgress: false, spawnContext: null, @@ -2464,6 +2511,40 @@ export class TeamProvisioningService { if (run.provisioningComplete) { this.captureSendMessageToUser(run, content ?? []); } + + // Extract context window usage from message.usage for real-time tracking. + // SDKAssistantMessage wraps BetaMessage which contains usage stats. + const messageObj = (msg.message ?? msg) as Record; + if (messageObj && typeof messageObj === 'object') { + const msgId = typeof messageObj.id === 'string' ? messageObj.id : null; + const usage = messageObj.usage as Record | undefined; + if (usage && typeof usage === 'object') { + // Dedup: skip if same message.id (SDK bug: multi-block = same usage repeated) + if (!msgId || run.leadContextUsage?.lastUsageMessageId !== msgId) { + const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0; + const cacheCreation = + typeof usage.cache_creation_input_tokens === 'number' + ? usage.cache_creation_input_tokens + : 0; + const cacheRead = + typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0; + const currentTokens = inputTokens + cacheCreation + cacheRead; + + if (!run.leadContextUsage) { + run.leadContextUsage = { + currentTokens, + contextWindow: 200_000, + lastUsageMessageId: msgId, + lastEmittedAt: 0, + }; + } else { + run.leadContextUsage.currentTokens = currentTokens; + run.leadContextUsage.lastUsageMessageId = msgId; + } + this.emitLeadContextUsage(run); + } + } + } } // Capture session_id from any message type (first occurrence wins) @@ -2489,6 +2570,53 @@ export class TeamProvisioningService { })(); if (subtype === 'success') { logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`); + + // Extract contextWindow from modelUsage if available (SDKResultSuccess.modelUsage) + const modelUsageObj = (msg.modelUsage ?? + (msg.result as Record | undefined)?.modelUsage) as + | Record> + | undefined; + if (modelUsageObj && typeof modelUsageObj === 'object') { + for (const modelData of Object.values(modelUsageObj)) { + if ( + modelData && + typeof modelData === 'object' && + typeof modelData.contextWindow === 'number' && + modelData.contextWindow > 0 + ) { + if (run.leadContextUsage) { + run.leadContextUsage.contextWindow = modelData.contextWindow; + run.leadContextUsage.lastEmittedAt = 0; // force re-emit + this.emitLeadContextUsage(run); + } + break; + } + } + } + + // Extract usage from result message itself (final turn usage) + const resultUsage = (msg.usage ?? + (msg.result as Record | undefined)?.usage) as + | Record + | undefined; + if (resultUsage && typeof resultUsage === 'object') { + const inp = typeof resultUsage.input_tokens === 'number' ? resultUsage.input_tokens : 0; + const cc = + typeof resultUsage.cache_creation_input_tokens === 'number' + ? resultUsage.cache_creation_input_tokens + : 0; + const cr = + typeof resultUsage.cache_read_input_tokens === 'number' + ? resultUsage.cache_read_input_tokens + : 0; + const total = inp + cc + cr; + if (total > 0 && run.leadContextUsage) { + run.leadContextUsage.currentTokens = total; + run.leadContextUsage.lastEmittedAt = 0; + this.emitLeadContextUsage(run); + } + } + if (run.provisioningComplete) { this.setLeadActivity(run, 'idle'); } @@ -2585,6 +2713,15 @@ export class TeamProvisioningService { } } } + + // Handle compact_boundary — context was compacted, next assistant message will carry fresh usage + if (msg.type === 'system') { + const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined; + if (sub === 'compact_boundary' && run.leadContextUsage) { + run.leadContextUsage.lastUsageMessageId = null; + logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`); + } + } } /** diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts new file mode 100644 index 00000000..9661df70 --- /dev/null +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -0,0 +1,168 @@ +import { getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types'; + +const logger = createLogger('Service:TeamTaskAttachmentStore'); + +const TASK_ATTACHMENTS_DIR = 'task-attachments'; +const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB + +const ALLOWED_MIME_TYPES: ReadonlySet = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +]); + +export class TeamTaskAttachmentStore { + private assertSafePathSegment(label: string, value: string): void { + if ( + value.length === 0 || + value.includes('/') || + value.includes('\\') || + value.includes('..') || + value.includes('\0') + ) { + throw new Error(`Invalid ${label}`); + } + } + + /** Returns the directory for a specific task's attachments. */ + private getTaskDir(teamName: string, taskId: string): string { + this.assertSafePathSegment('teamName', teamName); + this.assertSafePathSegment('taskId', taskId); + return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId); + } + + /** Returns the file path for a specific attachment. */ + private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string { + this.assertSafePathSegment('attachmentId', attachmentId); + return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`); + } + + /** Map MIME type to file extension. */ + private mimeToExt(mimeType: AttachmentMediaType): string { + switch (mimeType) { + case 'image/png': + return '.png'; + case 'image/jpeg': + return '.jpg'; + case 'image/gif': + return '.gif'; + case 'image/webp': + return '.webp'; + } + } + + /** + * Save an attachment to disk. Data is expected as a base64-encoded string. + * Returns metadata for the saved attachment. + */ + async saveAttachment( + teamName: string, + taskId: string, + attachmentId: string, + filename: string, + mimeType: AttachmentMediaType, + base64Data: string + ): Promise { + if (!ALLOWED_MIME_TYPES.has(mimeType)) { + throw new Error(`Unsupported MIME type: ${mimeType}`); + } + + const trimmed = base64Data.trim(); + // Avoid allocating huge Buffers for obviously too-large payloads. + // Base64 decoded size is roughly 3/4 of the string length minus padding. + const padding = trimmed.endsWith('==') ? 2 : trimmed.endsWith('=') ? 1 : 0; + const estimatedBytes = Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding); + if (estimatedBytes > MAX_ATTACHMENT_SIZE) { + throw new Error( + `Attachment too large: ${(estimatedBytes / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)` + ); + } + + const buffer = Buffer.from(trimmed, 'base64'); + if (buffer.length > MAX_ATTACHMENT_SIZE) { + throw new Error( + `Attachment too large: ${(buffer.length / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)` + ); + } + + const dir = this.getTaskDir(teamName, taskId); + await fs.promises.mkdir(dir, { recursive: true }); + + const ext = this.mimeToExt(mimeType); + const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); + await fs.promises.writeFile(filePath, buffer); + + const meta: TaskAttachmentMeta = { + id: attachmentId, + filename, + mimeType, + size: buffer.length, + addedAt: new Date().toISOString(), + }; + + logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`); + return meta; + } + + /** + * Read an attachment file and return its base64 data. + */ + async getAttachment( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: AttachmentMediaType + ): Promise { + const ext = this.mimeToExt(mimeType); + const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); + + try { + const buffer = await fs.promises.readFile(filePath); + return buffer.toString('base64'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw error; + } + } + + /** + * Delete an attachment file from disk. + */ + async deleteAttachment( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: AttachmentMediaType + ): Promise { + const ext = this.mimeToExt(mimeType); + const filePath = this.getFilePath(teamName, taskId, attachmentId, ext); + + try { + await fs.promises.unlink(filePath); + logger.debug(`[${teamName}] Deleted task attachment ${attachmentId} for task #${taskId}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + + // Clean up empty directory + const dir = this.getTaskDir(teamName, taskId); + try { + const entries = await fs.promises.readdir(dir); + if (entries.length === 0) { + await fs.promises.rmdir(dir); + } + } catch { + // ignore cleanup errors + } + } +} diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 9cf4eee9..7b7246f6 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -8,7 +8,9 @@ import * as path from 'path'; import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import type { + AttachmentMediaType, StatusTransition, + TaskAttachmentMeta, TaskComment, TaskWorkInterval, TeamTask, @@ -18,6 +20,13 @@ import type { const logger = createLogger('Service:TeamTaskReader'); const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024; +const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet = new Set([ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +]); + export class TeamTaskReader { /** * Returns the next available numeric task ID by scanning ALL task files @@ -187,6 +196,21 @@ export class TeamTaskReader { type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type) ? c.type : ('regular' as const), + attachments: Array.isArray(c.attachments) + ? (c.attachments as unknown[]).filter( + (a): a is TaskAttachmentMeta => + Boolean(a) && + typeof a === 'object' && + typeof (a as Record).id === 'string' && + typeof (a as Record).filename === 'string' && + typeof (a as Record).mimeType === 'string' && + VALID_ATTACHMENT_MIME_TYPES.has( + (a as Record).mimeType as string + ) && + typeof (a as Record).size === 'number' && + typeof (a as Record).addedAt === 'string' + ) + : undefined, })) : undefined, needsClarification: (['lead', 'user'] as const).includes( @@ -195,6 +219,29 @@ export class TeamTaskReader { ? (parsed.needsClarification as 'lead' | 'user') : undefined, deletedAt: undefined, // deleted tasks are filtered out below + attachments: Array.isArray(parsed.attachments) + ? (parsed.attachments as unknown[]) + .filter( + (a): a is TaskAttachmentMeta => + Boolean(a) && + typeof a === 'object' && + typeof (a as Record).id === 'string' && + typeof (a as Record).filename === 'string' && + typeof (a as Record).mimeType === 'string' && + VALID_ATTACHMENT_MIME_TYPES.has( + (a as Record).mimeType as string + ) && + typeof (a as Record).size === 'number' && + typeof (a as Record).addedAt === 'string' + ) + .map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + addedAt: a.addedAt, + })) + : undefined, } satisfies Record; if (task.status === 'deleted') { continue; diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 757471fa..d1d6603e 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -7,6 +7,7 @@ import { atomicWriteAsync } from './atomicWrite'; import type { StatusTransition, + TaskAttachmentMeta, TaskComment, TaskCommentType, TeamTask, @@ -520,7 +521,13 @@ export class TeamTaskWriter { teamName: string, taskId: string, text: string, - options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType } + options?: { + id?: string; + author?: string; + createdAt?: string; + type?: TaskCommentType; + attachments?: TaskAttachmentMeta[]; + } ): Promise { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); const comment: TaskComment = { @@ -529,6 +536,9 @@ export class TeamTaskWriter { text, createdAt: options?.createdAt ?? new Date().toISOString(), type: options?.type ?? 'regular', + ...(options?.attachments && options.attachments.length > 0 + ? { attachments: options.attachments } + : {}), }; await withTaskLock(taskPath, async () => { @@ -554,4 +564,41 @@ export class TeamTaskWriter { return comment; } + + async addAttachment(teamName: string, taskId: string, meta: TaskAttachmentMeta): Promise { + const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); + + await withTaskLock(taskPath, async () => { + const raw = await fs.promises.readFile(taskPath, 'utf8'); + const task = JSON.parse(raw) as Record; + const existing = Array.isArray(task.attachments) + ? (task.attachments as TaskAttachmentMeta[]) + : []; + // Dedup by ID + if (existing.some((a) => a.id === meta.id)) { + return; + } + task.attachments = [...existing, meta]; + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + }); + } + + async removeAttachment(teamName: string, taskId: string, attachmentId: string): Promise { + const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); + + await withTaskLock(taskPath, async () => { + const raw = await fs.promises.readFile(taskPath, 'utf8'); + const task = JSON.parse(raw) as Record; + const existing = Array.isArray(task.attachments) + ? (task.attachments as TaskAttachmentMeta[]) + : []; + const filtered = existing.filter((a) => a.id !== attachmentId); + if (filtered.length > 0) { + task.attachments = filtered; + } else { + delete task.attachments; + } + await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2)); + }); + } } diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 1853b1ab..ee21dc5d 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -110,6 +110,7 @@ interface ParsedTask { metadata?: { _internal?: unknown }; workIntervals?: unknown; statusHistory?: unknown; + attachments?: unknown; } interface RawWorkInterval { @@ -596,6 +597,9 @@ async function readTasksDirForTeam( comments: normalizeComments(parsed), needsClarification, deletedAt: undefined, + attachments: Array.isArray(parsed.attachments) + ? (parsed.attachments as unknown[]) + : undefined, teamName, }); } catch (error) { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 0b823901..a3ca64c3 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -325,6 +325,9 @@ export const TEAM_KILL_PROCESS = 'team:killProcess'; /** Get lead process activity state (active/idle/offline) */ export const TEAM_LEAD_ACTIVITY = 'team:leadActivity'; +/** Get lead process context window usage */ +export const TEAM_LEAD_CONTEXT = 'team:leadContext'; + /** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */ export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask'; @@ -343,6 +346,15 @@ export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship'; /** Remove a relationship (blockedBy/blocks/related) between two tasks */ export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship'; +/** Save an image attachment to a task */ +export const TEAM_SAVE_TASK_ATTACHMENT = 'team:saveTaskAttachment'; + +/** Get base64 data for a task attachment */ +export const TEAM_GET_TASK_ATTACHMENT = 'team:getTaskAttachment'; + +/** Delete an attachment from a task */ +export const TEAM_DELETE_TASK_ATTACHMENT = 'team:deleteTaskAttachment'; + // ============================================================================= // CLI Installer API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a513c61..8361d241 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -79,6 +79,7 @@ import { TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, + TEAM_LEAD_CONTEXT, TEAM_LIST, TEAM_PERMANENTLY_DELETE, TEAM_PREPARE_PROVISIONING, @@ -89,6 +90,9 @@ import { TEAM_REMOVE_MEMBER, TEAM_REMOVE_TASK_RELATIONSHIP, TEAM_REPLACE_MEMBERS, + TEAM_SAVE_TASK_ATTACHMENT, + TEAM_GET_TASK_ATTACHMENT, + TEAM_DELETE_TASK_ATTACHMENT, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, @@ -173,6 +177,7 @@ import type { HunkDecision, IpcResult, KanbanColumnId, + LeadContextUsage, MemberFullStats, MemberLogSummary, NotificationTrigger, @@ -187,6 +192,8 @@ import type { SshConnectionConfig, SshConnectionStatus, SshLastConnection, + CommentAttachmentPayload, + TaskAttachmentMeta, TaskChangeSetV2, TaskComment, TeamChangeEvent, @@ -797,8 +804,19 @@ const electronAPI: ElectronAPI = { updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => { return invokeIpcWithResult(TEAM_UPDATE_CONFIG, teamName, updates); }, - addTaskComment: async (teamName: string, taskId: string, text: string) => { - return invokeIpcWithResult(TEAM_ADD_TASK_COMMENT, teamName, taskId, text); + addTaskComment: async ( + teamName: string, + taskId: string, + text: string, + attachments?: CommentAttachmentPayload[] + ) => { + return invokeIpcWithResult( + TEAM_ADD_TASK_COMMENT, + teamName, + taskId, + text, + attachments + ); }, addMember: async (teamName: string, request: AddMemberRequest) => { return invokeIpcWithResult(TEAM_ADD_MEMBER, teamName, request); @@ -825,6 +843,9 @@ const electronAPI: ElectronAPI = { const result = await invokeIpcWithResult(TEAM_LEAD_ACTIVITY, teamName); return result as 'active' | 'idle' | 'offline'; }, + getLeadContext: async (teamName: string) => { + return invokeIpcWithResult(TEAM_LEAD_CONTEXT, teamName); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, @@ -872,6 +893,52 @@ const electronAPI: ElectronAPI = { type ); }, + saveTaskAttachment: async ( + teamName: string, + taskId: string, + attachmentId: string, + filename: string, + mimeType: string, + base64Data: string + ) => { + return invokeIpcWithResult( + TEAM_SAVE_TASK_ATTACHMENT, + teamName, + taskId, + attachmentId, + filename, + mimeType, + base64Data + ); + }, + getTaskAttachment: async ( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: string + ) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ATTACHMENT, + teamName, + taskId, + attachmentId, + mimeType + ); + }, + deleteTaskAttachment: async ( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: string + ) => { + return invokeIpcWithResult( + TEAM_DELETE_TASK_ATTACHMENT, + teamName, + taskId, + attachmentId, + mimeType + ); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { ipcRenderer.on( TEAM_CHANGE, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index e9905e9d..b2c69fa8 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -796,6 +796,9 @@ export class HttpAPIClient implements ElectronAPI { getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => { return 'offline'; }, + getLeadContext: async () => { + return null; + }, softDeleteTask: async (_teamName: string, _taskId: string): Promise => { // Not available via HTTP client — no-op }, @@ -831,6 +834,32 @@ export class HttpAPIClient implements ElectronAPI { ): Promise => { throw new Error('Task relationships are not available in browser mode'); }, + saveTaskAttachment: async ( + _teamName: string, + _taskId: string, + _attachmentId: string, + _filename: string, + _mimeType: string, + _base64Data: string + ): Promise => { + throw new Error('Task attachments are not available in browser mode'); + }, + getTaskAttachment: async ( + _teamName: string, + _taskId: string, + _attachmentId: string, + _mimeType: string + ): Promise => { + return null; + }, + deleteTaskAttachment: async ( + _teamName: string, + _taskId: string, + _attachmentId: string, + _mimeType: string + ): Promise => { + throw new Error('Task attachments are not available in browser mode'); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { return this.addEventListener('team-change', (data: unknown) => callback(null, data as TeamChangeEvent) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 1c730df2..f7670baf 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from 'react-markdown'; import { api } from '@renderer/api'; import { CopyButton } from '@renderer/components/common/CopyButton'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { CODE_BG, CODE_BORDER, @@ -199,21 +200,45 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ), // Links — inline element, no hl(); parent block element's hl() descends here - a: ({ href, children }) => ( - { - e.preventDefault(); - if (href) { - void api.openExternal(href); - } - }} - > - {children} - - ), + // task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem) + // mention:// links render as colored inline badges + a: ({ href, children }) => { + if (href?.startsWith('mention://')) { + const path = href.slice('mention://'.length); + const slashIdx = path.indexOf('/'); + const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : ''; + const colorSet = getTeamColorSet(color); + const bg = colorSet.badge; + return ( + + {children} + + ); + } + return ( + { + e.preventDefault(); + if (href && !href.startsWith('task://')) { + void api.openExternal(href); + } + }} + > + {children} + + ); + }, // Strong/Bold — inline element, no hl() strong: ({ children }) => ( diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 1e411933..d82fc53a 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; +import { cn } from '@renderer/lib/utils'; import { ChevronRight } from 'lucide-react'; function scrollAfterExpand(el: HTMLElement): void { @@ -27,6 +28,10 @@ interface CollapsibleTeamSectionProps { sectionId?: string; /** Extra classes applied to the content wrapper (e.g. padding). */ contentClassName?: string; + /** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */ + headerClassName?: string; + /** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */ + headerContentClassName?: string; children: React.ReactNode; } @@ -41,6 +46,8 @@ export const CollapsibleTeamSection = ({ action, sectionId, contentClassName, + headerClassName, + headerContentClassName, children, }: CollapsibleTeamSectionProps): React.JSX.Element => { const [open, setOpen] = useState(defaultOpen); @@ -61,14 +68,24 @@ export const CollapsibleTeamSection = ({ return (
-
+
- +
+ + {isLeadRecipient ? ( + <> + + + + + + + {!isTeamAlive + ? 'Team must be online to attach images' + : !canAddMore + ? 'Maximum attachments reached' + : 'Attach images (paste or drag & drop)'} + + + + ) : null} +
+ + +
{quote ? (
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx new file mode 100644 index 00000000..1d68bcf2 --- /dev/null +++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx @@ -0,0 +1,359 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { useStore } from '@renderer/store'; +import { ImagePlus, Loader2, Trash2, X } from 'lucide-react'; + +import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types'; + +const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); + +const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB + +interface TaskAttachmentsProps { + teamName: string; + taskId: string; + attachments: TaskAttachmentMeta[]; +} + +export const TaskAttachments = ({ + teamName, + taskId, + attachments, +}: TaskAttachmentsProps): React.JSX.Element => { + const saveTaskAttachment = useStore((s) => s.saveTaskAttachment); + const deleteTaskAttachment = useStore((s) => s.deleteTaskAttachment); + const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData); + + const [uploading, setUploading] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [error, setError] = useState(null); + const [previewAttachment, setPreviewAttachment] = useState<{ + id: string; + mimeType: AttachmentMediaType; + dataUrl: string | null; + loading: boolean; + } | null>(null); + const fileInputRef = useRef(null); + + const handleFileSelect = useCallback( + async (files: FileList | null) => { + if (!files || files.length === 0) return; + setError(null); + setUploading(true); + + try { + for (const file of Array.from(files)) { + if (!ACCEPTED_TYPES.has(file.type)) { + setError(`Unsupported file type: ${file.type}`); + continue; + } + if (file.size > MAX_FILE_SIZE) { + setError(`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`); + continue; + } + + const base64 = await fileToBase64(file); + await saveTaskAttachment(teamName, taskId, { + name: file.name, + type: file.type, + base64, + }); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to upload'); + } finally { + setUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, + [teamName, taskId, saveTaskAttachment] + ); + + const handleDelete = useCallback( + async (attachmentId: string, mimeType: AttachmentMediaType) => { + setDeletingId(attachmentId); + try { + await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType); + if (previewAttachment?.id === attachmentId) { + setPreviewAttachment(null); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete'); + } finally { + setDeletingId(null); + } + }, + [teamName, taskId, deleteTaskAttachment, previewAttachment] + ); + + const handlePreview = useCallback( + async (att: TaskAttachmentMeta) => { + if (previewAttachment?.id === att.id && previewAttachment.dataUrl) { + setPreviewAttachment(null); + return; + } + setPreviewAttachment({ id: att.id, mimeType: att.mimeType, dataUrl: null, loading: true }); + try { + const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType); + if (base64) { + setPreviewAttachment({ + id: att.id, + mimeType: att.mimeType, + dataUrl: `data:${att.mimeType};base64,${base64}`, + loading: false, + }); + } else { + setPreviewAttachment(null); + setError('Attachment file not found'); + } + } catch { + setPreviewAttachment(null); + setError('Failed to load attachment'); + } + }, + [teamName, taskId, getTaskAttachmentData, previewAttachment] + ); + + // Handle paste events for quick image attachment + const containerRef = useRef(null); + useEffect(() => { + const handler = (e: ClipboardEvent): void => { + const items = e.clipboardData?.items; + if (!items) return; + const imageFiles: File[] = []; + for (const item of Array.from(items)) { + if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + if (imageFiles.length > 0) { + e.preventDefault(); + const dt = new DataTransfer(); + imageFiles.forEach((f) => dt.items.add(f)); + void handleFileSelect(dt.files); + } + }; + const el = containerRef.current; + if (el) { + el.addEventListener('paste', handler); + return () => el.removeEventListener('paste', handler); + } + }, [handleFileSelect]); + + // Handle drag-and-drop + const [dragOver, setDragOver] = useState(false); + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(true); + }, []); + const handleDragLeave = useCallback(() => setDragOver(false), []); + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + void handleFileSelect(e.dataTransfer.files); + }, + [handleFileSelect] + ); + + return ( +
+ {/* Attachment thumbnails */} + {attachments.length > 0 ? ( +
+ {attachments.map((att) => ( + void handlePreview(att)} + onDelete={() => void handleDelete(att.id, att.mimeType)} + /> + ))} +
+ ) : null} + + {/* Preview panel */} + {previewAttachment ? ( +
+ + {previewAttachment.loading ? ( +
+ + Loading image... +
+ ) : previewAttachment.dataUrl ? ( + Attachment preview + ) : null} +
+ ) : null} + + {/* Drop zone indicator */} + {dragOver ? ( +
+ Drop image here +
+ ) : null} + + {/* Controls */} +
+ void handleFileSelect(e.target.files)} + /> + + or paste / drag-drop +
+ + {error ?

{error}

: null} +
+ ); +}; + +// --------------------------------------------------------------------------- +// Thumbnail sub-component +// --------------------------------------------------------------------------- + +interface AttachmentThumbnailProps { + attachment: TaskAttachmentMeta; + teamName: string; + taskId: string; + isDeleting: boolean; + isPreviewActive: boolean; + onPreview: () => void; + onDelete: () => void; +} + +const AttachmentThumbnail = ({ + attachment, + teamName, + taskId, + isDeleting, + isPreviewActive, + onPreview, + onDelete, +}: AttachmentThumbnailProps): 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, + attachment.id, + attachment.mimeType + ); + if (!cancelled && base64) { + setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); + } + } catch { + // ignore + } + })(); + return () => { + cancelled = true; + }; + }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); + + const sizeLabel = + attachment.size < 1024 + ? `${attachment.size} B` + : attachment.size < 1024 * 1024 + ? `${(attachment.size / 1024).toFixed(0)} KB` + : `${(attachment.size / (1024 * 1024)).toFixed(1)} MB`; + + return ( +
+ {thumbUrl ? ( + {attachment.filename} + ) : ( + + )} + {/* Delete button overlay */} + + {/* Filename tooltip */} +
+ {attachment.filename} ({sizeLabel}) +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Strip the data URL prefix (e.g., "data:image/png;base64,") + const base64 = result.split(',')[1]; + if (base64) { + resolve(base64); + } else { + reject(new Error('Failed to read file as base64')); + } + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 602253a4..4d44f30f 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; @@ -9,12 +9,15 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { Send, X } from 'lucide-react'; +import { ImagePlus, Send, Trash2, X } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { ResolvedTeamMember } from '@shared/types'; +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']); interface TaskCommentInputProps { teamName: string; @@ -24,6 +27,15 @@ interface TaskCommentInputProps { onClearReply: () => void; } +interface PendingAttachment { + id: string; + filename: string; + mimeType: string; + base64Data: string; + previewUrl: string; + size: number; +} + export const TaskCommentInput = ({ teamName, taskId, @@ -37,6 +49,9 @@ export const TaskCommentInput = ({ const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` }); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); + const [pendingAttachments, setPendingAttachments] = useState([]); + const [attachError, setAttachError] = useState(null); + const fileInputRef = useRef(null); const mentionSuggestions = useMemo( () => @@ -51,19 +66,115 @@ export const TaskCommentInput = ({ const trimmed = draft.value.trim(); const remaining = MAX_COMMENT_LENGTH - trimmed.length; - const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment; + const canSubmit = + (trimmed.length > 0 || pendingAttachments.length > 0) && + trimmed.length <= MAX_COMMENT_LENGTH && + !addingComment; + + const addFiles = useCallback( + (files: FileList | File[]) => { + setAttachError(null); + const fileArray = Array.from(files); + for (const file of fileArray) { + if (!ACCEPTED_TYPES.has(file.type)) { + setAttachError(`Unsupported type: ${file.type}`); + continue; + } + if (file.size > MAX_FILE_SIZE) { + setAttachError( + `File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)` + ); + continue; + } + if (pendingAttachments.length >= MAX_ATTACHMENTS) { + setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`); + break; + } + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(',')[1]; + if (!base64) return; + const id = crypto.randomUUID(); + setPendingAttachments((prev) => { + if (prev.length >= MAX_ATTACHMENTS) return prev; + return [ + ...prev, + { + id, + filename: file.name, + mimeType: file.type, + base64Data: base64, + previewUrl: result, + size: file.size, + }, + ]; + }); + }; + reader.readAsDataURL(file); + } + }, + [pendingAttachments.length] + ); + + const removeAttachment = useCallback((id: string) => { + setPendingAttachments((prev) => prev.filter((a) => a.id !== id)); + }, []); const handleSubmit = useCallback(async () => { if (!canSubmit) return; try { - const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed; - await addTaskComment(teamName, taskId, text); + const text = replyTo + ? buildReplyBlock(replyTo.author, replyTo.text, trimmed || '(image)') + : trimmed || '(image)'; + const attachments: CommentAttachmentPayload[] | undefined = + pendingAttachments.length > 0 + ? pendingAttachments.map((a) => ({ + id: a.id, + filename: a.filename, + mimeType: a.mimeType as CommentAttachmentPayload['mimeType'], + base64Data: a.base64Data, + })) + : undefined; + await addTaskComment(teamName, taskId, text, attachments); draft.clearDraft(); + setPendingAttachments([]); + setAttachError(null); onClearReply(); } catch { // Error is stored in addCommentError via store } - }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo, onClearReply]); + }, [ + canSubmit, + addTaskComment, + teamName, + taskId, + trimmed, + draft, + replyTo, + onClearReply, + pendingAttachments, + ]); + + // Handle paste from MentionableTextarea area + const handlePaste = useCallback( + (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + if (!items) return; + const imageFiles: File[] = []; + for (const item of Array.from(items)) { + if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + if (imageFiles.length > 0) { + e.preventDefault(); + addFiles(imageFiles); + } + }, + [addFiles] + ); return (
@@ -103,7 +214,41 @@ export const TaskCommentInput = ({
) : null} -
+ {/* Pending attachment previews */} + {pendingAttachments.length > 0 ? ( +
+ {pendingAttachments.map((att) => ( +
+ {att.filename} + +
+ ))} +
+ ) : null} + + {attachError ?

{attachError}

: null} + +
+ { + if (e.target.files) addFiles(e.target.files); + e.target.value = ''; + }} + /> void handleSubmit()} - > - - Comment - +
+ + + + + Attach image (or paste) + + +
} footerRight={
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index cea7c649..4e12c84b 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock'; @@ -19,6 +19,7 @@ import { ChevronDown, ChevronUp, Eye, + Loader2, MessageSquare, Reply, Send, @@ -26,7 +27,21 @@ import { } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { ResolvedTeamMember, TaskComment } from '@shared/types'; +import type { + AttachmentMediaType, + ResolvedTeamMember, + TaskAttachmentMeta, + TaskComment, +} from '@shared/types'; + +/** + * Convert literal backslash-n sequences to real newlines. + * CLI tools (teamctl.js) may store `\n` as literal text when + * shell double-quotes don't interpret escape sequences. + */ +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; @@ -44,6 +59,26 @@ interface TaskCommentsSectionProps { hideInput?: boolean; /** Called when the user clicks Reply on a comment (used when input is rendered externally). */ onReply?: (author: string, text: string) => void; + /** Called when a task ID link (e.g. #10) is clicked in comment text. */ + onTaskIdClick?: (taskId: string) => void; +} + +/** Convert `#` in plain text to markdown links with task:// protocol. */ +function linkifyTaskIdsInMarkdown(text: string): string { + return text.replace(/#(\d+)/g, '[#$1](task://$1)'); +} + +/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */ +function linkifyMentionsInMarkdown(text: string, memberColorMap: Map): string { + if (memberColorMap.size === 0) return text; + const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length); + const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi'); + return text.replace(pattern, (match, prefix: string, name: string) => { + const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name; + const color = memberColorMap.get(canonical) ?? ''; + return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`; + }); } export const TaskCommentsSection = ({ @@ -54,6 +89,7 @@ export const TaskCommentsSection = ({ hideHeader = false, hideInput = false, onReply, + onTaskIdClick, }: TaskCommentsSectionProps): React.JSX.Element => { const addTaskComment = useStore((s) => s.addTaskComment); const addingComment = useStore((s) => s.addingComment); @@ -62,6 +98,7 @@ export const TaskCommentsSection = ({ const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null); const [expandedCommentIds, setExpandedCommentIds] = useState>(new Set()); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); + const [previewImageUrl, setPreviewImageUrl] = useState(null); // Reset local state when team/task changes (React-recommended pattern for // adjusting state based on props without using effects or refs during render) @@ -218,7 +255,7 @@ export const TaskCommentsSection = ({ {(() => { const reply = parseMessageReply(comment.text); const rawForDisplay = reply ? reply.replyText : comment.text; - const displayText = stripAgentBlocks(rawForDisplay); + const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay)); const needsExpandCollapse = displayText.includes('\n'); const expanded = expandedCommentIds.has(comment.id); const collapsedHeight = 'max-h-[120px]'; @@ -243,13 +280,36 @@ export const TaskCommentsSection = ({ } /> ) : ( - { + 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 } - bare - /> + > + { + let t = displayText; + if (onTaskIdClick) t = linkifyTaskIdsInMarkdown(t); + if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap); + return t; + })()} + maxHeight={ + needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none' + } + bare + /> + )} {showCollapsed && ( <> @@ -291,6 +351,14 @@ export const TaskCommentsSection = ({
); })()} + {comment.attachments && comment.attachments.length > 0 ? ( + + ) : null}
))} @@ -310,6 +378,24 @@ export const TaskCommentsSection = ({
) : null} + {/* Full-size image preview overlay */} + {previewImageUrl ? ( +
+ + Attachment preview +
+ ) : null} + {!hideInput && ( <> {replyTo ? ( @@ -382,6 +468,95 @@ export const TaskCommentsSection = ({ ); }; +// --------------------------------------------------------------------------- +// Comment attachment thumbnail (read-only, no delete) +// --------------------------------------------------------------------------- + +interface CommentAttachmentThumbnailProps { + attachment: TaskAttachmentMeta; + teamName: string; + taskId: string; + onPreview: (dataUrl: string) => void; +} + +const CommentAttachmentThumbnail = ({ + attachment, + teamName, + taskId, + onPreview, +}: CommentAttachmentThumbnailProps): 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, + attachment.id, + attachment.mimeType + ); + if (!cancelled && base64) { + setThumbUrl(`data:${attachment.mimeType};base64,${base64}`); + } + } catch { + // ignore — thumbnail simply won't render + } + })(); + return () => { + cancelled = true; + }; + }, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]); + + return ( +
thumbUrl && onPreview(thumbUrl)} + > + {thumbUrl ? ( + {attachment.filename} + ) : ( + + )} +
+ {attachment.filename} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Comment attachments grid +// --------------------------------------------------------------------------- + +interface CommentAttachmentsProps { + attachments: TaskAttachmentMeta[]; + teamName: string; + taskId: string; + onPreview: (dataUrl: string) => void; +} + +const CommentAttachments = ({ + attachments, + teamName, + taskId, + onPreview, +}: CommentAttachmentsProps): React.JSX.Element => ( +
+ {attachments.map((att) => ( + + ))} +
+); + function teamIdKey(teamName: string, taskId: string): string { return `${teamName}::${taskId}`; } diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index af5ca430..1845b8ed 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@renderer/components/ui/select'; import { Textarea } from '@renderer/components/ui/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { markAsRead } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; @@ -43,19 +44,23 @@ import { Eye, FileCode, FileDiff, + GitCompareArrows, HelpCircle, History, + ImageIcon, Link2, Loader2, MessageSquare, Pencil, PenLine, ScrollText, + SquarePen, Trash2, X, } from 'lucide-react'; import { StatusHistoryTimeline } from './StatusHistoryTimeline'; +import { TaskAttachments } from './TaskAttachments'; import { TaskCommentInput } from './TaskCommentInput'; import { TaskCommentsSection } from './TaskCommentsSection'; @@ -74,6 +79,7 @@ interface TaskDetailDialogProps { onScrollToTask?: (taskId: string) => void; onOwnerChange?: (taskId: string, owner: string | null) => void; onViewChanges?: (taskId: string, filePath?: string) => void; + onOpenInEditor?: (filePath: string) => void; onDeleteTask?: (taskId: string) => void; /** Extra content rendered in the dialog header (e.g. "Open team" button). */ headerExtra?: React.ReactNode; @@ -92,6 +98,7 @@ export const TaskDetailDialog = ({ onScrollToTask, onOwnerChange, onViewChanges, + onOpenInEditor, onDeleteTask, headerExtra, }: TaskDetailDialogProps): React.JSX.Element => { @@ -457,6 +464,8 @@ export const TaskDetailDialog = ({ title="Description" icon={} contentClassName="pl-2.5" + headerClassName="-mx-6 w-[calc(100%+3rem)]" + headerContentClassName="pl-6" defaultOpen > {editingDescription ? ( @@ -562,6 +571,27 @@ export const TaskDetailDialog = ({ )} + {/* Attachments */} + } + badge={ + (currentTask.attachments?.length ?? 0) > 0 + ? (currentTask.attachments?.length ?? 0) + : undefined + } + contentClassName="pl-2.5" + headerClassName="-mx-6 w-[calc(100%+3rem)]" + headerContentClassName="pl-6" + defaultOpen={(currentTask.attachments?.length ?? 0) > 0} + > + + + {/* Changes */} {variant === 'team' && isTaskCompleted && onViewChanges ? ( } badge={taskChangesFiles ? taskChangesFiles.length : undefined} contentClassName="pl-2.5" + headerClassName="-mx-6 w-[calc(100%+3rem)]" + headerContentClassName="pl-6" defaultOpen={taskKnownHasChanges} > {changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? ( @@ -579,19 +611,21 @@ export const TaskDetailDialog = ({ ) : taskChangesFiles && taskChangesFiles.length > 0 ? (
{taskChangesFiles.map((file) => ( - {file.linesAdded > 0 ? ( +{file.linesAdded} @@ -600,7 +634,38 @@ export const TaskDetailDialog = ({ -{file.linesRemoved} ) : null} - + + + + + + Review diff + + {onOpenInEditor ? ( + + + + + Open in editor + + ) : null} + +
))}
) : ( @@ -623,6 +688,8 @@ export const TaskDetailDialog = ({ ) : null } contentClassName="pl-2.5" + headerClassName="-mx-6 w-[calc(100%+3rem)]" + headerContentClassName="pl-6" defaultOpen >
@@ -774,6 +841,8 @@ export const TaskDetailDialog = ({ icon={} badge={currentTask.statusHistory.length} contentClassName="pl-2.5" + headerClassName="-mx-6 w-[calc(100%+3rem)]" + headerContentClassName="pl-6" defaultOpen={false} > @@ -790,6 +859,8 @@ export const TaskDetailDialog = ({ : undefined } contentClassName="pl-2.5" + headerClassName="-mx-6 w-[calc(100%+3rem)]" + headerContentClassName="pl-6" defaultOpen > handleDependencyClick(taskId) : undefined} /> diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 79704020..fdff66fb 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 { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react'; @@ -39,8 +40,18 @@ export const MemberCard = ({ onSendMessage, onAssignTask, }: MemberCardProps): React.JSX.Element => { + const teamName = useStore((s) => s.selectedTeamName); + const leadContext = useStore((s) => + member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + ); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); - const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); + const presenceLabel = getPresenceLabel( + member, + isTeamAlive, + isTeamProvisioning, + leadActivity, + leadContext?.percent + ); const colors = getTeamColorSet(memberColor); const pending = taskCounts?.pending ?? 0; const inProgress = taskCounts?.inProgress ?? 0; @@ -171,6 +182,29 @@ export const MemberCard = ({ />
)} + {leadContext && leadContext.percent > 0 && ( + + +
+
90 + ? 'bg-red-500' + : leadContext.percent > 70 + ? 'bg-amber-500' + : 'bg-blue-500' + }`} + style={{ width: `${Math.min(leadContext.percent, 100)}%` }} + /> +
+ + + Context: {Math.round(leadContext.percent)}% ( + {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} + {(leadContext.contextWindow / 1000).toFixed(0)}k tokens) + + + )}
{!isRemoved && (
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 73e13c20..5dc422ae 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { Pencil } from 'lucide-react'; @@ -30,9 +31,20 @@ export const MemberDetailHeader = ({ }: MemberDetailHeaderProps): React.JSX.Element => { const [editing, setEditing] = useState(false); + const teamName = useStore((s) => s.selectedTeamName); + const leadContext = useStore((s) => + member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + ); + const colors = getTeamColorSet(member.color ?? ''); const role = member.role || formatAgentRole(member.agentType); - const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); + const presenceLabel = getPresenceLabel( + member, + isTeamAlive, + isTeamProvisioning, + leadActivity, + leadContext?.percent + ); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); const canEditRole = @@ -88,12 +100,20 @@ export const MemberDetailHeader = ({ )} {!editing && ( - - {presenceLabel} - + <> + + {presenceLabel} + + {leadContext && leadContext.percent > 0 && ( + + {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} + {(leadContext.contextWindow / 1000).toFixed(0)}k + + )} + )}
diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 0d302436..c9201512 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -77,7 +77,7 @@ export const MessageComposer = ({ clearAttachments, handlePaste, handleDrop, - } = useAttachments(); + } = useAttachments({ persistenceKey: `compose:${teamName}:attachments` }); const colorMap = useMemo(() => buildMemberColorMap(members), [members]); diff --git a/src/renderer/hooks/useAttachments.ts b/src/renderer/hooks/useAttachments.ts index 001f2e8f..b1554fb5 100644 --- a/src/renderer/hooks/useAttachments.ts +++ b/src/renderer/hooks/useAttachments.ts @@ -1,5 +1,6 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { draftStorage } from '@renderer/services/draftStorage'; import { fileToAttachmentPayload, MAX_FILES, @@ -9,6 +10,11 @@ import { import type { AttachmentPayload } from '@shared/types'; +interface UseAttachmentsOptions { + /** When provided, attachments are persisted to IndexedDB under this key. */ + persistenceKey?: string; +} + interface UseAttachmentsReturn { attachments: AttachmentPayload[]; error: string | null; @@ -21,67 +27,204 @@ interface UseAttachmentsReturn { handleDrop: (event: React.DragEvent) => void; } -export function useAttachments(): UseAttachmentsReturn { +const DEBOUNCE_MS = 500; + +function isValidAttachmentArray(data: unknown): data is AttachmentPayload[] { + if (!Array.isArray(data)) return false; + return data.every((raw) => { + if (typeof raw !== 'object' || raw === null) return false; + const item = raw as Record; + return ( + typeof item.id === 'string' && + typeof item.filename === 'string' && + typeof item.mimeType === 'string' && + typeof item.size === 'number' && + typeof item.data === 'string' + ); + }); +} + +export function useAttachments(options?: UseAttachmentsOptions): UseAttachmentsReturn { + const persistenceKey = options?.persistenceKey; + const [attachments, setAttachments] = useState([]); const [error, setError] = useState(null); + const attachmentsRef = useRef([]); + const timerRef = useRef | null>(null); + const pendingRef = useRef(null); + const keyRef = useRef(persistenceKey); + keyRef.current = persistenceKey; + + // Sync ref with state + const updateAttachments = useCallback((next: AttachmentPayload[]) => { + attachmentsRef.current = next; + setAttachments(next); + }, []); + + // Persist helper — schedule debounced save + const schedulePersist = useCallback((nextAttachments: AttachmentPayload[]) => { + const key = keyRef.current; + if (!key) return; + + pendingRef.current = nextAttachments; + + if (timerRef.current != null) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + timerRef.current = null; + const pending = pendingRef.current; + pendingRef.current = null; + if (pending == null) return; + + if (pending.length === 0) { + void draftStorage.deleteDraft(key); + } else { + void draftStorage.saveDraft(key, JSON.stringify(pending)); + } + }, DEBOUNCE_MS); + }, []); + + const flushPending = useCallback(() => { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (pendingRef.current != null) { + const val = pendingRef.current; + const key = keyRef.current; + pendingRef.current = null; + if (!key) return; + if (val.length === 0) { + void draftStorage.deleteDraft(key); + } else { + void draftStorage.saveDraft(key, JSON.stringify(val)); + } + } + }, []); + + // Load persisted attachments on mount + useEffect(() => { + if (!persistenceKey) return; + + let cancelled = false; + void (async () => { + const raw = await draftStorage.loadDraft(persistenceKey); + if (cancelled || raw == null) return; + try { + const parsed: unknown = JSON.parse(raw); + if (isValidAttachmentArray(parsed)) { + // Verify total size is still within limits + const total = parsed.reduce((sum, a) => sum + a.size, 0); + if (total <= MAX_TOTAL_SIZE && parsed.length <= MAX_FILES) { + attachmentsRef.current = parsed; + setAttachments(parsed); + } else { + // Stored data exceeds limits — discard + void draftStorage.deleteDraft(persistenceKey); + } + } + } catch { + // Invalid JSON — ignore + } + })(); + return () => { + cancelled = true; + }; + }, [persistenceKey]); + + // Flush on unmount + useEffect(() => { + return () => { + flushPending(); + }; + }, [flushPending]); + 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[]) => { - setError(null); - const fileArray = Array.from(files); - if (fileArray.length === 0) return; + const addFiles = useCallback( + async (files: FileList | File[]) => { + setError(null); + const fileArray = Array.from(files); + if (fileArray.length === 0) return; - let batchSize = 0; - let valid = true; - for (const file of fileArray) { - const validation = validateAttachment(file); - if (!validation.valid) { - setError(validation.error); - valid = false; - break; + let batchSize = 0; + let valid = true; + for (const file of fileArray) { + const validation = validateAttachment(file); + if (!validation.valid) { + setError(validation.error); + valid = false; + break; + } + batchSize += file.size; } - batchSize += file.size; - } - if (!valid) return; + if (!valid) return; - const newPayloads: AttachmentPayload[] = []; - for (const file of fileArray) { - try { - const payload = await fileToAttachmentPayload(file); - newPayloads.push(payload); - } catch { - setError(`Failed to read file: ${file.name}`); - valid = false; - break; + const newPayloads: AttachmentPayload[] = []; + for (const file of fileArray) { + try { + const payload = await fileToAttachmentPayload(file); + newPayloads.push(payload); + } catch { + setError(`Failed to read file: ${file.name}`); + valid = false; + break; + } } - } - if (!valid) return; + if (!valid) return; - setAttachments((prev) => { - if (prev.length + newPayloads.length > MAX_FILES) { - setError(`Maximum ${MAX_FILES} attachments allowed`); - return prev; - } - const currentTotal = prev.reduce((sum, a) => sum + a.size, 0); - if (currentTotal + batchSize > MAX_TOTAL_SIZE) { - setError('Total attachment size exceeds 20MB limit'); - return prev; - } - return [...prev, ...newPayloads]; - }); - }, []); + setAttachments((prev) => { + if (prev.length + newPayloads.length > MAX_FILES) { + setError(`Maximum ${MAX_FILES} attachments allowed`); + return prev; + } + const currentTotal = prev.reduce((sum, a) => sum + a.size, 0); + if (currentTotal + batchSize > MAX_TOTAL_SIZE) { + setError('Total attachment size exceeds 20MB limit'); + return prev; + } + const next = [...prev, ...newPayloads]; + attachmentsRef.current = next; + schedulePersist(next); + return next; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable + }, + [schedulePersist] + ); - const removeAttachment = useCallback((id: string) => { - setAttachments((prev) => prev.filter((a) => a.id !== id)); - setError(null); - }, []); + const removeAttachment = useCallback( + (id: string) => { + setAttachments((prev) => { + const next = prev.filter((a) => a.id !== id); + attachmentsRef.current = next; + schedulePersist(next); + return next; + }); + setError(null); + // eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable + }, + [schedulePersist] + ); const clearAttachments = useCallback(() => { - setAttachments([]); + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + pendingRef.current = null; + attachmentsRef.current = []; + updateAttachments([]); setError(null); - }, []); + const key = keyRef.current; + if (key) { + void draftStorage.deleteDraft(key); + } + }, [updateAttachments]); const handlePaste = useCallback( (event: React.ClipboardEvent) => { diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index dbf9c56d..ae763dd1 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -284,7 +284,7 @@ export function useKeyboardShortcuts(): void { event.preventDefault(); if (selectedProjectId && selectedSessionId) { void Promise.all([ - fetchSessionDetail(selectedProjectId, selectedSessionId), + fetchSessionDetail(selectedProjectId, selectedSessionId, activeTabId ?? undefined), fetchSessions(selectedProjectId), ]); } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7fb0450e..0829db2e 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -28,7 +28,12 @@ import { createUpdateSlice } from './slices/updateSlice'; import type { DetectedError } from '../types/data'; import type { AppState } from './types'; -import type { CliInstallerProgress, TeamChangeEvent, UpdaterStatus } from '@shared/types'; +import type { + CliInstallerProgress, + LeadContextUsage, + TeamChangeEvent, + UpdaterStatus, +} from '@shared/types'; // ============================================================================= // Store Creation @@ -362,11 +367,33 @@ export function initializeNotificationListeners(): () => void { }; } + // Clear context data when lead goes offline + if (nextActivity === 'offline') { + nextState.leadContextByTeam = { ...prev.leadContextByTeam }; + delete (nextState.leadContextByTeam as Record)[ + event.teamName + ]; + } + return nextState as typeof prev; }); return; } + // Immediate in-memory update for lead context usage — no filesystem refresh needed + if (event.type === 'lead-context' && event.detail) { + try { + const ctx = JSON.parse(event.detail) as LeadContextUsage; + useStore.setState((prev) => ({ + ...prev, + leadContextByTeam: { ...prev.leadContextByTeam, [event.teamName]: ctx }, + })); + } catch { + /* ignore malformed detail */ + } + return; + } + // Throttled refresh of summary list (keeps TeamListView current without flooding). if (!teamListRefreshTimer) { teamListRefreshTimer = setTimeout(() => { diff --git a/src/renderer/store/slices/paneSlice.ts b/src/renderer/store/slices/paneSlice.ts index 85d6628e..d6ed9901 100644 --- a/src/renderer/store/slices/paneSlice.ts +++ b/src/renderer/store/slices/paneSlice.ts @@ -180,9 +180,10 @@ export const createPaneSlice: StateCreator = (set, const pane = findPane(paneLayout, paneId); if (!pane) return; - // Cleanup tab UI state for all tabs in the pane + // Cleanup tab UI state and session data for all tabs in the pane for (const tab of pane.tabs) { state.cleanupTabUIState(tab.id); + state.cleanupTabSessionData(tab.id); } const newLayout = removePane(paneLayout, paneId); diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index ced6eb56..d98d91b3 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -550,6 +550,7 @@ export const createTabSlice: StateCreator = (set, ge const tabsToClose = pane.tabs.filter((t) => t.id !== tabId); for (const tab of tabsToClose) { state.cleanupTabUIState(tab.id); + state.cleanupTabSessionData(tab.id); } const keepTab = pane.tabs.find((t) => t.id === tabId); @@ -581,6 +582,7 @@ export const createTabSlice: StateCreator = (set, ge const tabsToClose = pane.tabs.slice(index + 1); for (const tab of tabsToClose) { state.cleanupTabUIState(tab.id); + state.cleanupTabSessionData(tab.id); } const newTabs = pane.tabs.slice(0, index + 1); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 5463311a..1e203b26 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -69,6 +69,7 @@ import type { GlobalTask, KanbanColumnId, LeadActivityState, + LeadContextUsage, SendMessageRequest, SendMessageResult, TaskComment, @@ -256,6 +257,7 @@ export interface TeamSlice { */ provisioningStartedAtFloorByTeam: Record; leadActivityByTeam: Record; + leadContextByTeam: Record; activeProvisioningRunId: string | null; provisioningError: string | null; clearProvisioningError: () => void; @@ -288,7 +290,12 @@ export interface TeamSlice { ) => Promise; addingComment: boolean; addCommentError: string | null; - addTaskComment: (teamName: string, taskId: string, text: string) => Promise; + addTaskComment: ( + teamName: string, + taskId: string, + text: string, + attachments?: import('@shared/types').CommentAttachmentPayload[] + ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; updateMemberRole: ( @@ -313,6 +320,23 @@ export interface TeamSlice { taskId: string, value: 'lead' | 'user' | null ) => Promise; + saveTaskAttachment: ( + teamName: string, + taskId: string, + file: { name: string; type: string; base64: string } + ) => Promise; + deleteTaskAttachment: ( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: string + ) => Promise; + getTaskAttachmentData: ( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: string + ) => Promise; deletedTasks: TeamTask[]; deletedTasksLoading: boolean; softDeleteTask: (teamName: string, taskId: string) => Promise; @@ -352,6 +376,7 @@ export const createTeamSlice: StateCreator = (set, provisioningRuns: {}, provisioningStartedAtFloorByTeam: {}, leadActivityByTeam: {}, + leadContextByTeam: {}, activeProvisioningRunId: null, provisioningError: null, clearProvisioningError: () => set({ provisioningError: null }), @@ -810,11 +835,32 @@ export const createTeamSlice: StateCreator = (set, await get().fetchAllTasks(); }, - addTaskComment: async (teamName, taskId, text) => { + saveTaskAttachment: async (teamName, taskId, file) => { + const id = crypto.randomUUID(); + await unwrapIpc('team:saveTaskAttachment', () => + api.teams.saveTaskAttachment(teamName, taskId, id, file.name, file.type, file.base64) + ); + await get().refreshTeamData(teamName); + }, + + deleteTaskAttachment: async (teamName, taskId, attachmentId, mimeType) => { + await unwrapIpc('team:deleteTaskAttachment', () => + api.teams.deleteTaskAttachment(teamName, taskId, attachmentId, mimeType) + ); + await get().refreshTeamData(teamName); + }, + + getTaskAttachmentData: async (teamName, taskId, attachmentId, mimeType) => { + return unwrapIpc('team:getTaskAttachment', () => + api.teams.getTaskAttachment(teamName, taskId, attachmentId, mimeType) + ); + }, + + addTaskComment: async (teamName, taskId, text, attachments) => { set({ addingComment: true, addCommentError: null }); try { const comment = await unwrapIpc('team:addTaskComment', () => - api.teams.addTaskComment(teamName, taskId, text) + api.teams.addTaskComment(teamName, taskId, text, attachments) ); set({ addingComment: false }); await get().refreshTeamData(teamName); diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index de937fa9..9509bc6e 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -41,13 +41,19 @@ export function getPresenceLabel( member: ResolvedTeamMember, isTeamAlive?: boolean, isTeamProvisioning?: boolean, - leadActivity?: LeadActivityState + leadActivity?: LeadActivityState, + leadContextPercent?: number ): string { if (member.status === 'terminated') return 'terminated'; if (isTeamProvisioning) return 'connecting'; if (isTeamAlive === false) return 'offline'; if (leadActivity && member.agentType === 'team-lead') { - return leadActivity === 'active' ? 'processing' : 'ready'; + if (leadActivity === 'active') { + return leadContextPercent != null && leadContextPercent > 0 + ? `processing (${Math.round(leadContextPercent)}%)` + : 'processing'; + } + return 'ready'; } if (member.status === 'unknown') return 'idle'; return member.currentTaskId ? 'working' : 'idle'; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index c4b962c5..46fb4e87 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -30,15 +30,19 @@ import type { import type { AddMemberRequest, AttachmentFileData, + AttachmentMediaType, + CommentAttachmentPayload, CreateTaskRequest, GlobalTask, KanbanColumnId, LeadActivityState, + LeadContextUsage, MemberFullStats, MemberLogSummary, ReplaceMembersRequest, SendMessageRequest, SendMessageResult, + TaskAttachmentMeta, TaskComment, TeamChangeEvent, TeamConfig, @@ -448,7 +452,12 @@ export interface TeamsAPI { memberName: string, role: string | undefined ) => Promise; - addTaskComment: (teamName: string, taskId: string, text: string) => Promise; + addTaskComment: ( + teamName: string, + taskId: string, + text: string, + attachments?: CommentAttachmentPayload[] + ) => Promise; setTaskClarification: ( teamName: string, taskId: string, @@ -458,6 +467,7 @@ export interface TeamsAPI { getAttachments: (teamName: string, messageId: string) => Promise; killProcess: (teamName: string, pid: number) => Promise; getLeadActivity: (teamName: string) => Promise; + getLeadContext: (teamName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; @@ -474,6 +484,26 @@ export interface TeamsAPI { targetId: string, type: 'blockedBy' | 'blocks' | 'related' ) => Promise; + saveTaskAttachment: ( + teamName: string, + taskId: string, + attachmentId: string, + filename: string, + mimeType: string, + base64Data: string + ) => Promise; + getTaskAttachment: ( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: string + ) => Promise; + deleteTaskAttachment: ( + teamName: string, + taskId: string, + attachmentId: string, + mimeType: string + ) => Promise; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onProvisioningProgress: ( callback: (event: unknown, data: TeamProvisioningProgress) => void diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e87afc4f..f67c6c9c 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -84,6 +84,8 @@ export interface TaskComment { text: string; createdAt: string; type: TaskCommentType; + /** Image attachments on this comment. Metadata only — files stored on disk. */ + attachments?: TaskAttachmentMeta[]; } // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. @@ -123,6 +125,8 @@ export interface TeamTask { needsClarification?: 'lead' | 'user'; /** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */ deletedAt?: string; + /** Image attachments associated with this task. Metadata only — actual files stored on disk. */ + attachments?: TaskAttachmentMeta[]; } /** Task enriched for UI/DTO use (overlay from kanban-state.json). */ @@ -131,6 +135,28 @@ export interface TeamTaskWithKanban extends TeamTask { kanbanColumn?: 'review' | 'approved'; } +/** Metadata for an image attached to a task description. */ +export interface TaskAttachmentMeta { + /** Unique attachment ID (uuid). */ + id: string; + /** Original filename (e.g. "screenshot.png"). */ + filename: string; + /** MIME type. */ + mimeType: AttachmentMediaType; + /** File size in bytes. */ + size: number; + /** ISO timestamp when the attachment was added. */ + addedAt: string; +} + +/** Payload for uploading an attachment with base64 data (renderer → main). */ +export interface CommentAttachmentPayload { + id: string; + filename: string; + mimeType: AttachmentMediaType; + base64Data: string; +} + export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; export interface AttachmentMeta { @@ -268,8 +294,19 @@ export interface CreateTaskRequest { export type LeadActivityState = 'active' | 'idle' | 'offline'; +export interface LeadContextUsage { + /** Total tokens currently in context (input + cache_creation + cache_read) */ + currentTokens: number; + /** Model's context window size */ + contextWindow: number; + /** Usage percentage (0-100) */ + percent: number; + /** ISO timestamp of last update */ + updatedAt: string; +} + export interface TeamChangeEvent { - type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'process'; + type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'process'; teamName: string; detail?: string; } diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index e377c187..c1227e30 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -55,6 +55,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({ TEAM_RESTORE: 'team:restoreTeam', TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam', TEAM_RESTORE_TASK: 'team:restoreTask', + TEAM_SAVE_TASK_ATTACHMENT: 'team:saveTaskAttachment', + TEAM_GET_TASK_ATTACHMENT: 'team:getTaskAttachment', + TEAM_DELETE_TASK_ATTACHMENT: 'team:deleteTaskAttachment', })); import {