feat: add team restoration and permanent deletion features

- Implemented functionality for restoring soft-deleted teams and permanently deleting teams from the system.
- Introduced new IPC channels for team restoration and permanent deletion, enhancing team management capabilities.
- Updated TeamDataService and related components to handle the new operations, ensuring data integrity during team management.
- Enhanced UI components to support team restoration and deletion actions, improving user experience and management workflows.
This commit is contained in:
iliya 2026-02-26 12:15:47 +02:00
parent a02f46c3aa
commit 7019bf6114
43 changed files with 2493 additions and 1042 deletions

466
docs/research/git.md Normal file
View file

@ -0,0 +1,466 @@
# Исследование: встраивание Git UI в Electron + React приложение
> Дата: 2026-02-25
## TL;DR
**Готового `<GitPanel repo="/path" />` компонента не существует.** Все Git GUI (GitHub Desktop, GitButler, Ungit) — монолитные приложения с тесно связанными компонентами. Реалистичный путь — собрать из кирпичиков или встроить терминал с lazygit.
---
## Оглавление
1. [Git Backend библиотеки](#1-git-backend-библиотеки)
2. [UI-компоненты (npm-пакеты)](#2-ui-компоненты-npm-пакеты)
3. [Open-source Git GUI приложения (референсы)](#3-open-source-git-gui-приложения)
4. [IDE-embedded Git UI (не извлекаемые)](#4-ide-embedded-git-ui)
5. [Подходы к интеграции](#5-подходы-к-интеграции)
6. [Итоговая сравнительная таблица](#6-итоговая-сравнительная-таблица)
7. [Рекомендация](#7-рекомендация)
---
## 1. Git Backend библиотеки
Обеспечивают программный доступ к Git-операциям из Node.js/Electron.
### simple-git ⭐ РЕКОМЕНДУЕТСЯ
- **GitHub**: [simple-git-js/simple-git](https://github.com/simple-git-js/simple-git)
- **Stars**: ~3,550
- **npm downloads**: ~5.8M/week
- **Версия**: 3.32.2 (февраль 2026)
- **Лицензия**: MIT
- **Тип**: CLI wrapper (требует git binary)
- **Особенности**:
- Легковесная обертка вокруг `git` CLI
- ES Modules, CommonJS, TypeScript
- Async/await, promise chaining
- Progress monitoring для clone/checkout
- Concurrency control (`maxConcurrentProcesses`)
- **Плюсы**: Простейший API, самые высокие downloads, отличная TS поддержка, активно поддерживается
- **Минусы**: Требует установленный Git на машине; спавнит shell-процессы
```typescript
import simpleGit, { SimpleGit } from 'simple-git';
const git: SimpleGit = simpleGit('/path/to/repo');
const status = await git.status(); // modified, staged, not_added
const log = await git.log({ maxCount: 50 }); // hash, date, message, author
const diff = await git.diff(['--staged']); // staged diff
const branches = await git.branch(); // all branches
await git.add(['src/file.ts']);
await git.commit('fix: resolve issue');
await git.stash(['push', '-m', 'WIP']);
```
### isomorphic-git
- **GitHub**: [isomorphic-git/isomorphic-git](https://github.com/isomorphic-git/isomorphic-git)
- **Stars**: ~8,100
- **npm downloads**: ~300-600K/week
- **Версия**: 1.37.1 (февраль 2026)
- **Лицензия**: MIT
- **Тип**: Pure JavaScript Git implementation
- **Особенности**:
- Pure JS — zero native dependencies
- Работает в Node.js И в browser/renderer
- Clone, commit, push, pull, fetch, branch, merge, checkout
- 100% совместимость с canonical git
- Читает/пишет `.git` директорию напрямую
- **Плюсы**: Нет нативных зависимостей, работает везде
- **Минусы**: Медленнее нативных реализаций на больших репо; некоторые продвинутые git-фичи отсутствуют
### dugite
- **GitHub**: [desktop/dugite](https://github.com/desktop/dugite)
- **Stars**: ~495
- **npm downloads**: ~3-6K/week
- **Версия**: 2.7.1
- **Лицензия**: MIT
- **Тип**: Бандлит git binary (свой Git в пакете)
- **Особенности**:
- Поставляет скомпилированный Git binary — пользователю НЕ нужен установленный Git
- TypeScript
- Используется GitHub Desktop (проверено в production)
- Создан командой GitHub Desktop
- **Плюсы**: Гарантия наличия Git; battle-tested
- **Минусы**: Увеличивает размер бандла; возможные проблемы с corporate proxy
### nodegit ❌ НЕ РЕКОМЕНДУЕТСЯ
- **GitHub**: [nodegit/nodegit](https://github.com/nodegit/nodegit)
- **Stars**: ~5,750
- **Тип**: Native C++ bindings к libgit2
- **Проблемы**: Плохо поддерживается (последний stable-релиз много лет назад); нативный C++ build ломается; persistent проблемы совместимости с Electron
- **Вердикт**: Не использовать для новых проектов
---
## 2. UI-компоненты (npm-пакеты)
### Diff Viewers
#### @git-diff-view/react ⭐ РЕКОМЕНДУЕТСЯ
- **GitHub**: [MrWangJustToDo/git-diff-view](https://github.com/MrWangJustToDo/git-diff-view)
- **Версия**: 0.0.36 (февраль 2026, активно обновляется)
- **Лицензия**: MIT
- **Особенности**:
- GitHub-parity UI (выглядит как GitHub diff)
- Web Worker для 60fps рендеринга
- Split и unified views
- Zero dependencies, pure CSS
- SSR/RSC support
- **Virtual scrolling** — ~280ms рендер 10K+ строк
- Multi-framework (React, Vue, Solid, Svelte)
- **Плюсы**: Самый активно поддерживаемый; лучшая производительность; GitHub-quality UI
- **Минусы**: Pre-1.0 (v0.0.x)
#### react-diff-view
- **GitHub**: [otakustay/react-diff-view](https://github.com/otakustay/react-diff-view)
- **Stars**: ~977 | **Downloads**: ~140K/week
- **Версия**: 3.3.2
- **Лицензия**: MIT
- **Особенности**:
- Принимает `git diff -U1` output напрямую (самый git-native)
- Split и unified views
- Collapsed code expansion
- Code comments support
- Large diff lazy loading
- Гибкая система decoration/widget
- **Плюсы**: Самый Git-native; хорошая производительность; extensible
#### react-diff-viewer-continued
- **GitHub**: [ralzinov/react-diff-viewer-continued](https://github.com/ralzinov/react-diff-viewer-continued)
- **Версия**: 3.4.0
- **Лицензия**: MIT
- **Описание**: Maintained форк заброшенного react-diff-viewer. Split/inline view, word diff, GitHub-style
#### Monaco DiffEditor (@monaco-editor/react)
- **GitHub**: [suren-atoyan/monaco-react](https://github.com/suren-atoyan/monaco-react)
- **Описание**: VS Code Monaco Editor с встроенным DiffEditor
- **Плюсы**: Production-grade (тот же движок что в VS Code); отличная подсветка синтаксиса
- **Минусы**: Тяжелый бандл; overkill если нужен только просмотр diff
### Commit Graph Visualization
#### @dolthub/gitgraph-react
- **npm**: [@dolthub/gitgraph-react](https://www.npmjs.com/package/@dolthub/gitgraph-react)
- **Описание**: Живой форк архивированного @gitgraph/react, поддерживается DoltHub
- **Плюсы**: Активный форк; декларативный API
- **Минусы**: Кастомизирован под нужды DoltHub
#### @gitgraph/react ❌ АРХИВИРОВАН
- **GitHub**: [nicoespeon/gitgraph.js](https://github.com/nicoespeon/gitgraph.js)
- **Downloads**: ~4,300/week
- **Статус**: Архивирован с 2019. Автор рекомендует Mermaid.js
#### Mermaid.js + @mermaid-js/react-wrapper
- **GitHub**: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid)
- **Stars**: ~60,000+
- **Лицензия**: MIT
- **Описание**: Нативный `gitGraph` тип диаграмм. Text-based DSL
- **Плюсы**: Огромное сообщество, активно поддерживается
- **Минусы**: Text-based input; больше для документации/иллюстраций, чем для интерактивных графов
#### commit-graph (CommitGraph)
- **GitHub**: [liuliu-dev/CommitGraph](https://github.com/liuliu-dev/CommitGraph)
- **Описание**: Interactive commit graph с infinite scrolling и pagination
- **Особенности**: `commitSpacing`, `branchSpacing`, `nodeRadius`, `branchColors`, `onCommitClick`
- **Плюсы**: Построен для реальных данных; пагинация
- **Минусы**: Новый, мало adoption
#### @gitkraken/gitkraken-components
- **npm**: v11.0.7 (февраль 2026)
- **Описание**: Shared React-компоненты между GitKraken Desktop и GitLens. Включает `GraphContainer` для commit graph
- **Плюсы**: Production-proven (GitKraken), активно обновляется
- **Минусы**: **Без документации**, требует React 17, undocumented API
### File Tree
#### react-arborist
- **GitHub**: [brimdata/react-arborist](https://github.com/brimdata/react-arborist)
- **Stars**: ~3,542 | **Downloads**: ~225K/week
- **Версия**: 3.4.3 (февраль 2025)
- **Лицензия**: MIT
- **Описание**: Полное tree view (как VS Code sidebar). Selection, multi-select, drag-and-drop, виртуализация, кастомный рендеринг нод
- **Использование**: Для git staging panel с file tree + status indicators
### Terminal Emulator
#### xterm.js (@xterm/xterm)
- **GitHub**: [xtermjs/xterm.js](https://github.com/xtermjs/xterm.js)
- **Описание**: Полный терминальный эмулятор в браузере/Electron. Используется VS Code, Hyper, Wave Terminal
- **React wrapper**: [Qovery/react-xtermjs](https://github.com/Qovery/react-xtermjs)
- **Использование**: Для встраивания lazygit/tig как терминальной панели
---
## 3. Open-source Git GUI приложения
### GitHub Desktop ⭐ ЛУЧШИЙ РЕФЕРЕНС
- **GitHub**: [desktop/desktop](https://github.com/desktop/desktop)
- **Stars**: ~21,000
- **Стек**: Electron + React + TypeScript
- **Git backend**: dugite
- **Лицензия**: MIT
- **Статус**: Активно поддерживается (февраль 2026)
- **Извлекаемость**: Монолитное приложение. Компоненты тесно связаны с внутренним `git-store.ts`. Нельзя npm install, но можно изучить архитектуру и адаптировать паттерны
- **Ключевые файлы для изучения**: `src/ui/diff/`, `src/ui/history/`, `src/lib/stores/git-store.ts`
### Ungit
- **GitHub**: [FredrikNoren/ungit](https://github.com/FredrikNoren/ungit)
- **Stars**: ~10,456
- **Стек**: Node.js web server (Knockout.js)
- **Лицензия**: MIT
- **Описание**: Web-based Git GUI. Запускает HTTP-сервер на localhost. Есть pre-built Electron-пакеты
- **Встраивание**: Можно через iframe/webview, но свой UI (Knockout.js), невозможно стилизовать
### GitButler
- **GitHub**: [gitbutlerapp/gitbutler](https://github.com/gitbutlerapp/gitbutler)
- **Stars**: ~14,000
- **Стек**: Tauri + Svelte + TypeScript + Rust
- **Лицензия**: Fair Source (→ MIT через 2 года)
- **Извлекаемость**: Не React, не Electron. Есть `@gitbutler/ui` но на Svelte
### Sapling ISL (Facebook) — интересная находка
- **GitHub**: [facebook/sapling](https://github.com/facebook/sapling) → `addons/isl/`
- **Стек**: React 18 + Jotai + StyleX + Vite
- **Лицензия**: MIT
- **Описание**: Interactive Smartlog — web GUI для Sapling SCM
- **Компоненты**: Commit tree visualization, drag-and-drop rebase, commit details panel, PR integration
- **Проблемы**: Заточен под Sapling SCM (не Git напрямую); требует isl-server backend
- **Ценность**: Отличный референс React-архитектуры для Git UI
### Другие
| Проект | Стек | Stars | Статус |
|--------|------|-------|--------|
| Thermal | Electron + Vue | - | Не React |
| Gitamine | Electron + React + NodeGit | 142 | Неактивен (2019), GPL v3 |
| LithiumGit | Electron + TypeScript | 20 | Активен, MIT |
| NeatGit | Electron + React + Tailwind + Vite | 3 | Ранняя разработка |
---
## 4. IDE-embedded Git UI
Все **не извлекаемые** для standalone использования.
### Eclipse Theia (@theia/git)
- **Статус**: **DEPRECATED** — рекомендуют использовать VS Code Git extension
- **Проблемы**: InversifyJS DI-контейнер, PhosphorJS/Lumino виджеты (не React), нужна полная Theia среда
- [Обсуждение Copia Automation](https://github.com/eclipse-theia/theia/discussions/15151) — вывод: проще написать свой view
### VS Code Git Extension
- **Архитектура**: Extension Host + webview API. Глубоко интегрирован в workbench. Не React. С VS Code 1.93 Git Graph встроен
- **Извлекаемость**: Невозможна без переписывания workbench
### Другие IDE
| IDE | Вердикт |
|-----|---------|
| Gitpod / OpenVSCode Server | Форк VS Code, не экспортирует компоненты |
| JetBrains Fleet | Proprietary, Kotlin/Skia рендеринг |
| Sourcegraph | Нет git management компонентов, фокус на code search |
---
## 5. Подходы к интеграции
### Подход A: xterm.js + lazygit (~200 LOC)
Быстрейший путь к полному Git UI.
```
Electron Main Process
└── node-pty.spawn('lazygit', [], { cwd: repoPath })
├── stdout → xterm.js (renderer)
└── stdin ← xterm.js keyboard events
```
```typescript
// Main process
import * as pty from 'node-pty';
const ptyProcess = pty.spawn('lazygit', [], {
name: 'xterm-256color',
cols: 120, rows: 40,
cwd: '/path/to/repo',
env: { ...process.env, TERM: 'xterm-256color' }
});
ptyProcess.onData((data) => mainWindow.webContents.send('terminal:data', data));
ipcMain.on('terminal:input', (_, data) => ptyProcess.write(data));
ipcMain.on('terminal:resize', (_, { cols, rows }) => ptyProcess.resize(cols, rows));
```
```tsx
// Renderer (React)
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
function LazyGitTerminal({ repoPath }: { repoPath: string }) {
const termRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const term = new Terminal({
theme: { background: '#141416' },
fontSize: 13
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(termRef.current!);
fitAddon.fit();
term.onData((data) => window.api.terminalInput(data));
window.api.onTerminalData((data: string) => term.write(data));
return () => term.dispose();
}, []);
return <div ref={termRef} className="w-full h-full" />;
}
```
| Критерий | Оценка |
|---|---|
| Сложность | **Низкая** (~200 LOC) |
| React-совместимость | Хорошая (xterm.js оборачивается в компонент) |
| Git-полнота | **Отличная** (lazygit покрывает всё) |
| Кастомизация UI | **Никакая** (черный ящик) |
| Зависимости | `node-pty` (нативный модуль), lazygit должен быть установлен |
### Подход B: Кастомный React UI (из кирпичиков)
```
Electron Main Process
└── GitService (simple-git)
├── IPC: git:status
├── IPC: git:log
├── IPC: git:diff
├── IPC: git:commit
├── IPC: git:branch
├── IPC: git:checkout
├── IPC: git:stash
└── IPC: git:merge
Electron Renderer (React + Zustand)
└── gitSlice (status, log, branches, diff)
├── GitStatusPanel (кастомный)
├── GitLogView + CommitGraph (@dolthub/gitgraph-react)
├── GitDiffViewer (@git-diff-view/react)
├── CommitForm (кастомный)
├── BranchSelector (кастомный)
└── StashPanel (кастомный)
```
| Критерий | Оценка |
|---|---|
| Сложность | **Высокая** (полная реализация), **Средняя** (базовые функции) |
| React-совместимость | **Идеальная** (нативные React-компоненты, Zustand, Tailwind) |
| Git-полнота | Настраиваемая — от status/commit/diff до полного |
| Кастомизация UI | **Полная** |
| Объем работ | ~500-1000 LOC для базового функционала |
### Подход C: Embed Ungit (iframe)
```
Electron Main Process
└── spawn('ungit', ['--port', '9001'])
Renderer
└── <iframe src="http://localhost:9001" />
```
| Критерий | Оценка |
|---|---|
| Сложность | **Низкая** |
| React-совместимость | **Плохая** (чужой UI, Knockout.js) |
| Git-полнота | Хорошая |
| Кастомизация UI | **Никакая** |
---
## 6. Итоговая сравнительная таблица
| Подход | Сложность | React-совместимость | Git-полнота | Кастомизация | Зависимости |
|---|---|---|---|---|---|
| **xterm.js + lazygit** | Низкая | Хорошая | Отличная | Нет | node-pty, lazygit |
| **Кастомный React UI** | Высокая | Идеальная | Настраиваемая | Полная | simple-git, @git-diff-view/react |
| **Embed Ungit** | Низкая | Плохая | Хорошая | Нет | ungit |
| **VS Code SCM API** | Нереальная | Никакая | Отличная | — | — |
---
## 7. Рекомендация
### Гибридная стратегия
**Фаза 1 — Быстрый старт:** xterm.js + lazygit
- Встраиваем lazygit как терминальную панель/вкладку
- Полный git-функционал за ~200 LOC
- Подходит для power-users
**Фаза 2 — Нативный React UI:**
1. `simple-git` как backend через IPC
2. `@git-diff-view/react` для просмотра диффов
3. Кастомные компоненты для status, commit, branches
4. `@dolthub/gitgraph-react` или `commit-graph` для визуализации графа коммитов
5. `react-arborist` для file tree в staging panel
### npm-пакеты для установки
```bash
# Backend
pnpm add simple-git
# UI-компоненты (по мере необходимости)
pnpm add @git-diff-view/react # diff viewer
pnpm add react-arborist # file tree
pnpm add @xterm/xterm @xterm/addon-fit # terminal (для lazygit)
# Commit graph (выбрать один)
pnpm add @dolthub/gitgraph-react # форк gitgraph.js
pnpm add commit-graph # interactive commit graph
```
---
## Источники
- [simple-git](https://github.com/simple-git-js/simple-git)
- [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git)
- [dugite](https://github.com/desktop/dugite)
- [GitHub Desktop](https://github.com/desktop/desktop)
- [Ungit](https://github.com/FredrikNoren/ungit)
- [@git-diff-view/react](https://github.com/MrWangJustToDo/git-diff-view)
- [react-diff-view](https://github.com/otakustay/react-diff-view)
- [react-arborist](https://github.com/brimdata/react-arborist)
- [xterm.js](https://xtermjs.org/)
- [node-pty](https://github.com/microsoft/node-pty)
- [Mermaid.js GitGraph](https://mermaid.js.org/syntax/gitgraph.html)
- [@gitkraken/gitkraken-components](https://www.npmjs.com/package/@gitkraken/gitkraken-components)
- [Sapling ISL](https://github.com/facebook/sapling/tree/main/addons/isl)
- [GitButler](https://github.com/gitbutlerapp/gitbutler)
- [Electron Web Embeds](https://www.electronjs.org/docs/latest/tutorial/web-embeds/)
- [@theia/git](https://www.npmjs.com/package/@theia/git) (deprecated)
- [VS Code SCM API](https://code.visualstudio.com/api/extension-guides/scm-provider)

View file

@ -60,6 +60,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2", "@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",

View file

@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@codemirror/autocomplete':
specifier: ^6.20.0
version: 6.20.0
'@codemirror/commands': '@codemirror/commands':
specifier: ^6.10.2 specifier: ^6.10.2
version: 6.10.2 version: 6.10.2

View file

@ -21,6 +21,7 @@ import {
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
TEAM_LIST, TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING, TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE, TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND, TEAM_PROCESS_SEND,
@ -28,6 +29,8 @@ import {
TEAM_PROVISIONING_STATUS, TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER, TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_SOFT_DELETE_TASK, TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK, TEAM_START_TASK,
@ -180,6 +183,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus); ipcMain.handle(TEAM_UPDATE_TASK_STATUS, handleUpdateTaskStatus);
ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner); ipcMain.handle(TEAM_UPDATE_TASK_OWNER, handleUpdateTaskOwner);
ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam); ipcMain.handle(TEAM_DELETE_TEAM, handleDeleteTeam);
ipcMain.handle(TEAM_RESTORE, handleRestoreTeam);
ipcMain.handle(TEAM_PERMANENTLY_DELETE, handlePermanentlyDeleteTeam);
ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend); ipcMain.handle(TEAM_PROCESS_SEND, handleProcessSend);
ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive); ipcMain.handle(TEAM_PROCESS_ALIVE, handleProcessAlive);
ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList); ipcMain.handle(TEAM_ALIVE_LIST, handleAliveList);
@ -200,6 +205,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess); ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity); ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask); ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks); ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
logger.info('Team handlers registered'); logger.info('Team handlers registered');
} }
@ -220,6 +226,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS); ipcMain.removeHandler(TEAM_UPDATE_TASK_STATUS);
ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER); ipcMain.removeHandler(TEAM_UPDATE_TASK_OWNER);
ipcMain.removeHandler(TEAM_DELETE_TEAM); ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_RESTORE);
ipcMain.removeHandler(TEAM_PERMANENTLY_DELETE);
ipcMain.removeHandler(TEAM_PROCESS_SEND); ipcMain.removeHandler(TEAM_PROCESS_SEND);
ipcMain.removeHandler(TEAM_PROCESS_ALIVE); ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
ipcMain.removeHandler(TEAM_ALIVE_LIST); ipcMain.removeHandler(TEAM_ALIVE_LIST);
@ -240,6 +248,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_KILL_PROCESS); ipcMain.removeHandler(TEAM_KILL_PROCESS);
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
} }
@ -382,6 +391,30 @@ async function handleDeleteTeam(
return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!)); return wrapTeamHandler('deleteTeam', () => getTeamDataService().deleteTeam(validated.value!));
} }
async function handleRestoreTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<void>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('restoreTeam', () => getTeamDataService().restoreTeam(validated.value!));
}
async function handlePermanentlyDeleteTeam(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<void>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };
}
return wrapTeamHandler('permanentlyDeleteTeam', () =>
getTeamDataService().permanentlyDeleteTeam(validated.value!)
);
}
async function handleUpdateConfig( async function handleUpdateConfig(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,
@ -1095,6 +1128,26 @@ async function handleSoftDeleteTask(
); );
} }
async function handleRestoreTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<void>> {
const validatedTeamName = validateTeamName(teamName);
if (!validatedTeamName.valid) {
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
}
const validatedTaskId = validateTaskId(taskId);
if (!validatedTaskId.valid) {
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
}
return wrapTeamHandler('restoreTask', () =>
getTeamDataService().restoreTask(validatedTeamName.value!, validatedTaskId.value!)
);
}
async function handleGetDeletedTasks( async function handleGetDeletedTasks(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown teamName: unknown

View file

@ -4,10 +4,14 @@ import * as readline from 'readline';
import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { type TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { MemberFullStats } from '@shared/types'; import type { FileLineStats, MemberFullStats } from '@shared/types';
const logger = createLogger('Service:MemberStatsComputer'); const logger = createLogger('Service:MemberStatsComputer');
function isValidFilePath(value: string): boolean {
return value.length > 0 && value !== 'null' && value !== 'undefined' && value !== 'None';
}
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface CacheEntry { interface CacheEntry {
@ -32,6 +36,7 @@ export class MemberStatsComputer {
let linesAdded = 0; let linesAdded = 0;
let linesRemoved = 0; let linesRemoved = 0;
const filesTouchedSet = new Set<string>(); const filesTouchedSet = new Set<string>();
const perFileStats: Record<string, FileLineStats> = {};
const toolUsage: Record<string, number> = {}; const toolUsage: Record<string, number> = {};
let inputTokens = 0; let inputTokens = 0;
let outputTokens = 0; let outputTokens = 0;
@ -40,26 +45,38 @@ export class MemberStatsComputer {
let totalDurationMs = 0; let totalDurationMs = 0;
for (const filePath of paths) { for (const filePath of paths) {
const fileStats = await this.parseFile(filePath); const parsed = await this.parseFile(filePath);
linesAdded += fileStats.linesAdded; linesAdded += parsed.linesAdded;
linesRemoved += fileStats.linesRemoved; linesRemoved += parsed.linesRemoved;
for (const f of fileStats.filesTouched) filesTouchedSet.add(f); for (const f of parsed.filesTouched) filesTouchedSet.add(f);
for (const [tool, count] of Object.entries(fileStats.toolUsage)) { for (const [fp, fls] of Object.entries(parsed.perFileStats)) {
const existing = perFileStats[fp];
if (existing) {
existing.added += fls.added;
existing.removed += fls.removed;
} else {
perFileStats[fp] = { added: fls.added, removed: fls.removed };
}
}
for (const [tool, count] of Object.entries(parsed.toolUsage)) {
toolUsage[tool] = (toolUsage[tool] ?? 0) + count; toolUsage[tool] = (toolUsage[tool] ?? 0) + count;
} }
inputTokens += fileStats.inputTokens; inputTokens += parsed.inputTokens;
outputTokens += fileStats.outputTokens; outputTokens += parsed.outputTokens;
cacheReadTokens += fileStats.cacheReadTokens; cacheReadTokens += parsed.cacheReadTokens;
messageCount += fileStats.messageCount; messageCount += parsed.messageCount;
totalDurationMs += fileStats.durationMs; totalDurationMs += parsed.durationMs;
} }
const validFiles = [...filesTouchedSet]
.filter(isValidFilePath)
.sort((a, b) => a.localeCompare(b));
const stats: MemberFullStats = { const stats: MemberFullStats = {
linesAdded, linesAdded,
linesRemoved, linesRemoved,
filesTouched: [...filesTouchedSet] filesTouched: validFiles,
.filter((f) => f && f !== 'null' && f !== 'undefined') fileStats: perFileStats,
.sort((a, b) => a.localeCompare(b)),
toolUsage, toolUsage,
inputTokens, inputTokens,
outputTokens, outputTokens,
@ -80,6 +97,7 @@ export class MemberStatsComputer {
linesAdded: number; linesAdded: number;
linesRemoved: number; linesRemoved: number;
filesTouched: string[]; filesTouched: string[];
perFileStats: Record<string, FileLineStats>;
toolUsage: Record<string, number>; toolUsage: Record<string, number>;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;
@ -90,6 +108,7 @@ export class MemberStatsComputer {
let linesAdded = 0; let linesAdded = 0;
let linesRemoved = 0; let linesRemoved = 0;
const filesTouchedSet = new Set<string>(); const filesTouchedSet = new Set<string>();
const perFileStats: Record<string, FileLineStats> = {};
const toolUsage: Record<string, number> = {}; const toolUsage: Record<string, number> = {};
let inputTokens = 0; let inputTokens = 0;
let outputTokens = 0; let outputTokens = 0;
@ -98,6 +117,21 @@ export class MemberStatsComputer {
let firstTimestamp: string | null = null; let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null; let lastTimestamp: string | null = null;
const trackFile = (fp: string): void => {
if (typeof fp === 'string' && isValidFilePath(fp)) filesTouchedSet.add(fp);
};
const addFileLines = (fp: string, added: number, removed: number): void => {
if (!isValidFilePath(fp)) return;
const existing = perFileStats[fp];
if (existing) {
existing.added += added;
existing.removed += removed;
} else {
perFileStats[fp] = { added, removed };
}
};
try { try {
const stream = createReadStream(filePath, { encoding: 'utf8' }); const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
@ -146,10 +180,10 @@ export class MemberStatsComputer {
// Track files // Track files
if (typeof input.file_path === 'string') { if (typeof input.file_path === 'string') {
filesTouchedSet.add(input.file_path); trackFile(input.file_path);
} }
if (typeof input.path === 'string' && toolName === 'Read') { if (typeof input.path === 'string' && toolName === 'Read') {
filesTouchedSet.add(input.path); trackFile(input.path);
} }
// Count lines for Edit // Count lines for Edit
@ -158,15 +192,24 @@ export class MemberStatsComputer {
const newStr = typeof input.new_string === 'string' ? input.new_string : ''; const newStr = typeof input.new_string === 'string' ? input.new_string : '';
const oldLines = oldStr ? oldStr.split('\n').length : 0; const oldLines = oldStr ? oldStr.split('\n').length : 0;
const newLines = newStr ? newStr.split('\n').length : 0; const newLines = newStr ? newStr.split('\n').length : 0;
if (newLines > oldLines) linesAdded += newLines - oldLines; const fileAdded = newLines > oldLines ? newLines - oldLines : 0;
if (oldLines > newLines) linesRemoved += oldLines - newLines; const fileRemoved = oldLines > newLines ? oldLines - newLines : 0;
linesAdded += fileAdded;
linesRemoved += fileRemoved;
if (typeof input.file_path === 'string') {
addFileLines(input.file_path, fileAdded, fileRemoved);
}
} }
// Count lines for Write // Count lines for Write
if (toolName === 'Write') { if (toolName === 'Write') {
const writeContent = typeof input.content === 'string' ? input.content : ''; const writeContent = typeof input.content === 'string' ? input.content : '';
if (writeContent) { if (writeContent) {
linesAdded += writeContent.split('\n').length; const fileAdded = writeContent.split('\n').length;
linesAdded += fileAdded;
if (typeof input.file_path === 'string') {
addFileLines(input.file_path, fileAdded, 0);
}
} }
} }
@ -174,10 +217,14 @@ export class MemberStatsComputer {
if (toolName === 'NotebookEdit') { if (toolName === 'NotebookEdit') {
const src = typeof input.new_source === 'string' ? input.new_source : ''; const src = typeof input.new_source === 'string' ? input.new_source : '';
if (src) { if (src) {
linesAdded += src.split('\n').length; const fileAdded = src.split('\n').length;
linesAdded += fileAdded;
if (typeof input.notebook_path === 'string') {
addFileLines(input.notebook_path, fileAdded, 0);
}
} }
if (typeof input.notebook_path === 'string') { if (typeof input.notebook_path === 'string') {
filesTouchedSet.add(input.notebook_path); trackFile(input.notebook_path);
} }
} }
@ -188,7 +235,10 @@ export class MemberStatsComputer {
const bashLines = estimateBashLinesChanged(cmd); const bashLines = estimateBashLinesChanged(cmd);
linesAdded += bashLines.added; linesAdded += bashLines.added;
linesRemoved += bashLines.removed; linesRemoved += bashLines.removed;
for (const f of bashLines.files) filesTouchedSet.add(f); for (const f of bashLines.files) {
trackFile(f);
addFileLines(f, bashLines.added, bashLines.removed);
}
} }
} }
} }
@ -218,6 +268,7 @@ export class MemberStatsComputer {
linesAdded, linesAdded,
linesRemoved, linesRemoved,
filesTouched: [...filesTouchedSet], filesTouched: [...filesTouchedSet],
perFileStats,
toolUsage, toolUsage,
inputTokens, inputTokens,
outputTokens, outputTokens,

View file

@ -121,6 +121,7 @@ export class TeamConfigReader {
? config.projectPathHistory ? config.projectPathHistory
: undefined, : undefined,
sessionHistory: Array.isArray(config.sessionHistory) ? config.sessionHistory : undefined, sessionHistory: Array.isArray(config.sessionHistory) ? config.sessionHistory : undefined,
deletedAt: typeof config.deletedAt === 'string' ? config.deletedAt : undefined,
}); });
} catch { } catch {
logger.debug(`Skipping team dir without valid config: ${entry.name}`); logger.debug(`Skipping team dir without valid config: ${entry.name}`);

View file

@ -85,14 +85,20 @@ export class TeamDataService {
this.configReader.listTeams(), this.configReader.listTeams(),
]); ]);
const teamInfoMap = new Map<string, { displayName: string; projectPath?: string }>(); const teamInfoMap = new Map<
string,
{ displayName: string; projectPath?: string; deletedAt?: string }
>();
for (const team of teams) { for (const team of teams) {
teamInfoMap.set(team.teamName, { teamInfoMap.set(team.teamName, {
displayName: team.displayName, displayName: team.displayName,
projectPath: team.projectPath, projectPath: team.projectPath,
deletedAt: team.deletedAt,
}); });
} }
const deletedTeams = new Set(teams.filter((t) => t.deletedAt).map((t) => t.teamName));
const teamNames = [ const teamNames = [
...new Set(rawTasks.map((t) => t.teamName).filter((n) => teamInfoMap.has(n))), ...new Set(rawTasks.map((t) => t.teamName).filter((n) => teamInfoMap.has(n))),
]; ];
@ -123,6 +129,7 @@ export class TeamDataService {
teamDisplayName: info.displayName, teamDisplayName: info.displayName,
projectPath: task.projectPath ?? info.projectPath, projectPath: task.projectPath ?? info.projectPath,
kanbanColumn, kanbanColumn,
teamDeleted: deletedTeams.has(task.teamName) || undefined,
}; };
}); });
} }
@ -135,6 +142,26 @@ export class TeamDataService {
} }
async deleteTeam(teamName: string): Promise<void> { async deleteTeam(teamName: string): Promise<void> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
config.deletedAt = new Date().toISOString();
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
}
async restoreTeam(teamName: string): Promise<void> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
throw new Error(`Team not found: ${teamName}`);
}
delete config.deletedAt;
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
}
async permanentlyDeleteTeam(teamName: string): Promise<void> {
const teamsDir = path.join(getTeamsBasePath(), teamName); const teamsDir = path.join(getTeamsBasePath(), teamName);
await fs.promises.rm(teamsDir, { recursive: true, force: true }); await fs.promises.rm(teamsDir, { recursive: true, force: true });
@ -668,6 +695,10 @@ export class TeamDataService {
await this.taskWriter.softDelete(teamName, taskId); await this.taskWriter.softDelete(teamName, taskId);
} }
async restoreTask(teamName: string, taskId: string): Promise<void> {
await this.taskWriter.restoreTask(teamName, taskId);
}
async getDeletedTasks(teamName: string): Promise<TeamTask[]> { async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
return this.taskReader.getDeletedTasks(teamName); return this.taskReader.getDeletedTasks(teamName);
} }

View file

@ -169,6 +169,27 @@ export class TeamTaskWriter {
}); });
} }
async restoreTask(teamName: string, taskId: string): Promise<void> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
await withTaskLock(taskPath, async () => {
let raw: string;
try {
raw = await fs.promises.readFile(taskPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(`Task not found: ${taskId}`);
}
throw error;
}
const task = JSON.parse(raw) as TeamTask;
task.status = 'pending';
delete task.deletedAt;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
});
}
async addComment( async addComment(
teamName: string, teamName: string,
taskId: string, taskId: string,

View file

@ -239,9 +239,18 @@ export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
/** Update task owner (reassign) */ /** Update task owner (reassign) */
export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner'; export const TEAM_UPDATE_TASK_OWNER = 'team:updateTaskOwner';
/** Delete a team and its associated task directory */ /** Soft-delete a team (sets deletedAt in config) */
export const TEAM_DELETE_TEAM = 'team:deleteTeam'; export const TEAM_DELETE_TEAM = 'team:deleteTeam';
/** Restore a soft-deleted team (removes deletedAt from config) */
export const TEAM_RESTORE = 'team:restoreTeam';
/** Permanently delete a team and its associated task directory */
export const TEAM_PERMANENTLY_DELETE = 'team:permanentlyDeleteTeam';
/** Restore a soft-deleted task (removes deletedAt, sets status back to pending) */
export const TEAM_RESTORE_TASK = 'team:restoreTask';
/** Get list of teams with live CLI processes */ /** Get list of teams with live CLI processes */
export const TEAM_ALIVE_LIST = 'team:aliveList'; export const TEAM_ALIVE_LIST = 'team:aliveList';
export const TEAM_STOP = 'team:stop'; export const TEAM_STOP = 'team:stop';

View file

@ -54,6 +54,7 @@ import {
TEAM_LAUNCH, TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
TEAM_LIST, TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING, TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE, TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND, TEAM_PROCESS_SEND,
@ -61,6 +62,8 @@ import {
TEAM_PROVISIONING_STATUS, TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER, TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW, TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE, TEAM_SEND_MESSAGE,
TEAM_SOFT_DELETE_TASK, TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK, TEAM_START_TASK,
@ -566,6 +569,12 @@ const electronAPI: ElectronAPI = {
deleteTeam: async (teamName: string) => { deleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName); return invokeIpcWithResult<void>(TEAM_DELETE_TEAM, teamName);
}, },
restoreTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_RESTORE, teamName);
},
permanentlyDeleteTeam: async (teamName: string) => {
return invokeIpcWithResult<void>(TEAM_PERMANENTLY_DELETE, teamName);
},
prepareProvisioning: async (cwd?: string) => { prepareProvisioning: async (cwd?: string) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd); return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
}, },
@ -681,6 +690,9 @@ const electronAPI: ElectronAPI = {
softDeleteTask: async (teamName: string, taskId: string) => { softDeleteTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId); return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
}, },
restoreTask: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<void>(TEAM_RESTORE_TASK, teamName, taskId);
},
getDeletedTasks: async (teamName: string) => { getDeletedTasks: async (teamName: string) => {
return invokeIpcWithResult<TeamTask[]>(TEAM_GET_DELETED_TASKS, teamName); return invokeIpcWithResult<TeamTask[]>(TEAM_GET_DELETED_TASKS, teamName);
}, },

View file

@ -635,6 +635,12 @@ export class HttpAPIClient implements ElectronAPI {
deleteTeam: async (_teamName: string): Promise<void> => { deleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team deletion is not available in browser mode'); throw new Error('Team deletion is not available in browser mode');
}, },
restoreTeam: async (_teamName: string): Promise<void> => {
throw new Error('Team restore is not available in browser mode');
},
permanentlyDeleteTeam: async (_teamName: string): Promise<void> => {
throw new Error('Permanent team deletion is not available in browser mode');
},
prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => { prepareProvisioning: async (_cwd?: string): Promise<TeamProvisioningPrepareResult> => {
throw new Error('Team provisioning is not available in browser mode'); throw new Error('Team provisioning is not available in browser mode');
}, },
@ -721,6 +727,7 @@ export class HttpAPIClient implements ElectronAPI {
linesAdded: 0, linesAdded: 0,
linesRemoved: 0, linesRemoved: 0,
filesTouched: [], filesTouched: [],
fileStats: {},
toolUsage: {}, toolUsage: {},
inputTokens: 0, inputTokens: 0,
outputTokens: 0, outputTokens: 0,
@ -770,6 +777,9 @@ export class HttpAPIClient implements ElectronAPI {
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => { softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op // Not available via HTTP client — no-op
}, },
restoreTask: async (_teamName: string, _taskId: string): Promise<void> => {
// Not available via HTTP client — no-op
},
getDeletedTasks: async (_teamName: string): Promise<TeamTask[]> => { getDeletedTasks: async (_teamName: string): Promise<TeamTask[]> => {
return []; return [];
}, },

View file

@ -7,7 +7,7 @@
* - Border-first project cards with minimal backgrounds * - Border-first project cards with minimal backgrounds
*/ */
import React, { useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
@ -18,13 +18,14 @@ import {
normalizePath, normalizePath,
type TaskStatusCounts, type TaskStatusCounts,
} from '@renderer/utils/pathNormalize'; } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
import { formatShortcut } from '@renderer/utils/stringUtils'; import { formatShortcut } from '@renderer/utils/stringUtils';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView'); const logger = createLogger('Component:DashboardView');
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings, Users } from 'lucide-react'; import { Command, FolderGit2, FolderOpen, GitBranch, Search, Users } from 'lucide-react';
import type { RepositoryGroup } from '@renderer/types/data'; import type { RepositoryGroup } from '@renderer/types/data';
@ -131,27 +132,78 @@ const RepositoryCard = ({
const projectPath = repo.worktrees[0]?.path || ''; const projectPath = repo.worktrees[0]?.path || '';
const formattedPath = formatProjectPath(projectPath); const formattedPath = formatProjectPath(projectPath);
// Git branch info from worktrees
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
const mainBranch = mainWorktree?.gitBranch;
const color = useMemo(() => projectColor(repo.name), [repo.name]);
const cardRef = useRef<HTMLButtonElement>(null);
const [isHovered, setIsHovered] = useState(false);
const handleOpenPath = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (projectPath) {
void api.openPath(projectPath);
}
},
[projectPath]
);
return ( return (
<button <button
ref={cardRef}
onClick={onClick} onClick={onClick}
className={`group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border p-4 text-left transition-all duration-300 ${ onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] p-4 text-left transition-all duration-300 ${
isHighlighted isHighlighted
? 'border-border-emphasis bg-surface-raised' ? 'border-border-emphasis bg-surface-raised'
: 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised' : 'bg-surface/50 border-border hover:border-border-emphasis hover:bg-surface-raised'
} `} } `}
style={{
borderLeftColor: color.border,
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
}}
> >
{/* Icon + Project name */} {/* Icon + Project name */}
<div className="mb-1 flex items-center gap-2.5"> <div className="mb-1 flex items-center gap-2.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis"> <div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
<FolderGit2 className="size-4 text-text-secondary transition-colors group-hover:text-text" /> <FolderGit2
className="size-4 transition-colors group-hover:text-text"
style={{ color: color.icon }}
/>
</div> </div>
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text"> <h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name} {repo.name}
</h3> </h3>
</div> </div>
{/* Project path - monospace, muted */} {/* Project path - monospace, muted, clickable to open in file manager */}
<p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p> <button
type="button"
onClick={handleOpenPath}
className="flex w-full min-w-0 items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
title={`Open in file manager: ${projectPath}`}
>
<FolderOpen className="size-3 shrink-0" />
<span className="truncate">{formattedPath}</span>
</button>
{/* Git branch / worktree info */}
{mainBranch ? (
<div className="mb-auto mt-1 flex items-center gap-1.5 truncate">
<GitBranch className="size-3 shrink-0 text-text-muted" />
<span className="truncate text-[10px] text-text-secondary">{mainBranch}</span>
{hasMultipleWorktrees && (
<span className="shrink-0 rounded bg-surface-raised px-1 py-px text-[9px] text-text-muted">
+{worktreeCount - 1}
</span>
)}
</div>
) : (
<div className="mb-auto" />
)}
{/* Meta row: worktrees, sessions, time */} {/* Meta row: worktrees, sessions, time */}
<div className="mt-3 flex flex-wrap items-center gap-2"> <div className="mt-3 flex flex-wrap items-center gap-2">
@ -531,12 +583,7 @@ const ProjectsGrid = ({
export const DashboardView = (): React.JSX.Element => { export const DashboardView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const { openSettingsTab, openTeamsTab } = useStore( const openTeamsTab = useStore((s) => s.openTeamsTab);
useShallow((s) => ({
openSettingsTab: s.openSettingsTab,
openTeamsTab: s.openTeamsTab,
}))
);
return ( return (
<div className="relative flex-1 overflow-auto bg-surface"> <div className="relative flex-1 overflow-auto bg-surface">
@ -568,24 +615,14 @@ export const DashboardView = (): React.JSX.Element => {
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted"> <h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'} {searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
</h2> </h2>
<div className="flex items-center gap-3"> {searchQuery.trim() && (
{searchQuery.trim() && (
<button
onClick={() => setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
Clear search
</button>
)}
<button <button
onClick={() => openSettingsTab('general')} onClick={() => setSearchQuery('')}
className="flex items-center gap-1.5 text-xs text-text-muted transition-colors hover:text-text-secondary" className="text-xs text-text-muted transition-colors hover:text-text-secondary"
title="Change Claude data folder"
> >
<Settings className="size-3" /> Clear search
Change default folder
</button> </button>
</div> )}
</div> </div>
{/* Projects Grid */} {/* Projects Grid */}

View file

@ -16,7 +16,6 @@ import { useShallow } from 'zustand/react/shallow';
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions'; import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
import { GlobalTaskList } from '../sidebar/GlobalTaskList'; import { GlobalTaskList } from '../sidebar/GlobalTaskList';
import { TaskFiltersPopover } from '../sidebar/TaskFiltersPopover';
import { defaultTaskFiltersState } from '../sidebar/taskFiltersState'; import { defaultTaskFiltersState } from '../sidebar/taskFiltersState';
import { SidebarHeader } from './SidebarHeader'; import { SidebarHeader } from './SidebarHeader';
@ -30,13 +29,9 @@ const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 280; const DEFAULT_WIDTH = 280;
export const Sidebar = (): React.JSX.Element => { export const Sidebar = (): React.JSX.Element => {
const { projects, projectsLoading, fetchProjects, sidebarCollapsed, teams } = useStore( const { sidebarCollapsed } = useStore(
useShallow((s) => ({ useShallow((s) => ({
projects: s.projects,
projectsLoading: s.projectsLoading,
fetchProjects: s.fetchProjects,
sidebarCollapsed: s.sidebarCollapsed, sidebarCollapsed: s.sidebarCollapsed,
teams: s.teams,
})) }))
); );
const [width, setWidth] = useState(DEFAULT_WIDTH); const [width, setWidth] = useState(DEFAULT_WIDTH);
@ -46,13 +41,6 @@ export const Sidebar = (): React.JSX.Element => {
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false); const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null); const sidebarRef = useRef<HTMLDivElement>(null);
// Fetch projects on mount if not loaded
useEffect(() => {
if (projects.length === 0 && !projectsLoading) {
void fetchProjects();
}
}, [projects.length, projectsLoading, fetchProjects]);
// Handle mouse move during resize // Handle mouse move during resize
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
@ -167,18 +155,7 @@ export const Sidebar = (): React.JSX.Element => {
Sessions Sessions
</button> </button>
</div> </div>
<div className="flex flex-1 justify-end pb-0.5"> <div className="flex-1" />
{sidebarTab === 'tasks' && (
<TaskFiltersPopover
open={taskFiltersPopoverOpen}
onOpenChange={setTaskFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={taskFilters}
onFiltersChange={setTaskFilters}
onApply={() => {}}
/>
)}
</div>
</div> </div>
{/* Content: Tasks list or Sessions list */} {/* Content: Tasks list or Sessions list */}

View file

@ -1,249 +1,32 @@
/** /**
* SidebarHeader - Linear-style header with project name and worktree selector. * SidebarHeader - Minimal header with logo and collapse button.
* *
* Layout (2 stacked horizontal bars): * Layout:
* - Row 1: Project name (left-aligned after macOS traffic lights) * - Row 1: Logo (left, after macOS traffic lights) + Collapse button (right)
* - Row 2: Worktree selector (full-width button)
*
* Visual requirements:
* - Row 1 is the drag region for window movement * - Row 1 is the drag region for window movement
* - Row 1 reserves left space for macOS traffic lights via shared layout CSS variable
* - Row 2 is a full-width button with no side margins
*/ */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useState } from 'react';
import { isElectronMode } from '@renderer/api'; import { isElectronMode } from '@renderer/api';
import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatShortcut, truncateMiddle } from '@renderer/utils/stringUtils'; import { formatShortcut } from '@renderer/utils/stringUtils';
import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react'; import { PanelLeft } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { AppLogo } from '../common/AppLogo'; import { AppLogo } from '../common/AppLogo';
import { WorktreeBadge } from '../common/WorktreeBadge';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import type { Worktree, WorktreeSource } from '@renderer/types/data';
/**
* Group worktrees by source for organized dropdown display.
* Returns: main worktree first, then groups sorted by most recent activity.
*/
interface WorktreeGroup {
source: WorktreeSource;
label: string;
worktrees: Worktree[];
mostRecent: number;
}
const SOURCE_LABELS: Record<WorktreeSource, string> = {
'vibe-kanban': 'Vibe Kanban',
conductor: 'Conductor',
'auto-claude': 'Auto Claude',
'21st': '21st',
'claude-desktop': 'Claude Desktop',
ccswitch: 'ccswitch',
git: 'Git',
unknown: 'Other',
};
function groupWorktreesBySource(worktrees: Worktree[]): {
mainWorktree: Worktree | null;
groups: WorktreeGroup[];
} {
// Find main worktree
const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null;
// Group remaining worktrees by source
const groupMap = new Map<WorktreeSource, Worktree[]>();
for (const wt of worktrees) {
if (wt.isMainWorktree) continue; // Skip main, handled separately
const existing = groupMap.get(wt.source) ?? [];
existing.push(wt);
groupMap.set(wt.source, existing);
}
// Convert to array and sort each group internally by most recent
const groups: WorktreeGroup[] = [];
for (const [source, wts] of groupMap) {
// Sort worktrees within group by most recent
const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0));
groups.push({
source,
label: SOURCE_LABELS[source] ?? source,
worktrees: sorted,
mostRecent,
});
}
// Sort groups by most recent activity
groups.sort((a, b) => b.mostRecent - a.mostRecent);
return { mainWorktree, groups };
}
/**
* Individual worktree item in the dropdown.
*/
interface WorktreeItemProps {
worktree: Worktree;
isSelected: boolean;
onSelect: () => void;
}
const WorktreeItem = ({
worktree,
isSelected,
onSelect,
}: Readonly<WorktreeItemProps>): React.JSX.Element => {
const [isHovered, setIsHovered] = useState(false);
const buttonStyle: React.CSSProperties = isSelected
? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }
: {
backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent',
opacity: isHovered ? 0.5 : 1,
};
return (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-left transition-colors"
style={buttonStyle}
>
<GitBranch
className="size-3.5 shrink-0"
style={{ color: isSelected ? '#34d399' : 'var(--color-text-muted)' }}
/>
{/* Only show badge for main worktree - others are grouped by header */}
{worktree.isMainWorktree && <WorktreeBadge source={worktree.source} isMain />}
<span
className="flex-1 truncate font-mono text-xs"
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{truncateMiddle(worktree.name, 28)}
</span>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{worktree.sessions.length}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};
export const SidebarHeader = (): React.JSX.Element => { export const SidebarHeader = (): React.JSX.Element => {
const isMacElectron = const isMacElectron =
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac'); isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
const { const { toggleSidebar } = useStore(
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
selectWorktree,
selectRepository,
viewMode,
projects,
activeProjectId,
setActiveProject,
clearActiveProject,
fetchRepositoryGroups,
fetchProjects,
toggleSidebar,
} = useStore(
useShallow((s) => ({ useShallow((s) => ({
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
selectWorktree: s.selectWorktree,
selectRepository: s.selectRepository,
viewMode: s.viewMode,
projects: s.projects,
activeProjectId: s.activeProjectId,
setActiveProject: s.setActiveProject,
clearActiveProject: s.clearActiveProject,
fetchRepositoryGroups: s.fetchRepositoryGroups,
fetchProjects: s.fetchProjects,
toggleSidebar: s.toggleSidebar, toggleSidebar: s.toggleSidebar,
})) }))
); );
// Fetch data on mount based on view mode
useEffect(() => {
if (viewMode === 'grouped' && repositoryGroups.length === 0) {
void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0) {
void fetchProjects();
}
}, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]);
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const worktreeDropdownRef = useRef<HTMLDivElement>(null);
// Find the active repository and worktree
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
// Filter worktrees to only show those with sessions
const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0);
const hasMultipleWorktrees = worktrees.length > 1;
// Group worktrees by source for organized dropdown
const worktreeGroupingResult = groupWorktreesBySource(worktrees);
const mainWorktree = worktreeGroupingResult.mainWorktree;
const worktreeGroups = worktreeGroupingResult.groups;
const worktreeName = activeWorktree?.name ?? 'main';
const handleSelectWorktree = (worktree: Worktree): void => {
selectWorktree(worktree.id);
setIsWorktreeDropdownOpen(false);
};
const handleProjectValueChange = (id: string): void => {
if (viewMode === 'grouped') selectRepository(id);
else setActiveProject(id);
};
// Items for project combobox - filter out repositories/projects with 0 sessions
const projectItems =
viewMode === 'grouped'
? repositoryGroups.filter((r) => r.totalSessions > 0)
: projects.filter((p) => p.sessions.length > 0);
const projectComboboxOptions = useMemo((): ComboboxOption[] => {
const items =
viewMode === 'grouped'
? repositoryGroups.filter((r) => r.totalSessions > 0)
: projects.filter((p) => p.sessions.length > 0);
return items.map((item) => {
const sessionCount =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).totalSessions
: (item as (typeof projects)[0]).sessions.length;
const path =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path
: (item as (typeof projects)[0]).path;
return {
value: item.id,
label: item.name,
description: path,
meta: { sessionCount, path },
};
});
}, [viewMode, repositoryGroups, projects]);
const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId;
const [isCollapseHovered, setIsCollapseHovered] = useState(false); const [isCollapseHovered, setIsCollapseHovered] = useState(false);
return ( return (
@ -251,7 +34,6 @@ export const SidebarHeader = (): React.JSX.Element => {
className="flex w-full flex-col" className="flex w-full flex-col"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }} style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
> >
{/* ROW 1: Logo in corner, project selector fills width, collapse button */}
<div <div
className="flex select-none items-center gap-1.5 pr-1" className="flex select-none items-center gap-1.5 pr-1"
style={ style={
@ -265,58 +47,7 @@ export const SidebarHeader = (): React.JSX.Element => {
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}> <div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<AppLogo size={22} className="shrink-0" /> <AppLogo size={22} className="shrink-0" />
</div> </div>
<div <div className="flex-1" />
className="min-w-0 flex-1"
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
>
<Combobox
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
placeholder="Select Project"
searchPlaceholder="Search..."
emptyMessage={
projectItems.length === 0
? `No ${viewMode === 'grouped' ? 'repositories' : 'projects'} found`
: 'Nothing found'
}
className="text-sm font-medium"
resetLabel="Reset selection"
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined;
return (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'text-indigo-400 opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate',
isSelected
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
>
{option.label}
</p>
{path ? (
<p className="truncate text-[10px] text-[var(--color-text-muted)]">{path}</p>
) : null}
</div>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{sessionCount}
</span>
</>
);
}}
/>
</div>
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
onMouseEnter={() => setIsCollapseHovered(true)} onMouseEnter={() => setIsCollapseHovered(true)}
@ -334,109 +65,6 @@ export const SidebarHeader = (): React.JSX.Element => {
<PanelLeft className="size-4" /> <PanelLeft className="size-4" />
</button> </button>
</div> </div>
{/* ROW 2: Worktree Selector (Full Width) */}
{viewMode === 'grouped' && activeRepo && (
<div ref={worktreeDropdownRef} className="relative w-full">
<button
onClick={() =>
hasMultipleWorktrees && setIsWorktreeDropdownOpen(!isWorktreeDropdownOpen)
}
disabled={!hasMultipleWorktrees}
className={`flex w-full items-center justify-between px-4 text-left transition-colors ${hasMultipleWorktrees ? 'cursor-pointer' : 'cursor-default'}`}
style={{
height: `${HEADER_ROW2_HEIGHT}px`,
backgroundColor: isWorktreeDropdownOpen
? 'var(--color-surface-raised)'
: 'var(--color-surface-sidebar)',
color: isWorktreeDropdownOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
}}
>
<div className="flex flex-1 items-center gap-1.5 overflow-hidden">
<GitBranch
className="size-4 shrink-0"
style={{ color: isWorktreeDropdownOpen ? '#34d399' : 'rgba(52, 211, 153, 0.7)' }}
/>
{activeWorktree?.isMainWorktree ? (
<WorktreeBadge source={activeWorktree.source} isMain />
) : (
activeWorktree?.source && <WorktreeBadge source={activeWorktree.source} />
)}
<span className="truncate font-mono text-xs">{truncateMiddle(worktreeName, 28)}</span>
</div>
{hasMultipleWorktrees && (
<ChevronDown
className={`size-4 shrink-0 transition-transform ${isWorktreeDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
)}
</button>
{/* Worktree Dropdown */}
{isWorktreeDropdownOpen && hasMultipleWorktrees && (
<>
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsWorktreeDropdownOpen(false)}
/>
<div
className="absolute inset-x-0 top-full z-20 mt-0 max-h-[400px] overflow-y-auto py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderTopWidth: '0',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
<div
className="px-4 py-2 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch Worktree
</div>
{/* Main worktree first */}
{mainWorktree && (
<WorktreeItem
worktree={mainWorktree}
isSelected={mainWorktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(mainWorktree)}
/>
)}
{/* Grouped worktrees by source */}
{worktreeGroups.map((group) => (
<div key={group.source}>
{/* Group header */}
<div
className="mt-1 px-4 py-1.5 text-[9px] font-medium uppercase tracking-wider"
style={{
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{group.label}
</div>
{/* Worktrees in group */}
{group.worktrees.map((worktree) => (
<WorktreeItem
key={worktree.id}
worktree={worktree}
isSelected={worktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(worktree)}
/>
))}
</div>
))}
</div>
</>
)}
</div>
)}
</div> </div>
); );
}; };

View file

@ -46,7 +46,7 @@ export const GeneralInfoSection = ({
{/* Scope/Tool Name */} {/* Scope/Tool Name */}
<div className="flex items-center justify-between border-b border-border-subtle py-2"> <div className="flex items-center justify-between border-b border-border-subtle py-2">
<label htmlFor="new-trigger-tool-name" className="text-sm text-text-secondary"> <label htmlFor="new-trigger-tool-name" className="label-optional text-sm">
Scope / Tool Name (optional) Scope / Tool Name (optional)
</label> </label>
<select <select

View file

@ -7,19 +7,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { import {
getNonEmptyCategories, getNonEmptyCategories,
groupSessionsByDate, groupSessionsByDate,
separatePinnedSessions, separatePinnedSessions,
} from '@renderer/utils/dateGrouping'; } from '@renderer/utils/dateGrouping';
import { truncateMiddle } from '@renderer/utils/stringUtils';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { import {
ArrowDownWideNarrow, ArrowDownWideNarrow,
Calendar, Calendar,
Check,
CheckSquare, CheckSquare,
ChevronDown,
Eye, Eye,
EyeOff, EyeOff,
GitBranch,
Loader2, Loader2,
MessageSquareOff, MessageSquareOff,
Pin, Pin,
@ -27,11 +32,109 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { WorktreeBadge } from '../common/WorktreeBadge';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { SessionItem } from './SessionItem'; import { SessionItem } from './SessionItem';
import type { Session } from '@renderer/types/data'; import type { Session, Worktree, WorktreeSource } from '@renderer/types/data';
import type { DateCategory } from '@renderer/types/tabs'; import type { DateCategory } from '@renderer/types/tabs';
// ---------------------------------------------------------------------------
// Worktree grouping helpers (moved from SidebarHeader)
// ---------------------------------------------------------------------------
interface WorktreeGroup {
source: WorktreeSource;
label: string;
worktrees: Worktree[];
mostRecent: number;
}
const SOURCE_LABELS: Record<WorktreeSource, string> = {
'vibe-kanban': 'Vibe Kanban',
conductor: 'Conductor',
'auto-claude': 'Auto Claude',
'21st': '21st',
'claude-desktop': 'Claude Desktop',
ccswitch: 'ccswitch',
git: 'Git',
unknown: 'Other',
};
function groupWorktreesBySource(worktrees: Worktree[]): {
mainWorktree: Worktree | null;
groups: WorktreeGroup[];
} {
const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null;
const groupMap = new Map<WorktreeSource, Worktree[]>();
for (const wt of worktrees) {
if (wt.isMainWorktree) continue;
const existing = groupMap.get(wt.source) ?? [];
existing.push(wt);
groupMap.set(wt.source, existing);
}
const groups: WorktreeGroup[] = [];
for (const [source, wts] of groupMap) {
const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0));
groups.push({ source, label: SOURCE_LABELS[source] ?? source, worktrees: sorted, mostRecent });
}
groups.sort((a, b) => b.mostRecent - a.mostRecent);
return { mainWorktree, groups };
}
// ---------------------------------------------------------------------------
// WorktreeItem (inline, moved from SidebarHeader)
// ---------------------------------------------------------------------------
const WorktreeItem = ({
worktree,
isSelected,
onSelect,
}: {
worktree: Worktree;
isSelected: boolean;
onSelect: () => void;
}): React.JSX.Element => {
const [isHovered, setIsHovered] = useState(false);
const buttonStyle: React.CSSProperties = isSelected
? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' }
: {
backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent',
opacity: isHovered ? 0.5 : 1,
};
return (
<button
onClick={onSelect}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="flex w-full items-center gap-1.5 px-4 py-1.5 text-left transition-colors"
style={buttonStyle}
>
<GitBranch
className="size-3.5 shrink-0"
style={{ color: isSelected ? '#34d399' : 'var(--color-text-muted)' }}
/>
{worktree.isMainWorktree && <WorktreeBadge source={worktree.source} isMain />}
<span
className="flex-1 truncate font-mono text-xs"
style={{ color: isSelected ? 'var(--color-text)' : 'var(--color-text-muted)' }}
>
{truncateMiddle(worktree.name, 28)}
</span>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{worktree.sessions.length}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
);
};
// Virtual list item types // Virtual list item types
type VirtualItem = type VirtualItem =
| { type: 'header'; category: DateCategory; id: string } | { type: 'header'; category: DateCategory; id: string }
@ -74,6 +177,19 @@ export const DateGroupedSessions = (): React.JSX.Element => {
hideMultipleSessions, hideMultipleSessions,
unhideMultipleSessions, unhideMultipleSessions,
pinMultipleSessions, pinMultipleSessions,
// Project / repository state
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
selectWorktree,
selectRepository,
viewMode,
projects,
activeProjectId,
setActiveProject,
clearActiveProject,
fetchRepositoryGroups,
fetchProjects,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
sessions: s.sessions, sessions: s.sessions,
@ -98,12 +214,82 @@ export const DateGroupedSessions = (): React.JSX.Element => {
hideMultipleSessions: s.hideMultipleSessions, hideMultipleSessions: s.hideMultipleSessions,
unhideMultipleSessions: s.unhideMultipleSessions, unhideMultipleSessions: s.unhideMultipleSessions,
pinMultipleSessions: s.pinMultipleSessions, pinMultipleSessions: s.pinMultipleSessions,
// Project / repository
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
selectWorktree: s.selectWorktree,
selectRepository: s.selectRepository,
viewMode: s.viewMode,
projects: s.projects,
activeProjectId: s.activeProjectId,
setActiveProject: s.setActiveProject,
clearActiveProject: s.clearActiveProject,
fetchRepositoryGroups: s.fetchRepositoryGroups,
fetchProjects: s.fetchProjects,
})) }))
); );
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const countRef = useRef<HTMLSpanElement>(null); const countRef = useRef<HTMLSpanElement>(null);
const [showCountTooltip, setShowCountTooltip] = useState(false); const [showCountTooltip, setShowCountTooltip] = useState(false);
const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false);
const worktreeDropdownRef = useRef<HTMLDivElement>(null);
// Fetch project data on mount
useEffect(() => {
if (viewMode === 'grouped' && repositoryGroups.length === 0) {
void fetchRepositoryGroups();
} else if (viewMode === 'flat' && projects.length === 0) {
void fetchProjects();
}
}, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]);
// Project combobox options
const projectComboboxOptions = useMemo((): ComboboxOption[] => {
const items =
viewMode === 'grouped'
? repositoryGroups.filter((r) => r.totalSessions > 0)
: projects.filter((p) => p.sessions.length > 0);
return items.map((item) => {
const sessionCount =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).totalSessions
: (item as (typeof projects)[0]).sessions.length;
const path =
viewMode === 'grouped'
? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path
: (item as (typeof projects)[0]).path;
return {
value: item.id,
label: item.name,
description: path,
meta: { sessionCount, path },
};
});
}, [viewMode, repositoryGroups, projects]);
const activeProjectValue = viewMode === 'grouped' ? selectedRepositoryId : activeProjectId;
const handleProjectValueChange = (id: string): void => {
if (viewMode === 'grouped') selectRepository(id);
else setActiveProject(id);
};
// Worktree state
const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId);
const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0);
const hasMultipleWorktrees = worktrees.length > 1;
const worktreeGroupingResult = useMemo(() => groupWorktreesBySource(worktrees), [worktrees]);
const mainWorktree = worktreeGroupingResult.mainWorktree;
const worktreeGroups = worktreeGroupingResult.groups;
const worktreeName = activeWorktree?.name ?? 'main';
const handleSelectWorktree = (worktree: Worktree): void => {
selectWorktree(worktree.id);
setIsWorktreeDropdownOpen(false);
};
const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]); const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]);
const hasHiddenSessions = hiddenSessionIds.length > 0; const hasHiddenSessions = hiddenSessionIds.length > 0;
@ -295,11 +481,158 @@ export const DateGroupedSessions = (): React.JSX.Element => {
clearSidebarSelection(); clearSidebarSelection();
}, [pinMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]); }, [pinMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]);
// Project selector (always rendered at top)
const projectSelector = (
<div className="shrink-0 space-y-0">
{/* Project combobox */}
<div className="px-2 py-1.5">
<Combobox
options={projectComboboxOptions}
value={activeProjectValue ?? ''}
onValueChange={handleProjectValueChange}
placeholder="Select Project"
searchPlaceholder="Search..."
emptyMessage="Nothing found"
className="text-[12px]"
resetLabel="Reset selection"
onReset={clearActiveProject}
renderOption={(option, isSelected) => {
const sessionCount = (option.meta?.sessionCount as number) ?? 0;
const path = option.meta?.path as string | undefined;
return (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'text-indigo-400 opacity-100' : 'opacity-0'
)}
/>
<div className="min-w-0 flex-1">
<p
className={cn(
'truncate',
isSelected
? 'font-medium text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]'
)}
>
{option.label}
</p>
{path ? (
<p className="truncate text-[10px] text-[var(--color-text-muted)]">{path}</p>
) : null}
</div>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{sessionCount}
</span>
</>
);
}}
/>
</div>
{/* Worktree selector (grouped mode only, when multiple worktrees) */}
{viewMode === 'grouped' && activeRepo && hasMultipleWorktrees && (
<div ref={worktreeDropdownRef} className="relative w-full">
<button
onClick={() => setIsWorktreeDropdownOpen(!isWorktreeDropdownOpen)}
className="flex w-full items-center justify-between px-3 py-1 text-left transition-colors"
style={{
backgroundColor: isWorktreeDropdownOpen
? 'var(--color-surface-raised)'
: 'transparent',
color: isWorktreeDropdownOpen ? 'var(--color-text)' : 'var(--color-text-muted)',
}}
>
<div className="flex flex-1 items-center gap-1.5 overflow-hidden">
<GitBranch
className="size-3.5 shrink-0"
style={{ color: isWorktreeDropdownOpen ? '#34d399' : 'rgba(52, 211, 153, 0.7)' }}
/>
{activeWorktree?.isMainWorktree ? (
<WorktreeBadge source={activeWorktree.source} isMain />
) : (
activeWorktree?.source && <WorktreeBadge source={activeWorktree.source} />
)}
<span className="truncate font-mono text-[11px]">
{truncateMiddle(worktreeName, 24)}
</span>
</div>
<ChevronDown
className={`size-3.5 shrink-0 transition-transform ${isWorktreeDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
</button>
{isWorktreeDropdownOpen && (
<>
<div
role="presentation"
className="fixed inset-0 z-10"
onClick={() => setIsWorktreeDropdownOpen(false)}
/>
<div
className="absolute inset-x-0 top-full z-20 mt-0 max-h-[300px] overflow-y-auto py-1 shadow-xl"
style={{
backgroundColor: 'var(--color-surface-sidebar)',
borderWidth: '1px',
borderTopWidth: '0',
borderStyle: 'solid',
borderColor: 'var(--color-border)',
}}
>
<div
className="px-4 py-1.5 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Switch Worktree
</div>
{mainWorktree && (
<WorktreeItem
worktree={mainWorktree}
isSelected={mainWorktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(mainWorktree)}
/>
)}
{worktreeGroups.map((group) => (
<div key={group.source}>
<div
className="mt-1 px-4 py-1.5 text-[9px] font-medium uppercase tracking-wider"
style={{
borderTopWidth: '1px',
borderTopStyle: 'solid',
borderTopColor: 'var(--color-border)',
color: 'var(--color-text-muted)',
}}
>
{group.label}
</div>
{group.worktrees.map((worktree) => (
<WorktreeItem
key={worktree.id}
worktree={worktree}
isSelected={worktree.id === selectedWorktreeId}
onSelect={() => handleSelectWorktree(worktree)}
/>
))}
</div>
))}
</div>
</>
)}
</div>
)}
</div>
);
if (!selectedProjectId) { if (!selectedProjectId) {
return ( return (
<div className="p-4"> <div className="flex h-full flex-col">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}> {projectSelector}
<p>Select a project to view sessions</p> <div className="flex flex-1 items-center justify-center p-4">
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<p>Select a project to view sessions</p>
</div>
</div> </div>
</div> </div>
); );
@ -313,8 +646,9 @@ export const DateGroupedSessions = (): React.JSX.Element => {
]; ];
return ( return (
<div className="p-4"> <div className="flex h-full flex-col">
<div className="space-y-3"> {projectSelector}
<div className="space-y-3 p-4">
{widths.map((w, i) => ( {widths.map((w, i) => (
<div key={i} className="space-y-2"> <div key={i} className="space-y-2">
<div <div
@ -338,19 +672,22 @@ export const DateGroupedSessions = (): React.JSX.Element => {
if (sessionsError) { if (sessionsError) {
return ( return (
<div className="p-4"> <div className="flex h-full flex-col">
<div {projectSelector}
className="rounded-lg border p-3 text-sm" <div className="p-4">
style={{ <div
borderColor: 'var(--color-border)', className="rounded-lg border p-3 text-sm"
backgroundColor: 'var(--color-surface-raised)', style={{
color: 'var(--color-text-muted)', borderColor: 'var(--color-border)',
}} backgroundColor: 'var(--color-surface-raised)',
> color: 'var(--color-text-muted)',
<p className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}> }}
Error loading sessions >
</p> <p className="mb-1 font-semibold" style={{ color: 'var(--color-text)' }}>
<p>{sessionsError}</p> Error loading sessions
</p>
<p>{sessionsError}</p>
</div>
</div> </div>
</div> </div>
); );
@ -358,11 +695,14 @@ export const DateGroupedSessions = (): React.JSX.Element => {
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="p-4"> <div className="flex h-full flex-col">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}> {projectSelector}
<MessageSquareOff className="mx-auto mb-2 size-8 opacity-50" /> <div className="flex flex-1 items-center justify-center p-4">
<p className="mb-2">No sessions found</p> <div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<p className="text-xs opacity-70">This project has no sessions yet</p> <MessageSquareOff className="mx-auto mb-2 size-8 opacity-50" />
<p className="mb-2">No sessions found</p>
<p className="text-xs opacity-70">This project has no sessions yet</p>
</div>
</div> </div>
</div> </div>
); );
@ -370,7 +710,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<div className="mt-2 flex items-center gap-2 px-4 py-3"> {projectSelector}
<div className="flex items-center gap-2 px-4 py-2">
<Calendar className="size-4" style={{ color: 'var(--color-text-muted)' }} /> <Calendar className="size-4" style={{ color: 'var(--color-text-muted)' }} />
<h2 <h2
className="text-xs uppercase tracking-wider" className="text-xs uppercase tracking-wider"

View file

@ -3,14 +3,18 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
import { import {
getNonEmptyTaskCategories, getNonEmptyTaskCategories,
groupTasksByDate, groupTasksByDate,
groupTasksByProject, groupTasksByProject,
sortTasksByFreshness,
} from '@renderer/utils/taskGrouping'; } from '@renderer/utils/taskGrouping';
import { ListTodo, Search, X } from 'lucide-react'; import { ListTodo, Search, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { SidebarTaskItem } from './SidebarTaskItem'; import { SidebarTaskItem } from './SidebarTaskItem';
import { TaskFiltersPopover } from './TaskFiltersPopover'; import { TaskFiltersPopover } from './TaskFiltersPopover';
import { import {
@ -25,16 +29,16 @@ import type { GlobalTask } from '@shared/types';
const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping'; const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping';
export type TaskGroupingMode = 'project' | 'time'; export type TaskGroupingMode = 'none' | 'project' | 'time';
function loadGroupingMode(): TaskGroupingMode { function loadGroupingMode(): TaskGroupingMode {
try { try {
const v = localStorage.getItem(TASK_GROUPING_STORAGE_KEY); const v = localStorage.getItem(TASK_GROUPING_STORAGE_KEY);
if (v === 'project' || v === 'time') return v; if (v === 'none' || v === 'project' || v === 'time') return v;
} catch { } catch {
/* ignore */ /* ignore */
} }
return 'project'; return 'none';
} }
function saveGroupingMode(mode: TaskGroupingMode): void { function saveGroupingMode(mode: TaskGroupingMode): void {
@ -89,11 +93,8 @@ export const GlobalTaskList = ({
globalTasksLoading, globalTasksLoading,
fetchAllTasks, fetchAllTasks,
projects, projects,
activeProjectId,
viewMode, viewMode,
repositoryGroups, repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
teams, teams,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
@ -101,11 +102,8 @@ export const GlobalTaskList = ({
globalTasksLoading: s.globalTasksLoading, globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
projects: s.projects, projects: s.projects,
activeProjectId: s.activeProjectId,
viewMode: s.viewMode, viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups, repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
teams: s.teams, teams: s.teams,
})) }))
); );
@ -122,6 +120,9 @@ export const GlobalTaskList = ({
const hasFetchedRef = useRef(false); const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot(); const readState = useReadStateSnapshot();
// Local project filter (independent from sessions tab)
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
const setGroupingMode = (mode: TaskGroupingMode): void => { const setGroupingMode = (mode: TaskGroupingMode): void => {
setGroupingModeState(mode); setGroupingModeState(mode);
saveGroupingMode(mode); saveGroupingMode(mode);
@ -134,22 +135,34 @@ export const GlobalTaskList = ({
} }
}, [fetchAllTasks]); }, [fetchAllTasks]);
const selectedProjectPath = useMemo(() => { // Build project combobox options from available projects/repos
if (viewMode === 'grouped') { const projectFilterOptions = useMemo((): ComboboxOption[] => {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId); const items =
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId); viewMode === 'grouped'
return worktree?.path ?? null; ? repositoryGroups
} .filter((r) => r.totalSessions > 0)
const project = projects.find((p) => p.id === activeProjectId); .map((r) => ({
return project?.path ?? null; value: r.worktrees[0]?.path ?? r.id,
}, [ label: r.name,
viewMode, path: r.worktrees[0]?.path,
repositoryGroups, }))
selectedRepositoryId, : projects
selectedWorktreeId, .filter((p) => p.sessions.length > 0)
projects, .map((p) => ({
activeProjectId, value: p.path,
]); label: p.name,
path: p.path,
}));
return items.map((item) => ({
value: item.value,
label: item.label,
description: item.path,
}));
}, [viewMode, repositoryGroups, projects]);
// Resolve local filter to a project path
const selectedProjectPath = localProjectFilter;
const filtered = useMemo(() => { const filtered = useMemo(() => {
let result = globalTasks; let result = globalTasks;
@ -175,35 +188,32 @@ export const GlobalTaskList = ({
readState, readState,
]); ]);
const sortedFlat = useMemo(() => sortTasksByFreshness(filtered), [filtered]);
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]); const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
const projectGroups = useMemo(() => groupTasksByProject(filtered), [filtered]); const projectGroups = useMemo(() => groupTasksByProject(filtered), [filtered]);
const hasContent = const hasContent =
groupingMode === 'time' ? categories.length > 0 : projectGroups.some((g) => g.tasks.length > 0); groupingMode === 'none'
? sortedFlat.length > 0
: groupingMode === 'time'
? categories.length > 0
: projectGroups.some((g) => g.tasks.length > 0);
return ( return (
<div className="flex size-full min-w-0 flex-col"> <div className="flex size-full min-w-0 flex-col">
{!hideHeader && ( {!hideHeader && (
<div <div
className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5" className="flex shrink-0 items-center gap-2 border-b px-3 py-1.5"
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
> >
<span className="text-[12px] font-semibold text-text-secondary">Tasks</span> <span className="text-[12px] font-semibold text-text-secondary">Tasks</span>
<TaskFiltersPopover
open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={filters}
onFiltersChange={setFilters}
onApply={() => {}}
/>
</div> </div>
)} )}
{/* Search bar */} {/* Search bar */}
<div <div
className="flex shrink-0 items-center gap-1.5 border-b px-2 py-1" className="mb-[5px] flex shrink-0 items-center gap-1.5 border-b px-2 py-1"
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
> >
<Search className="size-3 shrink-0 text-text-muted" /> <Search className="size-3 shrink-0 text-text-muted" />
@ -227,6 +237,29 @@ export const GlobalTaskList = ({
<X className="size-3" /> <X className="size-3" />
</button> </button>
)} )}
<TaskFiltersPopover
open={filtersPopoverOpen}
onOpenChange={setFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={filters}
onFiltersChange={setFilters}
onApply={() => {}}
/>
</div>
{/* Project filter */}
<div className="shrink-0 px-2 py-1">
<Combobox
options={projectFilterOptions}
value={localProjectFilter ?? ''}
onValueChange={(v) => setLocalProjectFilter(v)}
placeholder="All Projects"
searchPlaceholder="Search projects..."
emptyMessage="No projects"
className="text-[11px]"
resetLabel="All Projects"
onReset={() => setLocalProjectFilter(null)}
/>
</div> </div>
{/* Grouping mode — compact segmented toggle */} {/* Grouping mode — compact segmented toggle */}
@ -237,21 +270,24 @@ export const GlobalTaskList = ({
role="group" role="group"
aria-label="Group by" aria-label="Group by"
> >
{(['project', 'time'] as const).map((mode) => ( {(['none', 'project', 'time'] as const).map((mode) => {
<button const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
key={mode} return (
type="button" <button
onClick={() => setGroupingMode(mode)} key={mode}
className={cn( type="button"
'rounded px-2 py-0.5 transition-colors', onClick={() => setGroupingMode(mode)}
groupingMode === mode className={cn(
? 'bg-surface-raised text-text shadow-sm' 'rounded px-2 py-0.5 transition-colors',
: 'text-text-muted hover:text-text-secondary' groupingMode === mode
)} ? 'bg-surface-raised text-text shadow-sm'
> : 'text-text-muted hover:text-text-secondary'
{mode === 'project' ? 'Project' : 'Time'} )}
</button> >
))} {label}
</button>
);
})}
</div> </div>
</div> </div>
@ -274,6 +310,11 @@ export const GlobalTaskList = ({
</div> </div>
)} )}
{groupingMode === 'none' &&
sortedFlat.map((task) => (
<SidebarTaskItem key={`${task.teamName}-${task.id}`} task={task} showTeamName />
))}
{groupingMode === 'project' && {groupingMode === 'project' &&
projectGroups.map((group) => { projectGroups.map((group) => {
if (group.tasks.length === 0) return null; if (group.tasks.length === 0) return null;
@ -281,10 +322,16 @@ export const GlobalTaskList = ({
return ( return (
<div key={group.projectKey}> <div key={group.projectKey}>
<div <div
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary" className="sticky top-0 z-10 flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }} style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
> >
{group.projectLabel} <span
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: projectColor(group.projectLabel).border }}
/>
<span style={{ color: projectColor(group.projectLabel).text }}>
{group.projectLabel}
</span>
</div> </div>
{group.tasks.map((task) => { {group.tasks.map((task) => {
const showTeamHeader = task.teamName !== lastTeam; const showTeamHeader = task.teamName !== lastTeam;

View file

@ -1,7 +1,14 @@
import { useMemo } from 'react';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { nameColorSet } from '@renderer/utils/projectColor';
import { projectColor } from '@renderer/utils/projectColor';
import { projectLabelFromPath } from '@renderer/utils/taskGrouping';
import { format, isThisYear, isToday, isYesterday } from 'date-fns'; import { format, isThisYear, isToday, isYesterday } from 'date-fns';
import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck } from 'lucide-react'; import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck, Trash2 } from 'lucide-react';
import type { GlobalTask, TeamTaskStatus } from '@shared/types'; import type { GlobalTask, TeamTaskStatus } from '@shared/types';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
@ -47,13 +54,16 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
interface SidebarTaskItemProps { interface SidebarTaskItemProps {
task: GlobalTask; task: GlobalTask;
hideTeamName?: boolean; hideTeamName?: boolean;
showTeamName?: boolean;
} }
export const SidebarTaskItem = ({ export const SidebarTaskItem = ({
task, task,
hideTeamName, hideTeamName,
showTeamName,
}: SidebarTaskItemProps): React.JSX.Element => { }: SidebarTaskItemProps): React.JSX.Element => {
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
const teamMembers = useStore((s) => s.teams.find((t) => t.teamName === task.teamName)?.members);
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
const cfg = const cfg =
task.kanbanColumn === 'approved' task.kanbanColumn === 'approved'
@ -65,14 +75,40 @@ export const SidebarTaskItem = ({
const updatedLabel = formatUpdatedLabel(task); const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
const ownerColorSet = useMemo(() => {
if (!teamMembers || !task.owner) return null;
const colorMap = buildMemberColorMap(teamMembers);
const colorName = colorMap.get(task.owner);
return colorName ? getTeamColorSet(colorName) : null;
}, [teamMembers, task.owner]);
const projectLabel = useMemo(() => {
if (!task.projectPath?.trim()) return null;
return projectLabelFromPath(task.projectPath);
}, [task.projectPath]);
const projectColorSet = useMemo(
() => (projectLabel ? projectColor(projectLabel) : null),
[projectLabel]
);
const teamColor = useMemo(
() => (showTeamName ? nameColorSet(task.teamDisplayName) : null),
[showTeamName, task.teamDisplayName]
);
const showTeamRow = showTeamName && !hideTeamName;
return ( return (
<button <button
type="button" type="button"
className="flex h-[48px] w-full cursor-pointer flex-col justify-center border-b px-3 py-2 text-left transition-colors hover:bg-surface-raised" className={`flex w-full cursor-pointer flex-col justify-center border-b px-3 py-1.5 text-left transition-colors hover:bg-surface-raised ${task.teamDeleted ? 'opacity-50' : ''}`}
style={{ borderColor: 'var(--color-border)' }} style={{ borderColor: 'var(--color-border)' }}
onClick={() => openGlobalTaskDetail(task.teamName, task.id)} onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
> >
{/* Row 1: status + subject */}
<div className="flex w-full items-center gap-1.5 overflow-hidden"> <div className="flex w-full items-center gap-1.5 overflow-hidden">
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
<span <span
className="truncate text-[13px] font-medium leading-tight" className="truncate text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
@ -85,28 +121,59 @@ export const SidebarTaskItem = ({
title={`${unreadCount} unread`} title={`${unreadCount} unread`}
/> />
)} )}
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
</div> </div>
{/* Row 2: project + owner (when no team row) + date */}
<div <div
className="mt-0.5 flex items-center gap-1.5 text-[10px] leading-tight" className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
<span>{task.owner ?? 'unassigned'}</span> {task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
{!hideTeamName && ( {projectLabel && (
<> <span
<span className="opacity-40">·</span> className="shrink-0"
<span className="truncate">{task.teamDisplayName}</span> style={projectColorSet ? { color: projectColorSet.text } : undefined}
</> >
{projectLabel}
</span>
)} )}
{dateLabel && ( {!showTeamRow && (
<> <>
<span className="opacity-40">·</span> {projectLabel && <span className="opacity-40">·</span>}
<span className={`shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}> <span
{dateLabel} className="shrink-0 opacity-60"
style={ownerColorSet ? { color: ownerColorSet.text } : undefined}
>
{task.owner ?? 'unassigned'}
</span> </span>
</> </>
)} )}
{dateLabel && (
<span className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
{dateLabel}
</span>
)}
</div> </div>
{/* Row 3: Team: name · owner */}
{showTeamRow && (
<div
className="mt-0.5 flex w-full items-center gap-1.5 text-[10px] leading-tight"
style={{ color: 'var(--color-text-muted)' }}
>
<span className="shrink-0 opacity-50">Team:</span>
<span className="shrink-0" style={teamColor ? { color: teamColor.text } : undefined}>
{task.teamDisplayName}
</span>
<span className="opacity-40">·</span>
<span
className="shrink-0 opacity-60"
style={ownerColorSet ? { color: ownerColorSet.text } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</div>
)}
</button> </button>
); );
}; };

