commit
23faa432d6
47 changed files with 3368 additions and 191 deletions
|
|
@ -202,8 +202,10 @@ pnpm dist # macOS + Windows + Linux
|
|||
|
||||
## TODO
|
||||
|
||||
- [ ] Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||
- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills)
|
||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
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);
|
||||
|
||||
// Process inbox change events — relay to lead + native OS notifications.
|
||||
// Process inbox and task change events.
|
||||
try {
|
||||
if (!event || typeof event !== 'object') return;
|
||||
const row = event as { type?: unknown; teamName?: unknown; detail?: unknown };
|
||||
if (row.type !== 'inbox') return;
|
||||
if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return;
|
||||
const teamName = row.teamName.trim();
|
||||
const detail = typeof row.detail === 'string' ? row.detail : '';
|
||||
|
||||
// Auto-relay direct messages to live team lead process (no UI dependency).
|
||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||
void teamProvisioningService
|
||||
.relayLeadInboxMessages(teamName)
|
||||
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
|
||||
// --- Inbox change events: relay to lead + native OS notifications ---
|
||||
if (row.type === 'inbox') {
|
||||
// Auto-relay direct messages to live team lead process (no UI dependency).
|
||||
if (teamProvisioningService.isTeamAlive(teamName)) {
|
||||
void teamProvisioningService
|
||||
.relayLeadInboxMessages(teamName)
|
||||
.catch((e: unknown) => logger.warn(`[FileWatcher] relay failed for ${teamName}: ${e}`));
|
||||
}
|
||||
|
||||
// Show native OS notification for new inbox messages (debounced per inbox).
|
||||
if (detail.startsWith('inboxes/')) {
|
||||
const timerKey = `${teamName}:${detail}`;
|
||||
const existing = inboxNotifyTimers.get(timerKey);
|
||||
if (existing) clearTimeout(existing);
|
||||
inboxNotifyTimers.set(
|
||||
timerKey,
|
||||
setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
|
||||
}, INBOX_NOTIFY_DEBOUNCE_MS)
|
||||
);
|
||||
}
|
||||
|
||||
// Show native OS notification for new lead → user messages (sentMessages.json).
|
||||
if (detail === 'sentMessages.json') {
|
||||
const timerKey = `${teamName}:sentMessages`;
|
||||
const existing = inboxNotifyTimers.get(timerKey);
|
||||
if (existing) clearTimeout(existing);
|
||||
inboxNotifyTimers.set(
|
||||
timerKey,
|
||||
setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewSentMessages(teamName).catch(() => undefined);
|
||||
}, INBOX_NOTIFY_DEBOUNCE_MS)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show native OS notification for new inbox messages (debounced per inbox).
|
||||
if (detail.startsWith('inboxes/')) {
|
||||
const timerKey = `${teamName}:${detail}`;
|
||||
const existing = inboxNotifyTimers.get(timerKey);
|
||||
if (existing) clearTimeout(existing);
|
||||
inboxNotifyTimers.set(
|
||||
timerKey,
|
||||
setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewInboxMessages(teamName, detail).catch(() => undefined);
|
||||
}, INBOX_NOTIFY_DEBOUNCE_MS)
|
||||
);
|
||||
}
|
||||
|
||||
// Show native OS notification for new lead → user messages (sentMessages.json).
|
||||
if (detail === 'sentMessages.json') {
|
||||
const timerKey = `${teamName}:sentMessages`;
|
||||
const existing = inboxNotifyTimers.get(timerKey);
|
||||
if (existing) clearTimeout(existing);
|
||||
inboxNotifyTimers.set(
|
||||
timerKey,
|
||||
setTimeout(() => {
|
||||
inboxNotifyTimers.delete(timerKey);
|
||||
void notifyNewSentMessages(teamName).catch(() => undefined);
|
||||
}, INBOX_NOTIFY_DEBOUNCE_MS)
|
||||
);
|
||||
// --- Task change events: notify lead when teammate starts a task via CLI ---
|
||||
if (row.type === 'task' && detail.endsWith('.json') && teamDataService) {
|
||||
const taskId = detail.replace('.json', '');
|
||||
void teamDataService
|
||||
.notifyLeadOnTeammateTaskStart(teamName, taskId)
|
||||
.catch((e: unknown) =>
|
||||
logger.warn(`[FileWatcher] task start notify failed for ${teamName}#${taskId}: ${e}`)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -49,6 +50,9 @@ import {
|
|||
TEAM_UPDATE_TASK_FIELDS,
|
||||
TEAM_UPDATE_TASK_OWNER,
|
||||
TEAM_UPDATE_TASK_STATUS,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_GET_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
||||
} from '@preload/constants/ipcChannels';
|
||||
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
|
||||
|
|
@ -75,6 +79,7 @@ const notifiedRateLimitKeys = new Set<string>();
|
|||
const RATE_LIMIT_KEYS_MAX = 500;
|
||||
|
||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||
|
||||
import type {
|
||||
MemberStatsComputer,
|
||||
|
|
@ -84,16 +89,19 @@ import type {
|
|||
} from '../services';
|
||||
import type {
|
||||
AttachmentFileData,
|
||||
AttachmentMediaType,
|
||||
AttachmentMeta,
|
||||
AttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadContextUsage,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
|
|
@ -165,6 +173,7 @@ let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
|
|||
let memberStatsComputer: MemberStatsComputer | null = null;
|
||||
|
||||
const attachmentStore = new TeamAttachmentStore();
|
||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||
|
||||
const ALLOWED_ATTACHMENT_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB per file
|
||||
|
|
@ -222,6 +231,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||
|
|
@ -229,6 +239,9 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
|
||||
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
|
||||
ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship);
|
||||
ipcMain.handle(TEAM_SAVE_TASK_ATTACHMENT, handleSaveTaskAttachment);
|
||||
ipcMain.handle(TEAM_GET_TASK_ATTACHMENT, handleGetTaskAttachment);
|
||||
ipcMain.handle(TEAM_DELETE_TASK_ATTACHMENT, handleDeleteTaskAttachment);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +284,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
|
|
@ -278,6 +292,9 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
||||
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
|
||||
ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP);
|
||||
ipcMain.removeHandler(TEAM_SAVE_TASK_ATTACHMENT);
|
||||
ipcMain.removeHandler(TEAM_GET_TASK_ATTACHMENT);
|
||||
ipcMain.removeHandler(TEAM_DELETE_TASK_ATTACHMENT);
|
||||
}
|
||||
|
||||
function getTeamDataService(): TeamDataService {
|
||||
|
|
@ -1280,12 +1297,21 @@ async function handleUpdateTaskOwner(
|
|||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||||
}
|
||||
|
||||
if (owner !== null && (typeof owner !== 'string' || owner.length === 0)) {
|
||||
return { success: false, error: 'owner must be a non-empty string or null' };
|
||||
let nextOwner: string | null = null;
|
||||
if (owner !== null) {
|
||||
const validatedOwner = validateMemberName(owner);
|
||||
if (!validatedOwner.valid) {
|
||||
return { success: false, error: validatedOwner.error ?? 'Invalid owner' };
|
||||
}
|
||||
nextOwner = validatedOwner.value!;
|
||||
}
|
||||
|
||||
return wrapTeamHandler('updateTaskOwner', () =>
|
||||
getTeamDataService().updateTaskOwner(validatedTeamName.value!, validatedTaskId.value!, owner)
|
||||
getTeamDataService().updateTaskOwner(
|
||||
validatedTeamName.value!,
|
||||
validatedTaskId.value!,
|
||||
nextOwner
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1510,6 +1536,19 @@ async function handleLeadActivity(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleLeadContext(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<LeadContextUsage | null>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('leadContext', async () =>
|
||||
getTeamProvisioningService().getLeadContextUsage(validated.value!)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
@ -1680,9 +1719,9 @@ async function handleUpdateTaskFields(
|
|||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
if (typeof taskId !== 'string' || !taskId.trim()) {
|
||||
return { success: false, error: 'taskId must be a non-empty string' };
|
||||
}
|
||||
const vTask = validateTaskId(taskId);
|
||||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||
const tid = vTask.value!;
|
||||
if (!fields || typeof fields !== 'object') {
|
||||
return { success: false, error: 'fields must be an object' };
|
||||
}
|
||||
|
|
@ -1698,7 +1737,7 @@ async function handleUpdateTaskFields(
|
|||
}
|
||||
|
||||
const validFields: { subject?: string; description?: string } = {};
|
||||
if (typeof subject === 'string') validFields.subject = subject;
|
||||
if (typeof subject === 'string') validFields.subject = subject.trim();
|
||||
if (typeof description === 'string') validFields.description = description;
|
||||
|
||||
if (Object.keys(validFields).length === 0) {
|
||||
|
|
@ -1707,7 +1746,7 @@ async function handleUpdateTaskFields(
|
|||
|
||||
return wrapTeamHandler('updateTaskFields', async () => {
|
||||
const tn = vTeam.value!;
|
||||
await getTeamDataService().updateTaskFields(tn, taskId, validFields);
|
||||
await getTeamDataService().updateTaskFields(tn, tid, validFields);
|
||||
|
||||
// Notify the lead about updated task fields
|
||||
const provisioning = getTeamProvisioningService();
|
||||
|
|
@ -1716,12 +1755,12 @@ async function handleUpdateTaskFields(
|
|||
if (validFields.subject) changedParts.push('title');
|
||||
if (validFields.description !== undefined) changedParts.push('description');
|
||||
const message =
|
||||
`Task #${taskId} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
|
||||
`Task #${tid} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
|
||||
`New title: "${validFields.subject ?? '(unchanged)'}".`;
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, message);
|
||||
} catch {
|
||||
logger.warn(`Failed to notify lead about task fields update for #${taskId} in ${tn}`);
|
||||
logger.warn(`Failed to notify lead about task fields update for #${tid} in ${tn}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1897,7 +1936,8 @@ async function handleAddTaskComment(
|
|||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
text: unknown
|
||||
text: unknown,
|
||||
attachments?: unknown
|
||||
): Promise<IpcResult<TaskComment>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
|
|
@ -1908,9 +1948,54 @@ async function handleAddTaskComment(
|
|||
if (text.trim().length > 2000)
|
||||
return { success: false, error: 'Comment exceeds 2000 characters' };
|
||||
|
||||
return wrapTeamHandler('addTaskComment', () =>
|
||||
getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim())
|
||||
);
|
||||
const rawAttachments = Array.isArray(attachments) ? attachments : [];
|
||||
if (rawAttachments.length > MAX_ATTACHMENTS) {
|
||||
return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addTaskComment', async () => {
|
||||
// Save comment attachments (images). Done inside wrapTeamHandler so failures return IpcResult.
|
||||
let savedAttachments: TaskAttachmentMeta[] | undefined;
|
||||
if (rawAttachments.length > 0) {
|
||||
savedAttachments = [];
|
||||
for (const att of rawAttachments) {
|
||||
if (!att || typeof att !== 'object') {
|
||||
throw new Error('Invalid attachment data');
|
||||
}
|
||||
const a = att as Record<string, unknown>;
|
||||
if (
|
||||
typeof a.id !== 'string' ||
|
||||
typeof a.filename !== 'string' ||
|
||||
typeof a.mimeType !== 'string' ||
|
||||
typeof a.base64Data !== 'string' ||
|
||||
a.base64Data.length === 0 ||
|
||||
!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)
|
||||
) {
|
||||
throw new Error('Invalid attachment data');
|
||||
}
|
||||
const safeId = a.id.trim();
|
||||
if (safeId.includes('/') || safeId.includes('\\') || safeId.includes('..')) {
|
||||
throw new Error('Invalid attachment ID');
|
||||
}
|
||||
const meta = await taskAttachmentStore.saveAttachment(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
safeId,
|
||||
a.filename,
|
||||
a.mimeType as AttachmentMediaType,
|
||||
a.base64Data
|
||||
);
|
||||
savedAttachments.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
return getTeamDataService().addTaskComment(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
text.trim(),
|
||||
savedAttachments
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const;
|
||||
|
|
@ -1975,3 +2060,122 @@ async function handleRemoveTaskRelationship(
|
|||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task Attachment Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleSaveTaskAttachment(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
attachmentId: unknown,
|
||||
filename: unknown,
|
||||
mimeType: unknown,
|
||||
base64Data: unknown
|
||||
): Promise<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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -703,6 +703,11 @@ export class ProjectScanner {
|
|||
let startIndex = 0;
|
||||
if (cursor) {
|
||||
try {
|
||||
// Defensive limit: cursor originates from a query param / IPC input and should be tiny.
|
||||
// Prevent pathological memory allocation on Buffer.from(cursor, 'base64').
|
||||
if (cursor.length > 4096) {
|
||||
throw new Error('cursor too large');
|
||||
}
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(cursor, 'base64').toString('utf8')
|
||||
) as SessionCursor;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,25 @@ const logger = createLogger('Service:TeamAttachmentStore');
|
|||
const ATTACHMENTS_DIR = 'attachments';
|
||||
|
||||
export class TeamAttachmentStore {
|
||||
private assertSafePathSegment(label: string, value: string): void {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.includes('/') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('..') ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new Error(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getDir(teamName: string): string {
|
||||
this.assertSafePathSegment('teamName', teamName);
|
||||
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR);
|
||||
}
|
||||
|
||||
private getFilePath(teamName: string, messageId: string): string {
|
||||
this.assertSafePathSegment('messageId', messageId);
|
||||
return path.join(this.getDir(teamName), `${messageId}.json`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,8 @@ const TASK_MAP_YIELD_EVERY = 250;
|
|||
export class TeamDataService {
|
||||
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private processHealthTeams = new Set<string>();
|
||||
/** Tracks notified task-start transitions to avoid duplicate lead notifications. */
|
||||
private notifiedTaskStarts = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
|
|
@ -873,6 +875,48 @@ export class TeamDataService {
|
|||
await this.taskWriter.updateStatus(teamName, taskId, status, actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a task file changes on disk (e.g. teammate CLI wrote it).
|
||||
* If the latest statusHistory entry shows a non-user actor started the task,
|
||||
* sends an inbox notification to the team lead.
|
||||
*/
|
||||
async notifyLeadOnTeammateTaskStart(teamName: string, taskId: string): Promise<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> {
|
||||
await this.taskWriter.softDelete(teamName, taskId, 'user');
|
||||
}
|
||||
|
|
@ -897,6 +941,22 @@ export class TeamDataService {
|
|||
await this.taskWriter.updateFields(teamName, taskId, fields);
|
||||
}
|
||||
|
||||
async addTaskAttachment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
meta: import('@shared/types').TaskAttachmentMeta
|
||||
): Promise<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(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
|
|
@ -923,8 +983,15 @@ export class TeamDataService {
|
|||
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
|
||||
}
|
||||
|
||||
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
||||
async addTaskComment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: import('@shared/types').TaskAttachmentMeta[]
|
||||
): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text, {
|
||||
attachments,
|
||||
});
|
||||
|
||||
try {
|
||||
const [tasks, toolPath, config] = await Promise.all([
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { TeamTaskReader } from './TeamTaskReader';
|
|||
|
||||
import type {
|
||||
InboxMessage,
|
||||
LeadContextUsage,
|
||||
TeamChangeEvent,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
|
|
@ -58,7 +59,7 @@ const LOG_PROGRESS_THROTTLE_MS = 300;
|
|||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||
const SHELL_ENV_TIMEOUT_MS = 12000;
|
||||
// const CLI_PREPARE_TIMEOUT_MS = 10000;
|
||||
const PROBE_CACHE_TTL_MS = 60_000;
|
||||
const PROBE_CACHE_TTL_MS = 10 * 60_000;
|
||||
const PREFLIGHT_TIMEOUT_MS = 30000;
|
||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
|
|
@ -154,6 +155,13 @@ interface ProvisioningRun {
|
|||
authFailureRetried: boolean;
|
||||
/** Set to true while auth-failure respawn is in progress to prevent duplicate handling. */
|
||||
authRetryInProgress: boolean;
|
||||
/** Tracks lead process context window usage from stream-json usage data. */
|
||||
leadContextUsage: {
|
||||
currentTokens: number;
|
||||
contextWindow: number;
|
||||
lastUsageMessageId: string | null;
|
||||
lastEmittedAt: number;
|
||||
} | null;
|
||||
/** Saved spawn context for auth-failure respawn. */
|
||||
spawnContext: {
|
||||
claudePath: string;
|
||||
|
|
@ -1014,6 +1022,16 @@ export class TeamProvisioningService {
|
|||
return run.leadActivityState;
|
||||
}
|
||||
|
||||
getLeadContextUsage(teamName: string): LeadContextUsage | null {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) return null;
|
||||
const run = this.runs.get(runId);
|
||||
if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null;
|
||||
const { currentTokens, contextWindow } = run.leadContextUsage;
|
||||
const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
|
||||
return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
|
||||
if (run.leadActivityState === state) return;
|
||||
run.leadActivityState = state;
|
||||
|
|
@ -1024,6 +1042,33 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
|
||||
|
||||
private emitLeadContextUsage(run: ProvisioningRun): void {
|
||||
if (!run.leadContextUsage || !run.provisioningComplete) return;
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - run.leadContextUsage.lastEmittedAt <
|
||||
TeamProvisioningService.CONTEXT_EMIT_THROTTLE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
run.leadContextUsage.lastEmittedAt = now;
|
||||
const { currentTokens, contextWindow } = run.leadContextUsage;
|
||||
const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
|
||||
const payload: LeadContextUsage = {
|
||||
currentTokens,
|
||||
contextWindow,
|
||||
percent,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'lead-context',
|
||||
teamName: run.teamName,
|
||||
detail: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
try {
|
||||
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) {
|
||||
|
|
@ -1433,6 +1478,7 @@ export class TeamProvisioningService {
|
|||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
leadContextUsage: null,
|
||||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
|
|
@ -1716,6 +1762,7 @@ export class TeamProvisioningService {
|
|||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
leadContextUsage: null,
|
||||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
|
|
@ -2464,6 +2511,40 @@ export class TeamProvisioningService {
|
|||
if (run.provisioningComplete) {
|
||||
this.captureSendMessageToUser(run, content ?? []);
|
||||
}
|
||||
|
||||
// Extract context window usage from message.usage for real-time tracking.
|
||||
// SDKAssistantMessage wraps BetaMessage which contains usage stats.
|
||||
const messageObj = (msg.message ?? msg) as Record<string, unknown>;
|
||||
if (messageObj && typeof messageObj === 'object') {
|
||||
const msgId = typeof messageObj.id === 'string' ? messageObj.id : null;
|
||||
const usage = messageObj.usage as Record<string, unknown> | undefined;
|
||||
if (usage && typeof usage === 'object') {
|
||||
// Dedup: skip if same message.id (SDK bug: multi-block = same usage repeated)
|
||||
if (!msgId || run.leadContextUsage?.lastUsageMessageId !== msgId) {
|
||||
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
|
||||
const cacheCreation =
|
||||
typeof usage.cache_creation_input_tokens === 'number'
|
||||
? usage.cache_creation_input_tokens
|
||||
: 0;
|
||||
const cacheRead =
|
||||
typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
|
||||
const currentTokens = inputTokens + cacheCreation + cacheRead;
|
||||
|
||||
if (!run.leadContextUsage) {
|
||||
run.leadContextUsage = {
|
||||
currentTokens,
|
||||
contextWindow: 200_000,
|
||||
lastUsageMessageId: msgId,
|
||||
lastEmittedAt: 0,
|
||||
};
|
||||
} else {
|
||||
run.leadContextUsage.currentTokens = currentTokens;
|
||||
run.leadContextUsage.lastUsageMessageId = msgId;
|
||||
}
|
||||
this.emitLeadContextUsage(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture session_id from any message type (first occurrence wins)
|
||||
|
|
@ -2489,6 +2570,53 @@ export class TeamProvisioningService {
|
|||
})();
|
||||
if (subtype === 'success') {
|
||||
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
||||
|
||||
// Extract contextWindow from modelUsage if available (SDKResultSuccess.modelUsage)
|
||||
const modelUsageObj = (msg.modelUsage ??
|
||||
(msg.result as Record<string, unknown> | undefined)?.modelUsage) as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined;
|
||||
if (modelUsageObj && typeof modelUsageObj === 'object') {
|
||||
for (const modelData of Object.values(modelUsageObj)) {
|
||||
if (
|
||||
modelData &&
|
||||
typeof modelData === 'object' &&
|
||||
typeof modelData.contextWindow === 'number' &&
|
||||
modelData.contextWindow > 0
|
||||
) {
|
||||
if (run.leadContextUsage) {
|
||||
run.leadContextUsage.contextWindow = modelData.contextWindow;
|
||||
run.leadContextUsage.lastEmittedAt = 0; // force re-emit
|
||||
this.emitLeadContextUsage(run);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract usage from result message itself (final turn usage)
|
||||
const resultUsage = (msg.usage ??
|
||||
(msg.result as Record<string, unknown> | undefined)?.usage) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (resultUsage && typeof resultUsage === 'object') {
|
||||
const inp = typeof resultUsage.input_tokens === 'number' ? resultUsage.input_tokens : 0;
|
||||
const cc =
|
||||
typeof resultUsage.cache_creation_input_tokens === 'number'
|
||||
? resultUsage.cache_creation_input_tokens
|
||||
: 0;
|
||||
const cr =
|
||||
typeof resultUsage.cache_read_input_tokens === 'number'
|
||||
? resultUsage.cache_read_input_tokens
|
||||
: 0;
|
||||
const total = inp + cc + cr;
|
||||
if (total > 0 && run.leadContextUsage) {
|
||||
run.leadContextUsage.currentTokens = total;
|
||||
run.leadContextUsage.lastEmittedAt = 0;
|
||||
this.emitLeadContextUsage(run);
|
||||
}
|
||||
}
|
||||
|
||||
if (run.provisioningComplete) {
|
||||
this.setLeadActivity(run, 'idle');
|
||||
}
|
||||
|
|
@ -2585,6 +2713,15 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compact_boundary — context was compacted, next assistant message will carry fresh usage
|
||||
if (msg.type === 'system') {
|
||||
const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
|
||||
if (sub === 'compact_boundary' && run.leadContextUsage) {
|
||||
run.leadContextUsage.lastUsageMessageId = null;
|
||||
logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
168
src/main/services/team/TeamTaskAttachmentStore.ts
Normal file
168
src/main/services/team/TeamTaskAttachmentStore.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamTaskAttachmentStore');
|
||||
|
||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export class TeamTaskAttachmentStore {
|
||||
private assertSafePathSegment(label: string, value: string): void {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.includes('/') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('..') ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new Error(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the directory for a specific task's attachments. */
|
||||
private getTaskDir(teamName: string, taskId: string): string {
|
||||
this.assertSafePathSegment('teamName', teamName);
|
||||
this.assertSafePathSegment('taskId', taskId);
|
||||
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
|
||||
}
|
||||
|
||||
/** Returns the file path for a specific attachment. */
|
||||
private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string {
|
||||
this.assertSafePathSegment('attachmentId', attachmentId);
|
||||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`);
|
||||
}
|
||||
|
||||
/** Map MIME type to file extension. */
|
||||
private mimeToExt(mimeType: AttachmentMediaType): string {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
case 'image/jpeg':
|
||||
return '.jpg';
|
||||
case 'image/gif':
|
||||
return '.gif';
|
||||
case 'image/webp':
|
||||
return '.webp';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an attachment to disk. Data is expected as a base64-encoded string.
|
||||
* Returns metadata for the saved attachment.
|
||||
*/
|
||||
async saveAttachment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
attachmentId: string,
|
||||
filename: string,
|
||||
mimeType: AttachmentMediaType,
|
||||
base64Data: string
|
||||
): Promise<TaskAttachmentMeta> {
|
||||
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
|
||||
throw new Error(`Unsupported MIME type: ${mimeType}`);
|
||||
}
|
||||
|
||||
const trimmed = base64Data.trim();
|
||||
// Avoid allocating huge Buffers for obviously too-large payloads.
|
||||
// Base64 decoded size is roughly 3/4 of the string length minus padding.
|
||||
const padding = trimmed.endsWith('==') ? 2 : trimmed.endsWith('=') ? 1 : 0;
|
||||
const estimatedBytes = Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding);
|
||||
if (estimatedBytes > MAX_ATTACHMENT_SIZE) {
|
||||
throw new Error(
|
||||
`Attachment too large: ${(estimatedBytes / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(trimmed, 'base64');
|
||||
if (buffer.length > MAX_ATTACHMENT_SIZE) {
|
||||
throw new Error(
|
||||
`Attachment too large: ${(buffer.length / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
|
||||
);
|
||||
}
|
||||
|
||||
const dir = this.getTaskDir(teamName, taskId);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
|
||||
const ext = this.mimeToExt(mimeType);
|
||||
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
|
||||
await fs.promises.writeFile(filePath, buffer);
|
||||
|
||||
const meta: TaskAttachmentMeta = {
|
||||
id: attachmentId,
|
||||
filename,
|
||||
mimeType,
|
||||
size: buffer.length,
|
||||
addedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`);
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an attachment file and return its base64 data.
|
||||
*/
|
||||
async getAttachment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
attachmentId: string,
|
||||
mimeType: AttachmentMediaType
|
||||
): Promise<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 type {
|
||||
AttachmentMediaType,
|
||||
StatusTransition,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskWorkInterval,
|
||||
TeamTask,
|
||||
|
|
@ -18,6 +20,13 @@ import type {
|
|||
const logger = createLogger('Service:TeamTaskReader');
|
||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export class TeamTaskReader {
|
||||
/**
|
||||
* Returns the next available numeric task ID by scanning ALL task files
|
||||
|
|
@ -187,6 +196,21 @@ export class TeamTaskReader {
|
|||
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
|
||||
? c.type
|
||||
: ('regular' as const),
|
||||
attachments: Array.isArray(c.attachments)
|
||||
? (c.attachments as unknown[]).filter(
|
||||
(a): a is TaskAttachmentMeta =>
|
||||
Boolean(a) &&
|
||||
typeof a === 'object' &&
|
||||
typeof (a as Record<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'
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
: undefined,
|
||||
needsClarification: (['lead', 'user'] as const).includes(
|
||||
|
|
@ -195,6 +219,29 @@ export class TeamTaskReader {
|
|||
? (parsed.needsClarification as 'lead' | 'user')
|
||||
: undefined,
|
||||
deletedAt: undefined, // deleted tasks are filtered out below
|
||||
attachments: Array.isArray(parsed.attachments)
|
||||
? (parsed.attachments as unknown[])
|
||||
.filter(
|
||||
(a): a is TaskAttachmentMeta =>
|
||||
Boolean(a) &&
|
||||
typeof a === 'object' &&
|
||||
typeof (a as Record<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>;
|
||||
if (task.status === 'deleted') {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { atomicWriteAsync } from './atomicWrite';
|
|||
|
||||
import type {
|
||||
StatusTransition,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskCommentType,
|
||||
TeamTask,
|
||||
|
|
@ -520,7 +521,13 @@ export class TeamTaskWriter {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
|
||||
options?: {
|
||||
id?: string;
|
||||
author?: string;
|
||||
createdAt?: string;
|
||||
type?: TaskCommentType;
|
||||
attachments?: TaskAttachmentMeta[];
|
||||
}
|
||||
): Promise<TaskComment> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
const comment: TaskComment = {
|
||||
|
|
@ -529,6 +536,9 @@ export class TeamTaskWriter {
|
|||
text,
|
||||
createdAt: options?.createdAt ?? new Date().toISOString(),
|
||||
type: options?.type ?? 'regular',
|
||||
...(options?.attachments && options.attachments.length > 0
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
|
|
@ -554,4 +564,41 @@ export class TeamTaskWriter {
|
|||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async addAttachment(teamName: string, taskId: string, meta: TaskAttachmentMeta): Promise<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 };
|
||||
workIntervals?: unknown;
|
||||
statusHistory?: unknown;
|
||||
attachments?: unknown;
|
||||
}
|
||||
|
||||
interface RawWorkInterval {
|
||||
|
|
@ -596,6 +597,9 @@ async function readTasksDirForTeam(
|
|||
comments: normalizeComments(parsed),
|
||||
needsClarification,
|
||||
deletedAt: undefined,
|
||||
attachments: Array.isArray(parsed.attachments)
|
||||
? (parsed.attachments as unknown[])
|
||||
: undefined,
|
||||
teamName,
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -325,6 +325,9 @@ export const TEAM_KILL_PROCESS = 'team:killProcess';
|
|||
/** Get lead process activity state (active/idle/offline) */
|
||||
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
||||
|
||||
/** Get lead process context window usage */
|
||||
export const TEAM_LEAD_CONTEXT = 'team:leadContext';
|
||||
|
||||
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||
|
||||
|
|
@ -343,6 +346,15 @@ export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship';
|
|||
/** Remove a relationship (blockedBy/blocks/related) between two tasks */
|
||||
export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship';
|
||||
|
||||
/** Save an image attachment to a task */
|
||||
export const TEAM_SAVE_TASK_ATTACHMENT = 'team:saveTaskAttachment';
|
||||
|
||||
/** Get base64 data for a task attachment */
|
||||
export const TEAM_GET_TASK_ATTACHMENT = 'team:getTaskAttachment';
|
||||
|
||||
/** Delete an attachment from a task */
|
||||
export const TEAM_DELETE_TASK_ATTACHMENT = 'team:deleteTaskAttachment';
|
||||
|
||||
// =============================================================================
|
||||
// CLI Installer API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import {
|
|||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -89,6 +90,9 @@ import {
|
|||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REPLACE_MEMBERS,
|
||||
TEAM_SAVE_TASK_ATTACHMENT,
|
||||
TEAM_GET_TASK_ATTACHMENT,
|
||||
TEAM_DELETE_TASK_ATTACHMENT,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
|
|
@ -173,6 +177,7 @@ import type {
|
|||
HunkDecision,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadContextUsage,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
NotificationTrigger,
|
||||
|
|
@ -187,6 +192,8 @@ import type {
|
|||
SshConnectionConfig,
|
||||
SshConnectionStatus,
|
||||
SshLastConnection,
|
||||
CommentAttachmentPayload,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangeSetV2,
|
||||
TaskComment,
|
||||
TeamChangeEvent,
|
||||
|
|
@ -797,8 +804,19 @@ const electronAPI: ElectronAPI = {
|
|||
updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => {
|
||||
return invokeIpcWithResult<TeamConfig>(TEAM_UPDATE_CONFIG, teamName, updates);
|
||||
},
|
||||
addTaskComment: async (teamName: string, taskId: string, text: string) => {
|
||||
return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, text);
|
||||
addTaskComment: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: CommentAttachmentPayload[]
|
||||
) => {
|
||||
return invokeIpcWithResult<TaskComment>(
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
teamName,
|
||||
taskId,
|
||||
text,
|
||||
attachments
|
||||
);
|
||||
},
|
||||
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
|
||||
|
|
@ -825,6 +843,9 @@ const electronAPI: ElectronAPI = {
|
|||
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
||||
return result as 'active' | 'idle' | 'offline';
|
||||
},
|
||||
getLeadContext: async (teamName: string) => {
|
||||
return invokeIpcWithResult<LeadContextUsage | null>(TEAM_LEAD_CONTEXT, teamName);
|
||||
},
|
||||
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||
},
|
||||
|
|
@ -872,6 +893,52 @@ const electronAPI: ElectronAPI = {
|
|||
type
|
||||
);
|
||||
},
|
||||
saveTaskAttachment: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
attachmentId: string,
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
base64Data: string
|
||||
) => {
|
||||
return invokeIpcWithResult<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) => {
|
||||
ipcRenderer.on(
|
||||
TEAM_CHANGE,
|
||||
|
|
|
|||
|
|
@ -796,6 +796,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||
return 'offline';
|
||||
},
|
||||
getLeadContext: async () => {
|
||||
return null;
|
||||
},
|
||||
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||
// Not available via HTTP client — no-op
|
||||
},
|
||||
|
|
@ -831,6 +834,32 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<void> => {
|
||||
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) => {
|
||||
return this.addEventListener('team-change', (data: unknown) =>
|
||||
callback(null, data as TeamChangeEvent)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from 'react-markdown';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import {
|
||||
CODE_BG,
|
||||
CODE_BORDER,
|
||||
|
|
@ -199,21 +200,45 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
),
|
||||
|
||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="cursor-pointer no-underline hover:underline"
|
||||
style={{ color: PROSE_LINK }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href) {
|
||||
void api.openExternal(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem)
|
||||
// mention:// links render as colored inline badges
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith('mention://')) {
|
||||
const path = href.slice('mention://'.length);
|
||||
const slashIdx = path.indexOf('/');
|
||||
const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
|
||||
const colorSet = getTeamColorSet(color);
|
||||
const bg = colorSet.badge;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: bg,
|
||||
color: colorSet.text,
|
||||
borderRadius: '3px',
|
||||
boxShadow: `0 0 0 1.5px ${bg}`,
|
||||
fontSize: 'inherit',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="cursor-pointer no-underline hover:underline"
|
||||
style={{ color: PROSE_LINK }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href && !href.startsWith('task://')) {
|
||||
void api.openExternal(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// Strong/Bold — inline element, no hl()
|
||||
strong: ({ children }) => (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
function scrollAfterExpand(el: HTMLElement): void {
|
||||
|
|
@ -27,6 +28,10 @@ interface CollapsibleTeamSectionProps {
|
|||
sectionId?: string;
|
||||
/** Extra classes applied to the content wrapper (e.g. padding). */
|
||||
contentClassName?: string;
|
||||
/** Extra classes for the header bar (e.g. "-mx-6 w-[calc(100%+3rem)]" to match parent padding). */
|
||||
headerClassName?: string;
|
||||
/** Extra classes for the inner header content (e.g. "pl-6" to match parent padding). */
|
||||
headerContentClassName?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -41,6 +46,8 @@ export const CollapsibleTeamSection = ({
|
|||
action,
|
||||
sectionId,
|
||||
contentClassName,
|
||||
headerClassName,
|
||||
headerContentClassName,
|
||||
children,
|
||||
}: CollapsibleTeamSectionProps): React.JSX.Element => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
|
@ -61,14 +68,24 @@ export const CollapsibleTeamSection = ({
|
|||
|
||||
return (
|
||||
<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
|
||||
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]'}`}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
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
|
||||
size={14}
|
||||
className={`shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
|
||||
|
|
|
|||
|
|
@ -1498,20 +1498,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
<SendMessageDialog
|
||||
open={sendDialogOpen}
|
||||
teamName={teamName}
|
||||
members={activeMembers}
|
||||
defaultRecipient={sendDialogRecipient}
|
||||
defaultText={sendDialogDefaultText}
|
||||
defaultChip={sendDialogDefaultChip}
|
||||
quotedMessage={replyQuote}
|
||||
isTeamAlive={data.isAlive}
|
||||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
lastResult={lastSendMessageResult}
|
||||
onSend={(member, text, summary) => {
|
||||
onSend={(member, text, summary, attachments) => {
|
||||
void (async () => {
|
||||
const sentAtMs = Date.now();
|
||||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||
try {
|
||||
await sendTeamMessage(teamName, { member, text, summary });
|
||||
await sendTeamMessage(teamName, { member, text, summary, attachments });
|
||||
} catch {
|
||||
setPendingRepliesByMember((prev) => {
|
||||
if (prev[member] !== sentAtMs) return prev;
|
||||
|
|
@ -1551,6 +1553,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
void updateTaskOwner(teamName, taskId, owner);
|
||||
}}
|
||||
onViewChanges={handleViewChangesForFile}
|
||||
onOpenInEditor={(filePath) => {
|
||||
const { revealFileInEditor } = useStore.getState();
|
||||
revealFileInEditor(filePath);
|
||||
}}
|
||||
onDeleteTask={handleDeleteTask}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ interface ActivityItemProps {
|
|||
recipientColor?: string;
|
||||
/** When true, show a blue unread dot. */
|
||||
isUnread?: boolean;
|
||||
/** Map of member name → color name for @mention badge rendering. */
|
||||
memberColorMap?: Map<string, string>;
|
||||
onMemberNameClick?: (memberName: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -153,6 +155,26 @@ function linkifyTaskIdsInMarkdown(text: string): string {
|
|||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `@memberName` in plain text to markdown links with mention:// protocol.
|
||||
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
|
||||
* Greedy match: longer names are tried first to avoid partial matches.
|
||||
*/
|
||||
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
|
||||
if (memberColorMap.size === 0) return text;
|
||||
// Sort by name length descending for greedy matching
|
||||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||
// Build regex that matches @name at start or after whitespace, followed by boundary
|
||||
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
|
||||
return text.replace(pattern, (match, prefix: string, name: string) => {
|
||||
// Find the canonical name (case-insensitive lookup)
|
||||
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
const color = memberColorMap.get(canonical) ?? '';
|
||||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Render `#<digits>` in plain text as clickable inline elements. */
|
||||
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
||||
return text.split(/(#\d+)/g).map((part, i) => {
|
||||
|
|
@ -182,6 +204,7 @@ export const ActivityItem = ({
|
|||
memberColor,
|
||||
recipientColor,
|
||||
isUnread,
|
||||
memberColorMap,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
|
|
@ -210,13 +233,19 @@ export const ActivityItem = ({
|
|||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||
|
||||
// Strip agent-only blocks from displayed text + linkify task IDs
|
||||
// Strip agent-only blocks from displayed text + linkify task IDs + @mentions
|
||||
const displayText = useMemo(() => {
|
||||
if (structured) return null;
|
||||
const stripped = stripAgentBlocks(message.text).trim();
|
||||
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
||||
return onTaskIdClick ? linkifyTaskIdsInMarkdown(stripped) : stripped;
|
||||
}, [structured, message.text, onTaskIdClick]);
|
||||
// Normalize literal \n from CLI tools (teamctl.js) to real newlines
|
||||
const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
let result = normalized;
|
||||
if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result);
|
||||
if (memberColorMap && memberColorMap.size > 0)
|
||||
result = linkifyMentionsInMarkdown(result, memberColorMap);
|
||||
return result;
|
||||
}, [structured, message.text, onTaskIdClick, memberColorMap]);
|
||||
|
||||
// Check if this is a reply message
|
||||
const parsedReply = useMemo(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const MessageRowWithObserver = ({
|
|||
isUnread,
|
||||
isNew,
|
||||
zebraShade,
|
||||
memberColorMap,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
|
|
@ -54,6 +55,7 @@ const MessageRowWithObserver = ({
|
|||
isUnread?: boolean;
|
||||
isNew?: boolean;
|
||||
zebraShade?: boolean;
|
||||
memberColorMap?: Map<string, string>;
|
||||
onMemberNameClick?: (name: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -101,6 +103,7 @@ const MessageRowWithObserver = ({
|
|||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
zebraShade={zebraShade}
|
||||
memberColorMap={memberColorMap}
|
||||
onMemberNameClick={onMemberNameClick}
|
||||
onCreateTask={onCreateTask}
|
||||
onReply={onReply}
|
||||
|
|
@ -274,6 +277,7 @@ export const ActivityTimeline = ({
|
|||
isUnread={isUnread}
|
||||
isNew={newMessageKeys.has(messageKey)}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
memberColorMap={colorMap}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ export const ReplyQuoteBlock = ({
|
|||
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
@{reply.agentName}
|
||||
</span>
|
||||
<p className="line-clamp-3 text-xs text-[var(--color-text-muted)]">{reply.originalText}</p>
|
||||
<div className="line-clamp-3 text-xs text-[var(--color-text-muted)]">
|
||||
<MarkdownViewer content={reply.originalText} maxHeight="max-h-[60px]" bare />
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable />
|
||||
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -411,7 +411,9 @@ export const CreateTeamDialog = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps -- dev defaults applied once on open
|
||||
}, [open]);
|
||||
|
||||
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (cwdMode !== 'project') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -426,7 +428,7 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
}
|
||||
setSelectedProjectPath(projects[0].path);
|
||||
}, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
|
||||
|
|
|
|||
|
|
@ -215,8 +215,9 @@ export const LaunchTeamDialog = ({
|
|||
};
|
||||
}, [open, repositoryGroups]);
|
||||
|
||||
// Pre-select defaultProjectPath when projects loaded
|
||||
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (cwdMode !== 'project') {
|
||||
return;
|
||||
}
|
||||
|
|
@ -231,7 +232,7 @@ export const LaunchTeamDialog = ({
|
|||
}
|
||||
}
|
||||
setSelectedProjectPath(projects[0].path);
|
||||
}, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
||||
|
||||
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 { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
|
|
@ -22,6 +24,7 @@ import {
|
|||
} from '@renderer/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useAttachments } from '@renderer/hooks/useAttachments';
|
||||
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -30,13 +33,13 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { X } from 'lucide-react';
|
||||
import { ImagePlus, X } from 'lucide-react';
|
||||
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
|
||||
interface QuotedMessage {
|
||||
from: string;
|
||||
|
|
@ -45,6 +48,7 @@ interface QuotedMessage {
|
|||
|
||||
interface SendMessageDialogProps {
|
||||
open: boolean;
|
||||
teamName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
defaultRecipient?: string;
|
||||
/** Pre-filled message text (e.g. from editor selection action) */
|
||||
|
|
@ -52,10 +56,16 @@ interface SendMessageDialogProps {
|
|||
/** Pre-filled inline code chip (from editor selection action) */
|
||||
defaultChip?: InlineChip;
|
||||
quotedMessage?: QuotedMessage;
|
||||
isTeamAlive?: boolean;
|
||||
sending: boolean;
|
||||
sendError: string | null;
|
||||
lastResult: SendMessageResult | null;
|
||||
onSend: (member: string, text: string, summary?: string) => void;
|
||||
onSend: (
|
||||
member: string,
|
||||
text: string,
|
||||
summary?: string,
|
||||
attachments?: AttachmentPayload[]
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -63,11 +73,13 @@ const NO_MEMBER = '__none__';
|
|||
|
||||
export const SendMessageDialog = ({
|
||||
open,
|
||||
teamName,
|
||||
members,
|
||||
defaultRecipient,
|
||||
defaultText,
|
||||
defaultChip,
|
||||
quotedMessage,
|
||||
isTeamAlive,
|
||||
sending,
|
||||
sendError,
|
||||
lastResult,
|
||||
|
|
@ -85,6 +97,25 @@ export const SendMessageDialog = ({
|
|||
const [prevOpen, setPrevOpen] = useState(false);
|
||||
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:${teamName}: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
|
||||
if (open && !prevOpen) {
|
||||
setMember(defaultRecipient ?? '');
|
||||
|
|
@ -118,6 +149,7 @@ export const SendMessageDialog = ({
|
|||
if (pendingAutoClose) {
|
||||
textDraft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
clearAttachments();
|
||||
setPendingAutoClose(false);
|
||||
onClose();
|
||||
}
|
||||
|
|
@ -156,9 +188,15 @@ export const SendMessageDialog = ({
|
|||
if (!canSend) return;
|
||||
const serialized = serializeChipsWithText(textDraft.value.trim(), chipDraft.chips);
|
||||
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();
|
||||
chipDraft.clearChipDraft();
|
||||
clearAttachments();
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
|
|
@ -167,9 +205,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 (
|
||||
<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>
|
||||
<DialogTitle>Send Message</DialogTitle>
|
||||
<DialogDescription>Send a direct message to a team member.</DialogDescription>
|
||||
|
|
@ -215,7 +308,51 @@ export const SendMessageDialog = ({
|
|||
</div>
|
||||
|
||||
<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'}>
|
||||
{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">
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -9,12 +9,15 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { Send, X } from 'lucide-react';
|
||||
import { ImagePlus, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 2000;
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
interface TaskCommentInputProps {
|
||||
teamName: string;
|
||||
|
|
@ -24,6 +27,15 @@ interface TaskCommentInputProps {
|
|||
onClearReply: () => void;
|
||||
}
|
||||
|
||||
interface PendingAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
base64Data: string;
|
||||
previewUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const TaskCommentInput = ({
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -37,6 +49,9 @@ export const TaskCommentInput = ({
|
|||
|
||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [attachError, setAttachError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
|
|
@ -51,19 +66,115 @@ export const TaskCommentInput = ({
|
|||
|
||||
const trimmed = draft.value.trim();
|
||||
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
|
||||
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment;
|
||||
const canSubmit =
|
||||
(trimmed.length > 0 || pendingAttachments.length > 0) &&
|
||||
trimmed.length <= MAX_COMMENT_LENGTH &&
|
||||
!addingComment;
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
setAttachError(null);
|
||||
const fileArray = Array.from(files);
|
||||
for (const file of fileArray) {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
setAttachError(`Unsupported type: ${file.type}`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setAttachError(
|
||||
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
|
||||
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
|
||||
break;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1];
|
||||
if (!base64) return;
|
||||
const id = crypto.randomUUID();
|
||||
setPendingAttachments((prev) => {
|
||||
if (prev.length >= MAX_ATTACHMENTS) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
base64Data: base64,
|
||||
previewUrl: result,
|
||||
size: file.size,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
},
|
||||
[pendingAttachments.length]
|
||||
);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed;
|
||||
await addTaskComment(teamName, taskId, text);
|
||||
const text = replyTo
|
||||
? buildReplyBlock(replyTo.author, replyTo.text, trimmed || '(image)')
|
||||
: trimmed || '(image)';
|
||||
const attachments: CommentAttachmentPayload[] | undefined =
|
||||
pendingAttachments.length > 0
|
||||
? pendingAttachments.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType as CommentAttachmentPayload['mimeType'],
|
||||
base64Data: a.base64Data,
|
||||
}))
|
||||
: undefined;
|
||||
await addTaskComment(teamName, taskId, text, attachments);
|
||||
draft.clearDraft();
|
||||
setPendingAttachments([]);
|
||||
setAttachError(null);
|
||||
onClearReply();
|
||||
} catch {
|
||||
// Error is stored in addCommentError via store
|
||||
}
|
||||
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo, onClearReply]);
|
||||
}, [
|
||||
canSubmit,
|
||||
addTaskComment,
|
||||
teamName,
|
||||
taskId,
|
||||
trimmed,
|
||||
draft,
|
||||
replyTo,
|
||||
onClearReply,
|
||||
pendingAttachments,
|
||||
]);
|
||||
|
||||
// Handle paste from MentionableTextarea area
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) {
|
||||
const file = item.getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
addFiles(imageFiles);
|
||||
}
|
||||
},
|
||||
[addFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -103,7 +214,41 @@ export const TaskCommentInput = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative">
|
||||
{/* Pending attachment previews */}
|
||||
{pendingAttachments.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{pendingAttachments.map((att) => (
|
||||
<div
|
||||
key={att.id}
|
||||
className="group relative size-14 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
>
|
||||
<img src={att.previewUrl} alt={att.filename} className="size-full object-cover" />
|
||||
<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={() => removeAttachment(att.id)}
|
||||
>
|
||||
<Trash2 size={8} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachError ? <p className="mb-1 text-[10px] text-red-400">{attachError}</p> : null}
|
||||
|
||||
<div className="relative" onPaste={handlePaste}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<MentionableTextarea
|
||||
id={`task-comment-${taskId}`}
|
||||
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
|
||||
|
|
@ -116,15 +261,30 @@ export const TaskCommentInput = ({
|
|||
maxLength={MAX_COMMENT_LENGTH}
|
||||
disabled={addingComment}
|
||||
cornerAction={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
<Send size={12} />
|
||||
Comment
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center rounded-full p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
||||
disabled={addingComment || pendingAttachments.length >= MAX_ATTACHMENTS}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Attach image (or paste)</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
<Send size={12} />
|
||||
Comment
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Reply,
|
||||
Send,
|
||||
|
|
@ -26,7 +27,21 @@ import {
|
|||
} from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
||||
import type {
|
||||
AttachmentMediaType,
|
||||
ResolvedTeamMember,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
} from '@shared/types';
|
||||
|
||||
/**
|
||||
* Convert literal backslash-n sequences to real newlines.
|
||||
* CLI tools (teamctl.js) may store `\n` as literal text when
|
||||
* shell double-quotes don't interpret escape sequences.
|
||||
*/
|
||||
function normalizeLiteralNewlines(text: string): string {
|
||||
return text.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
}
|
||||
|
||||
const MAX_COMMENT_LENGTH = 2000;
|
||||
const INITIAL_VISIBLE_COMMENTS = 30;
|
||||
|
|
@ -44,6 +59,26 @@ interface TaskCommentsSectionProps {
|
|||
hideInput?: boolean;
|
||||
/** Called when the user clicks Reply on a comment (used when input is rendered externally). */
|
||||
onReply?: (author: string, text: string) => void;
|
||||
/** Called when a task ID link (e.g. #10) is clicked in comment text. */
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/** Convert `#<digits>` in plain text to markdown links with task:// protocol. */
|
||||
function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
|
||||
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
|
||||
if (memberColorMap.size === 0) return text;
|
||||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
|
||||
return text.replace(pattern, (match, prefix: string, name: string) => {
|
||||
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
const color = memberColorMap.get(canonical) ?? '';
|
||||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskCommentsSection = ({
|
||||
|
|
@ -54,6 +89,7 @@ export const TaskCommentsSection = ({
|
|||
hideHeader = false,
|
||||
hideInput = false,
|
||||
onReply,
|
||||
onTaskIdClick,
|
||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||
const addingComment = useStore((s) => s.addingComment);
|
||||
|
|
@ -62,6 +98,7 @@ export const TaskCommentsSection = ({
|
|||
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
||||
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
||||
|
||||
// Reset local state when team/task changes (React-recommended pattern for
|
||||
// adjusting state based on props without using effects or refs during render)
|
||||
|
|
@ -218,7 +255,7 @@ export const TaskCommentsSection = ({
|
|||
{(() => {
|
||||
const reply = parseMessageReply(comment.text);
|
||||
const rawForDisplay = reply ? reply.replyText : comment.text;
|
||||
const displayText = stripAgentBlocks(rawForDisplay);
|
||||
const displayText = normalizeLiteralNewlines(stripAgentBlocks(rawForDisplay));
|
||||
const needsExpandCollapse = displayText.includes('\n');
|
||||
const expanded = expandedCommentIds.has(comment.id);
|
||||
const collapsedHeight = 'max-h-[120px]';
|
||||
|
|
@ -243,13 +280,36 @@ export const TaskCommentsSection = ({
|
|||
}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownViewer
|
||||
content={displayText}
|
||||
maxHeight={
|
||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (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={(() => {
|
||||
let t = displayText;
|
||||
if (onTaskIdClick) t = linkifyTaskIdsInMarkdown(t);
|
||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||
return t;
|
||||
})()}
|
||||
maxHeight={
|
||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||
}
|
||||
bare
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{showCollapsed && (
|
||||
<>
|
||||
|
|
@ -291,6 +351,14 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
|
@ -310,6 +378,24 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Full-size image preview overlay */}
|
||||
{previewImageUrl ? (
|
||||
<div className="relative mb-3 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={() => setPreviewImageUrl(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="Attachment preview"
|
||||
className="max-h-[400px] max-w-full rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hideInput && (
|
||||
<>
|
||||
{replyTo ? (
|
||||
|
|
@ -382,6 +468,95 @@ export const TaskCommentsSection = ({
|
|||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment attachment thumbnail (read-only, no delete)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentAttachmentThumbnailProps {
|
||||
attachment: TaskAttachmentMeta;
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
onPreview: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
const CommentAttachmentThumbnail = ({
|
||||
attachment,
|
||||
teamName,
|
||||
taskId,
|
||||
onPreview,
|
||||
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
|
||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||
const [thumbUrl, setThumbUrl] = useState<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 — thumbnail simply won't render
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
|
||||
onClick={() => thumbUrl && onPreview(thumbUrl)}
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||
) : (
|
||||
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment attachments grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentAttachmentsProps {
|
||||
attachments: TaskAttachmentMeta[];
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
onPreview: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
const CommentAttachments = ({
|
||||
attachments,
|
||||
teamName,
|
||||
taskId,
|
||||
onPreview,
|
||||
}: CommentAttachmentsProps): React.JSX.Element => (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{attachments.map((att) => (
|
||||
<CommentAttachmentThumbnail
|
||||
key={att.id}
|
||||
attachment={att}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
function teamIdKey(teamName: string, taskId: string): string {
|
||||
return `${teamName}::${taskId}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { Textarea } from '@renderer/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -43,19 +44,23 @@ import {
|
|||
Eye,
|
||||
FileCode,
|
||||
FileDiff,
|
||||
GitCompareArrows,
|
||||
HelpCircle,
|
||||
History,
|
||||
ImageIcon,
|
||||
Link2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
PenLine,
|
||||
ScrollText,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { StatusHistoryTimeline } from './StatusHistoryTimeline';
|
||||
import { TaskAttachments } from './TaskAttachments';
|
||||
import { TaskCommentInput } from './TaskCommentInput';
|
||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||
|
||||
|
|
@ -74,6 +79,7 @@ interface TaskDetailDialogProps {
|
|||
onScrollToTask?: (taskId: string) => void;
|
||||
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||
onOpenInEditor?: (filePath: string) => void;
|
||||
onDeleteTask?: (taskId: string) => void;
|
||||
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
||||
headerExtra?: React.ReactNode;
|
||||
|
|
@ -92,6 +98,7 @@ export const TaskDetailDialog = ({
|
|||
onScrollToTask,
|
||||
onOwnerChange,
|
||||
onViewChanges,
|
||||
onOpenInEditor,
|
||||
onDeleteTask,
|
||||
headerExtra,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
|
|
@ -457,6 +464,8 @@ export const TaskDetailDialog = ({
|
|||
title="Description"
|
||||
icon={<AlignLeft size={14} />}
|
||||
contentClassName="pl-2.5"
|
||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen
|
||||
>
|
||||
{editingDescription ? (
|
||||
|
|
@ -562,6 +571,27 @@ export const TaskDetailDialog = ({
|
|||
)}
|
||||
</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 */}
|
||||
{variant === 'team' && isTaskCompleted && onViewChanges ? (
|
||||
<CollapsibleTeamSection
|
||||
|
|
@ -569,6 +599,8 @@ export const TaskDetailDialog = ({
|
|||
icon={<FileDiff size={14} />}
|
||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||
contentClassName="pl-2.5"
|
||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen={taskKnownHasChanges}
|
||||
>
|
||||
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
|
||||
|
|
@ -579,19 +611,21 @@ export const TaskDetailDialog = ({
|
|||
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
|
||||
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
||||
{taskChangesFiles.map((file) => (
|
||||
<button
|
||||
<div
|
||||
key={file.filePath}
|
||||
type="button"
|
||||
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);
|
||||
}}
|
||||
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)]"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
<span className="text-emerald-400">+{file.linesAdded}</span>
|
||||
|
|
@ -600,7 +634,38 @@ export const TaskDetailDialog = ({
|
|||
<span className="text-red-400">-{file.linesRemoved}</span>
|
||||
) : null}
|
||||
</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>
|
||||
) : (
|
||||
|
|
@ -623,6 +688,8 @@ export const TaskDetailDialog = ({
|
|||
) : null
|
||||
}
|
||||
contentClassName="pl-2.5"
|
||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen
|
||||
>
|
||||
<div className="min-w-0">
|
||||
|
|
@ -774,6 +841,8 @@ export const TaskDetailDialog = ({
|
|||
icon={<History size={14} />}
|
||||
badge={currentTask.statusHistory.length}
|
||||
contentClassName="pl-2.5"
|
||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen={false}
|
||||
>
|
||||
<StatusHistoryTimeline history={currentTask.statusHistory} />
|
||||
|
|
@ -790,6 +859,8 @@ export const TaskDetailDialog = ({
|
|||
: undefined
|
||||
}
|
||||
contentClassName="pl-2.5"
|
||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||
headerContentClassName="pl-6"
|
||||
defaultOpen
|
||||
>
|
||||
<TaskCommentInput
|
||||
|
|
@ -807,6 +878,7 @@ export const TaskDetailDialog = ({
|
|||
hideHeader
|
||||
hideInput
|
||||
onReply={handleReply}
|
||||
onTaskIdClick={onScrollToTask ? (taskId) => handleDependencyClick(taskId) : undefined}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
|
@ -39,8 +40,18 @@ export const MemberCard = ({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
const pending = taskCounts?.pending ?? 0;
|
||||
const inProgress = taskCounts?.inProgress ?? 0;
|
||||
|
|
@ -171,6 +182,29 @@ export const MemberCard = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
leadContext.percent > 90
|
||||
? 'bg-red-500'
|
||||
: leadContext.percent > 70
|
||||
? 'bg-amber-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(leadContext.percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Context: {Math.round(leadContext.percent)}% (
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k tokens)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{!isRemoved && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
|
@ -30,9 +31,20 @@ export const MemberDetailHeader = ({
|
|||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
|
||||
const colors = getTeamColorSet(member.color ?? '');
|
||||
const role = member.role || formatAgentRole(member.agentType);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
|
||||
const canEditRole =
|
||||
|
|
@ -88,12 +100,20 @@ export const MemberDetailHeader = ({
|
|||
</>
|
||||
)}
|
||||
{!editing && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
>
|
||||
{presenceLabel}
|
||||
</Badge>
|
||||
<>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
>
|
||||
{presenceLabel}
|
||||
</Badge>
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const MessageComposer = ({
|
|||
clearAttachments,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
} = useAttachments();
|
||||
} = useAttachments({ persistenceKey: `compose:${teamName}:attachments` });
|
||||
|
||||
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 {
|
||||
fileToAttachmentPayload,
|
||||
MAX_FILES,
|
||||
|
|
@ -9,6 +10,11 @@ import {
|
|||
|
||||
import type { AttachmentPayload } from '@shared/types';
|
||||
|
||||
interface UseAttachmentsOptions {
|
||||
/** When provided, attachments are persisted to IndexedDB under this key. */
|
||||
persistenceKey?: string;
|
||||
}
|
||||
|
||||
interface UseAttachmentsReturn {
|
||||
attachments: AttachmentPayload[];
|
||||
error: string | null;
|
||||
|
|
@ -21,67 +27,204 @@ interface UseAttachmentsReturn {
|
|||
handleDrop: (event: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
export function useAttachments(): UseAttachmentsReturn {
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
function isValidAttachmentArray(data: unknown): data is AttachmentPayload[] {
|
||||
if (!Array.isArray(data)) return false;
|
||||
return data.every((raw) => {
|
||||
if (typeof raw !== 'object' || raw === null) return false;
|
||||
const item = raw as Record<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 [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 canAddMore = attachments.length < MAX_FILES && totalSize < MAX_TOTAL_SIZE;
|
||||
|
||||
const addFiles = useCallback(async (files: FileList | File[]) => {
|
||||
setError(null);
|
||||
const fileArray = Array.from(files);
|
||||
if (fileArray.length === 0) return;
|
||||
const addFiles = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
setError(null);
|
||||
const fileArray = Array.from(files);
|
||||
if (fileArray.length === 0) return;
|
||||
|
||||
let batchSize = 0;
|
||||
let valid = true;
|
||||
for (const file of fileArray) {
|
||||
const validation = validateAttachment(file);
|
||||
if (!validation.valid) {
|
||||
setError(validation.error);
|
||||
valid = false;
|
||||
break;
|
||||
let batchSize = 0;
|
||||
let valid = true;
|
||||
for (const file of fileArray) {
|
||||
const validation = validateAttachment(file);
|
||||
if (!validation.valid) {
|
||||
setError(validation.error);
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
batchSize += file.size;
|
||||
}
|
||||
batchSize += file.size;
|
||||
}
|
||||
if (!valid) return;
|
||||
if (!valid) return;
|
||||
|
||||
const newPayloads: AttachmentPayload[] = [];
|
||||
for (const file of fileArray) {
|
||||
try {
|
||||
const payload = await fileToAttachmentPayload(file);
|
||||
newPayloads.push(payload);
|
||||
} catch {
|
||||
setError(`Failed to read file: ${file.name}`);
|
||||
valid = false;
|
||||
break;
|
||||
const newPayloads: AttachmentPayload[] = [];
|
||||
for (const file of fileArray) {
|
||||
try {
|
||||
const payload = await fileToAttachmentPayload(file);
|
||||
newPayloads.push(payload);
|
||||
} catch {
|
||||
setError(`Failed to read file: ${file.name}`);
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!valid) return;
|
||||
if (!valid) return;
|
||||
|
||||
setAttachments((prev) => {
|
||||
if (prev.length + newPayloads.length > MAX_FILES) {
|
||||
setError(`Maximum ${MAX_FILES} attachments allowed`);
|
||||
return prev;
|
||||
}
|
||||
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
|
||||
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
|
||||
setError('Total attachment size exceeds 20MB limit');
|
||||
return prev;
|
||||
}
|
||||
return [...prev, ...newPayloads];
|
||||
});
|
||||
}, []);
|
||||
setAttachments((prev) => {
|
||||
if (prev.length + newPayloads.length > MAX_FILES) {
|
||||
setError(`Maximum ${MAX_FILES} attachments allowed`);
|
||||
return prev;
|
||||
}
|
||||
const currentTotal = prev.reduce((sum, a) => sum + a.size, 0);
|
||||
if (currentTotal + batchSize > MAX_TOTAL_SIZE) {
|
||||
setError('Total attachment size exceeds 20MB limit');
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev, ...newPayloads];
|
||||
attachmentsRef.current = next;
|
||||
schedulePersist(next);
|
||||
return next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable
|
||||
},
|
||||
[schedulePersist]
|
||||
);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
setError(null);
|
||||
}, []);
|
||||
const removeAttachment = useCallback(
|
||||
(id: string) => {
|
||||
setAttachments((prev) => {
|
||||
const next = prev.filter((a) => a.id !== id);
|
||||
attachmentsRef.current = next;
|
||||
schedulePersist(next);
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- schedulePersist is stable
|
||||
},
|
||||
[schedulePersist]
|
||||
);
|
||||
|
||||
const clearAttachments = useCallback(() => {
|
||||
setAttachments([]);
|
||||
if (timerRef.current != null) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
pendingRef.current = null;
|
||||
attachmentsRef.current = [];
|
||||
updateAttachments([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
const key = keyRef.current;
|
||||
if (key) {
|
||||
void draftStorage.deleteDraft(key);
|
||||
}
|
||||
}, [updateAttachments]);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: React.ClipboardEvent) => {
|
||||
|
|
|
|||
|
|
@ -284,7 +284,7 @@ export function useKeyboardShortcuts(): void {
|
|||
event.preventDefault();
|
||||
if (selectedProjectId && selectedSessionId) {
|
||||
void Promise.all([
|
||||
fetchSessionDetail(selectedProjectId, selectedSessionId),
|
||||
fetchSessionDetail(selectedProjectId, selectedSessionId, activeTabId ?? undefined),
|
||||
fetchSessions(selectedProjectId),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ import { createUpdateSlice } from './slices/updateSlice';
|
|||
|
||||
import type { DetectedError } from '../types/data';
|
||||
import type { AppState } from './types';
|
||||
import type { CliInstallerProgress, TeamChangeEvent, UpdaterStatus } from '@shared/types';
|
||||
import type {
|
||||
CliInstallerProgress,
|
||||
LeadContextUsage,
|
||||
TeamChangeEvent,
|
||||
UpdaterStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
// =============================================================================
|
||||
// Store Creation
|
||||
|
|
@ -362,11 +367,33 @@ export function initializeNotificationListeners(): () => void {
|
|||
};
|
||||
}
|
||||
|
||||
// Clear context data when lead goes offline
|
||||
if (nextActivity === 'offline') {
|
||||
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
||||
delete (nextState.leadContextByTeam as Record<string, LeadContextUsage>)[
|
||||
event.teamName
|
||||
];
|
||||
}
|
||||
|
||||
return nextState as typeof prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Immediate in-memory update for lead context usage — no filesystem refresh needed
|
||||
if (event.type === 'lead-context' && event.detail) {
|
||||
try {
|
||||
const ctx = JSON.parse(event.detail) as LeadContextUsage;
|
||||
useStore.setState((prev) => ({
|
||||
...prev,
|
||||
leadContextByTeam: { ...prev.leadContextByTeam, [event.teamName]: ctx },
|
||||
}));
|
||||
} catch {
|
||||
/* ignore malformed detail */
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
||||
if (!teamListRefreshTimer) {
|
||||
teamListRefreshTimer = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -180,9 +180,10 @@ export const createPaneSlice: StateCreator<AppState, [], [], PaneSlice> = (set,
|
|||
const pane = findPane(paneLayout, paneId);
|
||||
if (!pane) return;
|
||||
|
||||
// Cleanup tab UI state for all tabs in the pane
|
||||
// Cleanup tab UI state and session data for all tabs in the pane
|
||||
for (const tab of pane.tabs) {
|
||||
state.cleanupTabUIState(tab.id);
|
||||
state.cleanupTabSessionData(tab.id);
|
||||
}
|
||||
|
||||
const newLayout = removePane(paneLayout, paneId);
|
||||
|
|
|
|||
|
|
@ -550,6 +550,7 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
|||
const tabsToClose = pane.tabs.filter((t) => t.id !== tabId);
|
||||
for (const tab of tabsToClose) {
|
||||
state.cleanupTabUIState(tab.id);
|
||||
state.cleanupTabSessionData(tab.id);
|
||||
}
|
||||
|
||||
const keepTab = pane.tabs.find((t) => t.id === tabId);
|
||||
|
|
@ -581,6 +582,7 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
|
|||
const tabsToClose = pane.tabs.slice(index + 1);
|
||||
for (const tab of tabsToClose) {
|
||||
state.cleanupTabUIState(tab.id);
|
||||
state.cleanupTabSessionData(tab.id);
|
||||
}
|
||||
|
||||
const newTabs = pane.tabs.slice(0, index + 1);
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import type {
|
|||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
|
|
@ -256,6 +257,7 @@ export interface TeamSlice {
|
|||
*/
|
||||
provisioningStartedAtFloorByTeam: Record<string, string>;
|
||||
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||
leadContextByTeam: Record<string, LeadContextUsage>;
|
||||
activeProvisioningRunId: string | null;
|
||||
provisioningError: string | null;
|
||||
clearProvisioningError: () => void;
|
||||
|
|
@ -288,7 +290,12 @@ export interface TeamSlice {
|
|||
) => Promise<void>;
|
||||
addingComment: boolean;
|
||||
addCommentError: string | null;
|
||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
addTaskComment: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: import('@shared/types').CommentAttachmentPayload[]
|
||||
) => Promise<TaskComment>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
updateMemberRole: (
|
||||
|
|
@ -313,6 +320,23 @@ export interface TeamSlice {
|
|||
taskId: string,
|
||||
value: 'lead' | 'user' | null
|
||||
) => 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[];
|
||||
deletedTasksLoading: boolean;
|
||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
|
|
@ -352,6 +376,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
provisioningRuns: {},
|
||||
provisioningStartedAtFloorByTeam: {},
|
||||
leadActivityByTeam: {},
|
||||
leadContextByTeam: {},
|
||||
activeProvisioningRunId: null,
|
||||
provisioningError: null,
|
||||
clearProvisioningError: () => set({ provisioningError: null }),
|
||||
|
|
@ -810,11 +835,32 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
await get().fetchAllTasks();
|
||||
},
|
||||
|
||||
addTaskComment: async (teamName, taskId, text) => {
|
||||
saveTaskAttachment: async (teamName, taskId, file) => {
|
||||
const id = crypto.randomUUID();
|
||||
await unwrapIpc('team:saveTaskAttachment', () =>
|
||||
api.teams.saveTaskAttachment(teamName, taskId, id, file.name, file.type, file.base64)
|
||||
);
|
||||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
deleteTaskAttachment: async (teamName, taskId, attachmentId, mimeType) => {
|
||||
await unwrapIpc('team:deleteTaskAttachment', () =>
|
||||
api.teams.deleteTaskAttachment(teamName, taskId, attachmentId, mimeType)
|
||||
);
|
||||
await get().refreshTeamData(teamName);
|
||||
},
|
||||
|
||||
getTaskAttachmentData: async (teamName, taskId, attachmentId, mimeType) => {
|
||||
return unwrapIpc('team:getTaskAttachment', () =>
|
||||
api.teams.getTaskAttachment(teamName, taskId, attachmentId, mimeType)
|
||||
);
|
||||
},
|
||||
|
||||
addTaskComment: async (teamName, taskId, text, attachments) => {
|
||||
set({ addingComment: true, addCommentError: null });
|
||||
try {
|
||||
const comment = await unwrapIpc('team:addTaskComment', () =>
|
||||
api.teams.addTaskComment(teamName, taskId, text)
|
||||
api.teams.addTaskComment(teamName, taskId, text, attachments)
|
||||
);
|
||||
set({ addingComment: false });
|
||||
await get().refreshTeamData(teamName);
|
||||
|
|
|
|||
|
|
@ -41,13 +41,19 @@ export function getPresenceLabel(
|
|||
member: ResolvedTeamMember,
|
||||
isTeamAlive?: boolean,
|
||||
isTeamProvisioning?: boolean,
|
||||
leadActivity?: LeadActivityState
|
||||
leadActivity?: LeadActivityState,
|
||||
leadContextPercent?: number
|
||||
): string {
|
||||
if (member.status === 'terminated') return 'terminated';
|
||||
if (isTeamProvisioning) return 'connecting';
|
||||
if (isTeamAlive === false) return 'offline';
|
||||
if (leadActivity && member.agentType === 'team-lead') {
|
||||
return leadActivity === 'active' ? 'processing' : 'ready';
|
||||
if (leadActivity === 'active') {
|
||||
return leadContextPercent != null && leadContextPercent > 0
|
||||
? `processing (${Math.round(leadContextPercent)}%)`
|
||||
: 'processing';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
if (member.status === 'unknown') return 'idle';
|
||||
return member.currentTaskId ? 'working' : 'idle';
|
||||
|
|
|
|||
|
|
@ -30,15 +30,19 @@ import type {
|
|||
import type {
|
||||
AddMemberRequest,
|
||||
AttachmentFileData,
|
||||
AttachmentMediaType,
|
||||
CommentAttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
ReplaceMembersRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TeamChangeEvent,
|
||||
TeamConfig,
|
||||
|
|
@ -448,7 +452,12 @@ export interface TeamsAPI {
|
|||
memberName: string,
|
||||
role: string | undefined
|
||||
) => Promise<void>;
|
||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
addTaskComment: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: CommentAttachmentPayload[]
|
||||
) => Promise<TaskComment>;
|
||||
setTaskClarification: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
|
|
@ -458,6 +467,7 @@ export interface TeamsAPI {
|
|||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||
getLeadContext: (teamName: string) => Promise<LeadContextUsage | null>;
|
||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||
|
|
@ -474,6 +484,26 @@ export interface TeamsAPI {
|
|||
targetId: string,
|
||||
type: 'blockedBy' | 'blocks' | 'related'
|
||||
) => 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;
|
||||
onProvisioningProgress: (
|
||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export interface TaskComment {
|
|||
text: string;
|
||||
createdAt: string;
|
||||
type: TaskCommentType;
|
||||
/** Image attachments on this comment. Metadata only — files stored on disk. */
|
||||
attachments?: TaskAttachmentMeta[];
|
||||
}
|
||||
|
||||
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
||||
|
|
@ -123,6 +125,8 @@ export interface TeamTask {
|
|||
needsClarification?: 'lead' | 'user';
|
||||
/** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */
|
||||
deletedAt?: string;
|
||||
/** Image attachments associated with this task. Metadata only — actual files stored on disk. */
|
||||
attachments?: TaskAttachmentMeta[];
|
||||
}
|
||||
|
||||
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
||||
|
|
@ -131,6 +135,28 @@ export interface TeamTaskWithKanban extends TeamTask {
|
|||
kanbanColumn?: 'review' | 'approved';
|
||||
}
|
||||
|
||||
/** Metadata for an image attached to a task description. */
|
||||
export interface TaskAttachmentMeta {
|
||||
/** Unique attachment ID (uuid). */
|
||||
id: string;
|
||||
/** Original filename (e.g. "screenshot.png"). */
|
||||
filename: string;
|
||||
/** MIME type. */
|
||||
mimeType: AttachmentMediaType;
|
||||
/** File size in bytes. */
|
||||
size: number;
|
||||
/** ISO timestamp when the attachment was added. */
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
/** Payload for uploading an attachment with base64 data (renderer → main). */
|
||||
export interface CommentAttachmentPayload {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: AttachmentMediaType;
|
||||
base64Data: string;
|
||||
}
|
||||
|
||||
export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||
|
||||
export interface AttachmentMeta {
|
||||
|
|
@ -268,8 +294,19 @@ export interface CreateTaskRequest {
|
|||
|
||||
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
export interface LeadContextUsage {
|
||||
/** Total tokens currently in context (input + cache_creation + cache_read) */
|
||||
currentTokens: number;
|
||||
/** Model's context window size */
|
||||
contextWindow: number;
|
||||
/** Usage percentage (0-100) */
|
||||
percent: number;
|
||||
/** ISO timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TeamChangeEvent {
|
||||
type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'process';
|
||||
type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'process';
|
||||
teamName: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
TEAM_RESTORE: 'team:restoreTeam',
|
||||
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
|
||||
TEAM_RESTORE_TASK: 'team:restoreTask',
|
||||
TEAM_SAVE_TASK_ATTACHMENT: 'team:saveTaskAttachment',
|
||||
TEAM_GET_TASK_ATTACHMENT: 'team:getTaskAttachment',
|
||||
TEAM_DELETE_TASK_ATTACHMENT: 'team:deleteTaskAttachment',
|
||||
}));
|
||||
|
||||
import {
|
||||
|
|
|
|||
Loading…
Reference in a new issue