feat: enhance README and add detailed diff view implementation plan
- Updated README to specify CLI runtime capabilities for headless environments. - Introduced a comprehensive implementation plan for the diff view feature, detailing phases, required packages, types, IPC channels, backend services, and UI components. - Added new documentation on competitors in AI orchestration platforms and research on local image storage and markdown rendering pipeline. - Included a workflow editor research document to evaluate visual libraries for node-based workflow orchestration.
This commit is contained in:
parent
faf07322eb
commit
1f35e86f0a
38 changed files with 2503 additions and 119 deletions
|
|
@ -202,8 +202,7 @@ pnpm dist # macOS + Windows + Linux
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
- [ ] CLI runtime: 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)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
332
docs/research/competitors.md
Normal file
332
docs/research/competitors.md
Normal file
|
|
@ -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)
|
||||||
164
docs/research/local-image-storage.md
Normal file
164
docs/research/local-image-storage.md
Normal file
|
|
@ -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.
|
||||||
143
docs/research/markdown-rendering-pipeline.md
Normal file
143
docs/research/markdown-rendering-pipeline.md
Normal file
|
|
@ -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 <MarkdownViewer content={displayText} bare />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" bare />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit preview mode (lines 494-501):**
|
||||||
|
```tsx
|
||||||
|
<MarkdownViewer content={descriptionDraft} maxHeight="max-h-[180px]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conclusion: TaskDetailDialog DOES use MarkdownViewer
|
||||||
|
|
||||||
|
The description is rendered with `<MarkdownViewer bare />` 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
|
||||||
|
<div
|
||||||
|
className={`min-w-0 overflow-hidden ${bare ? '' : 'rounded-lg shadow-sm'} ...`}
|
||||||
|
style={bare ? undefined : { backgroundColor: CODE_BG, border: `1px solid ${CODE_BORDER}` }}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
**`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 `<MarkdownViewer content={displayText} bare />`.
|
||||||
|
|
||||||
|
3. **Structured JSON messages in ActivityItem**
|
||||||
|
When `parseStructuredAgentMessage()` returns a match, the structured path renders `autoSummary` as a `<p>` tag and shows raw JSON in a `<details>` block — no MarkdownViewer. This is intentional for JSON protocol messages.
|
||||||
|
|
||||||
|
4. **Summary text in ActivityItem header**
|
||||||
|
Line 375-377: `summaryText` is rendered as plain `<span>` 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 `<ReplyQuoteBlock>` 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 `<MarkdownViewer bare />` 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 |
|
||||||
183
docs/research/web-deployment.md
Normal file
183
docs/research/web-deployment.md
Normal file
|
|
@ -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<T>(channel: string, ...args: unknown[]): Promise<T>;
|
||||||
|
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 дня)
|
||||||
|
- Даёт полный контроль над архитектурой
|
||||||
202
docs/research/workflow-editors.md
Normal file
202
docs/research/workflow-editors.md
Normal file
|
|
@ -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)
|
||||||
|
|
@ -438,48 +438,60 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
}
|
}
|
||||||
httpServer?.broadcast('team-change', event);
|
httpServer?.broadcast('team-change', event);
|
||||||
|
|
||||||
// Process inbox change events — relay to lead + native OS notifications.
|
// Process inbox and task change events.
|
||||||
try {
|
try {
|
||||||
if (!event || typeof event !== 'object') return;
|
if (!event || typeof event !== 'object') return;
|
||||||
const row = event as { type?: unknown; teamName?: unknown; detail?: unknown };
|
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;
|
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||||
const teamName = row.teamName.trim();
|
const teamName = row.teamName.trim();
|
||||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||||
|
|
||||||
// Auto-relay direct messages to live team lead process (no UI dependency).
|
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
if (row.type === 'inbox') {
|
||||||
void teamProvisioningService
|
// Auto-relay direct messages to live team lead process (no UI dependency).
|
||||||
.relayLeadInboxMessages(teamName)
|
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||||
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
|
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).
|
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
||||||
if (detail.startsWith('inboxes/')) {
|
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
||||||
const timerKey = `${teamName}:${detail}`;
|
const taskId = detail.replace('.json', '');
|
||||||
const existing = inboxNotifyTimers.get(timerKey);
|
void teamDataService
|
||||||
if (existing) clearTimeout(existing);
|
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
||||||
inboxNotifyTimers.set(
|
.catch((e: unknown) =>
|
||||||
timerKey,
|
logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`)
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ import {
|
||||||
TEAM_UPDATE_TASK_FIELDS,
|
TEAM_UPDATE_TASK_FIELDS,
|
||||||
TEAM_UPDATE_TASK_OWNER,
|
TEAM_UPDATE_TASK_OWNER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
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
|
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||||
} from '@preload/constants/ipcChannels';
|
} from '@preload/constants/ipcChannels';
|
||||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||||
|
|
@ -75,6 +78,7 @@ const notifiedRateLimitKeys = new Set<string>();
|
||||||
const RATE_LIMIT_KEYS_MAX = 500;
|
const RATE_LIMIT_KEYS_MAX = 500;
|
||||||
|
|
||||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberStatsComputer,
|
MemberStatsComputer,
|
||||||
|
|
@ -84,6 +88,7 @@ import type {
|
||||||
} from '../services';
|
} from '../services';
|
||||||
import type {
|
import type {
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
|
AttachmentMediaType,
|
||||||
AttachmentMeta,
|
AttachmentMeta,
|
||||||
AttachmentPayload,
|
AttachmentPayload,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
|
|
@ -94,6 +99,7 @@ import type {
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
|
|
@ -165,6 +171,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
||||||
let memberStatsComputer: MemberStatsComputer | null = null;
|
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||||
|
|
||||||
const attachmentStore = new TeamAttachmentStore();
|
const attachmentStore = new TeamAttachmentStore();
|
||||||
|
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
|
|
||||||
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
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_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
|
||||||
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
|
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
|
||||||
ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship);
|
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');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -278,6 +288,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
||||||
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
|
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
|
||||||
ipcMain.removeHandler(TEAM_REMOVE_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 {
|
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<IpcResult<TaskAttachmentMeta>> {
|
||||||
|
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<IpcResult<string | null>> {
|
||||||
|
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<IpcResult<void>> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ const TASK_MAP_YIELD_EVERY = 250;
|
||||||
export class TeamDataService {
|
export class TeamDataService {
|
||||||
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
private processHealthTeams = new Set<string>();
|
private processHealthTeams = new Set<string>();
|
||||||
|
/** Tracks notified task-start transitions to avoid duplicate lead notifications. */
|
||||||
|
private notifiedTaskStarts = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
|
|
@ -873,6 +875,48 @@ export class TeamDataService {
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, status, actor);
|
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<void> {
|
||||||
|
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<void> {
|
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
||||||
await this.taskWriter.softDelete(teamName, taskId, 'user');
|
await this.taskWriter.softDelete(teamName, taskId, 'user');
|
||||||
}
|
}
|
||||||
|
|
@ -897,6 +941,22 @@ export class TeamDataService {
|
||||||
await this.taskWriter.updateFields(teamName, taskId, fields);
|
await this.taskWriter.updateFields(teamName, taskId, fields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addTaskAttachment(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
meta: import('@shared/types').TaskAttachmentMeta
|
||||||
|
): Promise<void> {
|
||||||
|
await this.taskWriter.addAttachment(teamName, taskId, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeTaskAttachment(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.taskWriter.removeAttachment(teamName, taskId, attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
async setTaskNeedsClarification(
|
async setTaskNeedsClarification(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
|
||||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||||
// const CLI_PREPARE_TIMEOUT_MS = 10000;
|
// 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_TIMEOUT_MS = 30000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
|
|
|
||||||
142
src/main/services/team/TeamTaskAttachmentStore.ts
Normal file
142
src/main/services/team/TeamTaskAttachmentStore.ts
Normal file
|
|
@ -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<string> = new Set<AttachmentMediaType>([
|
||||||
|
'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<TaskAttachmentMeta> {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,9 @@ import * as path from 'path';
|
||||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AttachmentMediaType,
|
||||||
StatusTransition,
|
StatusTransition,
|
||||||
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskWorkInterval,
|
TaskWorkInterval,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
|
|
@ -18,6 +20,13 @@ import type {
|
||||||
const logger = createLogger('Service:TeamTaskReader');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
|
||||||
export class TeamTaskReader {
|
export class TeamTaskReader {
|
||||||
/**
|
/**
|
||||||
* Returns the next available numeric task ID by scanning ALL task files
|
* Returns the next available numeric task ID by scanning ALL task files
|
||||||
|
|
@ -195,6 +204,29 @@ export class TeamTaskReader {
|
||||||
? (parsed.needsClarification as 'lead' | 'user')
|
? (parsed.needsClarification as 'lead' | 'user')
|
||||||
: undefined,
|
: undefined,
|
||||||
deletedAt: undefined, // deleted tasks are filtered out below
|
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<string, unknown>).id === 'string' &&
|
||||||
|
typeof (a as Record<string, unknown>).filename === 'string' &&
|
||||||
|
typeof (a as Record<string, unknown>).mimeType === 'string' &&
|
||||||
|
VALID_ATTACHMENT_MIME_TYPES.has(
|
||||||
|
(a as Record<string, unknown>).mimeType as string
|
||||||
|
) &&
|
||||||
|
typeof (a as Record<string, unknown>).size === 'number' &&
|
||||||
|
typeof (a as Record<string, unknown>).addedAt === 'string'
|
||||||
|
)
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
size: a.size,
|
||||||
|
addedAt: a.addedAt,
|
||||||
|
}))
|
||||||
|
: undefined,
|
||||||
} satisfies Record<keyof TeamTask, unknown>;
|
} satisfies Record<keyof TeamTask, unknown>;
|
||||||
if (task.status === 'deleted') {
|
if (task.status === 'deleted') {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
StatusTransition,
|
StatusTransition,
|
||||||
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TaskCommentType,
|
TaskCommentType,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
|
|
@ -554,4 +555,41 @@ export class TeamTaskWriter {
|
||||||
|
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addAttachment(teamName: string, taskId: string, meta: TaskAttachmentMeta): Promise<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ interface ParsedTask {
|
||||||
metadata?: { _internal?: unknown };
|
metadata?: { _internal?: unknown };
|
||||||
workIntervals?: unknown;
|
workIntervals?: unknown;
|
||||||
statusHistory?: unknown;
|
statusHistory?: unknown;
|
||||||
|
attachments?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawWorkInterval {
|
interface RawWorkInterval {
|
||||||
|
|
@ -596,6 +597,9 @@ async function readTasksDirForTeam(
|
||||||
comments: normalizeComments(parsed),
|
comments: normalizeComments(parsed),
|
||||||
needsClarification,
|
needsClarification,
|
||||||
deletedAt: undefined,
|
deletedAt: undefined,
|
||||||
|
attachments: Array.isArray(parsed.attachments)
|
||||||
|
? (parsed.attachments as unknown[])
|
||||||
|
: undefined,
|
||||||
teamName,
|
teamName,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,15 @@ export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship';
|
||||||
/** Remove a relationship (blockedBy/blocks/related) between two tasks */
|
/** Remove a relationship (blockedBy/blocks/related) between two tasks */
|
||||||
export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship';
|
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
|
// CLI Installer API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,9 @@ import {
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
TEAM_REPLACE_MEMBERS,
|
TEAM_REPLACE_MEMBERS,
|
||||||
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
|
TEAM_GET_TASK_ATTACHMENT,
|
||||||
|
TEAM_DELETE_TASK_ATTACHMENT,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
|
|
@ -187,6 +190,7 @@ import type {
|
||||||
SshConnectionConfig,
|
SshConnectionConfig,
|
||||||
SshConnectionStatus,
|
SshConnectionStatus,
|
||||||
SshLastConnection,
|
SshLastConnection,
|
||||||
|
TaskAttachmentMeta,
|
||||||
TaskChangeSetV2,
|
TaskChangeSetV2,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
|
|
@ -872,6 +876,52 @@ const electronAPI: ElectronAPI = {
|
||||||
type
|
type
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
saveTaskAttachment: async (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
filename: string,
|
||||||
|
mimeType: string,
|
||||||
|
base64Data: string
|
||||||
|
) => {
|
||||||
|
return invokeIpcWithResult<TaskAttachmentMeta>(
|
||||||
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
attachmentId,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
base64Data
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getTaskAttachment: async (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
mimeType: string
|
||||||
|
) => {
|
||||||
|
return invokeIpcWithResult<string | null>(
|
||||||
|
TEAM_GET_TASK_ATTACHMENT,
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
attachmentId,
|
||||||
|
mimeType
|
||||||
|
);
|
||||||
|
},
|
||||||
|
deleteTaskAttachment: async (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
mimeType: string
|
||||||
|
) => {
|
||||||
|
return invokeIpcWithResult<void>(
|
||||||
|
TEAM_DELETE_TASK_ATTACHMENT,
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
attachmentId,
|
||||||
|
mimeType
|
||||||
|
);
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
|
|
|
||||||
|
|
@ -831,6 +831,32 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
throw new Error('Task relationships are not available in browser mode');
|
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<never> => {
|
||||||
|
throw new Error('Task attachments are not available in browser mode');
|
||||||
|
},
|
||||||
|
getTaskAttachment: async (
|
||||||
|
_teamName: string,
|
||||||
|
_taskId: string,
|
||||||
|
_attachmentId: string,
|
||||||
|
_mimeType: string
|
||||||
|
): Promise<string | null> => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
deleteTaskAttachment: async (
|
||||||
|
_teamName: string,
|
||||||
|
_taskId: string,
|
||||||
|
_attachmentId: string,
|
||||||
|
_mimeType: string
|
||||||
|
): Promise<void> => {
|
||||||
|
throw new Error('Task attachments are not available in browser mode');
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
return this.addEventListener('team-change', (data: unknown) =>
|
return this.addEventListener('team-change', (data: unknown) =>
|
||||||
callback(null, data as TeamChangeEvent)
|
callback(null, data as TeamChangeEvent)
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
),
|
),
|
||||||
|
|
||||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
// 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 }) => (
|
a: ({ href, children }) => (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
|
|
@ -206,7 +207,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
style={{ color: PROSE_LINK }}
|
style={{ color: PROSE_LINK }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (href) {
|
if (href && !href.startsWith('task://')) {
|
||||||
void api.openExternal(href);
|
void api.openExternal(href);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { ChevronRight } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
function scrollAfterExpand(el: HTMLElement): void {
|
function scrollAfterExpand(el: HTMLElement): void {
|
||||||
|
|
@ -27,6 +28,10 @@ interface CollapsibleTeamSectionProps {
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
/** Extra classes applied to the content wrapper (e.g. padding). */
|
/** Extra classes applied to the content wrapper (e.g. padding). */
|
||||||
contentClassName?: string;
|
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;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,6 +46,8 @@ export const CollapsibleTeamSection = ({
|
||||||
action,
|
action,
|
||||||
sectionId,
|
sectionId,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
headerClassName,
|
||||||
|
headerContentClassName,
|
||||||
children,
|
children,
|
||||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
@ -61,14 +68,24 @@ export const CollapsibleTeamSection = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
||||||
<div className="relative -mx-4 flex min-h-9 w-[calc(100%+2rem)] items-stretch py-1.5">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative -mx-4 flex min-h-9 w-[calc(100%+2rem)] items-stretch py-1.5',
|
||||||
|
headerClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-white/[0.07] hover:bg-white/[0.1]' : 'rounded-md bg-white/[0.04] hover:bg-white/[0.08]'}`}
|
className={`absolute inset-0 z-0 cursor-pointer transition-colors ${isOpen ? 'rounded-t-md bg-white/[0.07] hover:bg-white/[0.1]' : 'rounded-md bg-white/[0.04] hover:bg-white/[0.08]'}`}
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
aria-label={isOpen ? 'Collapse section' : 'Expand section'}
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 basis-0 items-center gap-2 pl-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none relative z-10 flex min-w-0 flex-1 basis-0 items-center gap-2 pl-4',
|
||||||
|
headerContentClassName
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
size={14}
|
size={14}
|
||||||
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
|
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -1503,15 +1503,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
defaultText={sendDialogDefaultText}
|
defaultText={sendDialogDefaultText}
|
||||||
defaultChip={sendDialogDefaultChip}
|
defaultChip={sendDialogDefaultChip}
|
||||||
quotedMessage={replyQuote}
|
quotedMessage={replyQuote}
|
||||||
|
isTeamAlive={data.isAlive}
|
||||||
sending={sendingMessage}
|
sending={sendingMessage}
|
||||||
sendError={sendMessageError}
|
sendError={sendMessageError}
|
||||||
lastResult={lastSendMessageResult}
|
lastResult={lastSendMessageResult}
|
||||||
onSend={(member, text, summary) => {
|
onSend={(member, text, summary, attachments) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const sentAtMs = Date.now();
|
const sentAtMs = Date.now();
|
||||||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||||
try {
|
try {
|
||||||
await sendTeamMessage(teamName, { member, text, summary });
|
await sendTeamMessage(teamName, { member, text, summary, attachments });
|
||||||
} catch {
|
} catch {
|
||||||
setPendingRepliesByMember((prev) => {
|
setPendingRepliesByMember((prev) => {
|
||||||
if (prev[member] !== sentAtMs) return prev;
|
if (prev[member] !== sentAtMs) return prev;
|
||||||
|
|
@ -1551,6 +1552,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
void updateTaskOwner(teamName, taskId, owner);
|
void updateTaskOwner(teamName, taskId, owner);
|
||||||
}}
|
}}
|
||||||
onViewChanges={handleViewChangesForFile}
|
onViewChanges={handleViewChangesForFile}
|
||||||
|
onOpenInEditor={(filePath) => {
|
||||||
|
const { revealFileInEditor } = useStore.getState();
|
||||||
|
revealFileInEditor(filePath);
|
||||||
|
}}
|
||||||
onDeleteTask={handleDeleteTask}
|
onDeleteTask={handleDeleteTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,9 @@ export const ActivityItem = ({
|
||||||
if (structured) return null;
|
if (structured) return null;
|
||||||
const stripped = stripAgentBlocks(message.text).trim();
|
const stripped = stripAgentBlocks(message.text).trim();
|
||||||
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
||||||
return onTaskIdClick ? linkifyTaskIdsInMarkdown(stripped) : stripped;
|
// Normalize literal \n from CLI tools (teamctl.js) to real newlines
|
||||||
|
const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||||
|
return onTaskIdClick ? linkifyTaskIdsInMarkdown(normalized) : normalized;
|
||||||
}, [structured, message.text, onTaskIdClick]);
|
}, [structured, message.text, onTaskIdClick]);
|
||||||
|
|
||||||
// Check if this is a reply message
|
// Check if this is a reply message
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,9 @@ export const CreateTeamDialog = ({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev defaults applied once on open
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev defaults applied once on open
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
if (cwdMode !== 'project') {
|
if (cwdMode !== 'project') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -426,7 +428,7 @@ export const CreateTeamDialog = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSelectedProjectPath(projects[0].path);
|
setSelectedProjectPath(projects[0].path);
|
||||||
}, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||||
|
|
||||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,8 +215,9 @@ export const LaunchTeamDialog = ({
|
||||||
};
|
};
|
||||||
}, [open, repositoryGroups]);
|
}, [open, repositoryGroups]);
|
||||||
|
|
||||||
// Pre-select defaultProjectPath when projects loaded
|
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
if (cwdMode !== 'project') {
|
if (cwdMode !== 'project') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -231,7 +232,7 @@ export const LaunchTeamDialog = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSelectedProjectPath(projects[0].path);
|
setSelectedProjectPath(projects[0].path);
|
||||||
}, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||||
|
|
||||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { AttachmentPreviewList } from '@renderer/components/team/attachments/AttachmentPreviewList';
|
||||||
|
import { DropZoneOverlay } from '@renderer/components/team/attachments/DropZoneOverlay';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,6 +24,7 @@ import {
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
|
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -30,13 +33,13 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { X } from 'lucide-react';
|
import { ImagePlus, X } from 'lucide-react';
|
||||||
|
|
||||||
import { MemberBadge } from '../MemberBadge';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||||
|
|
||||||
interface QuotedMessage {
|
interface QuotedMessage {
|
||||||
from: string;
|
from: string;
|
||||||
|
|
@ -52,10 +55,16 @@ interface SendMessageDialogProps {
|
||||||
/** Pre-filled inline code chip (from editor selection action) */
|
/** Pre-filled inline code chip (from editor selection action) */
|
||||||
defaultChip?: InlineChip;
|
defaultChip?: InlineChip;
|
||||||
quotedMessage?: QuotedMessage;
|
quotedMessage?: QuotedMessage;
|
||||||
|
isTeamAlive?: boolean;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
sendError: string | null;
|
sendError: string | null;
|
||||||
lastResult: SendMessageResult | null;
|
lastResult: SendMessageResult | null;
|
||||||
onSend: (member: string, text: string, summary?: string) => void;
|
onSend: (
|
||||||
|
member: string,
|
||||||
|
text: string,
|
||||||
|
summary?: string,
|
||||||
|
attachments?: AttachmentPayload[]
|
||||||
|
) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +77,7 @@ export const SendMessageDialog = ({
|
||||||
defaultText,
|
defaultText,
|
||||||
defaultChip,
|
defaultChip,
|
||||||
quotedMessage,
|
quotedMessage,
|
||||||
|
isTeamAlive,
|
||||||
sending,
|
sending,
|
||||||
sendError,
|
sendError,
|
||||||
lastResult,
|
lastResult,
|
||||||
|
|
@ -85,6 +95,25 @@ export const SendMessageDialog = ({
|
||||||
const [prevOpen, setPrevOpen] = useState(false);
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null);
|
const [prevResult, setPrevResult] = useState<SendMessageResult | null>(null);
|
||||||
|
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const dragCounterRef = useRef(0);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
attachments,
|
||||||
|
error: attachmentError,
|
||||||
|
canAddMore,
|
||||||
|
addFiles,
|
||||||
|
removeAttachment,
|
||||||
|
clearAttachments,
|
||||||
|
handlePaste,
|
||||||
|
handleDrop,
|
||||||
|
} = useAttachments({ persistenceKey: 'sendMessage:attachments' });
|
||||||
|
|
||||||
|
const selectedMember = members.find((m) => m.name === member);
|
||||||
|
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||||
|
const canAttach = isLeadRecipient && isTeamAlive && canAddMore;
|
||||||
|
|
||||||
// Reset form when dialog opens
|
// Reset form when dialog opens
|
||||||
if (open && !prevOpen) {
|
if (open && !prevOpen) {
|
||||||
setMember(defaultRecipient ?? '');
|
setMember(defaultRecipient ?? '');
|
||||||
|
|
@ -118,6 +147,7 @@ export const SendMessageDialog = ({
|
||||||
if (pendingAutoClose) {
|
if (pendingAutoClose) {
|
||||||
textDraft.clearDraft();
|
textDraft.clearDraft();
|
||||||
chipDraft.clearChipDraft();
|
chipDraft.clearChipDraft();
|
||||||
|
clearAttachments();
|
||||||
setPendingAutoClose(false);
|
setPendingAutoClose(false);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
@ -156,9 +186,15 @@ export const SendMessageDialog = ({
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
|
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
|
||||||
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
const finalText = quote ? buildReplyBlock(quote.from, quote.text, serialized) : serialized;
|
||||||
onSend(member.trim(), finalText, summary.trim());
|
onSend(
|
||||||
|
member.trim(),
|
||||||
|
finalText,
|
||||||
|
summary.trim(),
|
||||||
|
attachments.length > 0 ? attachments : undefined
|
||||||
|
);
|
||||||
textDraft.clearDraft();
|
textDraft.clearDraft();
|
||||||
chipDraft.clearChipDraft();
|
chipDraft.clearChipDraft();
|
||||||
|
clearAttachments();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (nextOpen: boolean): void => {
|
const handleOpenChange = (nextOpen: boolean): void => {
|
||||||
|
|
@ -167,9 +203,64 @@ export const SendMessageDialog = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const input = e.target;
|
||||||
|
if (input.files?.length) {
|
||||||
|
void addFiles(input.files);
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounterRef.current += 1;
|
||||||
|
if (dragCounterRef.current === 1) setIsDragOver(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounterRef.current -= 1;
|
||||||
|
if (dragCounterRef.current <= 0) {
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setIsDragOver(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDropWrapper = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setIsDragOver(false);
|
||||||
|
if (canAttach) handleDrop(e);
|
||||||
|
},
|
||||||
|
[canAttach, handleDrop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePasteWrapper = useCallback(
|
||||||
|
(e: React.ClipboardEvent) => {
|
||||||
|
if (canAttach) handlePaste(e);
|
||||||
|
},
|
||||||
|
[canAttach, handlePaste]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-[440px]">
|
<DialogContent
|
||||||
|
className="sm:max-w-[560px]"
|
||||||
|
onDragEnter={canAttach ? handleDragEnter : undefined}
|
||||||
|
onDragLeave={canAttach ? handleDragLeave : undefined}
|
||||||
|
onDragOver={canAttach ? handleDragOver : undefined}
|
||||||
|
onDrop={canAttach ? handleDropWrapper : undefined}
|
||||||
|
onPaste={canAttach ? handlePasteWrapper : undefined}
|
||||||
|
>
|
||||||
|
<DropZoneOverlay active={isDragOver && !!canAttach} />
|
||||||
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Send Message</DialogTitle>
|
<DialogTitle>Send Message</DialogTitle>
|
||||||
<DialogDescription>Send a direct message to a team member.</DialogDescription>
|
<DialogDescription>Send a direct message to a team member.</DialogDescription>
|
||||||
|
|
@ -215,7 +306,51 @@ export const SendMessageDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="smd-message">Message</Label>
|
<div className="flex items-center gap-2">
|
||||||
|
<Label htmlFor="smd-message">Message</Label>
|
||||||
|
{isLeadRecipient ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileInputChange}
|
||||||
|
/>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`inline-flex items-center gap-1 rounded p-1 transition-colors ${
|
||||||
|
canAttach
|
||||||
|
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
|
||||||
|
: 'text-[var(--color-text-muted)] opacity-40'
|
||||||
|
}`}
|
||||||
|
disabled={!canAttach}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<ImagePlus size={14} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
{!isTeamAlive
|
||||||
|
? 'Team must be online to attach images'
|
||||||
|
: !canAddMore
|
||||||
|
? 'Maximum attachments reached'
|
||||||
|
: 'Attach images (paste or drag & drop)'}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AttachmentPreviewList
|
||||||
|
attachments={attachments}
|
||||||
|
onRemove={removeAttachment}
|
||||||
|
error={attachmentError}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
|
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
|
||||||
|
|
|
||||||
359
src/renderer/components/team/dialogs/TaskAttachments.tsx
Normal file
359
src/renderer/components/team/dialogs/TaskAttachments.tsx
Normal file
|
|
@ -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<string>(['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<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [previewAttachment, setPreviewAttachment] = useState<{
|
||||||
|
id: string;
|
||||||
|
mimeType: AttachmentMediaType;
|
||||||
|
dataUrl: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="space-y-2"
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{/* Attachment thumbnails */}
|
||||||
|
{attachments.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attachments.map((att) => (
|
||||||
|
<AttachmentThumbnail
|
||||||
|
key={att.id}
|
||||||
|
attachment={att}
|
||||||
|
teamName={teamName}
|
||||||
|
taskId={taskId}
|
||||||
|
isDeleting={deletingId === att.id}
|
||||||
|
isPreviewActive={previewAttachment?.id === att.id}
|
||||||
|
onPreview={() => void handlePreview(att)}
|
||||||
|
onDelete={() => void handleDelete(att.id, att.mimeType)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Preview panel */}
|
||||||
|
{previewAttachment ? (
|
||||||
|
<div className="relative rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={() => setPreviewAttachment(null)}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
{previewAttachment.loading ? (
|
||||||
|
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
Loading image...
|
||||||
|
</div>
|
||||||
|
) : previewAttachment.dataUrl ? (
|
||||||
|
<img
|
||||||
|
src={previewAttachment.dataUrl}
|
||||||
|
alt="Attachment preview"
|
||||||
|
className="max-h-[400px] max-w-full rounded object-contain"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Drop zone indicator */}
|
||||||
|
{dragOver ? (
|
||||||
|
<div className="flex items-center justify-center rounded-md border-2 border-dashed border-blue-500/40 bg-blue-500/5 py-4 text-xs text-blue-400">
|
||||||
|
Drop image here
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => void handleFileSelect(e.target.files)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs text-[var(--color-text-muted)]"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? <Loader2 size={12} className="animate-spin" /> : <ImagePlus size={12} />}
|
||||||
|
Attach image
|
||||||
|
</Button>
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">or paste / drag-drop</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className={`group relative flex size-20 cursor-pointer items-center justify-center overflow-hidden rounded border transition-colors ${
|
||||||
|
isPreviewActive
|
||||||
|
? 'border-blue-500/60 ring-1 ring-blue-500/30'
|
||||||
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-emphasis)]'
|
||||||
|
} bg-[var(--color-surface)]`}
|
||||||
|
onClick={onPreview}
|
||||||
|
>
|
||||||
|
{thumbUrl ? (
|
||||||
|
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Loader2 size={16} className="animate-spin text-[var(--color-text-muted)]" />
|
||||||
|
)}
|
||||||
|
{/* Delete button overlay */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-0.5 top-0.5 rounded bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 size={10} className="animate-spin" /> : <Trash2 size={10} />}
|
||||||
|
</button>
|
||||||
|
{/* Filename tooltip */}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-1 py-0.5 text-center text-[8px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
{attachment.filename} ({sizeLabel})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fileToBase64(file: File): Promise<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,15 @@ import {
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
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 MAX_COMMENT_LENGTH = 2000;
|
||||||
const INITIAL_VISIBLE_COMMENTS = 30;
|
const INITIAL_VISIBLE_COMMENTS = 30;
|
||||||
const VISIBLE_COMMENTS_STEP = 50;
|
const VISIBLE_COMMENTS_STEP = 50;
|
||||||
|
|
@ -44,6 +53,13 @@ interface TaskCommentsSectionProps {
|
||||||
hideInput?: boolean;
|
hideInput?: boolean;
|
||||||
/** Called when the user clicks Reply on a comment (used when input is rendered externally). */
|
/** Called when the user clicks Reply on a comment (used when input is rendered externally). */
|
||||||
onReply?: (author: string, text: string) => void;
|
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 `#<digits>` 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 = ({
|
export const TaskCommentsSection = ({
|
||||||
|
|
@ -54,6 +70,7 @@ export const TaskCommentsSection = ({
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
hideInput = false,
|
hideInput = false,
|
||||||
onReply,
|
onReply,
|
||||||
|
onTaskIdClick,
|
||||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||||
const addingComment = useStore((s) => s.addingComment);
|
const addingComment = useStore((s) => s.addingComment);
|
||||||
|
|
@ -218,7 +235,7 @@ export const TaskCommentsSection = ({
|
||||||
{(() => {
|
{(() => {
|
||||||
const reply = parseMessageReply(comment.text);
|
const reply = parseMessageReply(comment.text);
|
||||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||||
const displayText = stripAgentBlocks(rawForDisplay);
|
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
||||||
const needsExpandCollapse = displayText.includes('\n');
|
const needsExpandCollapse = displayText.includes('\n');
|
||||||
const expanded = expandedCommentIds.has(comment.id);
|
const expanded = expandedCommentIds.has(comment.id);
|
||||||
const collapsedHeight = 'max-h-[120px]';
|
const collapsedHeight = 'max-h-[120px]';
|
||||||
|
|
@ -243,13 +260,33 @@ export const TaskCommentsSection = ({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownViewer
|
<span
|
||||||
content={displayText}
|
onClickCapture={
|
||||||
maxHeight={
|
onTaskIdClick
|
||||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
? (e) => {
|
||||||
|
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||||
|
'a[href^="task://"]'
|
||||||
|
);
|
||||||
|
if (link) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const id = link.getAttribute('href')?.replace('task://', '');
|
||||||
|
if (id) onTaskIdClick(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
bare
|
>
|
||||||
/>
|
<MarkdownViewer
|
||||||
|
content={
|
||||||
|
onTaskIdClick ? linkifyTaskIdsInMarkdown(displayText) : displayText
|
||||||
|
}
|
||||||
|
maxHeight={
|
||||||
|
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||||
|
}
|
||||||
|
bare
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{showCollapsed && (
|
{showCollapsed && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@renderer/components/ui/select';
|
} from '@renderer/components/ui/select';
|
||||||
import { Textarea } from '@renderer/components/ui/textarea';
|
import { Textarea } from '@renderer/components/ui/textarea';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -43,19 +44,23 @@ import {
|
||||||
Eye,
|
Eye,
|
||||||
FileCode,
|
FileCode,
|
||||||
FileDiff,
|
FileDiff,
|
||||||
|
GitCompareArrows,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
History,
|
History,
|
||||||
|
ImageIcon,
|
||||||
Link2,
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Pencil,
|
Pencil,
|
||||||
PenLine,
|
PenLine,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
SquarePen,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { StatusHistoryTimeline } from './StatusHistoryTimeline';
|
import { StatusHistoryTimeline } from './StatusHistoryTimeline';
|
||||||
|
import { TaskAttachments } from './TaskAttachments';
|
||||||
import { TaskCommentInput } from './TaskCommentInput';
|
import { TaskCommentInput } from './TaskCommentInput';
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
||||||
|
|
@ -74,6 +79,7 @@ interface TaskDetailDialogProps {
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||||
|
onOpenInEditor?: (filePath: string) => void;
|
||||||
onDeleteTask?: (taskId: string) => void;
|
onDeleteTask?: (taskId: string) => void;
|
||||||
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
|
|
@ -92,6 +98,7 @@ export const TaskDetailDialog = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onOwnerChange,
|
onOwnerChange,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
|
onOpenInEditor,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
|
|
@ -457,6 +464,8 @@ export const TaskDetailDialog = ({
|
||||||
title="Description"
|
title="Description"
|
||||||
icon={<AlignLeft size={14} />}
|
icon={<AlignLeft size={14} />}
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
|
headerContentClassName="pl-6"
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
{editingDescription ? (
|
{editingDescription ? (
|
||||||
|
|
@ -562,6 +571,27 @@ export const TaskDetailDialog = ({
|
||||||
)}
|
)}
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
<CollapsibleTeamSection
|
||||||
|
title="Attachments"
|
||||||
|
icon={<ImageIcon size={14} />}
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<TaskAttachments
|
||||||
|
teamName={teamName}
|
||||||
|
taskId={currentTask.id}
|
||||||
|
attachments={currentTask.attachments ?? []}
|
||||||
|
/>
|
||||||
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
{/* Changes */}
|
{/* Changes */}
|
||||||
{variant === 'team' && isTaskCompleted && onViewChanges ? (
|
{variant === 'team' && isTaskCompleted && onViewChanges ? (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
|
@ -569,6 +599,8 @@ export const TaskDetailDialog = ({
|
||||||
icon={<FileDiff size={14} />}
|
icon={<FileDiff size={14} />}
|
||||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
|
headerContentClassName="pl-6"
|
||||||
defaultOpen={taskKnownHasChanges}
|
defaultOpen={taskKnownHasChanges}
|
||||||
>
|
>
|
||||||
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
||||||
|
|
@ -579,19 +611,21 @@ export const TaskDetailDialog = ({
|
||||||
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
|
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
|
||||||
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
||||||
{taskChangesFiles.map((file) => (
|
{taskChangesFiles.map((file) => (
|
||||||
<button
|
<div
|
||||||
key={file.filePath}
|
key={file.filePath}
|
||||||
type="button"
|
className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
|
|
||||||
onClick={() => {
|
|
||||||
handleClose();
|
|
||||||
onViewChanges(currentTask.id, file.filePath);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
<FileCode size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
<span className="truncate font-mono text-[var(--color-text-secondary)]">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
onViewChanges(currentTask.id, file.filePath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{file.relativePath}
|
{file.relativePath}
|
||||||
</span>
|
</button>
|
||||||
<span className="flex shrink-0 items-center gap-1.5">
|
<span className="flex shrink-0 items-center gap-1.5">
|
||||||
{file.linesAdded > 0 ? (
|
{file.linesAdded > 0 ? (
|
||||||
<span className="text-emerald-400">+{file.linesAdded}</span>
|
<span className="text-emerald-400">+{file.linesAdded}</span>
|
||||||
|
|
@ -600,7 +634,38 @@ export const TaskDetailDialog = ({
|
||||||
<span className="text-red-400">-{file.linesRemoved}</span>
|
<span className="text-red-400">-{file.linesRemoved}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={() => {
|
||||||
|
handleClose();
|
||||||
|
onViewChanges(currentTask.id, file.filePath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GitCompareArrows size={13} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Review diff</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
{onOpenInEditor ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={() => onOpenInEditor(file.filePath)}
|
||||||
|
>
|
||||||
|
<SquarePen size={13} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Open in editor</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -623,6 +688,8 @@ export const TaskDetailDialog = ({
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
|
headerContentClassName="pl-6"
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -774,6 +841,8 @@ export const TaskDetailDialog = ({
|
||||||
icon={<History size={14} />}
|
icon={<History size={14} />}
|
||||||
badge={currentTask.statusHistory.length}
|
badge={currentTask.statusHistory.length}
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
|
headerContentClassName="pl-6"
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
>
|
>
|
||||||
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
||||||
|
|
@ -790,6 +859,8 @@ export const TaskDetailDialog = ({
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
|
headerContentClassName="pl-6"
|
||||||
defaultOpen
|
defaultOpen
|
||||||
>
|
>
|
||||||
<TaskCommentInput
|
<TaskCommentInput
|
||||||
|
|
@ -807,6 +878,7 @@ export const TaskDetailDialog = ({
|
||||||
hideHeader
|
hideHeader
|
||||||
hideInput
|
hideInput
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
|
onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ export const MessageComposer = ({
|
||||||
clearAttachments,
|
clearAttachments,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
handleDrop,
|
handleDrop,
|
||||||
} = useAttachments();
|
} = useAttachments({ persistenceKey: `compose:${teamName}:attachments` });
|
||||||
|
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { draftStorage } from '@renderer/services/draftStorage';
|
||||||
import {
|
import {
|
||||||
fileToAttachmentPayload,
|
fileToAttachmentPayload,
|
||||||
MAX_FILES,
|
MAX_FILES,
|
||||||
|
|
@ -9,6 +10,11 @@ import {
|
||||||
|
|
||||||
import type { AttachmentPayload } from '@shared/types';
|
import type { AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
|
interface UseAttachmentsOptions {
|
||||||
|
/** When provided, attachments are persisted to IndexedDB under this key. */
|
||||||
|
persistenceKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UseAttachmentsReturn {
|
interface UseAttachmentsReturn {
|
||||||
attachments: AttachmentPayload[];
|
attachments: AttachmentPayload[];
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
@ -21,67 +27,204 @@ interface UseAttachmentsReturn {
|
||||||
handleDrop: (event: React.DragEvent) => void;
|
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<string, unknown>;
|
||||||
|
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<AttachmentPayload[]>([]);
|
const [attachments, setAttachments] = useState<AttachmentPayload[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const attachmentsRef = useRef<AttachmentPayload[]>([]);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pendingRef = useRef<AttachmentPayload[] | null>(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 totalSize = attachments.reduce((sum, a) => sum + a.size, 0);
|
||||||
const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE;
|
const canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE;
|
||||||
|
|
||||||
const addFiles = useCallback(async (files: FileList | File[]) => {
|
const addFiles = useCallback(
|
||||||
setError(null);
|
async (files: FileList | File[]) => {
|
||||||
const fileArray = Array.from(files);
|
setError(null);
|
||||||
if (fileArray.length === 0) return;
|
const fileArray = Array.from(files);
|
||||||
|
if (fileArray.length === 0) return;
|
||||||
|
|
||||||
let batchSize = 0;
|
let batchSize = 0;
|
||||||
let valid = true;
|
let valid = true;
|
||||||
for (const file of fileArray) {
|
for (const file of fileArray) {
|
||||||
const validation = validateAttachment(file);
|
const validation = validateAttachment(file);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
setError(validation.error);
|
setError(validation.error);
|
||||||
valid = false;
|
valid = false;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
batchSize += file.size;
|
||||||
}
|
}
|
||||||
batchSize += file.size;
|
if (!valid) return;
|
||||||
}
|
|
||||||
if (!valid) return;
|
|
||||||
|
|
||||||
const newPayloads: AttachmentPayload[] = [];
|
const newPayloads: AttachmentPayload[] = [];
|
||||||
for (const file of fileArray) {
|
for (const file of fileArray) {
|
||||||
try {
|
try {
|
||||||
const payload = await fileToAttachmentPayload(file);
|
const payload = await fileToAttachmentPayload(file);
|
||||||
newPayloads.push(payload);
|
newPayloads.push(payload);
|
||||||
} catch {
|
} catch {
|
||||||
setError(`Failed to read file: ${file.name}`);
|
setError(`Failed to read file: ${file.name}`);
|
||||||
valid = false;
|
valid = false;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!valid) return;
|
||||||
if (!valid) return;
|
|
||||||
|
|
||||||
setAttachments((prev) => {
|
setAttachments((prev) => {
|
||||||
if (prev.length + newPayloads.length > MAX_FILES) {
|
if (prev.length + newPayloads.length > MAX_FILES) {
|
||||||
setError(`Maximum ${MAX_FILES} attachments allowed`);
|
setError(`Maximum ${MAX_FILES} attachments allowed`);
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
|
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
|
||||||
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
|
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
|
||||||
setError('Total attachment size exceeds 20MB limit');
|
setError('Total attachment size exceeds 20MB limit');
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return [...prev, ...newPayloads];
|
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) => {
|
const removeAttachment = useCallback(
|
||||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
(id: string) => {
|
||||||
setError(null);
|
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(() => {
|
const clearAttachments = useCallback(() => {
|
||||||
setAttachments([]);
|
if (timerRef.current != null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
pendingRef.current = null;
|
||||||
|
attachmentsRef.current = [];
|
||||||
|
updateAttachments([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
const key = keyRef.current;
|
||||||
|
if (key) {
|
||||||
|
void draftStorage.deleteDraft(key);
|
||||||
|
}
|
||||||
|
}, [updateAttachments]);
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
(event: React.ClipboardEvent) => {
|
(event: React.ClipboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ export function useKeyboardShortcuts(): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedProjectId && selectedSessionId) {
|
if (selectedProjectId && selectedSessionId) {
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
fetchSessionDetail(selectedProjectId, selectedSessionId),
|
fetchSessionDetail(selectedProjectId, selectedSessionId, activeTabId ?? undefined),
|
||||||
fetchSessions(selectedProjectId),
|
fetchSessions(selectedProjectId),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,9 +180,10 @@ export const createPaneSlice: StateCreator<AppState, [], [], PaneSlice> = (set,
|
||||||
const pane = findPane(paneLayout, paneId);
|
const pane = findPane(paneLayout, paneId);
|
||||||
if (!pane) return;
|
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) {
|
for (const tab of pane.tabs) {
|
||||||
state.cleanupTabUIState(tab.id);
|
state.cleanupTabUIState(tab.id);
|
||||||
|
state.cleanupTabSessionData(tab.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLayout = removePane(paneLayout, paneId);
|
const newLayout = removePane(paneLayout, paneId);
|
||||||
|
|
|
||||||
|
|
@ -550,6 +550,7 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
const tabsToClose = pane.tabs.filter((t) => t.id !== tabId);
|
const tabsToClose = pane.tabs.filter((t) => t.id !== tabId);
|
||||||
for (const tab of tabsToClose) {
|
for (const tab of tabsToClose) {
|
||||||
state.cleanupTabUIState(tab.id);
|
state.cleanupTabUIState(tab.id);
|
||||||
|
state.cleanupTabSessionData(tab.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const keepTab = pane.tabs.find((t) => t.id === tabId);
|
const keepTab = pane.tabs.find((t) => t.id === tabId);
|
||||||
|
|
@ -581,6 +582,7 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
||||||
const tabsToClose = pane.tabs.slice(index + 1);
|
const tabsToClose = pane.tabs.slice(index + 1);
|
||||||
for (const tab of tabsToClose) {
|
for (const tab of tabsToClose) {
|
||||||
state.cleanupTabUIState(tab.id);
|
state.cleanupTabUIState(tab.id);
|
||||||
|
state.cleanupTabSessionData(tab.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newTabs = pane.tabs.slice(0, index + 1);
|
const newTabs = pane.tabs.slice(0, index + 1);
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,23 @@ export interface TeamSlice {
|
||||||
taskId: string,
|
taskId: string,
|
||||||
value: 'lead' | 'user' | null
|
value: 'lead' | 'user' | null
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
saveTaskAttachment: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
file: { name: string; type: string; base64: string }
|
||||||
|
) => Promise<void>;
|
||||||
|
deleteTaskAttachment: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
mimeType: string
|
||||||
|
) => Promise<void>;
|
||||||
|
getTaskAttachmentData: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
mimeType: string
|
||||||
|
) => Promise<string | null>;
|
||||||
deletedTasks: TeamTask[];
|
deletedTasks: TeamTask[];
|
||||||
deletedTasksLoading: boolean;
|
deletedTasksLoading: boolean;
|
||||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
|
|
@ -810,6 +827,27 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
await get().fetchAllTasks();
|
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) => {
|
addTaskComment: async (teamName, taskId, text) => {
|
||||||
set({ addingComment: true, addCommentError: null });
|
set({ addingComment: true, addCommentError: null });
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import type {
|
||||||
import type {
|
import type {
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
|
AttachmentMediaType,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
|
|
@ -39,6 +40,7 @@ import type {
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
TeamChangeEvent,
|
TeamChangeEvent,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
|
|
@ -474,6 +476,26 @@ export interface TeamsAPI {
|
||||||
targetId: string,
|
targetId: string,
|
||||||
type: 'blockedBy' | 'blocks' | 'related'
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
saveTaskAttachment: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
filename: string,
|
||||||
|
mimeType: string,
|
||||||
|
base64Data: string
|
||||||
|
) => Promise<TaskAttachmentMeta>;
|
||||||
|
getTaskAttachment: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
mimeType: string
|
||||||
|
) => Promise<string | null>;
|
||||||
|
deleteTaskAttachment: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
mimeType: string
|
||||||
|
) => Promise<void>;
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||||
onProvisioningProgress: (
|
onProvisioningProgress: (
|
||||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ export interface TeamTask {
|
||||||
needsClarification?: 'lead' | 'user';
|
needsClarification?: 'lead' | 'user';
|
||||||
/** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */
|
/** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */
|
||||||
deletedAt?: string;
|
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). */
|
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
||||||
|
|
@ -131,6 +133,20 @@ export interface TeamTaskWithKanban extends TeamTask {
|
||||||
kanbanColumn?: 'review' | 'approved';
|
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 type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||||
|
|
||||||
export interface AttachmentMeta {
|
export interface AttachmentMeta {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_RESTORE: 'team:restoreTeam',
|
TEAM_RESTORE: 'team:restoreTeam',
|
||||||
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
|
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
|
||||||
TEAM_RESTORE_TASK: 'team:restoreTask',
|
TEAM_RESTORE_TASK: 'team:restoreTask',
|
||||||
|
TEAM_SAVE_TASK_ATTACHMENT: 'team:saveTaskAttachment',
|
||||||
|
TEAM_GET_TASK_ATTACHMENT: 'team:getTaskAttachment',
|
||||||
|
TEAM_DELETE_TASK_ATTACHMENT: 'team:deleteTaskAttachment',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue