Merge pull request #15 from 777genius/dev

Dev
This commit is contained in:
Илия 2026-03-05 12:11:03 +02:00 committed by GitHub
commit 23faa432d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3368 additions and 191 deletions

View file

@ -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
---

View 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)

View 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.

View 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 |

View 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 дня)
- Даёт полный контроль над архитектурой

View 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)

View file

@ -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

View file

@ -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);
});
}

View file

@ -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;

View file

@ -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`);
}

View file

@ -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([

View file

@ -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`);
}
}
}
/**

View 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
}
}
}

View file

@ -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;

View file

@ -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));
});
}
}

View file

@ -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) {

View file

@ -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
// =============================================================================

View file

@ -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,

View file

@ -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)

View file

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

View file

@ -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' : ''}`}

View file

@ -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}
/>

View file

@ -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(

View file

@ -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}

View file

@ -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>
);

View file

@ -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();

View file

@ -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();

View file

@ -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">

View 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);
});
}

View 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">

View file

@ -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}`;
}

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -77,7 +77,7 @@ export const MessageComposer = ({
clearAttachments,
handlePaste,
handleDrop,
} = useAttachments();
} = useAttachments({ persistenceKey: `compose:${teamName}:attachments` });
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);

View file

@ -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) => {

View file

@ -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),
]);
}

View file

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

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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';

View file

@ -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

View file

@ -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;
}

View file

@ -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 {