diff --git a/README.md b/README.md index 4e1a084e..8eb74574 100644 --- a/README.md +++ b/README.md @@ -202,8 +202,7 @@ 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. --- 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..9f1a05c8 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -49,6 +49,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 +78,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,6 +88,7 @@ import type { } from '../services'; import type { AttachmentFileData, + AttachmentMediaType, AttachmentMeta, AttachmentPayload, CreateTaskRequest, @@ -94,6 +99,7 @@ import type { MemberLogSummary, SendMessageRequest, SendMessageResult, + TaskAttachmentMeta, TaskComment, TeamConfig, TeamCreateConfigRequest, @@ -165,6 +171,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 @@ -229,6 +236,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'); } @@ -278,6 +288,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 { @@ -1975,3 +1988,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/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 64fd6c37..749af3fd 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, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 74da9ae6..f75c87f7 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -58,7 +58,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; diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts new file mode 100644 index 00000000..c5bff7c4 --- /dev/null +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -0,0 +1,142 @@ +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 { + /** Returns the directory for a specific task's attachments. */ + private getTaskDir(teamName: string, taskId: string): string { + 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 { + 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 buffer = Buffer.from(base64Data, '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..339ad3d9 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 @@ -195,6 +204,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..e5e21971 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, @@ -554,4 +555,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..1e1c3416 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -343,6 +343,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..0a1f0fd9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -89,6 +89,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, @@ -187,6 +190,7 @@ import type { SshConnectionConfig, SshConnectionStatus, SshLastConnection, + TaskAttachmentMeta, TaskChangeSetV2, TaskComment, TeamChangeEvent, @@ -872,6 +876,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..66a8fd80 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -831,6 +831,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..17386879 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -199,6 +199,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ), // Links — inline element, no hl(); parent block element's hl() descends here + // task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem) a: ({ href, children }) => ( { e.preventDefault(); - if (href) { + if (href && !href.startsWith('task://')) { void api.openExternal(href); } }} 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 (
-
+
+ + + {!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/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index cea7c649..709e0998 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -28,6 +28,15 @@ import { import type { MentionSuggestion } from '@renderer/types/mention'; import type { ResolvedTeamMember, 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; const VISIBLE_COMMENTS_STEP = 50; @@ -44,6 +53,13 @@ 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)'); } export const TaskCommentsSection = ({ @@ -54,6 +70,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); @@ -218,7 +235,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 +260,33 @@ 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 - /> + > + + )} {showCollapsed && ( <> 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/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/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..ec69987b 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -313,6 +313,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; @@ -810,6 +827,27 @@ export const createTeamSlice: StateCreator = (set, await get().fetchAllTasks(); }, + 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) => { set({ addingComment: true, addCommentError: null }); try { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index c4b962c5..35e36d49 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -30,6 +30,7 @@ import type { import type { AddMemberRequest, AttachmentFileData, + AttachmentMediaType, CreateTaskRequest, GlobalTask, KanbanColumnId, @@ -39,6 +40,7 @@ import type { ReplaceMembersRequest, SendMessageRequest, SendMessageResult, + TaskAttachmentMeta, TaskComment, TeamChangeEvent, TeamConfig, @@ -474,6 +476,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..40ff3817 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -123,6 +123,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 +133,20 @@ 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; +} + export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; export interface AttachmentMeta { 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 {