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:
parent
a02f46c3aa
commit
7019bf6114
43 changed files with 2493 additions and 1042 deletions
466
docs/research/git.md
Normal file
466
docs/research/git.md
Normal 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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
185
src/renderer/components/team/TeamListFilterPopover.tsx
Normal file
185
src/renderer/components/team/TeamListFilterPopover.tsx
Normal 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 */
|
||||
|
|
@ -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 "{searchQuery.trim()}"
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)]">·</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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)" />
|
||||
|
|
|
|||
96
src/renderer/components/team/dialogs/MembersJsonEditor.tsx
Normal file
96
src/renderer/components/team/dialogs/MembersJsonEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -624,3 +624,8 @@ body {
|
|||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Optional field label — muted text for non-required form fields */
|
||||
.label-optional {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
36
src/renderer/utils/projectColor.ts
Normal file
36
src/renderer/utils/projectColor.ts
Normal 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)`,
|
||||
};
|
||||
}
|
||||
|
|
@ -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[] }>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue