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": {
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",

View file

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

View file

@ -21,6 +21,7 @@ import {
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
@ -28,6 +29,8 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE,
TEAM_SOFT_DELETE_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_OWNER, handleUpdateTaskOwner);
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_ALIVE, handleProcessAlive);
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_LEAD_ACTIVITY, handleLeadActivity);
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
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_OWNER);
ipcMain.removeHandler(TEAM_DELETE_TEAM);
ipcMain.removeHandler(TEAM_RESTORE);
ipcMain.removeHandler(TEAM_PERMANENTLY_DELETE);
ipcMain.removeHandler(TEAM_PROCESS_SEND);
ipcMain.removeHandler(TEAM_PROCESS_ALIVE);
ipcMain.removeHandler(TEAM_ALIVE_LIST);
@ -240,6 +248,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_KILL_PROCESS);
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
ipcMain.removeHandler(TEAM_RESTORE_TASK);
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
}
@ -382,6 +391,30 @@ async function handleDeleteTeam(
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(
_event: IpcMainInvokeEvent,
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(
_event: IpcMainInvokeEvent,
teamName: unknown

View file

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

View file

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

View file

@ -85,14 +85,20 @@ export class TeamDataService {
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) {
teamInfoMap.set(team.teamName, {
displayName: team.displayName,
projectPath: team.projectPath,
deletedAt: team.deletedAt,
});
}
const deletedTeams = new Set(teams.filter((t) => t.deletedAt).map((t) => t.teamName));
const teamNames = [
...new Set(rawTasks.map((t) => t.teamName).filter((n) => teamInfoMap.has(n))),
];
@ -123,6 +129,7 @@ export class TeamDataService {
teamDisplayName: info.displayName,
projectPath: task.projectPath ?? info.projectPath,
kanbanColumn,
teamDeleted: deletedTeams.has(task.teamName) || undefined,
};
});
}
@ -135,6 +142,26 @@ export class TeamDataService {
}
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);
await fs.promises.rm(teamsDir, { recursive: true, force: true });
@ -668,6 +695,10 @@ export class TeamDataService {
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[]> {
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(
teamName: string,
taskId: string,

View file

@ -239,9 +239,18 @@ export const TEAM_UPDATE_TASK_STATUS = 'team:updateTaskStatus';
/** Update task owner (reassign) */
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';
/** 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 */
export const TEAM_ALIVE_LIST = 'team:aliveList';
export const TEAM_STOP = 'team:stop';

View file

@ -54,6 +54,7 @@ import {
TEAM_LAUNCH,
TEAM_LEAD_ACTIVITY,
TEAM_LIST,
TEAM_PERMANENTLY_DELETE,
TEAM_PREPARE_PROVISIONING,
TEAM_PROCESS_ALIVE,
TEAM_PROCESS_SEND,
@ -61,6 +62,8 @@ import {
TEAM_PROVISIONING_STATUS,
TEAM_REMOVE_MEMBER,
TEAM_REQUEST_REVIEW,
TEAM_RESTORE,
TEAM_RESTORE_TASK,
TEAM_SEND_MESSAGE,
TEAM_SOFT_DELETE_TASK,
TEAM_START_TASK,
@ -566,6 +569,12 @@ const electronAPI: ElectronAPI = {
deleteTeam: async (teamName: string) => {
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) => {
return invokeIpcWithResult<TeamProvisioningPrepareResult>(TEAM_PREPARE_PROVISIONING, cwd);
},
@ -681,6 +690,9 @@ const electronAPI: ElectronAPI = {
softDeleteTask: async (teamName: string, taskId: string) => {
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) => {
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> => {
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> => {
throw new Error('Team provisioning is not available in browser mode');
},
@ -721,6 +727,7 @@ export class HttpAPIClient implements ElectronAPI {
linesAdded: 0,
linesRemoved: 0,
filesTouched: [],
fileStats: {},
toolUsage: {},
inputTokens: 0,
outputTokens: 0,
@ -770,6 +777,9 @@ export class HttpAPIClient implements ElectronAPI {
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
// 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[]> => {
return [];
},

View file

@ -7,7 +7,7 @@
* - 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 { useStore } from '@renderer/store';
@ -18,13 +18,14 @@ import {
normalizePath,
type TaskStatusCounts,
} from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { createLogger } from '@shared/utils/logger';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('Component:DashboardView');
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';
@ -131,27 +132,78 @@ const RepositoryCard = ({
const projectPath = repo.worktrees[0]?.path || '';
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 (
<button
ref={cardRef}
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
? 'border-border-emphasis 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 */}
<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">
<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>
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
{repo.name}
</h3>
</div>
{/* Project path - monospace, muted */}
<p className="mb-auto truncate font-mono text-[10px] text-text-muted">{formattedPath}</p>
{/* Project path - monospace, muted, clickable to open in file manager */}
<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 */}
<div className="mt-3 flex flex-wrap items-center gap-2">
@ -531,12 +583,7 @@ const ProjectsGrid = ({
export const DashboardView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState('');
const { openSettingsTab, openTeamsTab } = useStore(
useShallow((s) => ({
openSettingsTab: s.openSettingsTab,
openTeamsTab: s.openTeamsTab,
}))
);
const openTeamsTab = useStore((s) => s.openTeamsTab);
return (
<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">
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
</h2>
<div className="flex items-center gap-3">
{searchQuery.trim() && (
<button
onClick={() => setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
Clear search
</button>
)}
{searchQuery.trim() && (
<button
onClick={() => openSettingsTab('general')}
className="flex items-center gap-1.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
title="Change Claude data folder"
onClick={() => setSearchQuery('')}
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
>
<Settings className="size-3" />
Change default folder
Clear search
</button>
</div>
)}
</div>
{/* Projects Grid */}

View file

@ -16,7 +16,6 @@ import { useShallow } from 'zustand/react/shallow';
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
import { GlobalTaskList } from '../sidebar/GlobalTaskList';
import { TaskFiltersPopover } from '../sidebar/TaskFiltersPopover';
import { defaultTaskFiltersState } from '../sidebar/taskFiltersState';
import { SidebarHeader } from './SidebarHeader';
@ -30,13 +29,9 @@ const MAX_WIDTH = 500;
const DEFAULT_WIDTH = 280;
export const Sidebar = (): React.JSX.Element => {
const { projects, projectsLoading, fetchProjects, sidebarCollapsed, teams } = useStore(
const { sidebarCollapsed } = useStore(
useShallow((s) => ({
projects: s.projects,
projectsLoading: s.projectsLoading,
fetchProjects: s.fetchProjects,
sidebarCollapsed: s.sidebarCollapsed,
teams: s.teams,
}))
);
const [width, setWidth] = useState(DEFAULT_WIDTH);
@ -46,13 +41,6 @@ export const Sidebar = (): React.JSX.Element => {
const [taskFiltersPopoverOpen, setTaskFiltersPopoverOpen] = useState(false);
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
const handleMouseMove = useCallback(
(e: MouseEvent) => {
@ -167,18 +155,7 @@ export const Sidebar = (): React.JSX.Element => {
Sessions
</button>
</div>
<div className="flex flex-1 justify-end pb-0.5">
{sidebarTab === 'tasks' && (
<TaskFiltersPopover
open={taskFiltersPopoverOpen}
onOpenChange={setTaskFiltersPopoverOpen}
teams={teams.map((t) => ({ teamName: t.teamName, displayName: t.displayName }))}
filters={taskFilters}
onFiltersChange={setTaskFilters}
onApply={() => {}}
/>
)}
</div>
<div className="flex-1" />
</div>
{/* 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):
* - Row 1: Project name (left-aligned after macOS traffic lights)
* - Row 2: Worktree selector (full-width button)
*
* Visual requirements:
* Layout:
* - Row 1: Logo (left, after macOS traffic lights) + Collapse button (right)
* - 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 { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout';
import { cn } from '@renderer/lib/utils';
import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout';
import { useStore } from '@renderer/store';
import { formatShortcut, truncateMiddle } from '@renderer/utils/stringUtils';
import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react';
import { formatShortcut } from '@renderer/utils/stringUtils';
import { PanelLeft } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
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 => {
const isMacElectron =
isElectronMode() && window.navigator.userAgent.toLowerCase().includes('mac');
const {
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
selectWorktree,
selectRepository,
viewMode,
projects,
activeProjectId,
setActiveProject,
clearActiveProject,
fetchRepositoryGroups,
fetchProjects,
toggleSidebar,
} = useStore(
const { toggleSidebar } = useStore(
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,
}))
);
// 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);
return (
@ -251,7 +34,6 @@ export const SidebarHeader = (): React.JSX.Element => {
className="flex w-full flex-col"
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
>
{/* ROW 1: Logo in corner, project selector fills width, collapse button */}
<div
className="flex select-none items-center gap-1.5 pr-1"
style={
@ -265,58 +47,7 @@ export const SidebarHeader = (): React.JSX.Element => {
<div style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<AppLogo size={22} className="shrink-0" />
</div>
<div
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>
<div className="flex-1" />
<button
onClick={toggleSidebar}
onMouseEnter={() => setIsCollapseHovered(true)}
@ -334,109 +65,6 @@ export const SidebarHeader = (): React.JSX.Element => {
<PanelLeft className="size-4" />
</button>
</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>
);
};

View file

@ -46,7 +46,7 @@ export const GeneralInfoSection = ({
{/* Scope/Tool Name */}
<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)
</label>
<select

View file

@ -7,19 +7,24 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import {
getNonEmptyCategories,
groupSessionsByDate,
separatePinnedSessions,
} from '@renderer/utils/dateGrouping';
import { truncateMiddle } from '@renderer/utils/stringUtils';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
ArrowDownWideNarrow,
Calendar,
Check,
CheckSquare,
ChevronDown,
Eye,
EyeOff,
GitBranch,
Loader2,
MessageSquareOff,
Pin,
@ -27,11 +32,109 @@ import {
} from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { WorktreeBadge } from '../common/WorktreeBadge';
import { Combobox, type ComboboxOption } from '../ui/combobox';
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';
// ---------------------------------------------------------------------------
// 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
type VirtualItem =
| { type: 'header'; category: DateCategory; id: string }
@ -74,6 +177,19 @@ export const DateGroupedSessions = (): React.JSX.Element => {
hideMultipleSessions,
unhideMultipleSessions,
pinMultipleSessions,
// Project / repository state
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
selectWorktree,
selectRepository,
viewMode,
projects,
activeProjectId,
setActiveProject,
clearActiveProject,
fetchRepositoryGroups,
fetchProjects,
} = useStore(
useShallow((s) => ({
sessions: s.sessions,
@ -98,12 +214,82 @@ export const DateGroupedSessions = (): React.JSX.Element => {
hideMultipleSessions: s.hideMultipleSessions,
unhideMultipleSessions: s.unhideMultipleSessions,
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 countRef = useRef<HTMLSpanElement>(null);
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 hasHiddenSessions = hiddenSessionIds.length > 0;
@ -295,11 +481,158 @@ export const DateGroupedSessions = (): React.JSX.Element => {
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) {
return (
<div className="p-4">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<p>Select a project to view sessions</p>
<div className="flex h-full flex-col">
{projectSelector}
<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>
);
@ -313,8 +646,9 @@ export const DateGroupedSessions = (): React.JSX.Element => {
];
return (
<div className="p-4">
<div className="space-y-3">
<div className="flex h-full flex-col">
{projectSelector}
<div className="space-y-3 p-4">
{widths.map((w, i) => (
<div key={i} className="space-y-2">
<div
@ -338,19 +672,22 @@ export const DateGroupedSessions = (): React.JSX.Element => {
if (sessionsError) {
return (
<div className="p-4">
<div
className="rounded-lg border p-3 text-sm"
style={{
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>{sessionsError}</p>
<div className="flex h-full flex-col">
{projectSelector}
<div className="p-4">
<div
className="rounded-lg border p-3 text-sm"
style={{
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>{sessionsError}</p>
</div>
</div>
</div>
);
@ -358,11 +695,14 @@ export const DateGroupedSessions = (): React.JSX.Element => {
if (sessions.length === 0) {
return (
<div className="p-4">
<div className="py-8 text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<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 className="flex h-full flex-col">
{projectSelector}
<div className="flex flex-1 items-center justify-center p-4">
<div className="text-center text-sm" style={{ color: 'var(--color-text-muted)' }}>
<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>
);
@ -370,7 +710,8 @@ export const DateGroupedSessions = (): React.JSX.Element => {
return (
<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)' }} />
<h2
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 { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
import { projectColor } from '@renderer/utils/projectColor';
import {
getNonEmptyTaskCategories,
groupTasksByDate,
groupTasksByProject,
sortTasksByFreshness,
} from '@renderer/utils/taskGrouping';
import { ListTodo, Search, X } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Combobox, type ComboboxOption } from '../ui/combobox';
import { SidebarTaskItem } from './SidebarTaskItem';
import { TaskFiltersPopover } from './TaskFiltersPopover';
import {
@ -25,16 +29,16 @@ import type { GlobalTask } from '@shared/types';
const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping';
export type TaskGroupingMode = 'project' | 'time';
export type TaskGroupingMode = 'none' | 'project' | 'time';
function loadGroupingMode(): TaskGroupingMode {
try {
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 {
/* ignore */
}
return 'project';
return 'none';
}
function saveGroupingMode(mode: TaskGroupingMode): void {
@ -89,11 +93,8 @@ export const GlobalTaskList = ({
globalTasksLoading,
fetchAllTasks,
projects,
activeProjectId,
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
teams,
} = useStore(
useShallow((s) => ({
@ -101,11 +102,8 @@ export const GlobalTaskList = ({
globalTasksLoading: s.globalTasksLoading,
fetchAllTasks: s.fetchAllTasks,
projects: s.projects,
activeProjectId: s.activeProjectId,
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups,
selectedRepositoryId: s.selectedRepositoryId,
selectedWorktreeId: s.selectedWorktreeId,
teams: s.teams,
}))
);
@ -122,6 +120,9 @@ export const GlobalTaskList = ({
const hasFetchedRef = useRef(false);
const readState = useReadStateSnapshot();
// Local project filter (independent from sessions tab)
const [localProjectFilter, setLocalProjectFilter] = useState<string | null>(null);
const setGroupingMode = (mode: TaskGroupingMode): void => {
setGroupingModeState(mode);
saveGroupingMode(mode);
@ -134,22 +135,34 @@ export const GlobalTaskList = ({
}
}, [fetchAllTasks]);
const selectedProjectPath = useMemo(() => {
if (viewMode === 'grouped') {
const repo = repositoryGroups.find((r) => r.id === selectedRepositoryId);
const worktree = repo?.worktrees.find((w) => w.id === selectedWorktreeId);
return worktree?.path ?? null;
}
const project = projects.find((p) => p.id === activeProjectId);
return project?.path ?? null;
}, [
viewMode,
repositoryGroups,
selectedRepositoryId,
selectedWorktreeId,
projects,
activeProjectId,
]);
// Build project combobox options from available projects/repos
const projectFilterOptions = useMemo((): ComboboxOption[] => {
const items =
viewMode === 'grouped'
? repositoryGroups
.filter((r) => r.totalSessions > 0)
.map((r) => ({
value: r.worktrees[0]?.path ?? r.id,
label: r.name,
path: r.worktrees[0]?.path,
}))
: projects
.filter((p) => p.sessions.length > 0)
.map((p) => ({
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(() => {
let result = globalTasks;
@ -175,35 +188,32 @@ export const GlobalTaskList = ({
readState,
]);
const sortedFlat = useMemo(() => sortTasksByFreshness(filtered), [filtered]);
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
const projectGroups = useMemo(() => groupTasksByProject(filtered), [filtered]);
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 (
<div className="flex size-full min-w-0 flex-col">
{!hideHeader && (
<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)' }}
>
<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>
)}
{/* Search bar */}
<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)' }}
>
<Search className="size-3 shrink-0 text-text-muted" />
@ -227,6 +237,29 @@ export const GlobalTaskList = ({
<X className="size-3" />
</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>
{/* Grouping mode — compact segmented toggle */}
@ -237,21 +270,24 @@ export const GlobalTaskList = ({
role="group"
aria-label="Group by"
>
{(['project', 'time'] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-2 py-0.5 transition-colors',
groupingMode === mode
? 'bg-surface-raised text-text shadow-sm'
: 'text-text-muted hover:text-text-secondary'
)}
>
{mode === 'project' ? 'Project' : 'Time'}
</button>
))}
{(['none', 'project', 'time'] as const).map((mode) => {
const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time';
return (
<button
key={mode}
type="button"
onClick={() => setGroupingMode(mode)}
className={cn(
'rounded px-2 py-0.5 transition-colors',
groupingMode === mode
? 'bg-surface-raised text-text shadow-sm'
: 'text-text-muted hover:text-text-secondary'
)}
>
{label}
</button>
);
})}
</div>
</div>
@ -274,6 +310,11 @@ export const GlobalTaskList = ({
</div>
)}
{groupingMode === 'none' &&
sortedFlat.map((task) => (
<SidebarTaskItem key={`${task.teamName}-${task.id}`} task={task} showTeamName />
))}
{groupingMode === 'project' &&
projectGroups.map((group) => {
if (group.tasks.length === 0) return null;
@ -281,10 +322,16 @@ export const GlobalTaskList = ({
return (
<div key={group.projectKey}>
<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)' }}
>
{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>
{group.tasks.map((task) => {
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 { 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 { 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 { LucideIcon } from 'lucide-react';
@ -47,13 +54,16 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
interface SidebarTaskItemProps {
task: GlobalTask;
hideTeamName?: boolean;
showTeamName?: boolean;
}
export const SidebarTaskItem = ({
task,
hideTeamName,
showTeamName,
}: SidebarTaskItemProps): React.JSX.Element => {
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 cfg =
task.kanbanColumn === 'approved'
@ -65,14 +75,40 @@ export const SidebarTaskItem = ({
const updatedLabel = formatUpdatedLabel(task);
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 (
<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)' }}
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
>
{/* Row 1: status + subject */}
<div className="flex w-full items-center gap-1.5 overflow-hidden">
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
<span
className="truncate text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }}
@ -85,28 +121,59 @@ export const SidebarTaskItem = ({
title={`${unreadCount} unread`}
/>
)}
<StatusIcon className={`size-3 shrink-0 ${cfg.color}`} />
</div>
{/* Row 2: project + owner (when no team row) + date */}
<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)' }}
>
<span>{task.owner ?? 'unassigned'}</span>
{!hideTeamName && (
<>
<span className="opacity-40">·</span>
<span className="truncate">{task.teamDisplayName}</span>
</>
{task.teamDeleted && <Trash2 className="size-2.5 shrink-0 text-zinc-500" />}
{projectLabel && (
<span
className="shrink-0"
style={projectColorSet ? { color: projectColorSet.text } : undefined}
>
{projectLabel}
</span>
)}
{dateLabel && (
{!showTeamRow && (
<>
<span className="opacity-40">·</span>
<span className={`shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
{dateLabel}
{projectLabel && <span className="opacity-40">·</span>}
<span
className="shrink-0 opacity-60"
style={ownerColorSet ? { color: ownerColorSet.text } : undefined}
>
{task.owner ?? 'unassigned'}
</span>
</>
)}
{dateLabel && (
<span className={`ml-auto shrink-0 ${updatedLabel ? 'italic opacity-70' : ''}`}>
{dateLabel}
</span>
)}
</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>
);
};

View file

@ -54,10 +54,9 @@ export const TaskFiltersPopover = ({
<PopoverTrigger asChild>
<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" />
Filters
<Filter className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="end" sideOffset={6}>

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
@ -17,9 +18,11 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize';
import { nameColorSet } from '@renderer/utils/projectColor';
import { toMessageKey } from '@renderer/utils/teamMessageKey';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import {
AlertTriangle,
Bell,
CheckCheck,
FolderOpen,
@ -177,6 +180,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
kanbanFilterQuery,
clearKanbanFilter,
softDeleteTask,
restoreTask,
fetchDeletedTasks,
deletedTasks,
} = useStore(
@ -214,6 +218,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
kanbanFilterQuery: s.kanbanFilterQuery,
clearKanbanFilter: s.clearKanbanFilter,
softDeleteTask: s.softDeleteTask,
restoreTask: s.restoreTask,
fetchDeletedTasks: s.fetchDeletedTasks,
deletedTasks: s.deletedTasks,
}))
@ -510,7 +515,18 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
const handleDeleteTask = useCallback(
(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]
);
@ -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 (
<div className="size-full overflow-auto p-4">
@ -772,7 +790,10 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
{!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">
<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
variant="ghost"
size="sm"
@ -1392,7 +1413,14 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
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
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 { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
import { getBaseName } from '@renderer/utils/pathUtils';
import { nameColorSet } from '@renderer/utils/projectColor';
import {
CheckCircle,
Clock,
@ -23,6 +24,7 @@ import {
FolderOpen,
GitBranch,
Play,
RotateCcw,
Search,
Square,
Trash2,
@ -31,8 +33,10 @@ import { useShallow } from 'zustand/react/shallow';
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover';
import type {
TeamCreateRequest,
TeamProvisioningProgress,
@ -176,6 +180,7 @@ export const TeamListView = (): React.JSX.Element => {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [copyData, setCopyData] = useState<TeamCopyData | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
const {
@ -201,6 +206,8 @@ export const TeamListView = (): React.JSX.Element => {
fetchTeams: s.fetchTeams,
openTeamTab: s.openTeamTab,
deleteTeam: s.deleteTeam,
restoreTeam: s.restoreTeam,
permanentlyDeleteTeam: s.permanentlyDeleteTeam,
projects: s.projects,
globalTasks: s.globalTasks,
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 matchesProject = currentProjectPath
? (t: TeamSummary): boolean => {
@ -299,7 +327,15 @@ export const TeamListView = (): React.JSX.Element => {
});
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)
const projectPathsToPoll = useMemo(() => {
@ -344,14 +380,17 @@ export const TeamListView = (): React.JSX.Element => {
};
}, [electronMode, projectPathsToPoll]);
const restoreTeam = useStore((s) => s.restoreTeam);
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
const handleDeleteTeam = useCallback(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
void (async () => {
const confirmed = await confirm({
title: 'Delete team',
message: `Delete team "${teamName}"? This action is irreversible.`,
confirmLabel: 'Delete',
title: 'Move to trash',
message: `Move team "${teamName}" to trash? You can restore it later.`,
confirmLabel: 'Move to trash',
cancelLabel: 'Cancel',
variant: 'danger',
});
@ -363,6 +402,33 @@ export const TeamListView = (): React.JSX.Element => {
[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(
(teamName: string, e: React.MouseEvent) => {
e.stopPropagation();
@ -503,17 +569,25 @@ export const TeamListView = (): React.JSX.Element => {
) : null}
{teams.length > 0 ? (
<div className="relative mt-3">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
<div className="mt-3 flex items-center gap-2">
<div className="relative flex-1">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<Input
type="text"
placeholder="Search teams..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<TeamListFilterPopover
filter={filter}
teams={teams}
aliveTeams={aliveTeams}
onFilterChange={setFilter}
/>
</div>
) : null}
@ -554,197 +628,280 @@ export const TeamListView = (): React.JSX.Element => {
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 (
<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>
);
}
const activeFiltered = filteredTeams.filter((t) => !t.deletedAt);
const deletedFiltered = filteredTeams.filter((t) => t.deletedAt);
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredTeams.map((team) => {
const status = resolveTeamStatus(
team.teamName,
aliveTeams,
provisioningRuns,
leadActivityByTeam
);
const teamColorSet = team.color ? getTeamColorSet(team.color) : null;
const matchesCurrentProject =
!!currentProjectPath &&
((team.projectPath ? normalizePath(team.projectPath) === currentProjectPath : false) ||
(team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
false));
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{activeFiltered.map((team) => {
const status = resolveTeamStatus(
team.teamName,
aliveTeams,
provisioningRuns,
leadActivityByTeam
);
const teamColorSet = team.color
? getTeamColorSet(team.color)
: nameColorSet(team.displayName);
const matchesCurrentProject =
!!currentProjectPath &&
((team.projectPath
? normalizePath(team.projectPath) === currentProjectPath
: false) ||
(team.projectPathHistory?.some((p) => normalizePath(p) === currentProjectPath) ??
false));
return (
<div
key={team.teamName}
role="button"
tabIndex={0}
className={`group relative cursor-pointer overflow-hidden rounded-lg border bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)] ${
matchesCurrentProject
? 'border-emerald-500/70 ring-1 ring-emerald-500/30'
: 'border-[var(--color-border)]'
}`}
style={
teamColorSet
? { borderLeftWidth: '3px', borderLeftColor: teamColorSet.border }
: undefined
}
}}
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<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>
<StatusBadge status={status} />
</div>
<div className="flex shrink-0 gap-1">
{(status === 'active' || status === 'idle') && (
onClick={() => openTeamTab(team.teamName, team.projectPath)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openTeamTab(team.teamName, team.projectPath);
}
}}
>
{teamColorSet ? (
<div
className="pointer-events-none absolute inset-0 z-0 rounded-lg"
style={{ backgroundColor: teamColorSet.badge }}
/>
) : null}
<div className={teamColorSet ? 'relative z-10' : undefined}>
<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>
<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>
<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"
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) => handleCopyTeam(team.teamName, e)}
>
<Square size={14} fill="currentColor" />
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{stoppingTeamName === team.teamName ? 'Stopping…' : 'Stop team'}
</TooltipContent>
<TooltipContent side="bottom">Copy team</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-blue-500/10 hover:text-blue-300 group-hover:opacity-100"
onClick={(e) => handleCopyTeam(team.teamName, e)}
>
<Copy size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Copy team</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) => handleDeleteTeam(team.teamName, e)}
>
<Trash2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Delete team</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) => 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">
<p className="line-clamp-2 min-w-0 flex-1 text-xs text-[var(--color-text-muted)]">
{team.description || 'No description'}
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
if (!branch) return null;
<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)]">
{team.description || 'No description'}
</p>
{team.projectPath &&
(() => {
const branch = branchByPath.get(normalizePath(team.projectPath));
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 (
<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 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 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>
{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
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}
</span>
) : null}
<span
className="min-w-0 flex-1 truncate text-[10px]"
style={{ color: CARD_ICON_MUTED }}
>
<span className="shrink-0 text-[10px]" style={{ color: CARD_ICON_MUTED }}>
working on
</span>
{task &&
(onTaskClick ? (
<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` }}
onClick={() => onTaskClick(task)}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
#{task.id} {task.subject}
</button>
) : (
<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` }}
title={task.subject}
>
#{task.id} {task.subject.slice(0, 40)}
{task.subject.length > 40 ? '…' : ''}
#{task.id} {task.subject}
</span>
))}
</div>

View file

@ -212,7 +212,9 @@ export const ActivityItem = ({
const handleCreateTask = (): void => {
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);
onCreateTask?.(subject, description);
};

View file

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

View file

@ -28,13 +28,13 @@ import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertTriangle, Search } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface CreateTaskDialogProps {
open: boolean;
teamName: string;
members: ResolvedTeamMember[];
tasks: TeamTask[];
tasks: TeamTaskWithKanban[];
isTeamAlive?: boolean;
defaultSubject?: string;
defaultDescription?: string;
@ -114,7 +114,9 @@ export const CreateTaskDialog = ({
const canSubmit = subject.trim().length > 0 && !submitting && (!requiresOwner || !!owner);
// 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 => {
setBlockedBy((prev) =>
@ -151,7 +153,9 @@ export const CreateTaskDialog = ({
const assigneeField = (
<div className="grid gap-2">
<Label>{requiresOwner ? 'Assignee' : 'Assignee (optional)'}</Label>
<Label className={requiresOwner ? undefined : 'label-optional'}>
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
</Label>
<Select
value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
@ -226,7 +230,9 @@ export const CreateTaskDialog = ({
{assigneeField}
<div className="grid gap-2">
<Label htmlFor="task-description">Description (optional)</Label>
<Label htmlFor="task-description" className="label-optional">
Description (optional)
</Label>
<MentionableTextarea
id="task-description"
placeholder="Task details..."
@ -244,7 +250,9 @@ export const CreateTaskDialog = ({
</div>
<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
id="task-prompt"
placeholder="Custom instructions for the team member..."
@ -287,7 +295,7 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
<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)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
@ -356,7 +364,7 @@ export const CreateTaskDialog = ({
{availableTasks.length > 0 ? (
<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)]">
{availableTasks.length > 3 ? (
<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 { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
import { MembersJsonEditor } from './MembersJsonEditor';
const TEAM_COLOR_NAMES = [
'blue',
'green',
@ -271,6 +273,9 @@ export const CreateTeamDialog = ({
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const resetUIState = (): void => {
setLocalError(null);
@ -292,6 +297,9 @@ export const CreateTeamDialog = ({
setCustomCwd('');
setLaunchTeam(true);
setSelectedModel('');
setJsonEditorOpen(false);
setJsonText('');
setJsonError(null);
resetUIState();
};
@ -448,6 +456,60 @@ export const CreateTeamDialog = ({
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 prompt = promptDraft.value;
@ -655,9 +717,7 @@ export const CreateTeamDialog = ({
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-name" className="text-xs text-[var(--color-text-muted)]">
teamName
</Label>
<Label htmlFor="team-name">Team name</Label>
<Input
id="team-name"
className="h-8 text-xs"
@ -673,56 +733,23 @@ export const CreateTeamDialog = ({
</div>
<div className="space-y-1.5 md:col-span-2">
<Label htmlFor="team-description" className="text-xs text-[var(--color-text-muted)]">
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="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 className="flex items-center justify-between">
<Label>Members</Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setMembers((prev) => [...prev, createMemberDraft()]);
}}
>
Add member
</Button>
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
{jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
</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">
{members.map((member, index) => {
const memberColorSet = getTeamColorSet(getMemberColor(index));
@ -795,183 +822,252 @@ export const CreateTeamDialog = ({
</div>
);
})}
<Button
variant="outline"
size="sm"
onClick={() => {
setMembers((prev) => [...prev, createMemberDraft()]);
}}
>
Add member
</Button>
{jsonEditorOpen ? (
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
) : null}
</div>
{fieldErrors.members ? (
<p className="text-[11px] text-red-300">{fieldErrors.members}</p>
) : null}
</div>
<div className="flex items-center gap-2 md:col-span-2">
<Checkbox
id="launch-team"
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
}
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4 md:col-span-2">
<div className="flex items-center gap-2">
<Checkbox
id="launch-team"
checked={launchTeam}
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
/>
<Label htmlFor="launch-team" className="cursor-pointer">
Launch team
</Label>
</div>
) : null}
{launchTeam ? (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
) : null}
{launchTeam ? (
<div className="mt-4 space-y-4">
<div className="space-y-1.5">
<Label>Project</Label>
<div className="space-y-2">
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
<button
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
cwdMode === 'project'
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => setCwdMode('project')}
>
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 ? (
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs text-[var(--color-text-muted)]">Project</Label>
<div className="space-y-2">
<div className="flex items-center gap-1">
<Button
type="button"
variant={cwdMode === 'project' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('project')}
>
From project list
</Button>
<Button
type="button"
variant={cwdMode === 'custom' ? 'default' : 'outline'}
size="sm"
className="h-7 text-xs"
onClick={() => setCwdMode('custom')}
>
Custom path
</Button>
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={setSelectedProjectPath}
placeholder={
projectsLoading ? 'Loading projects...' : 'Select a project...'
}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<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>
{cwdMode === 'project' ? (
<div className="space-y-1.5">
<Combobox
options={projects.map((project) => ({
value: project.path,
label: project.name,
description: project.path,
}))}
value={selectedProjectPath}
onValueChange={setSelectedProjectPath}
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
searchPlaceholder="Search project by name or path"
emptyMessage="Nothing found"
disabled={projectsLoading || projects.length === 0}
renderOption={(option, isSelected, query) => (
<>
<Check
className={cn(
'mr-2 size-3.5 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<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 className="space-y-1.5">
<Label htmlFor="team-prompt" className="label-optional">
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
}
/>
</div>
<div className="space-y-1.5">
<Label className="label-optional">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
{canCreate && (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>
</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>
) : null}
{canCreate && 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}
</div>
{fieldErrors.cwd ? (
<p className="text-[11px] text-red-300">{fieldErrors.cwd}</p>
) : null}
) : null}
</div>
<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>
) : null}
</div>
</div>
{activeError ? (
@ -980,27 +1076,6 @@ export const CreateTeamDialog = ({
</p>
) : 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">
{canOpenExistingTeam ? (
<Button

View file

@ -129,7 +129,7 @@ export const EditTeamDialog = ({
</div>
<div>
{/* 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)
</label>
<div className="flex flex-wrap gap-2">

View file

@ -458,7 +458,7 @@ export const LaunchTeamDialog = ({
</div>
<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)
</Label>
<MentionableTextarea
@ -479,7 +479,7 @@ export const LaunchTeamDialog = ({
</div>
<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}>
<SelectTrigger className="h-8 text-xs">
<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>
<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
id="review-comment"
className="min-h-[110px] text-xs"

View file

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

View file

@ -6,8 +6,14 @@ import {
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@renderer/components/ui/tooltip';
import { formatDistanceToNow } from 'date-fns';
import { Trash2 } from 'lucide-react';
import { RotateCcw, Trash2 } from 'lucide-react';
import type { TeamTask } from '@shared/types';
@ -15,9 +21,15 @@ interface TrashDialogProps {
open: boolean;
tasks: TeamTask[];
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 (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-lg">
@ -33,37 +45,57 @@ export const TrashDialog = ({ open, tasks, onClose }: TrashDialogProps): React.J
No deleted tasks
</div>
) : (
<div className="max-h-[400px] overflow-y-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Subject</th>
<th className="pb-2 pr-3 font-medium">Owner</th>
<th className="pb-2 font-medium">Deleted</th>
</tr>
</thead>
<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>
<TooltipProvider delayDuration={300}>
<div className="max-h-[400px] overflow-y-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]">
<th className="pb-2 pr-3 font-medium">#</th>
<th className="pb-2 pr-3 font-medium">Subject</th>
<th className="pb-2 pr-3 font-medium">Owner</th>
<th className="pb-2 pr-3 font-medium">Deleted</th>
{onRestore ? <th className="pb-2 font-medium" /> : null}
</tr>
))}
</tbody>
</table>
</div>
</thead>
<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 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>

View file

@ -13,7 +13,7 @@ import {
Loader2,
} from 'lucide-react';
import type { MemberFullStats } from '@shared/types';
import type { FileLineStats, MemberFullStats } from '@shared/types';
interface MemberStatsTabProps {
teamName: string;
@ -95,6 +95,7 @@ export const MemberStatsTab = ({
<ToolUsageBars toolUsage={stats.toolUsage} />
<FilesTouchedSection
files={stats.filesTouched}
fileStats={stats.fileStats}
onFileClick={onFileClick}
onShowAll={onShowAllFiles}
/>
@ -191,27 +192,33 @@ const ToolUsageBars = ({
);
};
const INVALID_PATHS = new Set(['null', 'undefined', 'None', '']);
const FilesTouchedSection = ({
files,
fileStats,
onFileClick,
onShowAll,
}: {
files: string[];
fileStats?: Record<string, FileLineStats>;
onFileClick?: (filePath: string) => void;
onShowAll?: () => void;
}): React.JSX.Element | null => {
const [expanded, setExpanded] = useState(false);
if (files.length === 0) return null;
const visibleFiles = expanded ? files : files.slice(0, 5);
const hiddenCount = files.length - 5;
const validFiles = files.filter((f) => !INVALID_PATHS.has(f));
if (validFiles.length === 0) return null;
const visibleFiles = expanded ? validFiles : validFiles.slice(0, 5);
const hiddenCount = validFiles.length - 5;
const isClickable = !!onFileClick;
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-medium text-[var(--color-text-secondary)]">
Files Touched ({files.length})
Files Touched ({validFiles.length})
</p>
{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">
{visibleFiles.map((filePath) => {
const basename = filePath.split('/').pop() ?? filePath;
const fStats = fileStats?.[filePath];
return (
<button
key={filePath}
@ -236,7 +244,13 @@ const FilesTouchedSection = ({
disabled={!isClickable}
>
<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>
);
})}

View file

@ -4,6 +4,3 @@
*/
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%
);
}
/* 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: () => {
const state = get();
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);
if (!focusedPane) return;

View file

@ -107,8 +107,11 @@ export interface TeamSlice {
deletedTasks: TeamTask[];
deletedTasksLoading: boolean;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
restoreTask: (teamName: string, taskId: string) => Promise<void>;
fetchDeletedTasks: (teamName: string) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>;
restoreTeam: (teamName: string) => Promise<void>;
permanentlyDeleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
cancelProvisioning: (runId: string) => Promise<void>;
@ -516,6 +519,12 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
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) => {
set({ deletedTasksLoading: true });
try {
@ -531,11 +540,24 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deleteTeam: async (teamName: string) => {
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();
if (state.selectedTeamName === teamName) {
set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null });
}
await get().fetchTeams();
await get().fetchAllTasks();
},
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;
}
function projectLabelFromPath(path: string): string {
export function projectLabelFromPath(path: string): string {
const normalized = trimTrailingPathSep(path);
const segments = normalized
.split('/')
@ -108,6 +108,16 @@ function projectLabelFromPath(path: string): string {
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[] {
const byKey = new Map<string, { path: string; tasks: GlobalTask[] }>();

View file

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

View file

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

View file

@ -44,6 +44,9 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask',
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
TEAM_RESTORE: 'team:restoreTeam',
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
TEAM_RESTORE_TASK: 'team:restoreTask',
}));
import {
@ -79,7 +82,9 @@ import {
TEAM_GET_PROJECT_BRANCH,
TEAM_KILL_PROCESS,
TEAM_LEAD_ACTIVITY,
TEAM_PERMANENTLY_DELETE,
TEAM_REMOVE_MEMBER,
TEAM_RESTORE,
TEAM_SOFT_DELETE_TASK,
TEAM_UPDATE_MEMBER_ROLE,
} 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_SOFT_DELETE_TASK)).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 () => {
@ -501,5 +508,7 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
expect(handlers.has(TEAM_SOFT_DELETE_TASK)).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);
});
});