View file

@ -54,10 +54,9 @@ export const TaskFiltersPopover = ({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className="flex items-center gap-1 rounded px-2 py-0.5 text-[11px] font-medium text-text-muted transition-colors hover:text-text-secondary data-[state=open]:bg-surface-raised data-[state=open]:text-text" className="flex shrink-0 items-center justify-center rounded p-0.5 text-text-muted transition-colors hover:text-text-secondary data-[state=open]:bg-surface-raised data-[state=open]:text-text"
> >
<Filter className="size-3" /> <Filter className="size-3.5" />
Filters
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}> <PopoverContent className="w-64 p-3" align="end" sideOffset={6}>

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { import {
Dialog, Dialog,
@ -17,9 +18,11 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { import {
AlertTriangle,
Bell, Bell,
CheckCheck, CheckCheck,
FolderOpen, FolderOpen,
@ -177,6 +180,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
kanbanFilterQuery, kanbanFilterQuery,
clearKanbanFilter, clearKanbanFilter,
softDeleteTask, softDeleteTask,
restoreTask,
fetchDeletedTasks, fetchDeletedTasks,
deletedTasks, deletedTasks,
} = useStore( } = useStore(
@ -214,6 +218,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
kanbanFilterQuery: s.kanbanFilterQuery, kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter, clearKanbanFilter: s.clearKanbanFilter,
softDeleteTask: s.softDeleteTask, softDeleteTask: s.softDeleteTask,
restoreTask: s.restoreTask,
fetchDeletedTasks: s.fetchDeletedTasks, fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks, deletedTasks: s.deletedTasks,
})) }))
@ -510,7 +515,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const handleDeleteTask = useCallback( const handleDeleteTask = useCallback(
(taskId: string) => { (taskId: string) => {
void softDeleteTask(teamName, taskId); void (async () => {
const confirmed = await confirm({
title: 'Delete task',
message: `Move task #${taskId} to trash?`,
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (confirmed) {
void softDeleteTask(teamName, taskId);
}
})();
}, },
[teamName, softDeleteTask] [teamName, softDeleteTask]
); );
@ -626,7 +642,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
); );
} }
const headerColorSet = data.config.color ? getTeamColorSet(data.config.color) : null; const headerColorSet = data.config.color
? getTeamColorSet(data.config.color)
: nameColorSet(data.config.name);
return ( return (
<div className="size-full overflow-auto p-4"> <div className="size-full overflow-auto p-4">
@ -772,7 +790,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
{!data.isAlive && !isTeamProvisioning ? ( {!data.isAlive && !isTeamProvisioning ? (
<div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2"> <div className="mb-3 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2">
<span className="text-xs text-amber-200">Team is offline</span> <span className="flex items-center gap-1.5 text-xs text-amber-200">
<AlertTriangle size={14} className="shrink-0 text-amber-400" />
Team is offline
</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -1392,7 +1413,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
onDeleteTask={handleDeleteTask} onDeleteTask={handleDeleteTask}
/> />
<TrashDialog open={trashOpen} tasks={deletedTasks} onClose={() => setTrashOpen(false)} /> <TrashDialog
open={trashOpen}
tasks={deletedTasks}
onClose={() => setTrashOpen(false)}
onRestore={(taskId) => {
void restoreTask(teamName, taskId);
}}
/>
<ChangeReviewDialog <ChangeReviewDialog
open={reviewDialogState.open} open={reviewDialogState.open}

View file

@ -0,0 +1,185 @@
/* eslint-disable react-refresh/only-export-components -- TeamListFilterState and EMPTY_TEAM_FILTER shared with TeamListView */
import { useMemo } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getBaseName } from '@renderer/utils/pathUtils';
import { Filter } from 'lucide-react';
import type { TeamSummary } from '@shared/types';
export interface TeamListFilterState {
selectedProjects: Set<string>;
selectedStatuses: Set<string>;
}
export const EMPTY_TEAM_FILTER: TeamListFilterState = {
selectedProjects: new Set(),
selectedStatuses: new Set(),
};
function folderName(fullPath: string): string {
return getBaseName(fullPath) || fullPath;
}
interface TeamListFilterPopoverProps {
filter: TeamListFilterState;
teams: TeamSummary[];
aliveTeams: string[];
onFilterChange: (filter: TeamListFilterState) => void;
}
export const TeamListFilterPopover = ({
filter,
teams,
aliveTeams,
onFilterChange,
}: TeamListFilterPopoverProps): React.JSX.Element => {
const activeCount = useMemo(() => {
let count = 0;
if (filter.selectedStatuses.size > 0) count += 1;
if (filter.selectedProjects.size > 0) count += 1;
return count;
}, [filter.selectedStatuses, filter.selectedProjects]);
const uniqueProjects = useMemo(() => {
const paths = new Set<string>();
for (const team of teams) {
if (team.projectPath?.trim()) paths.add(team.projectPath.trim());
}
return [...paths].sort((a, b) => folderName(a).localeCompare(folderName(b)));
}, [teams]);
const handleStatusToggle = (status: string): void => {
const next = new Set(filter.selectedStatuses);
if (next.has(status)) {
next.delete(status);
} else {
next.add(status);
}
onFilterChange({ ...filter, selectedStatuses: next });
};
const handleProjectToggle = (project: string): void => {
const next = new Set(filter.selectedProjects);
if (next.has(project)) {
next.delete(project);
} else {
next.add(project);
}
onFilterChange({ ...filter, selectedProjects: next });
};
const handleClearAll = (): void => {
onFilterChange(EMPTY_TEAM_FILTER);
};
const aliveSet = useMemo(() => new Set(aliveTeams), [aliveTeams]);
const runningCount = useMemo(
() => teams.filter((t) => aliveSet.has(t.teamName)).length,
[teams, aliveSet]
);
const offlineCount = useMemo(
() => teams.filter((t) => !aliveSet.has(t.teamName)).length,
[teams, aliveSet]
);
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="relative h-8 px-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
aria-label="Filter teams"
>
<Filter size={14} />
{activeCount > 0 && (
<span className="absolute -right-1 -top-1 flex size-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-medium text-white">
{activeCount}
</span>
)}
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Filter teams</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0">
{/* Status section */}
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Status
</p>
<div className="space-y-1.5">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={filter.selectedStatuses.has('running')}
onCheckedChange={() => handleStatusToggle('running')}
/>
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-emerald-400" />
Running
<span className="text-[var(--color-text-muted)]">({runningCount})</span>
</span>
</label>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Radix Checkbox renders a button, not a native input */}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={filter.selectedStatuses.has('offline')}
onCheckedChange={() => handleStatusToggle('offline')}
/>
<span className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-zinc-500" />
Offline
<span className="text-[var(--color-text-muted)]">({offlineCount})</span>
</span>
</label>
</div>
</div>
{/* Project section */}
{uniqueProjects.length > 0 && (
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Project
</p>
<div className="max-h-40 space-y-1.5 overflow-y-auto">
{uniqueProjects.map((project) => (
<label
key={project}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
title={project}
>
<Checkbox
checked={filter.selectedProjects.has(project)}
onCheckedChange={() => handleProjectToggle(project)}
/>
<span className="truncate">{folderName(project)}</span>
</label>
))}
</div>
</div>
)}
{/* Footer */}
<div className="flex justify-end p-2">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={activeCount === 0}
onClick={handleClearAll}
>
Clear all
</Button>
</div>
</PopoverContent>
</Popover>
);
};
/* eslint-enable react-refresh/only-export-components -- pair for file-level disable */

View file

@ -16,6 +16,7 @@ import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils'; import { getBaseName } from '@renderer/utils/pathUtils';
import { nameColorSet } from '@renderer/utils/projectColor';
import { import {
CheckCircle, CheckCircle,
Clock, Clock,
@ -23,6 +24,7 @@ import {
FolderOpen, FolderOpen,
GitBranch, GitBranch,
Play, Play,
RotateCcw,
Search, Search,
Square, Square,
Trash2, Trash2,
@ -31,8 +33,10 @@ import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState'; import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type { import type {
TeamCreateRequest, TeamCreateRequest,
TeamProvisioningProgress, TeamProvisioningProgress,
@ -176,6 +180,7 @@ export const TeamListView = (): React.JSX.Element => {
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);
const [copyData, setCopyData] = useState<TeamCopyData | null>(null); const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
const [aliveTeams, setAliveTeams] = useState<string[]>([]); const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map()); const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
const { const {
@ -201,6 +206,8 @@ export const TeamListView = (): React.JSX.Element => {
fetchTeams: s.fetchTeams, fetchTeams: s.fetchTeams,
openTeamTab: s.openTeamTab, openTeamTab: s.openTeamTab,
deleteTeam: s.deleteTeam, deleteTeam: s.deleteTeam,
restoreTeam: s.restoreTeam,
permanentlyDeleteTeam: s.permanentlyDeleteTeam,
projects: s.projects, projects: s.projects,
globalTasks: s.globalTasks, globalTasks: s.globalTasks,
fetchAllTasks: s.fetchAllTasks, fetchAllTasks: s.fetchAllTasks,
@ -272,6 +279,27 @@ export const TeamListView = (): React.JSX.Element => {
); );
} }
if (filter.selectedStatuses.size > 0) {
result = result.filter((t) => {
const status = resolveTeamStatus(
t.teamName,
aliveTeams,
provisioningRuns,
leadActivityByTeam
);
const isRunning = status !== 'offline';
if (filter.selectedStatuses.has('running') && isRunning) return true;
if (filter.selectedStatuses.has('offline') && !isRunning) return true;
return false;
});
}
if (filter.selectedProjects.size > 0) {
result = result.filter(
(t) => t.projectPath != null && filter.selectedProjects.has(t.projectPath.trim())
);
}
const aliveSet = new Set(aliveTeams); const aliveSet = new Set(aliveTeams);
const matchesProject = currentProjectPath const matchesProject = currentProjectPath
? (t: TeamSummary): boolean => { ? (t: TeamSummary): boolean => {
@ -299,7 +327,15 @@ export const TeamListView = (): React.JSX.Element => {
}); });
return result; return result;
}, [teams, searchQuery, currentProjectPath, aliveTeams]); }, [
teams,
searchQuery,
currentProjectPath,
aliveTeams,
filter,
provisioningRuns,
leadActivityByTeam,
]);
// Live branch/worktree for team project paths (poll so it updates during process) // Live branch/worktree for team project paths (poll so it updates during process)
const projectPathsToPoll = useMemo(() => { const projectPathsToPoll = useMemo(() => {
@ -344,14 +380,17 @@ export const TeamListView = (): React.JSX.Element => {
}; };
}, [electronMode, projectPathsToPoll]); }, [electronMode, projectPathsToPoll]);
const restoreTeam = useStore((s) => s.restoreTeam);
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
const handleDeleteTeam = useCallback( const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => { (teamName: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
void (async () => { void (async () => {
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Delete team', title: 'Move to trash',
message: `Delete team "${teamName}"? This action is irreversible.`, message: `Move team "${teamName}" to trash? You can restore it later.`,
confirmLabel: 'Delete', confirmLabel: 'Move to trash',
cancelLabel: 'Cancel', cancelLabel: 'Cancel',
variant: 'danger', variant: 'danger',
}); });
@ -363,6 +402,33 @@ export const TeamListView = (): React.JSX.Element => {
[deleteTeam] [deleteTeam]
); );
const handleRestoreTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
void restoreTeam(teamName);
},
[restoreTeam]
);
const handlePermanentlyDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
void (async () => {
const confirmed = await confirm({
title: 'Delete permanently',
message: `Delete team "${teamName}" permanently? All data will be lost.`,
confirmLabel: 'Delete forever',
cancelLabel: 'Cancel',
variant: 'danger',
});
if (confirmed) {
void permanentlyDeleteTeam(teamName);
}
})();
},
[permanentlyDeleteTeam]
);
const handleCopyTeam = useCallback( const handleCopyTeam = useCallback(
(teamName: string, e: React.MouseEvent) => { (teamName: string, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -503,17 +569,25 @@ export const TeamListView = (): React.JSX.Element => {
) : null} ) : null}
{teams.length > 0 ? ( {teams.length > 0 ? (
<div className="relative mt-3"> <div className="mt-3 flex items-center gap-2">
<Search <div className="relative flex-1">
size={14} <Search
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]" size={14}
/> className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
<Input />
type="text" <Input
placeholder="Search teams..." type="text"
value={searchQuery} placeholder="Search teams..."
onChange={(e) => setSearchQuery(e.target.value)} value={searchQuery}
className="h-8 pl-8 text-xs" onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<TeamListFilterPopover
filter={filter}
teams={teams}
aliveTeams={aliveTeams}
onFilterChange={setFilter}
/> />
</div> </div>
) : null} ) : null}
@ -554,197 +628,280 @@ export const TeamListView = (): React.JSX.Element => {
return <TeamEmptyState />; return <TeamEmptyState />;
} }
if (filteredTeams.length === 0 && searchQuery.trim()) { const hasActiveFilters = filter.selectedStatuses.size > 0 || filter.selectedProjects.size > 0;
if (filteredTeams.length === 0 && (searchQuery.trim() || hasActiveFilters)) {
return ( return (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]"> <div className="flex items-center justify-center py-12 text-sm text-[var(--color-text-muted)]">
No teams matching &quot;{searchQuery.trim()}&quot; No teams matching current filters
</div> </div>
); );
} }
const activeFiltered = filteredTeams.filter((t) => !t.deletedAt);
const deletedFiltered = filteredTeams.filter((t) => t.deletedAt);
return ( return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> <>
{filteredTeams.map((team) => { <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
const status = resolveTeamStatus( {activeFiltered.map((team) => {
team.teamName, const status = resolveTeamStatus(
aliveTeams, team.teamName,
provisioningRuns, aliveTeams,
leadActivityByTeam provisioningRuns,
); leadActivityByTeam
const teamColorSet = team.color ? getTeamColorSet(team.color) : null; );
const matchesCurrentProject = const teamColorSet = team.color
!!currentProjectPath && ? getTeamColorSet(team.color)
((team.projectPath ? normalizePath(team.projectPath) === currentProjectPath : false) || : nameColorSet(team.displayName);
(team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ?? const matchesCurrentProject =
false)); !!currentProjectPath &&
return ( ((team.projectPath
<div ? normalizePath(team.projectPath) === currentProjectPath
key={team.teamName} : false) ||
role="button" (team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
tabIndex={0} false));
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${ return (
matchesCurrentProject <div
? 'border-emerald-500/70 ring-1 ring-emerald-500/30' key={team.teamName}
: 'border-[var(--color-border)]' role="button"
}`} tabIndex={0}
style={ className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
teamColorSet matchesCurrentProject
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border } ? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: undefined : 'border-[var(--color-border)]'
} }`}
onClick={() => openTeamTab(team.teamName, team.projectPath)} style={
onKeyDown={(e) => { teamColorSet
if (e.key === 'Enter' || e.key === ' ') { ? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
e.preventDefault(); : undefined
openTeamTab(team.teamName, team.projectPath);
} }
}} onClick={() => openTeamTab(team.teamName, team.projectPath)}
> onKeyDown={(e) => {
{teamColorSet ? ( if (e.key === 'Enter' || e.key === ' ') {
<div e.preventDefault();
className="pointer-events-none absolute inset-0 z-0 rounded-lg" openTeamTab(team.teamName, team.projectPath);
style={{ backgroundColor: teamColorSet.badge }} }
/> }}
) : null} >
<div className={teamColorSet ? 'relative z-10' : undefined}> {teamColorSet ? (
<div className="flex items-start justify-between"> <div
<div className="flex min-w-0 flex-1 items-center gap-2"> className="pointer-events-none absolute inset-0 z-0 rounded-lg"
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]"> style={{ backgroundColor: teamColorSet.badge }}
{team.displayName} />
</h3> ) : null}
<StatusBadge status={status} /> <div className={teamColorSet ? 'relative z-10' : undefined}>
</div> <div className="flex items-start justify-between">
<div className="flex shrink-0 gap-1"> <div className="flex min-w-0 flex-1 items-center gap-2">
{(status === 'active' || status === 'idle') && ( <h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
{(status === 'active' || status === 'idle') && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
>
<Square size={14} fill="currentColor" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip>
)}
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-amber-500/10 hover:text-amber-300 disabled:opacity-50 group-hover:opacity-100" className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleStopTeam(team.teamName, e)} onClick={(e) => handleCopyTeam(team.teamName, e)}
disabled={stoppingTeamName === team.teamName}
aria-label="Stop team"
> >
<Square size={14} fill="currentColor" /> <Copy size={14} />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">Copy team</TooltipContent>
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
</Tooltip> </Tooltip>
)} <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <button
<button type="button"
type="button" className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-blue-500/10 hover:text-blue-300 group-hover:opacity-100" onClick={(e) => handleDeleteTeam(team.teamName, e)}
onClick={(e) => handleCopyTeam(team.teamName, e)} >
> <Trash2 size={14} />
<Copy size={14} /> </button>
</button> </TooltipTrigger>
</TooltipTrigger> <TooltipContent side="bottom">Delete team</TooltipContent>
<TooltipContent side="bottom">Copy team</TooltipContent> </Tooltip>
</Tooltip> </div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</TooltipContent>
</Tooltip>
</div> </div>
</div> <div className="mt-2 flex min-h-10 items-start gap-2">
<div className="mt-2 flex min-h-10 items-start gap-2"> <p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]"> {team.description || 'No description'}
{team.description || 'No description'} </p>
</p> {team.projectPath &&
{team.projectPath && (() => {
(() => { const branch = branchByPath.get(normalizePath(team.projectPath));
const branch = branchByPath.get(normalizePath(team.projectPath)); if (!branch) return null;
if (!branch) return null; return (
<span
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]"
title={branch}
>
<GitBranch size={10} />
<span className="max-w-24 truncate">{branch}</span>
</span>
);
})()}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
renderMemberChips(team.members)
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return ( return (
<span <div className="mt-2 w-full space-y-1.5">
className="flex shrink-0 items-center gap-1 rounded bg-[var(--color-surface-raised)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]" <div className="flex items-center gap-2">
title={branch} <div
> className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
<GitBranch size={10} /> role="progressbar"
<span className="max-w-24 truncate">{branch}</span> aria-valuenow={completed}
</span> aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
); );
})()} })()}
</div>
{renderTeamRecentPaths(team, status)}
</div> </div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{team.members && team.members.length > 0 ? (
renderMemberChips(team.members)
) : (
<Badge variant="secondary" className="text-[10px] font-normal">
Members: {team.memberCount}
</Badge>
)}
{(() => {
const tc = taskCountsByTeam.get(team.teamName);
const pending = tc?.pending ?? 0;
const inProgress = tc?.inProgress ?? 0;
const completed = tc?.completed ?? 0;
const totalTasks = pending + inProgress + completed;
const completedRatio = totalTasks > 0 ? completed / totalTasks : 0;
return (
<div className="mt-2 w-full space-y-1.5">
<div className="flex items-center gap-2">
<div
className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-raised)]"
role="progressbar"
aria-valuenow={completed}
aria-valuemin={0}
aria-valuemax={totalTasks}
aria-label={`Tasks ${completed}/${totalTasks} completed`}
>
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-200"
style={{ width: `${Math.round(completedRatio * 100)}%` }}
/>
</div>
<span className="shrink-0 text-[10px] font-medium tracking-tight text-[var(--color-text-muted)]">
{completed}/{totalTasks}
</span>
</div>
{totalTasks > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-[10px] text-[var(--color-text-muted)]">
{inProgress > 0 && (
<span className="inline-flex items-center gap-1">
<Play size={10} className="shrink-0 text-blue-400" />
{inProgress} in_progress
</span>
)}
{pending > 0 && (
<span className="inline-flex items-center gap-1">
<Clock size={10} className="shrink-0 text-amber-400" />
{pending} pending
</span>
)}
{completed > 0 && (
<span className="inline-flex items-center gap-1">
<CheckCircle size={10} className="shrink-0 text-emerald-400" />
{completed} completed
</span>
)}
</div>
)}
</div>
);
})()}
</div>
{renderTeamRecentPaths(team, status)}
</div> </div>
);
})}
</div>
{deletedFiltered.length > 0 && (
<>
<div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-[var(--color-border)]" />
<span className="text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
Trash ({deletedFiltered.length})
</span>
<div className="h-px flex-1 bg-[var(--color-border)]" />
</div> </div>
); <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
})} {deletedFiltered.map((team) => (
</div> <div
key={team.teamName}
className="group relative cursor-default overflow-hidden rounded-lg border border-[var(--color-border)] bg-zinc-800/40 p-4 opacity-60"
>
<Trash2
size={64}
className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-zinc-400 opacity-[0.06]"
/>
<div className="relative z-10">
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center gap-2">
<h3 className="truncate text-sm font-semibold text-[var(--color-text)]">
{team.displayName}
</h3>
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-500/15 px-2 py-0.5 text-[10px] font-medium text-zinc-500">
Deleted
</span>
</div>
<div className="flex shrink-0 gap-1">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 group-hover:opacity-100"
onClick={(e) => handleRestoreTeam(team.teamName, e)}
aria-label="Restore team"
>
<RotateCcw size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Restore</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-red-500/10 hover:text-red-300 group-hover:opacity-100"
onClick={(e) => handlePermanentlyDeleteTeam(team.teamName, e)}
aria-label="Delete permanently"
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete forever</TooltipContent>
</Tooltip>
</div>
</div>
<p className="mt-2 line-clamp-2 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
{team.members && team.members.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{renderMemberChips(team.members)}
</div>
)}
</div>
</div>
))}
</div>
</>
)}
</>
); );
}; };

View file

@ -86,32 +86,27 @@ export const ActiveTasksBlock = ({
{roleLabel} {roleLabel}
</span> </span>
) : null} ) : null}
<span <span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
working on working on
</span> </span>
{task && {task &&
(onTaskClick ? ( (onTaskClick ? (
<button <button
type="button" type="button"
className="truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]" className="min-w-0 flex-1 truncate rounded px-1.5 py-0.5 text-left text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
style={{ border: `1px solid ${colors.border}40` }} style={{ border: `1px solid ${colors.border}40` }}
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
title={task.subject} title={task.subject}
> >
#{task.id} {task.subject.slice(0, 40)} #{task.id} {task.subject}
{task.subject.length > 40 ? '…' : ''}
</button> </button>
) : ( ) : (
<span <span
className="truncate px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)]" className="min-w-0 flex-1 truncate px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)]"
style={{ border: `1px solid ${colors.border}40` }} style={{ border: `1px solid ${colors.border}40` }}
title={task.subject} title={task.subject}
> >
#{task.id} {task.subject.slice(0, 40)} #{task.id} {task.subject}
{task.subject.length > 40 ? '…' : ''}
</span> </span>
))} ))}
</div> </div>

View file

@ -212,7 +212,9 @@ export const ActivityItem = ({
const handleCreateTask = (): void => { const handleCreateTask = (): void => {
const subject = message.summary || autoSummary || `Task from ${message.from}`; const subject = message.summary || autoSummary || `Task from ${message.from}`;
const plainText = structured ? JSON.stringify(structured, null, 2) : message.text; const plainText = structured
? JSON.stringify(structured, null, 2)
: stripAgentBlocks(message.text);
const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000); const description = `From: ${message.from}\nAt: ${timestamp}\n\n${plainText}`.slice(0, 2000);
onCreateTask?.(subject, description); onCreateTask?.(subject, description);
}; };

View file

@ -113,7 +113,7 @@ export const AddMemberDialog = ({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Role (optional)</Label> <Label className="label-optional">Role (optional)</Label>
<Select value={roleSelect} onValueChange={setRoleSelect}> <Select value={roleSelect} onValueChange={setRoleSelect}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="No role" /> <SelectValue placeholder="No role" />

View file

@ -28,13 +28,13 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertTriangle, Search } from 'lucide-react'; import { AlertTriangle, Search } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTask } from '@shared/types'; import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface CreateTaskDialogProps { interface CreateTaskDialogProps {
open: boolean; open: boolean;
teamName: string; teamName: string;
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
tasks: TeamTask[]; tasks: TeamTaskWithKanban[];
isTeamAlive?: boolean; isTeamAlive?: boolean;
defaultSubject?: string; defaultSubject?: string;
defaultDescription?: string; defaultDescription?: string;
@ -114,7 +114,9 @@ export const CreateTaskDialog = ({
const canSubmit = subject.trim().length > 0 && !submitting && (!requiresOwner || !!owner); const canSubmit = subject.trim().length > 0 && !submitting && (!requiresOwner || !!owner);
// Only show non-internal, non-deleted tasks as candidates for blocking // Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter((t) => t.status !== 'deleted'); const availableTasks = tasks.filter(
(t) => t.status !== 'deleted' && t.kanbanColumn !== 'approved'
);
const toggleBlockedBy = (taskId: string): void => { const toggleBlockedBy = (taskId: string): void => {
setBlockedBy((prev) => setBlockedBy((prev) =>
@ -151,7 +153,9 @@ export const CreateTaskDialog = ({
const assigneeField = ( const assigneeField = (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{requiresOwner ? 'Assignee' : 'Assignee (optional)'}</Label> <Label className={requiresOwner ? undefined : 'label-optional'}>
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
</Label>
<Select <Select
value={owner || '__unassigned__'} value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)} onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
@ -226,7 +230,9 @@ export const CreateTaskDialog = ({
{assigneeField} {assigneeField}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="task-description">Description (optional)</Label> <Label htmlFor="task-description" className="label-optional">
Description (optional)
</Label>
<MentionableTextarea <MentionableTextarea
id="task-description" id="task-description"
placeholder="Task details..." placeholder="Task details..."
@ -244,7 +250,9 @@ export const CreateTaskDialog = ({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="task-prompt">Prompt for assignee (optional)</Label> <Label htmlFor="task-prompt" className="label-optional">
Prompt for assignee (optional)
</Label>
<MentionableTextarea <MentionableTextarea
id="task-prompt" id="task-prompt"
placeholder="Custom instructions for the team member..." placeholder="Custom instructions for the team member..."
@ -287,7 +295,7 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? ( {availableTasks.length > 0 ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Blocked by tasks (optional)</Label> <Label className="label-optional">Blocked by tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"> <div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? ( {availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5"> <div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
@ -356,7 +364,7 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? ( {availableTasks.length > 0 ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Related tasks (optional)</Label> <Label className="label-optional">Related tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"> <div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? ( {availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5"> <div className="relative border-b border-[var(--color-border)] px-2 py-1.5">

View file

@ -30,6 +30,8 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react'; import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
import { MembersJsonEditor } from './MembersJsonEditor';
const TEAM_COLOR_NAMES = [ const TEAM_COLOR_NAMES = [
'blue', 'blue',
'green', 'green',
@ -271,6 +273,9 @@ export const CreateTeamDialog = ({
const [launchTeam, setLaunchTeam] = useState(true); const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState(''); const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState(''); const [selectedModel, setSelectedModel] = useState('');
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const resetUIState = (): void => { const resetUIState = (): void => {
setLocalError(null); setLocalError(null);
@ -292,6 +297,9 @@ export const CreateTeamDialog = ({
setCustomCwd(''); setCustomCwd('');
setLaunchTeam(true); setLaunchTeam(true);
setSelectedModel(''); setSelectedModel('');
setJsonEditorOpen(false);
setJsonText('');
setJsonError(null);
resetUIState(); resetUIState();
}; };
@ -448,6 +456,60 @@ export const CreateTeamDialog = ({
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
const membersToJsonText = (drafts: MemberDraft[]): string => {
const arr = drafts
.filter((d) => d.name.trim())
.map((d) => {
const role =
d.roleSelection === CUSTOM_ROLE
? d.customRole.trim() || undefined
: d.roleSelection === NO_ROLE
? undefined
: d.roleSelection.trim() || undefined;
return role ? { name: d.name.trim(), role } : { name: d.name.trim() };
});
return JSON.stringify(arr, null, 2);
};
const handleJsonChange = (text: string): void => {
setJsonText(text);
try {
const arr: unknown = JSON.parse(text);
if (!Array.isArray(arr)) {
setJsonError('Root must be an array');
return;
}
const drafts: MemberDraft[] = (arr as Record<string, unknown>[]).map((item) => {
const name = typeof item.name === 'string' ? item.name : '';
const role = typeof item.role === 'string' ? item.role.trim() : '';
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role);
return createMemberDraft({
name,
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
});
});
setMembers(drafts);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
}
};
const toggleJsonEditor = (): void => {
if (!jsonEditorOpen) {
setJsonText(membersToJsonText(members));
setJsonError(null);
}
setJsonEditorOpen((prev) => !prev);
};
useEffect(() => {
if (!jsonEditorOpen || jsonError !== null) return;
setJsonText(membersToJsonText(members));
}, [members, jsonEditorOpen, jsonError]);
const description = descriptionDraft.value; const description = descriptionDraft.value;
const prompt = promptDraft.value; const prompt = promptDraft.value;
@ -655,9 +717,7 @@ export const CreateTeamDialog = ({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1.5 md:col-span-2"> <div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-name" className="text-xs text-[var(--color-text-muted)]"> <Label htmlFor="team-name">Team name</Label>
teamName
</Label>
<Input <Input
id="team-name" id="team-name"
className="h-8 text-xs" className="h-8 text-xs"
@ -673,56 +733,23 @@ export const CreateTeamDialog = ({
</div> </div>
<div className="space-y-1.5 md:col-span-2"> <div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-description" className="text-xs text-[var(--color-text-muted)]"> <div className="flex items-center justify-between">
description (optional) <Label>Members</Label>
</Label> <div className="flex gap-2">
<AutoResizeTextarea <Button
id="team-description" variant="outline"
className="text-xs" size="sm"
minRows={2} onClick={() => {
maxRows={8} setMembers((prev) => [...prev, createMemberDraft()]);
value={description} }}
onChange={(event) => descriptionDraft.setValue(event.target.value)} >
placeholder="Brief description of the team purpose" Add member
/> </Button>
{descriptionDraft.isSaved ? ( <Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> {jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
) : null} </Button>
</div> </div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">color (optional)</Label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = teamColor === colorName;
return (
<button
key={colorName}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)}
style={{
backgroundColor: colorSet.badge,
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setTeamColor(isSelected ? '' : colorName)}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
</div> </div>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">members</Label>
<div className="space-y-2"> <div className="space-y-2">
{members.map((member, index) => { {members.map((member, index) => {
const memberColorSet = getTeamColorSet(getMemberColor(index)); const memberColorSet = getTeamColorSet(getMemberColor(index));
@ -795,183 +822,252 @@ export const CreateTeamDialog = ({
</div> </div>
); );
})} })}
<Button {jsonEditorOpen ? (
variant="outline" <MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
size="sm" ) : null}
onClick={() => {
setMembers((prev) => [...prev, createMemberDraft()]);
}}
>
Add member
</Button>
</div> </div>
{fieldErrors.members ? ( {fieldErrors.members ? (
<p className="text-[11px] text-red-300">{fieldErrors.members}</p> <p className="text-[11px] text-red-300">{fieldErrors.members}</p>
) : null} ) : null}
</div> </div>
<div className="flex items-center gap-2 md:col-span-2"> <div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4 md:col-span-2">
<Checkbox <div className="flex items-center gap-2">
id="launch-team" <Checkbox
checked={launchTeam} id="launch-team"
onCheckedChange={(checked) => setLaunchTeam(checked === true)} checked={launchTeam}
/> onCheckedChange={(checked) => setLaunchTeam(checked === true)}
<Label
htmlFor="launch-team"
className="cursor-pointer text-xs text-[var(--color-text)]"
>
Launch team
</Label>
</div>
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
Prompt for team lead (optional)
</Label>
<MentionableTextarea
id="team-prompt"
className="text-xs"
minRows={3}
maxRows={12}
value={prompt}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
placeholder="Instructions for the team lead during provisioning..."
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/> />
<Label htmlFor="launch-team" className="cursor-pointer">
Launch team
</Label>
</div> </div>
) : null}
{launchTeam ? ( {launchTeam ? (
<div className="space-y-1.5 md:col-span-2"> <div className="mt-4 space-y-4">
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label> <div className="space-y-1.5">
<Select value={selectedModel} onValueChange={setSelectedModel}> <Label>Project</Label>
<SelectTrigger className="h-8 text-xs"> <div className="space-y-2">
<SelectValue placeholder="Default (account setting)" /> <div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
</SelectTrigger> <button
<SelectContent> type="button"
<SelectItem value="__default__">Default (account setting)</SelectItem> className={cn(
<SelectItem value="opus">Opus 4.6</SelectItem> 'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
<SelectItem value="sonnet">Sonnet 4.5</SelectItem> cwdMode === 'project'
<SelectItem value="haiku">Haiku 4.5</SelectItem> ? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
</SelectContent> : 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
</Select> )}
</div> onClick={() => setCwdMode('project')}
) : null} >
From project list
</button>
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'custom'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => setCwdMode('custom')}
>
Custom path
</button>
</div>
{launchTeam ? ( {cwdMode === 'project' ? (
<div className="space-y-1.5 md:col-span-2"> <div className="space-y-1.5">
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label> <Combobox
<div className="space-y-2"> options={projects.map((project) => ({
<div className="flex items-center gap-1"> value: project.path,
<Button label: project.name,
type="button" description: project.path,
variant={cwdMode === 'project' ? 'default' : 'outline'} }))}
size="sm" value={selectedProjectPath}
className="h-7 text-xs" onValueChange={setSelectedProjectPath}
onClick={() => setCwdMode('project')} placeholder={
> projectsLoading ? 'Loading projects...' : 'Select a project...'
From project list }
</Button> searchPlaceholder="Search project by name or path"
<Button emptyMessage="Nothing found"
type="button" disabled={projectsLoading || projects.length === 0}
variant={cwdMode === 'custom' ? 'default' : 'outline'} renderOption={(option, isSelected, query) => (
size="sm" <>
className="h-7 text-xs" <Check
onClick={() => setCwdMode('custom')} className={cn(
> 'mr-2 size-3.5 shrink-0',
Custom path isSelected ? 'opacity-100' : 'opacity-0'
</Button> )}
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]">
{renderHighlightedText(option.label, query)}
</p>
<p className="truncate text-[var(--color-text-muted)]">
{renderHighlightedText(option.description ?? '', query)}
</p>
</div>
</>
)}
/>
{!selectedProjectPath ? (
<p className="text-[11px] text-[var(--color-text-muted)]">
Select a project from the list
</p>
) : null}
{projectsError ? (
<p className="text-[11px] text-red-300">{projectsError}</p>
) : null}
{!projectsLoading && projects.length === 0 ? (
<p className="text-[11px] text-amber-300">
No projects found, switch to custom path.
</p>
) : null}
</div>
) : (
<div className="space-y-1.5">
<div className="flex gap-2">
<Input
className="h-8 text-xs"
value={customCwd}
aria-label="Custom working directory"
onChange={(event) => setCustomCwd(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
setCustomCwd(paths[0]);
}
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div>
)}
</div>
{fieldErrors.cwd ? (
<p className="text-[11px] text-red-300">{fieldErrors.cwd}</p>
) : null}
</div> </div>
{cwdMode === 'project' ? ( <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="team-prompt" className="label-optional">
<Combobox Prompt for team lead (optional)
options={projects.map((project) => ({ </Label>
value: project.path, <MentionableTextarea
label: project.name, id="team-prompt"
description: project.path, className="text-xs"
}))} minRows={3}
value={selectedProjectPath} maxRows={12}
onValueChange={setSelectedProjectPath} value={prompt}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'} onValueChange={promptDraft.setValue}
searchPlaceholder="Search project by name or path" suggestions={mentionSuggestions}
emptyMessage="Nothing found" placeholder="Instructions for the team lead during provisioning..."
disabled={projectsLoading || projects.length === 0} footerRight={
renderOption={(option, isSelected, query) => ( promptDraft.isSaved ? (
<> <span className="text-[10px] text-[var(--color-text-muted)]">
<Check Draft saved
className={cn( </span>
'mr-2 size-3.5 shrink-0', ) : null
isSelected ? 'opacity-100' : 'opacity-0' }
)} />
/> </div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-[var(--color-text)]"> <div className="space-y-1.5">
{renderHighlightedText(option.label, query)} <Label className="label-optional">Model (optional)</Label>
</p> <Select value={selectedModel} onValueChange={setSelectedModel}>
<p className="truncate text-[var(--color-text-muted)]"> <SelectTrigger className="h-8 text-xs">
{renderHighlightedText(option.description ?? '', query)} <SelectValue placeholder="Default (account setting)" />
</p> </SelectTrigger>
</div> <SelectContent>
</> <SelectItem value="__default__">Default (account setting)</SelectItem>
)} <SelectItem value="opus">Opus 4.6</SelectItem>
/> <SelectItem value="sonnet">Sonnet 4.5</SelectItem>
{!selectedProjectPath ? ( <SelectItem value="haiku">Haiku 4.5</SelectItem>
<p className="text-[11px] text-[var(--color-text-muted)]"> </SelectContent>
Select a project from the list </Select>
</p> </div>
) : null}
{projectsError ? ( {canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
<p className="text-[11px] text-red-300">{projectsError}</p> <div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
) : null} <span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
{!projectsLoading && projects.length === 0 ? ( <span>
<p className="text-[11px] text-amber-300"> {prepareMessage ??
No projects found, switch to custom path. (prepareState === 'idle'
</p> ? 'Warming up CLI environment...'
) : null} : 'Preparing environment...')}
</span>
</div> </div>
) : ( ) : null}
<div className="space-y-1.5">
<div className="flex gap-2"> {canCreate && prepareState === 'ready' ? (
<Input <div className="flex items-center gap-2 text-xs text-emerald-400">
className="h-8 text-xs" <CheckCircle2 className="size-3.5 shrink-0" />
value={customCwd} <span>CLI environment ready</span>
aria-label="Custom working directory"
onChange={(event) => setCustomCwd(event.target.value)}
placeholder="/absolute/path/to/project"
/>
<Button
variant="outline"
size="sm"
onClick={() => {
void (async () => {
const paths = await api.config.selectFolders();
if (paths.length > 0) {
setCustomCwd(paths[0]);
}
})();
}}
>
Browse
</Button>
</div>
<p className="text-[11px] text-[var(--color-text-muted)]">
If the directory does not exist, it will be created automatically.
</p>
</div> </div>
)} ) : null}
</div> </div>
{fieldErrors.cwd ? ( ) : null}
<p className="text-[11px] text-red-300">{fieldErrors.cwd}</p> </div>
) : null}
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-description" className="label-optional">
Description (optional)
</Label>
<AutoResizeTextarea
id="team-description"
className="text-xs"
minRows={2}
maxRows={8}
value={description}
onChange={(event) => descriptionDraft.setValue(event.target.value)}
placeholder="Brief description of the team purpose"
/>
{descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="label-optional">Color (optional)</Label>
<div className="flex flex-wrap gap-2">
{TEAM_COLOR_NAMES.map((colorName) => {
const colorSet = getTeamColorSet(colorName);
const isSelected = teamColor === colorName;
return (
<button
key={colorName}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
)}
style={{
backgroundColor: colorSet.badge,
borderColor: isSelected ? colorSet.border : 'transparent',
}}
title={colorName}
onClick={() => setTeamColor(isSelected ? '' : colorName)}
>
<span
className="size-3.5 rounded-full"
style={{ backgroundColor: colorSet.border }}
/>
</button>
);
})}
</div> </div>
) : null} </div>
</div> </div>
{activeError ? ( {activeError ? (
@ -980,27 +1076,6 @@ export const CreateTeamDialog = ({
</p> </p>
) : null} ) : null}
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span>
{prepareMessage ??
(prepareState === 'idle'
? 'Warming up CLI environment...'
: 'Preparing environment...')}
</span>
<span className="text-[var(--color-text-muted)]">&middot;</span>
<span>Team provisioning via local Claude CLI.</span>
</div>
) : null}
{canCreate && launchTeam && prepareState === 'ready' ? (
<div className="flex items-center gap-2 text-xs text-emerald-400">
<CheckCircle2 className="size-3.5 shrink-0" />
<span>CLI environment ready</span>
</div>
) : null}
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
{canOpenExistingTeam ? ( {canOpenExistingTeam ? (
<Button <Button

View file

@ -129,7 +129,7 @@ export const EditTeamDialog = ({
</div> </div>
<div> <div>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */} {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
<label className="mb-1 block text-xs font-medium text-[var(--color-text-secondary)]"> <label className="label-optional mb-1 block text-xs font-medium">
Color (optional) Color (optional)
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View file

@ -458,7 +458,7 @@ export const LaunchTeamDialog = ({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="launch-prompt" className="text-xs text-[var(--color-text-muted)]"> <Label htmlFor="launch-prompt" className="label-optional text-xs">
Prompt (optional) Prompt (optional)
</Label> </Label>
<MentionableTextarea <MentionableTextarea
@ -479,7 +479,7 @@ export const LaunchTeamDialog = ({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label> <Label className="label-optional text-xs">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}> <Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" /> <SelectValue placeholder="Default (account setting)" />

View file

@ -0,0 +1,96 @@
import React, { useEffect, useRef } from 'react';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { json } from '@codemirror/lang-json';
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { EditorState } from '@codemirror/state';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
interface MembersJsonEditorProps {
value: string;
onChange: (json: string) => void;
error: string | null;
}
export const MembersJsonEditor = ({
value,
onChange,
error,
}: MembersJsonEditorProps): React.JSX.Element => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (!containerRef.current) return;
const state = EditorState.create({
doc: value,
extensions: [
json(),
oneDark,
lineNumbers(),
history(),
bracketMatching(),
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
EditorView.theme({
'&': {
fontSize: '12px',
maxHeight: '300px',
},
'.cm-scroller': {
overflow: 'auto',
},
'.cm-content': {
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
},
}),
],
});
const view = new EditorView({
state,
parent: containerRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- EditorView created once on mount
}, []);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const currentDoc = view.state.doc.toString();
if (currentDoc !== value) {
view.dispatch({
changes: { from: 0, to: currentDoc.length, insert: value },
});
}
}, [value]);
return (
<div className="space-y-1">
<div
ref={containerRef}
className="overflow-hidden rounded border border-[var(--color-border)]"
/>
{error ? <p className="text-[11px] text-red-300">{error}</p> : null}
</div>
);
};

View file

@ -78,7 +78,9 @@ export const ReviewDialog = ({
</DialogHeader> </DialogHeader>
<div className="grid gap-2 py-2"> <div className="grid gap-2 py-2">
<Label htmlFor="review-comment">Comment (optional)</Label> <Label htmlFor="review-comment" className="label-optional">
Comment (optional)
</Label>
<MentionableTextarea <MentionableTextarea
id="review-comment" id="review-comment"
className="min-h-[110px] text-xs" className="min-h-[110px] text-xs"

View file

@ -173,7 +173,9 @@ export const SendMessageDialog = ({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="smd-summary">Summary (optional)</Label> <Label htmlFor="smd-summary" className="label-optional">
Summary (optional)
</Label>
<Input <Input
id="smd-summary" id="smd-summary"
placeholder="Brief description shown as preview..." placeholder="Brief description shown as preview..."

View file

@ -6,8 +6,14 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { Trash2 } from 'lucide-react'; import { RotateCcw, Trash2 } from 'lucide-react';
import type { TeamTask } from '@shared/types'; import type { TeamTask } from '@shared/types';
@ -15,9 +21,15 @@ interface TrashDialogProps {
open: boolean; open: boolean;
tasks: TeamTask[]; tasks: TeamTask[];
onClose: () => void; onClose: () => void;
onRestore?: (taskId: string) => void;
} }
export const TrashDialog = ({ open, tasks, onClose }: TrashDialogProps): React.JSX.Element => { export const TrashDialog = ({
open,
tasks,
onClose,
onRestore,
}: TrashDialogProps): React.JSX.Element => {
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
@ -33,37 +45,57 @@ export const TrashDialog = ({ open, tasks, onClose }: TrashDialogProps): React.J
No deleted tasks No deleted tasks
</div> </div>
) : ( ) : (
<div className="max-h-[400px] overflow-y-auto"> <TooltipProvider delayDuration={300}>
<table className="w-full text-xs"> <div className="max-h-[400px] overflow-y-auto">
<thead> <table className="w-full text-xs">
<tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]"> <thead>
<th className="pb-2 pr-3 font-medium">#</th> <tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]">
<th className="pb-2 pr-3 font-medium">Subject</th> <th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Owner</th> <th className="pb-2 pr-3 font-medium">Subject</th>
<th className="pb-2 font-medium">Deleted</th> <th className="pb-2 pr-3 font-medium">Owner</th>
</tr> <th className="pb-2 pr-3 font-medium">Deleted</th>
</thead> {onRestore ? <th className="pb-2 font-medium" /> : null}
<tbody>
{tasks.map((task) => (
<tr
key={task.id}
className="border-b border-[var(--color-border-subtle)] last:border-0"
>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? '—'}
</td>
<td className="py-2 text-[var(--color-text-muted)]">
{task.deletedAt
? formatDistanceToNow(new Date(task.deletedAt), { addSuffix: true })
: '—'}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {tasks.map((task) => (
</div> <tr
key={task.id}
className="border-b border-[var(--color-border-subtle)] last:border-0"
>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? '—'}
</td>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
{task.deletedAt
? formatDistanceToNow(new Date(task.deletedAt), { addSuffix: true })
: '—'}
</td>
{onRestore ? (
<td className="py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={() => onRestore(task.id)}
aria-label="Restore task"
>
<RotateCcw size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Restore</TooltipContent>
</Tooltip>
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</TooltipProvider>
)} )}
<DialogFooter> <DialogFooter>

View file

@ -13,7 +13,7 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import type { MemberFullStats } from '@shared/types'; import type { FileLineStats, MemberFullStats } from '@shared/types';
interface MemberStatsTabProps { interface MemberStatsTabProps {
teamName: string; teamName: string;
@ -95,6 +95,7 @@ export const MemberStatsTab = ({
<ToolUsageBars toolUsage={stats.toolUsage} /> <ToolUsageBars toolUsage={stats.toolUsage} />
<FilesTouchedSection <FilesTouchedSection
files={stats.filesTouched} files={stats.filesTouched}
fileStats={stats.fileStats}
onFileClick={onFileClick} onFileClick={onFileClick}
onShowAll={onShowAllFiles} onShowAll={onShowAllFiles}
/> />
@ -191,27 +192,33 @@ const ToolUsageBars = ({
); );
}; };
const INVALID_PATHS = new Set(['null', 'undefined', 'None', '']);
const FilesTouchedSection = ({ const FilesTouchedSection = ({
files, files,
fileStats,
onFileClick, onFileClick,
onShowAll, onShowAll,
}: { }: {
files: string[]; files: string[];
fileStats?: Record<string, FileLineStats>;
onFileClick?: (filePath: string) => void; onFileClick?: (filePath: string) => void;
onShowAll?: () => void; onShowAll?: () => void;
}): React.JSX.Element | null => { }): React.JSX.Element | null => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
if (files.length === 0) return null;
const visibleFiles = expanded ? files : files.slice(0, 5); const validFiles = files.filter((f) => !INVALID_PATHS.has(f));
const hiddenCount = files.length - 5; if (validFiles.length === 0) return null;
const visibleFiles = expanded ? validFiles : validFiles.slice(0, 5);
const hiddenCount = validFiles.length - 5;
const isClickable = !!onFileClick; const isClickable = !!onFileClick;
return ( return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3"> <div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-medium text-[var(--color-text-secondary)]"> <p className="text-[11px] font-medium text-[var(--color-text-secondary)]">
Files Touched ({files.length}) Files Touched ({validFiles.length})
</p> </p>
{onShowAll && ( {onShowAll && (
<button className="text-[10px] text-blue-400 hover:text-blue-300" onClick={onShowAll}> <button className="text-[10px] text-blue-400 hover:text-blue-300" onClick={onShowAll}>
@ -222,6 +229,7 @@ const FilesTouchedSection = ({
<div className="space-y-0.5"> <div className="space-y-0.5">
{visibleFiles.map((filePath) => { {visibleFiles.map((filePath) => {
const basename = filePath.split('/').pop() ?? filePath; const basename = filePath.split('/').pop() ?? filePath;
const fStats = fileStats?.[filePath];
return ( return (
<button <button
key={filePath} key={filePath}
@ -236,7 +244,13 @@ const FilesTouchedSection = ({
disabled={!isClickable} disabled={!isClickable}
> >
<FileCode size={10} className="shrink-0 opacity-50" /> <FileCode size={10} className="shrink-0 opacity-50" />
<span className="truncate">{basename}</span> <span className="min-w-0 truncate">{basename}</span>
{fStats && (fStats.added > 0 || fStats.removed > 0) && (
<span className="ml-auto flex shrink-0 items-center gap-1 font-mono text-[10px]">
{fStats.added > 0 && <span className="text-emerald-400">+{fStats.added}</span>}
{fStats.removed > 0 && <span className="text-red-400">-{fStats.removed}</span>}
</span>
)}
</button> </button>
); );
})} })}

View file

@ -4,6 +4,3 @@
*/ */
export { getTrafficLightPaddingForZoom, HEADER_ROW1_HEIGHT } from '@shared/constants'; export { getTrafficLightPaddingForZoom, HEADER_ROW1_HEIGHT } from '@shared/constants';
/** Height of the secondary header row (worktree selector) */
export const HEADER_ROW2_HEIGHT = 30; // px

View file

@ -624,3 +624,8 @@ body {
transparent 100% transparent 100%
); );
} }
/* Optional field label — muted text for non-required form fields */
.label-optional {
color: var(--color-text-muted);
}

View file

@ -401,10 +401,17 @@ export const createTabSlice: StateCreator<AppState, [], [], TabSlice> = (set, ge
} }
}, },
// Open a new dashboard tab in the focused pane // Open a dashboard tab — reuse existing one if found, otherwise create new
openDashboard: () => { openDashboard: () => {
const state = get(); const state = get();
const { paneLayout } = state; const { paneLayout } = state;
const existing = getAllTabs(paneLayout).find((t) => t.type === 'dashboard');
if (existing) {
state.setActiveTab(existing.id);
return;
}
const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId); const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId);
if (!focusedPane) return; if (!focusedPane) return;

View file

@ -107,8 +107,11 @@ export interface TeamSlice {
deletedTasks: TeamTask[]; deletedTasks: TeamTask[];
deletedTasksLoading: boolean; deletedTasksLoading: boolean;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>; softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>;
fetchDeletedTasks: (teamName: string) => Promise<void>; fetchDeletedTasks: (teamName: string) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>; deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>; createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>; launchTeam: (request: TeamLaunchRequest) => Promise<string>;
cancelProvisioning: (runId: string) => Promise<void>; cancelProvisioning: (runId: string) => Promise<void>;
@ -516,6 +519,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
await get().fetchDeletedTasks(teamName); await get().fetchDeletedTasks(teamName);
}, },
restoreTask: async (teamName: string, taskId: string) => {
await unwrapIpc('team:restoreTask', () => api.teams.restoreTask(teamName, taskId));
await get().refreshTeamData(teamName);
await get().fetchDeletedTasks(teamName);
},
fetchDeletedTasks: async (teamName: string) => { fetchDeletedTasks: async (teamName: string) => {
set({ deletedTasksLoading: true }); set({ deletedTasksLoading: true });
try { try {
@ -531,11 +540,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deleteTeam: async (teamName: string) => { deleteTeam: async (teamName: string) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
await get().fetchTeams();
await get().fetchAllTasks();
},
restoreTeam: async (teamName: string) => {
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
await get().fetchTeams();
await get().fetchAllTasks();
},
permanentlyDeleteTeam: async (teamName: string) => {
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
const state = get(); const state = get();
if (state.selectedTeamName === teamName) { if (state.selectedTeamName === teamName) {
set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null }); set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null });
} }
await get().fetchTeams(); await get().fetchTeams();
await get().fetchAllTasks();
}, },
createTeam: async (request: TeamCreateRequest) => { createTeam: async (request: TeamCreateRequest) => {

View file

@ -0,0 +1,36 @@
import type { TeamColorSet } from '@renderer/constants/teamColors';
function hashStringToHue(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return ((hash % 360) + 360) % 360;
}
export interface ProjectColorSet {
border: string;
glow: string;
icon: string;
text: string;
}
export function projectColor(name: string): ProjectColorSet {
const hue = hashStringToHue(name);
return {
border: `hsla(${hue}, 70%, 55%, 0.5)`,
glow: `hsla(${hue}, 70%, 55%, 0.06)`,
icon: `hsla(${hue}, 70%, 65%, 0.8)`,
text: `hsla(${hue}, 40%, 65%, 0.55)`,
};
}
/** Generate a TeamColorSet from any name (deterministic hue). */
export function nameColorSet(name: string): TeamColorSet {
const hue = hashStringToHue(name);
return {
border: `hsl(${hue}, 70%, 55%)`,
badge: `hsla(${hue}, 70%, 55%, 0.08)`,
text: `hsla(${hue}, 35%, 70%, 0.55)`,
};
}

View file

@ -99,7 +99,7 @@ function trimTrailingPathSep(p: string): string {
return s; return s;
} }
function projectLabelFromPath(path: string): string { export function projectLabelFromPath(path: string): string {
const normalized = trimTrailingPathSep(path); const normalized = trimTrailingPathSep(path);
const segments = normalized const segments = normalized
.split('/') .split('/')
@ -108,6 +108,16 @@ function projectLabelFromPath(path: string): string {
return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL; return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL;
} }
/**
* Flat sort: newest (by updatedAt/createdAt) first, no grouping.
* Within the same team, tasks are ordered by freshness.
* Teams with more recent activity appear first.
*/
export function sortTasksByFreshness(tasks: GlobalTask[]): GlobalTask[] {
const teamTs = buildTeamMaxTs(tasks);
return [...tasks].sort((a, b) => compareByTeamFreshness(a, b, teamTs));
}
export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] { export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] {
const byKey = new Map<string, { path: string; tasks: GlobalTask[] }>(); const byKey = new Map<string, { path: string; tasks: GlobalTask[] }>();

View file

@ -386,6 +386,8 @@ export interface TeamsAPI {
list: () => Promise<TeamSummary[]>; list: () => Promise<TeamSummary[]>;
getData: (teamName: string) => Promise<TeamData>; getData: (teamName: string) => Promise<TeamData>;
deleteTeam: (teamName: string) => Promise<void>; deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
prepareProvisioning: (cwd?: string) => Promise<TeamProvisioningPrepareResult>; prepareProvisioning: (cwd?: string) => Promise<TeamProvisioningPrepareResult>;
createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>; createTeam: (request: TeamCreateRequest) => Promise<TeamCreateResponse>;
getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>; getProvisioningStatus: (runId: string) => Promise<TeamProvisioningProgress>;
@ -430,6 +432,7 @@ export interface TeamsAPI {
killProcess: (teamName: string, pid: number) => Promise<void>; killProcess: (teamName: string, pid: number) => Promise<void>;
getLeadActivity: (teamName: string) => Promise<LeadActivityState>; getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>; softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>;
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>; getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void; onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
onProvisioningProgress: ( onProvisioningProgress: (

View file

@ -19,6 +19,8 @@ export interface TeamConfig {
projectPathHistory?: string[]; projectPathHistory?: string[];
leadSessionId?: string; leadSessionId?: string;
sessionHistory?: string[]; sessionHistory?: string[];
/** ISO timestamp — soft delete marker. If set, the team is considered deleted. */
deletedAt?: string;
} }
export interface TeamUpdateConfigRequest { export interface TeamUpdateConfigRequest {
@ -47,6 +49,8 @@ export interface TeamSummary {
projectPathHistory?: string[]; projectPathHistory?: string[];
leadSessionId?: string; leadSessionId?: string;
sessionHistory?: string[]; sessionHistory?: string[];
/** Propagated from config.deletedAt — set when the team has been soft-deleted. */
deletedAt?: string;
} }
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
@ -292,6 +296,8 @@ export interface GlobalTask extends TeamTaskWithKanban {
teamName: string; teamName: string;
teamDisplayName: string; teamDisplayName: string;
projectPath?: string; projectPath?: string;
/** True when the parent team has been soft-deleted. */
teamDeleted?: boolean;
} }
export interface MemberSubagentSummary { export interface MemberSubagentSummary {
@ -331,10 +337,16 @@ export interface MemberLeadSessionLogSummary extends MemberLogSummaryBase {
export type MemberLogSummary = MemberSubagentLogSummary | MemberLeadSessionLogSummary; export type MemberLogSummary = MemberSubagentLogSummary | MemberLeadSessionLogSummary;
export interface FileLineStats {
added: number;
removed: number;
}
export interface MemberFullStats { export interface MemberFullStats {
linesAdded: number; linesAdded: number;
linesRemoved: number; linesRemoved: number;
filesTouched: string[]; filesTouched: string[];
fileStats: Record<string, FileLineStats>;
toolUsage: Record<string, number>; toolUsage: Record<string, number>;
inputTokens: number; inputTokens: number;
outputTokens: number; outputTokens: number;

View file

@ -44,6 +44,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_LEAD_ACTIVITY: 'team:leadActivity', TEAM_LEAD_ACTIVITY: 'team:leadActivity',
TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask', TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask',
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks', TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
TEAM_RESTORE: 'team:restoreTeam',
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
TEAM_RESTORE_TASK: 'team:restoreTask',
})); }));
import { import {
@ -79,7 +82,9 @@ import {
TEAM_GET_PROJECT_BRANCH, TEAM_GET_PROJECT_BRANCH,
TEAM_KILL_PROCESS, TEAM_KILL_PROCESS,
TEAM_LEAD_ACTIVITY, TEAM_LEAD_ACTIVITY,
TEAM_PERMANENTLY_DELETE,
TEAM_REMOVE_MEMBER, TEAM_REMOVE_MEMBER,
TEAM_RESTORE,
TEAM_SOFT_DELETE_TASK, TEAM_SOFT_DELETE_TASK,
TEAM_UPDATE_MEMBER_ROLE, TEAM_UPDATE_MEMBER_ROLE,
} from '../../../src/preload/constants/ipcChannels'; } from '../../../src/preload/constants/ipcChannels';
@ -189,6 +194,8 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true); expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true);
expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(true); expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(true);
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(true); expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(true);
expect(handlers.has(TEAM_RESTORE)).toBe(true);
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true);
}); });
it('returns success false on invalid sendMessage args', async () => { it('returns success false on invalid sendMessage args', async () => {
@ -501,5 +508,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false); expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(false); expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(false);
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(false); expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(false);
expect(handlers.has(TEAM_RESTORE)).toBe(false);
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false);
}); });
}); });